138 20 7MB
Polish Pages 343 Year 2015
Tytuł oryginału: High Performance Python: Practical Performant Programming for Humans Tłumaczenie: Piotr Pilch ISBN: 978-83-283-0469-7 © 2015 Helion S.A. Authorized Polish translation of the English edition of High Performance Python, ISBN 9781449361594 © 2014 Micha Gorelick and Ian Ozsvald. This translation is published and sold by permission of O’Reilly Media, Inc., which owns or controls all rights to publish and sell the same. All rights reserved. No part of this book may be reproduced or transmitted in any form or by any means, electronic or mechanical, including photocopying, recording or by any information storage retrieval system, without permission from the Publisher. Wszelkie prawa zastrzeżone. Nieautoryzowane rozpowszechnianie całości lub fragmentu niniejszej publikacji w jakiejkolwiek postaci jest zabronione. Wykonywanie kopii metodą kserograficzną, fotograficzną, a także kopiowanie książki na nośniku filmowym, magnetycznym lub innym powoduje naruszenie praw autorskich niniejszej publikacji. Wszystkie znaki występujące w tekście są zastrzeżonymi znakami firmowymi bądź towarowymi ich właścicieli. Autor oraz Wydawnictwo HELION dołożyli wszelkich starań, by zawarte w tej książce informacje były kompletne i rzetelne. Nie biorą jednak żadnej odpowiedzialności ani za ich wykorzystanie, ani za związane z tym ewentualne naruszenie praw patentowych lub autorskich. Autor oraz Wydawnictwo HELION nie ponoszą również żadnej odpowiedzialności za ewentualne szkody wynikłe z wykorzystania informacji zawartych w książce. Wydawnictwo HELION ul. Kościuszki 1c, 44-100 GLIWICE tel. 32 231 22 19, 32 230 98 63 e-mail: [email protected] WWW: http://helion.pl (księgarnia internetowa, katalog książek) Drogi Czytelniku! Jeżeli chcesz ocenić tę książkę, zajrzyj pod adres http://helion.pl/user/opinie/pytpsw_ebook Możesz tam wpisać swoje uwagi, spostrzeżenia, recenzję.
Poleć książkę na Facebook.com Kup w wersji papierowej Oceń książkę
Księgarnia internetowa Lubię to! » Nasza społeczność
Python. Programuj szybko i wydajnie
Micha Gorelick, Ian Ozsvald
HELION O’REILLY™ Beijing Cambridge Farnham Köln Sebastopol Tokyo
Spis treści
Przedmowa ....................................................................................................................9 1. Wydajny kod Python ................................................................................................... 15 Podstawowy system komputerowy ................................................................................................ 15 Jednostki obliczeniowe ................................................................................................................16 Jednostki pamięci .........................................................................................................................19 Warstwy komunikacji ..................................................................................................................21 Łączenie ze sobą podstawowych elementów ................................................................................ 22 Porównanie wyidealizowanego przetwarzania z maszyną wirtualną języka Python ............23 Dlaczego warto używać języka Python? ........................................................................................ 26
2. Użycie profilowania do znajdowania wąskich gardeł ...............................................29 Efektywne profilowanie .................................................................................................................... 30 Wprowadzenie do zbioru Julii ......................................................................................................... 31 Obliczanie pełnego zbioru Julii ........................................................................................................ 34 Proste metody pomiaru czasu — instrukcja print i dekorator ................................................... 37 Prosty pomiar czasu za pomocą polecenia time systemu Unix ................................................. 40 Użycie modułu cProfile ..................................................................................................................... 41 Użycie narzędzia runsnake do wizualizacji danych wyjściowych modułu cProfile ............. 46 Użycie narzędzia line_profiler do pomiarów dotyczących kolejnych wierszy kodu ............ 46 Użycie narzędzia memory_profiler do diagnozowania wykorzystania pamięci ................... 51 Inspekcja obiektów w stercie za pomocą narzędzia heapy ........................................................ 56 Użycie narzędzia dowser do generowania aktywnego wykresu dla zmiennych z utworzonymi instancjami ................................................................................ 58 Użycie modułu dis do sprawdzania kodu bajtowego narzędzia CPython ............................. 60 Różne metody, różna złożoność ................................................................................................62 Testowanie jednostkowe podczas optymalizacji w celu zachowania poprawności .............. 64 Dekorator @profile bez operacji ................................................................................................64 Strategie udanego profilowania kodu ............................................................................................ 66 Podsumowanie .................................................................................................................................... 67
3
3. Listy i krotki ..................................................................................................................69 Bardziej efektywne wyszukiwanie .................................................................................................. 71 Porównanie list i krotek .................................................................................................................... 73 Listy jako tablice dynamiczne .......................................................................................................... 74 Krotki w roli tablic statycznych ....................................................................................................... 77 Podsumowanie .................................................................................................................................... 78
4. Słowniki i zbiory .......................................................................................................... 79 Jak działają słowniki i zbiory? .......................................................................................................... 82 Wstawianie i pobieranie ..............................................................................................................82 Usuwanie .......................................................................................................................................85 Zmiana wielkości .........................................................................................................................85 Funkcje mieszania i entropia ......................................................................................................86 Słowniki i przestrzenie nazw ........................................................................................................... 89 Podsumowanie .................................................................................................................................... 92
5. Iteratory i generatory ..................................................................................................93 Iteratory dla szeregów nieskończonych ......................................................................................... 96 Wartościowanie leniwe generatora ................................................................................................. 97 Podsumowanie .................................................................................................................................. 101
6. Obliczenia macierzowe i wektorowe ....................................................................... 103 Wprowadzenie do problemu ......................................................................................................... 104 Czy listy języka Python są wystarczająco dobre? ....................................................................... 107 Problemy z przesadną alokacją ...............................................................................................109 Fragmentacja pamięci ...................................................................................................................... 111 Narzędzie perf ............................................................................................................................113 Podejmowanie decyzji z wykorzystaniem danych wyjściowych narzędzia perf ..........115 Wprowadzenie do narzędzia numpy .....................................................................................116 Zastosowanie narzędzia numpy w przypadku problemu dotyczącego dyfuzji .................. 119 Przydziały pamięci i operacje wewnętrzne ...........................................................................121 Optymalizacje selektywne: znajdowanie tego, co wymaga poprawienia .......................124 Moduł numexpr: przyspieszanie i upraszczanie operacji wewnętrznych ............................. 127 Przestroga: weryfikowanie „optymalizacji” (biblioteka scipy) ................................................ 129 Podsumowanie .................................................................................................................................. 131
7. Kompilowanie do postaci kodu C .............................................................................. 133 Jakie wzrosty szybkości są możliwe? ........................................................................................... 134 Porównanie kompilatorów JIT i AOT ........................................................................................... 136 Dlaczego informacje o typie ułatwiają przyspieszenie działania kodu? ................................ 136 Użycie kompilatora kodu C ............................................................................................................ 137 Analiza przykładu zbioru Julii ....................................................................................................... 138 Cython ................................................................................................................................................ 139 Kompilowanie czystego kodu Python za pomocą narzędzia Cython ..............................139 Użycie adnotacji kompilatora Cython do analizowania bloku kodu ...............................141 Dodawanie adnotacji typu .......................................................................................................143
4
Spis treści
Shed Skin ............................................................................................................................................ 147 Tworzenie modułu rozszerzenia .............................................................................................148 Koszt związany z kopiami pamięci ........................................................................................150 Cython i numpy ................................................................................................................................ 151 Przetwarzanie równoległe rozwiązania na jednym komputerze z wykorzystaniem interfejsu OpenMP ................................................................................152 Numba ................................................................................................................................................ 154 Pythran ............................................................................................................................................... 155 PyPy .................................................................................................................................................... 157 Różnice związane z czyszczeniem pamięci ...........................................................................158 Uruchamianie interpretera PyPy i instalowanie modułów ................................................159 Kiedy stosować poszczególne technologie? ................................................................................ 160 Inne przyszłe projekty ...............................................................................................................162 Uwaga dotycząca układów GPU ............................................................................................162 Oczekiwania dotyczące przyszłego projektu kompilatora .................................................163 Interfejsy funkcji zewnętrznych ..................................................................................................... 163 ctypes ............................................................................................................................................164 cffi ..................................................................................................................................................166 f2py ...............................................................................................................................................169 Moduł narzędzia CPython .......................................................................................................171 Podsumowanie .................................................................................................................................. 174
8. Współbieżność ............................................................................................................175 Wprowadzenie do programowania asynchronicznego ............................................................. 176 Przeszukiwacz szeregowy .............................................................................................................. 179 gevent .................................................................................................................................................. 181 tornado ............................................................................................................................................... 185 AsyncIO .............................................................................................................................................. 188 Przykład z bazą danych .................................................................................................................. 190 Podsumowanie .................................................................................................................................. 193
9. Moduł multiprocessing .............................................................................................. 195 Moduł multiprocessing ................................................................................................................... 198 Przybliżenie liczby pi przy użyciu metody Monte Carlo ......................................................... 200 Przybliżanie liczby pi za pomocą procesów i wątków .............................................................. 201 Zastosowanie obiektów języka Python ..................................................................................201 Liczby losowe w systemach przetwarzania równoległego ................................................208 Zastosowanie narzędzia numpy .............................................................................................209 Znajdowanie liczb pierwszych ....................................................................................................... 211 Kolejki zadań roboczych ...........................................................................................................217 Weryfikowanie liczb pierwszych za pomocą komunikacji międzyprocesowej .................... 221 Rozwiązanie z przetwarzaniem szeregowym ......................................................................225 Rozwiązanie z prostym obiektem Pool ..................................................................................225 Rozwiązanie z bardzo prostym obiektem Pool dla mniejszych liczb ..............................227 Użycie obiektu Manager.Value jako flagi ..............................................................................228
Spis treści
5
Użycie systemu Redis jako flagi ..............................................................................................229 Użycie obiektu RawValue jako flagi .......................................................................................232 Użycie modułu mmap jako flagi .............................................................................................232 Użycie modułu mmap do odtworzenia flagi ........................................................................233 Współużytkowanie danych narzędzia numpy za pomocą modułu multiprocessing ......... 236 Synchronizowanie dostępu do zmiennych i plików .................................................................. 242 Blokowanie plików ....................................................................................................................242 Blokowanie obiektu Value ........................................................................................................245 Podsumowanie .................................................................................................................................. 248
10. Klastry i kolejki zadań ...............................................................................................249 Zalety klastrowania .......................................................................................................................... 250 Wady klastrowania .......................................................................................................................... 251 Strata o wartości 462 milionów dolarów na giełdzie Wall Street z powodu kiepskiej strategii aktualizacji klastra ...............................................................252 24-godzinny przestój usługi Skype w skali globalnej .........................................................253 Typowe projekty klastrowe ............................................................................................................ 254 Metoda rozpoczęcia tworzenia rozwiązania klastrowego ........................................................ 254 Sposoby na uniknięcie kłopotów podczas korzystania z klastrów ......................................... 255 Trzy rozwiązania klastrowe ........................................................................................................... 257 Użycie modułu Parallel Python dla prostych klastrów lokalnych ...................................257 Użycie modułu IPython Parallel do obsługi badań .............................................................259 Użycie systemu NSQ dla niezawodnych klastrów produkcyjnych ........................................ 262 Kolejki ...........................................................................................................................................263 Publikator/subskrybent ............................................................................................................264 Rozproszone obliczenia liczb pierwszych .............................................................................266 Inne warte uwagi narzędzia klastrowania ................................................................................... 268 Podsumowanie .................................................................................................................................. 269
11. Mniejsze wykorzystanie pamięci RAM .....................................................................271 Obiekty typów podstawowych są kosztowne ............................................................................ 272 Moduł array zużywa mniej pamięci do przechowywania wielu obiektów typu podstawowego ................................................................................................................273 Analiza wykorzystania pamięci RAM w kolekcji ...................................................................... 276 Bajty i obiekty Unicode .................................................................................................................... 277 Efektywne przechowywanie zbiorów tekstowych w pamięci RAM ...................................... 279 Zastosowanie metod dla 8 milionów tokenów .....................................................................280 Wskazówki dotyczące mniejszego wykorzystania pamięci RAM .......................................... 288 Probabilistyczne struktury danych ............................................................................................... 289 Obliczenia o bardzo dużym stopniu przybliżenia z wykorzystaniem jednobajtowego licznika Morrisa .......................................................................................................................290 Wartości k-minimum .................................................................................................................291 Filtry Blooma ...............................................................................................................................295 Licznik LogLog ...........................................................................................................................299 Praktyczny przykład ..................................................................................................................303
6
Spis treści
12. Rady specjalistów z branży ....................................................................................... 307 Narzędzie Social Media Analytics (SoMA) firmy Adaptive Lab ............................................. 307 Język Python w firmie Adaptive Lab .....................................................................................308 Projekt narzędzia SoMA ...........................................................................................................308 Zastosowana metodologia projektowa ..................................................................................309 Serwisowanie systemu SoMA ..................................................................................................309 Rada dla inżynierów z branży .................................................................................................310 Technika głębokiego uczenia prezentowana przez firmę RadimRehurek.com .................... 310 Strzał w dziesiątkę .....................................................................................................................311 Rady dotyczące optymalizacji ..................................................................................................313 Podsumowanie ...........................................................................................................................315 Uczenie maszynowe o dużej skali gotowe do zastosowań produkcyjnych w firmie Lyst.com .................................................................. 315 Rola języka Python w witrynie Lyst .......................................................................................316 Projekt klastra .............................................................................................................................316 Ewolucja kodu w szybko rozwijającej się nowej firmie ......................................................316 Budowanie mechanizmu rekomendacji .................................................................................316 Raportowanie i monitorowanie ...............................................................................................317 Rada ..............................................................................................................................................317 Analiza serwisu społecznościowego o dużej skali w firmie Smesh ........................................ 318 Rola języka Python w firmie Smesh .......................................................................................318 Platforma ......................................................................................................................................318 Dopasowywanie łańcuchów w czasie rzeczywistym z dużą wydajnością .....................319 Raportowanie, monitorowanie, debugowanie i wdrażanie ...............................................320 Interpreter PyPy zapewniający powodzenie systemów przetwarzania danych i systemów internetowych ............................................................................................................ 322 Wymagania wstępne .................................................................................................................322 Baza danych ................................................................................................................................323 Aplikacja internetowa ................................................................................................................323 Mechanizm OCR i tłumaczenie ...............................................................................................324 Dystrybucja zadań i procesy robocze .....................................................................................324 Podsumowanie ...........................................................................................................................325 Kolejki zadań w serwisie internetowym Lanyrd.com ............................................................... 325 Rola języka Python w serwisie Lanyrd ..................................................................................325 Zapewnianie odpowiedniej wydajności kolejki zadań .......................................................326 Raportowanie, monitorowanie, debugowanie i wdrażanie ...............................................326 Rada dla programistów z branży ............................................................................................326
Skorowidz ..................................................................................................................329
Spis treści
7
8
Spis treści
Przedmowa
Język Python jest łatwy do opanowania. Prawdopodobnie czytasz tę książkę, ponieważ kod działa już poprawnie, ale chcesz, by miał większą wydajność. Podoba Ci się to, że kod jest łatwy do zmodyfikowania, a ponadto możesz szybko korzystać z iteracji przy realizacji swoich pomysłów. Ogólnie znaną sytuacją, która często powoduje lament, jest trudność pogodzenia łatwości projektowania z wymaganą szybkością działania kodu. Istnieją rozwiązania tego problemu. Niektórzy używają procesów szeregowych, które muszą przebiegać szybciej. Inni zajmują się problemami, w przypadku których korzystne byłoby zastosowanie architektur wielordzeniowych, klastrów lub układów GPU. Część osób wymaga systemów skalowalnych, które bez obniżenia poziomu niezawodności mogą przetwarzać więcej lub mniej danych, zależnie od względów praktycznych lub dostępnych funduszy. Część osób w pewnym momencie uświadomi sobie, że techniki kodowania, z których korzysta (często zapożyczone z innych języków), być może nie są tak naturalne jak przykłady przedstawione przez innych. W książce zostaną omówione wszystkie te zagadnienia. W ramach praktycznego przewodnika będziesz mieć okazję zapoznać się z wąskimi gardłami, a także z tworzeniem szybszych i bardziej skalowalnych rozwiązań. W książce przedstawiono też kilka prawdziwych historii osób, które od dawna zajmują się kwestiami wydajności. Ci ludzie sporo już doświadczyli, a dzięki ich historiom nie będziesz musiał przechodzić przez to samo. Język Python jest odpowiednio przystosowany do szybkiego projektowania, wdrożeń produkcyjnych i systemów skalowalnych. Społeczność związana z tym językiem jest złożona z mnóstwa osób, które współpracują w celu zapewnienia jego skalowalności. Dzięki temu będziesz miał czas na skoncentrowanie się na zadaniach, które uznasz za większe wyzwanie.
Dla kogo jest przeznaczona ta książka? Prawdopodobnie języka Python używasz wystarczająco długo, aby zorientować się, dlaczego określone elementy są powolne, a także aby poznać takie technologie jak Cython, numpy i PyPy, które są omawiane w książce jako możliwe rozwiązania. Być może programowałeś też z wykorzystaniem innych języków, dlatego wiesz, że problem z wydajnością może zostać rozwiązany przy użyciu więcej niż jednego sposobu.
9
Choć ta książka jest kierowana przede wszystkim do osób zajmujących się problemami, do których rozwiązania stosuje się głównie procesory, zajmiemy się także rozwiązaniami opartymi na pamięci i transferze danych. Zwykle z takimi problemami mają do czynienia naukowcy, inżynierowie, eksperci od analizy i pracownicy akademiccy. Przyjrzymy się również problemom, z którymi może się zetknąć projektant aplikacji internetowych. Dotyczą one przenoszenia danych i użycia kompilatorów JIT (Just in Time), takich jak PyPy, w celu uzyskania w prosty sposób wzrostu wydajności. Choć pomocna może okazać się znajomość języka C (lub C++ albo być może Java), nie jest to wymaganie wstępne przy lekturze tej książki. Najpopularniejszy interpreter języka Python, czyli CPython (standardowo udostępniany po wpisaniu polecenia python w wierszu poleceń), napisano w języku C. W związku z tym mechanizmy przechwytywania komunikatów oraz biblioteki opierają się na wewnętrznych mechanizmach języka C. W książce omówiono jednak również wiele innych technik, w przypadku których nie jest zakładana znajomość języka C. Być może dysponujesz specjalistyczną wiedzą na temat procesora, architektury pamięci i magistral danych. Jednakże i taka wiedza nie jest absolutnie konieczna.
Dla kogo nie jest przeznaczona ta książka? Książkę przewidziano dla programistów używających języka Python, którzy mogą się pochwalić jego średnią lub zaawansowaną znajomością. Zmotywowani początkujący programiści również mogą być w stanie poradzić sobie z materiałem w niej zamieszczonym, zalecamy jednak solidne przygotowanie z zakresu języka Python. W książce nie jest omawiana optymalizacja dotycząca systemów przechowywania danych. Jeśli masz do czynienia z wąskim gardłem występującym w bazie danych SQL lub NoSQL, ta książka raczej nie będzie pomocna.
Czego się dowiesz? W ciągu wielu lat pracy zarówno w firmach z branży, jak i na uczelniach korzystaliśmy z dużych wolumenów danych, byliśmy konfrontowani z wymaganiami w rodzaju Chcę szybciej uzyskać odpowiedzi!, a także z potrzebą zapewnienia skalowalnych architektur. Spróbujemy podzielić się zdobytym ciężką pracą doświadczeniem, aby oszczędzić Ci popełniania błędów, jakie sami popełniliśmy. Na początku każdego rozdziału znajduje się lista pytań, na które powinieneś uzyskać odpowiedź po przeczytaniu treści rozdziału (jeśli tak nie będzie, poinformuj nas o tym, a wprowadzimy odpowiednie poprawki w następnym wydaniu!). W książce omówiono następujące zagadnienia: Mechanizmy komputera — podstawowe informacje na ten temat pozwolą Ci zoriento-
wać się, jakie procesy zachodzą w tle. Listy i krotki — poznasz subtelne różnice dotyczące semantyki i wydajności tych funda-
mentalnych struktur danych.
10
Przedmowa
Słowniki i zbiory — dowiesz się, jakie są strategie przydzielania pamięci i algorytmy do-
stępu w przypadku tych istotnych struktur danych. Iteratory — nauczysz się tworzyć je w sposób bardziej typowy dla języka Python, wyjaśni-
my także, jak za pomocą iteracji uzyskać dostęp do nieograniczonych strumieni danych. Metody oparte wyłącznie na kodzie Python — poznasz sposób efektywnego użycia języka
Python i jego modułów. Macierze w przypadku narzędzia numpy — zaprezentujemy sposób wykorzystania w pełni
możliwości naszej ulubionej biblioteki numpy. Kompilacja i obliczenia za pomocą kompilatorów JIT — omówimy szybsze przetwarza-
nie przez kompilowanie do postaci kodu maszynowego przy zapewnieniu, że działa się zgodnie z wynikami profilowania.
Współbieżność — zapoznasz się z metodami efektywnego przemieszczania danych. Moduł multiprocessing — poznasz różne sposoby użycia wbudowanej biblioteki multiprocessing
do przetwarzania równoległego, omówimy wydajne współużytkowanie macierzy narzędzia numpy oraz wady i zalety związane z zastosowaniem komunikacji międzyprocesowej IPC. Obliczenia klastrowe — nauczysz się przekształcania kodu modułu multiprocessing tak,
aby mógł zostać uruchomiony w klastrze lokalnym lub zdalnym zarówno w przypadku systemów badawczych, jak i produkcyjnych.
Użycie mniejszej ilości pamięci RAM — poznasz metody rozwiązywania poważnych
problemów bez konieczności nabywania komputera o bardzo dużej mocy obliczeniowej. Porady specjalistów z branży — przeczytasz rady zawarte w autentycznych historiach
osób, które wiele już doświadczyły, dzięki czemu nie będziesz zmuszony do przechodzenia przez to samo.
Język Python 2.7 Wersja 2.7 to najpowszechniej używana wersja języka Python w przypadku obliczeń naukowych i inżynieryjnych. W tym segmencie zastosowań przeważa technologia 64-bitowa, a także środowiska uniksowe (często system Linux lub Mac). 64-bitowość pozwala adresować większe ilości pamięci RAM. Systemy uniksowe umożliwiają tworzenie aplikacji, które mogą być wdrażane i konfigurowane z wykorzystaniem dobrze znanych metod i wzorców. Jeśli jesteś użytkownikiem systemu Windows, koniecznie zapnij pasy. Większość rozwiązań zaprezentowanych w książce zadziała bez żadnych problemów, niektóre elementy są jednak specyficzne dla systemu operacyjnego, dlatego będziesz musiał poszukać rozwiązania dla systemu Windows. Największą trudnością, z jaką może mieć do czynienia użytkownik systemu Windows, jest instalacja modułów: poszukiwania w serwisach takich jak StackOverflow powinny zakończyć się uzyskaniem wymaganych rozwiązań. Jeżeli korzystasz z systemu Windows, użycie maszyny wirtualnej (np. VirtualBox) z uruchomioną instalacją systemu Linux może być pomocne w bardziej swobodnym eksperymentowaniu. Użytkownicy systemu Windows powinni zdecydowanie przyjrzeć się wybranemu rozwiązaniu w postaci pakietu, podobnemu do udostępnianych za pośrednictwem dystrybucji Anaconda, Canopy, Python(x,y) lub Sage. Sprawią one także, że praca użytkowników systemów Linux i Mac będzie znacznie łatwiejsza.
Język Python 2.7
11
Przejście na język Python 3 Python 3 to przyszłość języka Python. Wszyscy przechodzą na tę wersję. Niemniej jednak język Python 2.7 będzie wykorzystywany przez wiele kolejnych lat (niektóre instalacje nadal używają języka Python 2.4 z roku 2004). Datę wycofania tej wersji języka Python ustalono na rok 2020. Przejście na język Python w wersji 3.3 lub nowszej przyniosło wystarczającą liczbę problemów twórcom bibliotek, gdyż wiele osób nie spieszy się z przenoszeniem swojego kodu (z uzasadnionego powodu). Oznacza to powolny proces adaptacji języka Python 3. W głównej mierze jest to wynikiem złożoności procesu przechodzenia z kombinacji typów danych Unicode i łańcuchowych w skomplikowanych aplikacjach do obecnej w języku Python 3 implementacji typów danych Unicode i bajtowych. Zazwyczaj gdy wymagane są wyniki możliwe do odtworzenia na podstawie zestawu zaufanych bibliotek, niewskazane jest bycie zależnym od niesprawdzonej najnowszej technologii. Twórcy bardzo wydajnego kodu Python będą prawdopodobnie przez kolejne lata korzystać z godnego zaufania języka Python 2.7. Większość kodu zamieszczonego w tej książce będzie działać w przypadku języka Python w wersji 3.3 lub nowszej po wprowadzeniu niewielkich zmian (najbardziej znacząca modyfikacja będzie dotyczyć instrukcji print, którą przekształcono w funkcję). W kilku miejscach ze szczególną uwagą przyjrzymy się ulepszeniom zapewnianym przez język Python w wersji 3.3 lub nowszej. Jedna z rzeczy, która może zaskoczyć, dotyczy tego, że w języku Python 2.7 znak / oznacza dzielenie liczb całkowitych, w języku Python 3 natomiast znak ten reprezentuje dzielenie liczb zmiennoprzecinkowych. Oczywiście będąc dobrym programistą, dysponujesz dobrze skonstruowanym pakietem testów jednostkowych, które będą testować ważne ścieżki kodu. Dzięki temu testy jednostkowe ostrzegą Cię w sytuacji, gdy będzie trzeba dokonać poprawek w kodzie. Począwszy od końca roku 2010, biblioteki scipy i numpy są zgodne z językiem Python 3. Biblioteka matplotlib jest z nim zgodna od roku 2012, biblioteka scikit-learn od roku 2013, a biblioteka NLTK od 2014. Biblioteka Django uzyskała zgodność w roku 2013. Uwagi dotyczące przejścia każdej biblioteki są dostępne w jej repozytoriach i grupach dyskusyjnych. Warto przejrzeć używane przez biblioteki procesy, jeśli planuje się migrację starszego kodu w celu zapewnienia zgodności z językiem Python 3. Zachęcamy do poeksperymentowania z językiem Python w wersji 3.3 lub nowszej dla nowych projektów. Trzeba jednak zachować ostrożność w przypadku bibliotek, które zostały przeniesione całkiem niedawno i mają niewielu użytkowników. Identyfikowanie błędów będzie w ich przypadku znacznie trudniejsze. Warto zapewnić zgodność z językiem Python w wersji 3.3 lub nowszej (dowiedz się więcej o importach __future__), ponieważ przyszła aktualizacja będzie wówczas łatwiejsza. Dwa dobre przewodniki to Porting Python 2 Code to Python 3 (https://docs.python.org/3/howto/ pyporting.html) i Porting to Python 3: An in-depth guide (http://python3porting.com/). W przypadku takich dystrybucji jak Anaconda lub Canopy możliwe jest jednoczesne użycie języka Python w wersjach 2 i 3. Uprości to przenoszenie.
12
Przedmowa
Licencja Książka objęta jest licencją Creative Commons Attribution-NonCommercial-NoDerivs 3.0 (http:// creativecommons.org/licenses/by-nc-nd/3.0/). Możesz swobodnie wykorzystywać tę książkę do celów niekomercyjnych, w tym do nauczania o takim charakterze. Licencja zezwala jedynie na kompletne reprodukcje. W przypadku częściowych reprodukcji prosimy skontaktować się z wydawnictwem. Prosimy utworzyć atrybucję w sposób opisany w następnym podrozdziale. Ustaliliśmy, że książka powinna zostać objęta licencją Creative Commons, aby jej treść mogła być dalej rozpowszechniana na całym świecie. Jeśli taka decyzja okazała się dla Ciebie pomocna, będziemy naprawdę szczęśliwi, jeśli postawisz nam piwo. Podejrzewamy, że pracownicy wydawnictwa też będą z tego zadowoleni.
Sposób tworzenia atrybucji Licencja Creative Commons wymaga atrybucji użycia fragmentu treści książki. Atrybucja oznacza po prostu, że należy dołączyć informację, która pozwoli innym osobom na znalezienie tej książki. Proponujemy dodanie następującego tekstu: Micha Gorelick, Ian Ozsvald, Python. Programuj szybko i wydajnie, Helion 2015.
Errata i opinie Zachęcamy do ocenienia książki w publicznych serwisach internetowych. Pomóż innym dowiedzieć się, czy skorzystają na kupnie tej książki! Możesz też napisać do nas na adres e-mail [email protected]. Szczególnie chętnie dowiemy się o błędach w książce, pomyślnych przypadkach użycia, w których książka okazała się pomocna, a także o technikach zapewniających dużą wydajność, jakie powinny zostać przez nas uwzględnione w następnym wydaniu.
Konwencje zastosowane w książce W książce użyto następujących konwencji typograficznych: Kursywa Wskazuje nowe terminy, adresy URL, adres e-mail, nazwy plików i ich rozszerzenia. Czcionka o stałej szerokości
Używana do wyróżnienia w akapitach poleceń, modułów i elementów programów, takich jak nazwy zmiennych lub funkcji, baz danych, typów danych, zmiennych środowiskowych, instrukcji i słów kluczowych.
Listing
Wyróżnia fragmenty kodu poza treścią akapitów.
Konwencje zastosowane w książce
13
Ta ikona oznacza pytanie lub ćwiczenie.
Ta ikona oznacza ogólną uwagę.
Ta ikona oznacza ostrzeżenie.
Użycie przykładowych kodów Dodatkowe materiały (przykłady z kodami, ćwiczenia itp.) są dostępne do pobrania pod adresem ftp://ftp.helion.pl/przyklady/pytpsw.zip. Książka ma na celu ułatwienie zrealizowania zadania. Ogólnie rzecz biorąc, jeśli kod przykładu zamieszczono w książce, możesz go użyć we własnych programach i dokumentacji. Nie musisz kontaktować się z nami w celu uzyskania zgody, chyba że reprodukujesz znaczną część kodu. Na przykład napisanie programu, który zawiera kilka fragmentów kodu z książki, nie wymaga zgody. Sprzedawanie lub dystrybucja dysku CD-ROM z przykładami z książek wydawnictwa wymaga zgody. Udzielanie odpowiedzi na pytanie w postaci zacytowania fragmentu z książki wraz z kodem przykładu nie wymaga zgody. Uwzględnienie znacznej części kodu przykładu z książki w dokumentacji własnego produktu wymaga zgody. Jeśli uznasz, że użycie przykładów z kodem wykracza poza dozwolony zakres lub wymienione przypadki konieczności wyrażenia zgody, możesz bez obaw skontaktować się z wydawnictwem.
Podziękowania Podziękowania dla Jake’a Vanderplasa, Briana Grangera, Dana Foremana-Mackeya, Kyrana Dale’a, Johna Montgomery’ego, Jamiego Matthewsa, Calvina Gilesa, Williama Wintera, Christiana Schou Oxviga, Balthazara Rouberola, Matta „snakesa” Reifersona, Patricka Coopera i Michaela Skirpana za bezcenne uwagi i zaangażowanie. Ian dziękuje swojej żonie Emily za pozwolenie, by zniknął na 10 miesięcy w celu napisania tej książki (na szczęście jest szalenie wyrozumiała). Micha dziękuje Elaine i reszcie swoich znajomych oraz rodzinie za wielką cierpliwość w czasie, gdy uczył się pisać książkę. Również pracownikom wydawnictwa O’Reilly — współpraca z nimi była przyjemnością. Osoby zaangażowane w tworzenie rozdziału 12., „Rady specjalistów z branży”, były na tyle uprzejme, by podzielić się swoim czasem i wiedzą zdobytą ciężką pracą. Za poświęcony czas i trud dziękujemy następującym osobom: Benowi Jacksonowi, Radimowi Řehůřkowi, Sebastjanowi Trebce, Alexowi Kelly’emu, Markowi Tasicowi i Andrew Godwinowi.
14
Przedmowa
ROZDZIAŁ 1.
Wydajny kod Python
Pytania, na jakie będziesz w stanie udzielić odpowiedzi po przeczytaniu rozdziału Jakie są składniki architektury komputerowej? Jakie są typowe alternatywne architektury komputerowe? Jak w języku Python przeprowadzana jest abstrakcja bazowej architektury kom-
puterowej? Jakie są przeszkody na drodze do uzyskania wydajnego kodu Python? Jakie są różne typy problemów dotyczących wydajności?
Tworzenie oprogramowania dla komputerów można zobrazować jako proces przemieszczania porcji danych i przekształcania ich przy użyciu specjalnych sposobów, aby osiągnąć konkretny rezultat. Działania te wiążą się jednak z kosztem w postaci czasu. Oznacza to, że tworzenie oprogramowania o dużej wydajności polega na minimalizacji opisanych działań przez redukowanie powodowanego przez nie obciążenia (tj. przez pisanie bardziej wydajnego kodu) lub zmianę metody wykonywania działań w celu sprawienia, że każde z nich będzie bardziej konstruktywne (tj. znajdowanie bardziej odpowiedniego algorytmu). Skoncentrujmy się na zredukowaniu obciążenia powodowanego przez kod, aby bliżej zaznajomić się z samymi urządzeniami, które są wykorzystywane do przemieszczania porcji danych. Może się wydawać, że będzie to daremny trud, ponieważ w języku Python w celu ukrycia bezpośrednich interakcji ze sprzętem w dużym zakresie stosuje się abstrakcję. Zrozumienie najlepszej metody przemieszczania bitów danych w urządzeniach oraz sposobów wymuszania przemieszczania bitów przez abstrakcje w języku Python pozwoli jednak na pisanie w tym języku lepszych programów o dużej wydajności.
Podstawowy system komputerowy Opis bazowych elementów tworzących komputery można uprościć przez zaklasyfikowanie ich do trzech podstawowych grup: jednostek obliczeniowych, jednostek pamięci i połączeń między pierwszymi dwiema grupami. Każda z tych grup ma różne cechy, które umożliwiają
15
jej zrozumienie. Jednostka obliczeniowa cechuje się liczbą obliczeń, jakie może wykonać w ciągu sekundy. Jednostka pamięci wyróżnia się ilością danych, jakie może przechowywać, a także szybkością odczytu i zapisu danych. Z kolei połączenia są określone przez to, jak szybko mogą przemieszczać dane z jednego miejsca w drugie. Skoro wymieniliśmy już bloki konstrukcyjne, możemy omówić standardową stację roboczą na wielu poziomach zaawansowania. Taka stacja robocza może zawierać na przykład centralną jednostkę obliczeniową CPU (Central Processing Unit) połączoną z pamięcią RAM (Random Access Memory) i dyskiem twardym jako dwiema osobnymi jednostkami pamięci (każda z nich cechuje się różnymi pojemnościami oraz szybkościami odczytu/zapisu) oraz magistralę, która zapewnia połączenia między wszystkimi tymi komponentami. Można to jednak bardziej sprecyzować. Okaże się, że sam procesor CPU zawiera kilka jednostek pamięci. Są to pamięci podręczne L1, L2, a czasem nawet L3 i L4, które są bardzo szybkie, choć mają niewielkie pojemności (pojemność wynosi od kilku kilobajtów do tuzina megabajtów). Te dodatkowe jednostki pamięci są połączone z procesorem CPU za pomocą specjalnej magistrali określanej mianem magistrali BSB (Backside Bus). Co więcej, nowe architektury komputerowe oferują zwykle nowe konfiguracje (np. w przypadku procesorów Nehalem firmy Intel magistralę FSB zastąpiono technologią Intel QuickPath Interconnect, a ponadto przebudowano wiele połączeń). W obu przedstawionych ogólnych wariantach stacji roboczych nie doceniono znaczenia połączenia sieciowego, które może być bardzo wolne, z potencjalnymi wieloma innymi jednostkami obliczeniowymi i jednostkami pamięci! Aby ułatwić rozwikłanie tych różnych zawiłości, dokonajmy krótkiego przeglądu wymienionych podstawowych bloków.
Jednostki obliczeniowe Jednostka obliczeniowa komputera w największym stopniu stanowi o jego przydatności. Zapewnia ona możliwość przekształcenia dowolnych odebranych bitów w inne bity lub zmiany stanu bieżącego procesu. Jednostki CPU to najpowszechniej używane jednostki obliczeniowe. Graficzne jednostki obliczeniowe GPU (Graphics Processing Unit), które pierwotnie były zwykle wykorzystywane do przyspieszania grafiki komputerowej, a obecnie coraz częściej są stosowane na potrzeby aplikacji numerycznych, zyskują jednak na popularności. Wynika to z ich naturalnych możliwości przetwarzania równoległego, które pozwala na jednoczesne przeprowadzenie wielu obliczeń. Niezależnie od typu jednostka obliczeniowa pobiera zestaw bitów (np. reprezentujących liczby) i zwraca kolejny zestaw bitów (np. reprezentujących sumę tych liczb). Oprócz podstawowych operacji arytmetycznych na liczbach całkowitych i rzeczywistych oraz operacji bitowych na liczbach binarnych niektóre jednostki obliczeniowe zapewniają też bardzo specjalistyczne operacje. Przykładem jest operacja FMA (Fused Multiply Add), która pobiera trzy liczby A, B i C, a następnie zwraca wartość A * B + C. Podstawowe interesujące nas cechy jednostki obliczeniowej to liczba operacji, jaką może ona wykonać w jednym cyklu, a także liczba cykli możliwych do zrealizowania w ciągu sekundy. Pierwsza wartość jest mierzona za pomocą instrukcji przypadających na cykl (IPC — Instructions Per Cycle)1, natomiast druga wartość jest określana przy użyciu szybkości zegara jednostki. Podczas projektowania nowych jednostek obliczeniowych tym dwóm parametrom zawsze 1
Nie należy mylić z komunikacją międzyprocesową, w przypadku której używany jest taki sam skrót IPC (Inter-Process Communication). Zagadnienie to zostanie omówione w rozdziale 9.
16
Rozdział 1. Wydajny kod Python
towarzyszy rywalizacja. Na przykład procesory z serii Intel Core mają bardzo wysoką wartość IPC, ale mniejszą szybkość zegara. W przypadku układu Pentium 4 wygląda to odwrotnie. Z kolei jednostki GPU cechują się bardzo wysoką wartością IPC i szybkością zegara, ale dotyczą ich inne problemy, które zostaną dalej przybliżone. Choć zwiększanie szybkości zegara powoduje prawie natychmiastowe przyspieszenie wszystkich programów działających na danej jednostce obliczeniowej (ze względu na możliwość wykonania w ciągu sekundy większej liczby obliczeń), wyższa wartość IPC może też w znacznym stopniu wpłynąć na przetwarzanie przez zmianę możliwego poziomu wektoryzacji. Wektoryzacja ma miejsce wtedy, gdy jednostka CPU otrzymuje jednocześnie wiele porcji danych i ma możliwość działania na nich wszystkich w tym samym czasie. Tego rodzaju instrukcja procesora określana jest mianem instrukcji SIMD (Single Instruction, Multiple Data). Generalnie rzecz biorąc, w ciągu minionej dekady jednostki obliczeniowe były dość wolno rozwijane (rysunek 1.1). Szybkości zegara i wartość IPC nie zmieniały się znacząco z powodu fizycznych ograniczeń związanych z wytwarzaniem coraz mniejszych tranzystorów. W efekcie producenci układów bazowali na innych metodach zwiększania szybkości, w tym na wielowątkowości współbieżnej, bardziej inteligentnym wykonywaniu nieuporządkowanym i architekturach wielordzeniowych.
Rysunek 1.1. Zmiana szybkości zegara procesorów z upływem czasu (dane pochodzą z serwisu CPU DB (http://cpudb.stanford.edu/))
Podstawowy system komputerowy
17
Wielowątkowość współbieżna zapewnia systemowi operacyjnemu hosta drugi wirtualny procesor CPU. Bardziej inteligentna logika sprzętowa próbuje przeplatać dwa wątki instrukcji w jednostkach wykonawczych jednego procesora CPU. W przypadku powodzenia operacji możliwe jest osiągnięcie wzrostu wydajności w porównaniu z pojedynczym wątkiem. Wzrost ten wynosi nawet 30%. Zwykle sprawdza się to, gdy jednostki robocze w obu wątkach korzystają z różnych typów jednostki wykonawczej (na przykład jeden wątek wykonuje operacje zmiennoprzecinkowe, a drugi wątek realizuje operacje całkowitoliczbowe). Wykonywanie nieuporządkowane umożliwia kompilatorowi zidentyfikowanie wybranych części liniowych sekwencji programu, które nie są zależne od wyniku poprzedniego zadania roboczego. Dzięki temu dwa zadania robocze mogą potencjalnie wystąpić w dowolnej kolejności lub jednocześnie. Dopóki wyniki sekwencyjne są prezentowane w odpowiednim momencie, program poprawnie kontynuuje wykonywanie, nawet pomimo tego, że zadania robocze są przetwarzane bez zachowania kolejności określonej programowo. Umożliwia to wykonanie niektórych instrukcji, gdy inne mogą być blokowane (podczas oczekiwania na dostęp do pamięci). Dzięki temu możliwe jest lepsze ogólne wykorzystanie dostępnych zasobów. Z punktu widzenia programisty tworzącego kod na wyższym poziomie najważniejsza jest wszechobecność architektur wielordzeniowych. Uwzględniają one wiele procesorów w tej samej jednostce. Zwiększa to ogólne możliwości bez zbliżania się do ograniczeń związanych z przyspieszaniem każdej jednostki z osobna. Z tego właśnie powodu trudno obecnie znaleźć jakikolwiek komputer wyposażony w mniej niż dwa rdzenie (w tym przypadku komputer zawiera dwie fizyczne jednostki obliczeniowe, które są ze sobą połączone). Choć powoduje to zwiększenie łącznej liczby operacji możliwych do wykonania w ciągu sekundy, wprowadza trudności związane z pełnym jednoczesnym wykorzystaniem obu jednostek. Zwykłe dodanie większej liczby rdzeni do procesora nie zawsze powoduje skrócenie czasu wykonania programu. Wynika to z czegoś, co określane jest mianem prawa Amdahla. Mówiąc wprost, prawo to głosi, że jeśli program zaprojektowany do działania w wielu rdzeniach zawiera procedury, które wymagają uruchomienia tylko w jednym rdzeniu, będzie to wąskie gardło dla ostatecznego przyspieszenia możliwego do osiągnięcia przez przydzielenie większej liczby rdzeni. Jeśli na przykład miałaby zostać przeprowadzona ankieta ze stoma osobami, której wypełnienie zajęłoby minutę, zadanie to mogłoby zostać ukończone w ciągu 100 minut, gdyby pytania zadawała jedna osoba (czyli osoba ta udałaby się do uczestnika nr 1, zadała mu pytania, poczekała na odpowiedzi, a następnie udała się do uczestnika nr 2). Analogią do takiego wariantu przeprowadzania ankiety z jedną osobą zadającą pytania i czekającą na odpowiedzi jest proces szeregowy. W przypadku takich procesów operacje są realizowane po jednej naraz. Każda operacja czeka na zakończenie poprzedniej operacji. Możliwe byłoby jednak przeprowadzenie ankiety w sposób równoległy, gdyby pytania były zadawane przez dwie osoby. Pozwoliłoby to zakończyć cały proces w zaledwie 50 minut. Jest to możliwe, ponieważ każda osoba zadająca pytania nie musi niczego wiedzieć o drugiej osobie, która zadaje pytania. W rezultacie zadanie z łatwością może zostać podzielone bez istnienia żadnej zależności między osobami zadającymi pytania. Dodanie większej liczby osób zadających pytania zapewni dodatkowe skrócenie czasu. Będzie tak do momentu zaangażowania stu osób zadających pytania. W tym momencie cały proces zająłby minutę i byłby ograniczony jedynie przez czas, jaki zajmie uczestnikowi udzielenie odpowiedzi. Dodanie większej liczby osób, które zadają pytania, w żadnym stopniu nie przyspieszy
18
Rozdział 1. Wydajny kod Python
dodatkowo procesu, ponieważ nie będą one miały żadnych zadań do wykonania — wszystkim uczestnikom są już zadawane pytania! Na tym etapie jedyną metodą skrócenia ogólnego czasu przeprowadzania ankiety jest zredukowanie czasu, jaki zajmuje wypełnienie jednej ankiety (część problemu związana z procesem szeregowym). Podobnie jest w przypadku procesorów. W razie potrzeby możliwe jest dodanie większej liczby rdzeni, które mogą zajmować się różnymi zadaniami obliczeniowymi. Można to robić do momentu, w którym pojawi się wąskie gardło w postaci konkretnego rdzenia kończącego swoje zadanie. Innymi słowy, wąskie gardło w dowolnym przetwarzaniu równoległym zawsze ma postać mniejszych zadań szeregowych, które są rozdzielane. Co więcej, poważną przeszkodą związaną z wykorzystaniem wielu rdzeni w kodzie Python jest stosowanie przez język Python globalnej blokady interpretera GIL (Global Interpreter Lock). Blokada powoduje, że proces Python może naraz uruchomić tylko jedną instrukcję, niezależnie od liczby aktualnie używanych rdzeni. Oznacza to, że nawet pomimo tego, że część kodu Python ma w tym samym czasie dostęp do wielu rdzeni, w danej chwili instrukcja kodu Python jest wykonywana tylko przez jeden rdzeń. W przypadku zaprezentowanego wcześniej przykładu ankiety oznaczałoby to, że jeśli nawet zatrudniono by stu ankieterów, w danym momencie tylko jeden z nich mógłby zadać pytanie i wysłuchać odpowiedzi. Powoduje to utratę wszelkiego rodzaju korzyści wynikających z zaangażowania wielu ankieterów! Choć może to wyglądać na sporą przeszkodę, zwłaszcza biorąc pod uwagę to, że obecnym trendem w przetwarzaniu jest stosowanie wielu jednostek obliczeniowych, a nie szybszych jednostek, problemu tego można uniknąć dzięki wykorzystaniu innych standardowych narzędzi biblioteki (np. multiprocessing) lub technologii (np. numexpr, Cython albo rozproszone modele obliczeniowe).
Jednostki pamięci Jednostki pamięci w komputerach są używane do przechowywania bitów. Mogą to być bity reprezentujące zmienne w programie lub piksele obrazu. A zatem abstrakcja jednostki pamięci dotyczy rejestrów na płycie głównej, a także pamięci RAM i dysku twardego. Podstawową różnicą między wszystkimi tego typu jednostkami pamięci jest szybkość, z jaką są odczytywane lub zapisywane dane. Aby wszystko jeszcze bardziej skomplikować, szybkość odczytu/zapisu w dużym stopniu zależy od sposobu odczytywania danych. Na przykład większość jednostek pamięci działa znacznie lepiej, gdy wczytuje jedną dużą porcję danych zamiast wielu małych porcji (w odniesieniu do tego używane są pojęcia odczytu sekwencyjnego i danych losowych). Jeśli dane w takich jednostkach pamięci potraktuje się jak strony w pokaźnej książce, będzie to oznaczać, że większość jednostek pamięci oferuje większą szybkość odczytu/zapisu podczas przetwarzania książki strona po stronie niż w przypadku ciągłego przeskakiwania od jednej losowej strony do kolejnej. Chociaż generalnie dotyczy to wszystkich jednostek pamięci, skala oddziaływania dla poszczególnych typów jednostek jest diametralnie różna. Oprócz szybkości odczytu/zapisu jednostki pamięci cechują się też opóźnieniem, które można opisać jako czas, jakiego urządzenie potrzebuje na znalezienie używanych danych. W przypadku obracającego się dysku twardego opóźnienie może być duże, ponieważ dysk fizycznie musi osiągnąć zadaną prędkość obrotową, a głowica odczytująca musi przemieścić się do właściwego położenia. Z kolei w przypadku pamięci RAM opóźnienie może być niewielkie, gdyż w całości jest ona urządzeniem typu SS (Solid State). Oto krótki opis różnych jednostek pamięci, które są powszechnie spotykane w standardowej stacji roboczej (wymieniono w kolejności rosnącej szybkości odczytu/zapisu): Podstawowy system komputerowy
19
Obracający się dysk twardy Stosowany od dawna magazyn danych zachowywanych nawet po wyłączeniu komputera. Ogólnie rzecz biorąc, oferuje niewielkie szybkości odczytu/zapisu, ponieważ wymaga fizycznego uzyskania prędkości obrotowej i przemieszczenia głowicy. W przypadku wzorców dostępu losowego spada wydajność dysków twardych, ale mają one bardzo dużą pojemność (liczoną w terabajtach). Dysk twardy SSD (Solid State Drive) Urządzenie podobne do obracającego się dysku twardego, które oferuje większe szybkości odczytu/zapisu, ale z mniejszą pojemnością (liczoną w gigabajtach). Pamięć RAM Używana do przechowywania kodu i danych aplikacji (np. wszystkich wykorzystywanych zmiennych). Choć pamięć RAM cechuje się krótkim czasem odczytu/zapisu, a ponadto sprawdza się dobrze w przypadku wzorców dostępu losowego, generalnie ma ograniczoną pojemność (liczoną w gigabajtach). Pamięć podręczna L1/L2 Pamięć oferująca wyjątkowo duże szybkości odczytu/zapisu. Dane kierowane do procesora muszą po drodze trafić do tej pamięci. Pamięć ta ma bardzo niewielką pojemność (wyrażaną w kilobajtach). Na rysunku 1.2 przedstawiono graficzną reprezentację różnic między tymi typami jednostek pamięci, analizując parametry dostępnego aktualnie sprzętu konsumenckiego.
Rysunek 1.2. Wartości parametrów dla różnych typów jednostek pamięci (dane pochodzą z lutego 2014 r.)
20
Rozdział 1. Wydajny kod Python
Widoczny wyraźnie trend pokazuje, że szybkości odczytu/zapisu oraz pojemność są odwrotnie proporcjonalne. Przy zwiększaniu szybkości zmniejsza się pojemność. Z tego powodu wiele systemów stosuje w przypadku pamięci metodę warstwową: na początku dane w całości znajdują się na dysku twardym, ich część przenoszona jest do pamięci RAM, a następnie znacznie mniejszy podzbiór danych trafia do pamięci podręcznej L1/L2. Metoda warstwowa umożliwia programom utrzymywanie pamięci w różnych miejscach w zależności od wymagań dotyczących czasu dostępu. Przy podejmowaniu próby optymalizacji wzorców pamięci programu po prostu optymalizowane jest to, jakie dane są umieszczane w jakim miejscu, w jaki sposób są one rozmieszczane (w celu zwiększenia liczby odczytów sekwencyjnych), a także ile razy dane są przemieszczane między różnymi miejscami. Ponadto metody takie jak asynchroniczne wejście-wyjście i buforowanie z wywłaszczeniem bez konieczności marnowania dodatkowego czasu procesora pozwalają zapewnić, że dane zawsze będą tam, gdzie są wymagane. Większość takich procesów może odbywać się niezależnie podczas wykonywania innych obliczeń!
Warstwy komunikacji Przyjrzyjmy się jeszcze temu, jak przedstawione wcześniej podstawowe bloki komunikują się ze sobą. Wprawdzie istnieje wiele różnych trybów komunikacji, ale wszystkie są wariantami tego, co jest określane mianem magistrali. Na przykład magistrala FSB (Frontside Bus) to połączenie między pamięcią RAM i pamięcią podręczną L1/L2. Służy ona do przemieszczania danych gotowych do przekształcenia przez procesor do postaci pozwalającej na rozpoczęcie obliczeń, a także do przesyłania wyników po ich zakończeniu. Istnieją również inne magistrale, takie jak magistrala zewnętrzna pełniąca rolę głównej trasy prowadzącej od urządzeń (np. dyski twarde i karty sieciowe) do procesora i pamięci systemowej. Taka magistrala jest zwykle wolniejsza od magistrali FSB. Okazuje się, że wiele zalet pamięci podręcznej L1/L2 może być związanych z szybszą magistralą. Możliwość kolejkowania w wolnej magistrali (między pamięcią podręczną i procesorem) danych wymaganych do obliczeń w postaci dużych porcji, a następnie udostępnianie ich przy bardzo dużych szybkościach z poziomu magistrali BSB (Backside Bus), umożliwia procesorowi wykonanie większej liczby obliczeń bez czekania przez długi czas. Wiele mankamentów związanych z użyciem układu GPU wynika z korzystania z magistrali, z którą jest on połączony. Ponieważ GPU to zazwyczaj urządzenie peryferyjne, komunikuje się za pośrednictwem magistrali PCI, która jest znacznie wolniejsza niż magistrala FSB. W rezultacie pobieranie danych z układu GPU i wysyłanie ich do niego może być dość obciążającą operacją. Pojawienie się obliczeń heterogenicznych lub bloków obliczeniowych, które zawierają w magistrali FSB układy CPU i GPU, ma na celu zmniejszenie obciążenia związanego z transferem danych oraz zwiększenie możliwości stosowania do obliczeń układu GPU, nawet w sytuacji, gdy konieczne jest przesłanie dużej ilości danych. Oprócz bloków komunikacji wewnątrz komputera rolę kolejnego takiego bloku może pełnić sieć. W porównaniu z wcześniej omówionymi blokami ten blok jest jednak znacznie bardziej elastyczny. Urządzenie sieciowe może zostać połączone z urządzeniem pamięciowym, takim jak magazyn danych NAS (Network Attached Storage), lub z innym blokiem obliczeniowym, tak jak w przypadku węzła obliczeniowego w klastrze. Komunikacja sieciowa jest jednak zwykle znacznie wolniejsza od innych, wcześniej opisanych typów komunikacji. Magistrala FSB może przesyłać dziesiątki gigabitów w ciągu sekundy, sieć natomiast jest ograniczona do transferów rzędu kilkudziesięciu megabitów. Podstawowy system komputerowy
21
Jasne jest zatem, że podstawową zaletą magistrali jest jej szybkość, która określa, ile danych może zostać przemieszczonych w danym czasie. Cecha ta stanowi połączenie dwóch wielkości: ilości danych przemieszczanych w ramach jednej operacji transferu (szerokość magistrali) i liczby transferów możliwych w ciągu sekundy (częstotliwość magistrali). Godne uwagi jest to, że dane przesyłane w jednym transferze zawsze są sekwencyjne. Porcja danych odczytywana jest z pamięci i przemieszczana w inne miejsce. Oznacza to, że szybkość magistrali jest rozbijana na te dwie wielkości, ponieważ osobno mogą one mieć wpływ na różne aspekty związane z obliczeniami. Duża szerokość magistrali może ułatwić działanie kodu z wektoryzacją (lub dowolnego kodu, który dokonuje sekwencyjnego odczytu z pamięci), umożliwiając przesłanie wszystkich odpowiednich danych w ramach jednej operacji transferu. Z kolei magistrala o małej szerokości, lecz bardzo dużej częstotliwości transferów może ułatwić wykonywanie kodu, który musi wykonywać wiele odczytów z losowych obszarów pamięci. Interesujące jest to, że jedna z metod modyfikowania tych cech przez projektantów komputerów polega na wprowadzaniu zmian w fizycznym układzie elementów płyty głównej. Gdy układy zostaną umieszczone bliżej siebie, krótsze będą fizyczne połączenia między nimi. Pozwala to uzyskać większe szybkości transferów. Ponadto sama liczba połączeń określa szerokość magistrali (dzięki temu to pojęcie zyskuje realne znaczenie). Ponieważ interfejsy mogą być dostrajane w celu zaoferowania właściwej wydajności na potrzeby konkretnego zastosowania, nie jest zaskoczeniem, że istnieją setki różnych ich typów. Na rysunku 1.3 (uzyskanym z serwisu Wikimedia Commons (http://commons.wikimedia.org/wiki/Main_Page)) pokazano szybkości transmisji danych dla próbkowania typowych interfejsów. Zauważ, że informacje te wcale nie dotyczą opóźnienia połączeń, które określa, ile czasu zajmie utworzenie odpowiedzi dla żądania dotyczącego danych (choć opóźnienie w bardzo dużym stopniu zależy od komputera, istnieją zasadnicze ograniczenia, które są powiązane z używanymi interfejsami).
Łączenie ze sobą podstawowych elementów Zrozumienie, jakie są podstawowe komponenty komputerów, nie wystarczy do pełnego zaznajomienia się z problemami związanymi z programowaniem pod kątem dużej wydajności. Wzajemna zależność wszystkich tych komponentów oraz sposób ich współpracy w celu rozwiązania problemu wprowadzają dodatkowe poziomy złożoności. W tym podrozdziale zostaną omówione uproszczone problemy, które ilustrują, jak działałyby idealne rozwiązania, a także w jaki sposób z problemami radzi sobie język Python. Ostrzeżenie: ten podrozdział może sprawiać wrażenie przygnębiającego — większość uwag wydaje się wskazywać, że język Python z założenia nie jest w stanie poradzić sobie z problemami dotyczącymi wydajności. Nie jest to prawdą z dwóch powodów. Po pierwsze, w przypadku tych wszystkich „elementów wydajnego przetwarzania” nie doceniono bardzo istotnego „czynnika”, a mianowicie programisty. To, czego w kwestii wydajności standardowo język Python może być pozbawiony, od razu nadrabia dzięki szybkości tworzenia kodu. Po drugie, w książce zostaną przedstawione moduły i idee, które bez problemu mogą ułatwić ograniczenie skali wielu opisanych tutaj trudności. Po uwzględnieniu obu tych aspektów będziemy w stanie zachować możliwości szybkiego programowania w języku Python przy jednoczesnym usunięciu wielu ograniczeń dotyczących wydajności.
22
Rozdział 1. Wydajny kod Python
Rysunek 1.3. Szybkości połączeń różnych typowych interfejsów (obraz autorstwa Leadbuffalo (http://en.wikipedia.org/wiki/File:Speeds_of_common_interfaces.svg) [CC BY-SA 3.0])
Porównanie wyidealizowanego przetwarzania z maszyną wirtualną języka Python Aby lepiej zrozumieć szczegóły programowania pod kątem dużej wydajności, przyjrzyjmy się przykładowi prostego kodu, który sprawdza, czy dana liczba to liczba pierwsza: import math def check_prime(number): sqrt_number = math.sqrt(number) number_float = float(number) for i in xrange(2, int(sqrt_number)+1): if (number_float / i).is_integer(): return False return True print "check_prime(10000000) = ", check_prime(10000000) # False print "check_prime(10000019) = ", check_prime(10000019) # True
Łączenie ze sobą podstawowych elementów
23
Przeanalizujmy ten kod za pomocą abstrakcyjnego modelu obliczeniowego, a następnie porównajmy z tym, co ma miejsce podczas wykonywania tego kodu przez interpreter języka Python. Podobnie jak w przypadku dowolnej abstrakcji, pominiemy wiele subtelności dotyczących zarówno wyidealizowanego komputera, jak i sposobu wykonywania kodu przez interpreter języka Python. Ogólnie rzecz biorąc, jest to jednak odpowiednie ćwiczenie do wykonania przed rozwiązaniem problemu: pomyśl o ogólnych elementach algorytmu i zastanów się, jaki będzie najlepszy sposób połączenia ze sobą elementów obliczeniowych w celu znalezienia rozwiązania. Zrozumienie takiej idealnej sytuacji i uzyskanie wiedzy o tym, co w rzeczywistości ma miejsce wewnątrz interpretera języka Python, pozwoli w iteracyjny sposób zbliżyć tworzony kod Python do optymalnego kodu.
Wyidealizowane przetwarzanie Po rozpoczęciu wykonywania kodu w pamięci RAM przechowywana jest wartość zmiennej number. W celu obliczenia wartości zmiennych sqrt_number i number_float konieczne jest wysłanie tej wartości do procesora. W idealnej sytuacji możliwe by było jednokrotne wysłanie wartości, które zostałyby zapisane w pamięci podręcznej L1/L2 procesora. Procesor przeprowadziłby obliczenia, a następnie wysłał wartości z powrotem do pamięci RAM w celu ich zapisania. Taki scenariusz jest idealny, ponieważ zminimalizowana zostałaby liczba odczytów wartości zmiennej number z pamięci RAM, a zamiast tego zdecydowalibyśmy się na opcję odczytów z pamięci podręcznej L1/L2, co jest znacznie szybsze. Co więcej, zminimalizowalibyśmy liczbę transferów danych za pośrednictwem magistrali FSB, wybierając opcję komunikacji przy użyciu szybszej magistrali BSB (łączy różne pamięci podręczne z procesorem). Taka metoda utrzymywania danych tam, gdzie są potrzebne, oraz ograniczenie ich przesyłania, odgrywa bardzo ważną rolę w przypadku optymalizacji. Pojęcie „ciężkich danych” (ang. heavy data) odnosi się do tego, że przemieszczanie danych zajmuje czas i zasoby, a tego chcielibyśmy uniknąć. Zamiast w przypadku pętli zawartej w kodzie wysyłać do procesora w danej chwili jedną wartość zmiennej i, bardziej pożądane będzie jednoczesne wysłanie zmiennej number_float oraz kilku wartości zmiennej i w celu ich sprawdzenia. Jest to możliwe, ponieważ procesor wektoryzuje operacje bez ponoszenia dodatkowego kosztu w postaci czasu. Oznacza to, że w tym samym czasie procesor może wykonywać wiele niezależnych obliczeń. A zatem chcemy wysłać do pamięci podręcznej procesora zmienną number_float, a także taką liczbę wartości zmiennej i, jaką może pomieścić ta pamięć. Dla każdej pary zmiennych number_float i i zostanie wykonane dzielenie i sprawdzenie, czy wynik jest liczbą całkowitą. Następnie zostanie wysłany z powrotem sygnał wskazujący, czy dowolna z wartości rzeczywiście okazała się liczbą całkowitą. Jeśli tak, funkcja zostanie zakończona. W przeciwnym razie operacja zostanie powtórzona. Dzięki temu dla wielu wartości zmiennej i konieczne jest zwrócenie tylko jednego wyniku, co eliminuje uzależnienie od wolnej magistrali dla każdej wartości. W tym przypadku wykorzystywana jest możliwość wektoryzacji obliczenia przez procesor lub uruchomienia jednej instrukcji dla wielu danych w jednym cyklu zegarowym. Pojęcie wektoryzacji zostało zilustrowane w następującym kodzie: import math def check_prime(number): sqrt_number = math.sqrt(number) number_float = float(number) numbers = range(2, int(sqrt_number)+1) for i in xrange(0, len(numbers), 5):
24
Rozdział 1. Wydajny kod Python
# poniższy wiersz nie zawiera poprawnego kodu Python result = (number_float / numbers[i:(i+5)]).is_integer() if any(result): return False return True
W kodzie określono obliczenia, w przypadku których dzielenie i sprawdzanie liczb całkowitych realizowane jest jednocześnie dla zestawu pięciu wartości zmiennej i. Jeśli dokonano poprawnej wektoryzacji, procesor może wykonać te operacje w jednym kroku, zamiast przeprowadzać osobne obliczenie dla każdej wartości zmiennej i. W idealnej sytuacji operacja any(result) wystąpiłaby w procesorze bez konieczności przesyłania wyników z powrotem do pamięci RAM. W rozdziale 6. w szerszym zakresie zostanie omówiona wektoryzacja, sposób jej działania, a także to, kiedy przynosi korzyści w kodzie.
Maszyna wirtualna języka Python Interpreter języka Python wykonuje wiele działań, aby podjąć próbę utworzenia abstrakcji używanych bazowych elementów obliczeniowych. Programista wcale nie musi przejmować się przydzielaniem pamięci tablicom, sposobem zorganizowania tej pamięci lub tym, w jakiej kolejności dane są wysyłane do procesora. Jest to zaleta języka Python, gdyż pozwala skoncentrować się na implementowanych algorytmach. Kosztem tego jest jednak spory spadek wydajności. Ważne jest zdanie sobie sprawy z tego, że w zasadzie interpreter języka Python to tak naprawdę działający zestaw bardzo dobrze zoptymalizowanych instrukcji. Zastosowany zabieg polega jednak na tym, że w tym języku instrukcje te są wykonywane w odpowiedniej kolejności w celu osiągnięcia lepszej wydajności. Dobrze widać to w poniższym przykładzie, w którym funkcja search_fast zadziała szybciej niż funkcja search_slow, nawet pomimo tego, że podczas działania obie mają złożoność obliczeniową O(n). Wynika to po prostu stąd, że pierwsza z funkcji pomija zbędne obliczenia, które wiążą się z tym, że wykonywanie pętli nie zostało wcześniej zakończone. 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
Identyfikowanie obszarów kodu o małej wydajności za pomocą profilowania i znajdowania bardziej efektywnych metod przeprowadzania tych samych obliczeń przypomina wyszukiwanie tych bezwartościowych operacji i usuwanie ich. Końcowy rezultat jest identyczny, ale znacznie zmniejszona zostaje liczba obliczeń i transferów danych. Jednym z efektów użycia takiej warstwy abstrakcji jest to, że wektoryzacja nie jest od razu uzyskiwana. Zamiast połączenia kilku iteracji w przykładzie kodu sprawdzającego początkową liczbę pierwszą dla wartości zmiennej i zostanie wykonana jedna iteracja pętli. Gdy jednak przyjrzysz się przykładowi abstrakcji wektoryzacji, stwierdzisz, że nie jest to poprawny kod Python, ponieważ nie jest możliwe dzielenie liczby zmiennoprzecinkowej przy użyciu listy. Zewnętrzne biblioteki, takie jak numpy, okażą się pomocne w takiej sytuacji, zapewniając możliwość wykonywania wektoryzowanych operacji matematycznych. Łączenie ze sobą podstawowych elementów
25
Co więcej, abstrakcja w języku Python ma negatywny wpływ na wszelkie optymalizacje, które bazują na wypełnianiu pamięci podręcznej L1/L2 odpowiednimi danymi na potrzeby następnego obliczenia. Wynika to z wielu czynników, z których najważniejszym jest to, że obiekty Python nie są rozmieszczone w pamięci w najbardziej optymalny sposób. Jest to konsekwencją tego, że język Python to język dokonujący czyszczenia pamięci (jest ona automatycznie przydzielana i w razie potrzeby uwalniana). Powoduje to fragmentację pamięci, która może niekorzystnie wpłynąć na transfery do pamięci podręcznych procesora. Ponadto w żadnym momencie nie ma możliwości zmiany układu struktury danych bezpośrednio w pamięci. Oznacza to, że jedna operacja transferu w magistrali może nie zawierać wszystkich odpowiednich informacji na potrzeby obliczeń, nawet pomimo tego, że szerokość magistrali może być wystarczająca dla całych danych. Poza tym fundamentalny problem wynika z typów dynamicznych języka Python, którego kod nie jest kompilowany. Jak wielu programistów używających języka C nauczyło się w ciągu wielu lat, kompilator jest czasami sprytniejszy od nas. Podczas kompilowania statycznego kodu kompilator może stosować liczne zabiegi w celu zmiany sposobu rozmieszczania elementów, a także sposobu, w jaki procesor będzie uruchamiać określone instrukcje pod kątem zoptymalizowania ich. Kod Python nie jest jednak kompilowany. Co gorsza, zawiera on typy dynamiczne. Oznacza to, że określanie jakichkolwiek ewentualnych możliwości algorytmicznych optymalizacji jest znacząco trudniejsze, ponieważ funkcjonalność kodu może być modyfikowana podczas jego wykonywania. Istnieje wiele metod zmniejszania skali tego problemu. Podstawową jest użycie narzędzia Cython, które umożliwia kompilowanie kodu Python, a ponadto pozwala użytkownikowi tworzyć „wskazówki” dla kompilatora określające rzeczywistą dynamiczność kodu. Ponadto przy próbie równoległego wykonywania kodu wspomniana wcześniej globalna blokada interpretera GIL może spowodować spadek wydajności. Dla przykładu załóżmy, że kod jest modyfikowany w celu użycia wielu rdzeni procesora tak, aby każdy z nich otrzymał porcję liczb z zakresu od 2 do sqrtN. Każdy rdzeń może przeprowadzić obliczenia dla swojej porcji liczb, a następnie po ich zakończeniu rdzenie mogą porównać własne obliczenia. Wygląda to na dobre rozwiązanie, ponieważ choć tracimy możliwość wczesnego zakończenia wykonywania pętli, możemy zmniejszyć liczbę sprawdzeń, jaką każdy rdzeń musi wykonać, podzieloną przez liczbę używanych rdzeni (oznacza to, że w przypadku M rdzeni każdy z nich musiałby przeprowadzić sqrtN/M sprawdzeń). Jednak z powodu globalnej blokady interpretera GIL w danej chwili może być używany tylko jeden rdzeń. Oznacza to, że w efekcie zostałby wykonany taki sam kod jak w przypadku wersji kodu z równoległym wykonywaniem, ale bez możliwości wczesnego zakończenia. Problemu tego można uniknąć, zastępując wiele wątków wieloma procesami (z wykorzystaniem modułu multiprocessing) bądź za pomocą narzędzia Cython lub funkcji zewnętrznych.
Dlaczego warto używać języka Python? Język Python cechuje się wysokim stopniem ekspresywności i jest łatwy do opanowania. Programiści, którzy zaczynają go używać, szybko stwierdzają, że w krótkim czasie mogą dzięki niemu osiągnąć całkiem sporo. Za pomocą innych języków zostało napisanych wiele narzędzi opakowujących biblioteki języka Python, które ułatwiają wywoływanie innych systemów. Na przykład system uczenia maszynowego scikitlearn opakowuje biblioteki LIBLINEAR i LIBSVM
26
Rozdział 1. Wydajny kod Python
(obie napisano w języku C), a biblioteka numpy zawiera bibliotekę BLAS oraz inne biblioteki języków C i Fortran. W rezultacie kod Python, który poprawnie wykorzystuje te moduły, może być naprawdę równie szybki jak porównywalny kod C. Język Python jest określany mianem „uwzględniającego baterie”, gdyż wbudowano w niego wiele ważnych i stabilnych bibliotek. To następujące biblioteki: unicode i bytes
Scalone z rdzeniem języka.
array
Wydajne pod względem wykorzystywanej pamięci tablice przeznaczone dla typów podstawowych.
math
Podstawowe operacje matematyczne, w tym kilka prostych funkcji statystycznych.
sqlite3
Biblioteka opakowująca powszechnie używany mechanizm magazynowania danych oparty na plikach SQLite3.
collections
Przeróżne obiekty, w tym obiekt deque, licznik i warianty słownika.
Poza obrębem rdzenia języka dostępna jest ogromna liczba różnych bibliotek. Oto niektóre z nich: numpy
Numeryczna biblioteka języka Python (podstawowa biblioteka w przypadku wszystkiego, co ma związek z macierzami).
scipy
Bardzo duża kolekcja zaufanych bibliotek naukowych, które często opakowują cieszące się dużym uznaniem biblioteki języków C i Fortran.
pandas
Biblioteka służąca do analizy danych, która przypomina ramki danych języka R lub arkusz kalkulacyjny programu Excel. Biblioteka bazuje na bibliotekach scipy i numpy.
scikit-learn
Bazująca na bibliotece scipy biblioteka szybko przyjmująca postać domyślnej biblioteki uczenia maszynowego.
biopython
Stosowana w bioinformatyce biblioteka, która przypomina bibliotekę bioperl.
tornado
Biblioteka, która zapewnia proste powiązania na potrzeby współbieżności.
Powiązania bazy danych Służą do komunikacji z niemal wszystkimi bazami danych, w tym Redis, MongoDB, HDF5 i SQL. Środowiska do projektowania aplikacji internetowych Wydajne systemy służące do tworzenia witryn internetowych, takie jak django, pyramid, flask i tornado.
Dlaczego warto używać języka Python?
27
OpenCV
Powiązania na potrzeby rozpoznawania obrazów.
Powiązania interfejsów API Ułatwiają dostęp do popularnych interfejsów API serwisów internetowych, takich jak Google, Twitter i LinkedIn. Dostosowanie do różnych scenariuszy wdrażania jest możliwe dzięki dużej liczbie dostępnych środowisk zarządzanych i powłok. Oto one: Standardowa dystrybucja dostępna pod adresem http://python.org/. Bardzo dojrzałe środowiska o dużych możliwościach EPD i Canopy firmy Enthought. Przeznaczone dla naukowców środowisko Anaconda firmy Continuum. Przypominające oprogramowanie Matlab środowisko Sage, które zawiera zintegrowane
środowisko programistyczne IDE (Integrated Development Environment). Python(x,y). IPython, czyli interaktywna powłoka języka Python używana przez naukowców i pro-
jektantów. Oparty na przeglądarce interfejs powłoki IPython o nazwie IPython Notebook, który jest
intensywnie wykorzystywany do celów edukacyjnych i pokazowych. Interaktywna powłoka języka Python BPython.
Jedną z głównych zalet języka Python jest to, że pozwala na szybkie prototypowanie koncepcji. Ze względu na bogactwo bibliotek pomocniczych proste jest sprawdzenie, czy koncepcja jest możliwa do zrealizowania (nawet jeśli pierwsza implementacja może okazać się dziwna). Aby przyspieszyć procedury matematyczne, sprawdź bibliotekę numpy. Jeżeli chcesz poeksperymentować z uczeniem maszynowym, wypróbuj bibliotekę scikit-learn. Jeśli porządkujesz i modyfikujesz dane, dobrą propozycją będzie biblioteka pandas. Ogólnie rzecz biorąc, sensowne jest zadanie sobie następującego pytania: „Jeśli system działa szybciej, czy jako zespół długoterminowo będziemy pracować wolniej?”. Choć zawsze możliwe jest uzyskanie wzrostu wydajności systemu, jeśli poświęci się na to wystarczającą ilość czasu przy udziale odpowiedniej liczby osób, może to doprowadzić do uzyskania nieznacznych i źle zrozumianych optymalizacji, które ostatecznie spowodują utrudnienia w pracy zespołu. Przykładem może być wprowadzenie narzędzia Cython (omówiono je w rozdziale 7., w podrozdziale „Cython”). Jest to oparte na kompilatorze rozwiązanie służące do tworzenia adnotacji kodu Python za pomocą typów podobnych do tych wykorzystywanych w języku C. Dzięki temu przekształcony kod może być kompilowany przy użyciu kompilatora języka C. Wprawdzie przyrost szybkości może robić wrażenie (często przy stosunkowo niewielkim nakładzie pracy osiągane są szybkości porównywalne z szybkością kodu C), ale zwiększy się koszt obsługi takiego kodu. W szczególności trudniejsza może być obsługa nowego modułu, ponieważ od członków zespołu wymagane będzie określone doświadczenie programistyczne, pozwalające zrozumieć niektóre zależności, które wystąpiły po zrezygnowaniu z maszyny wirtualnej języka Python i uzyskaniu wzrostu wydajności.
28
Rozdział 1. Wydajny kod Python
ROZDZIAŁ 2.
Użycie profilowania do znajdowania wąskich gardeł
Pytania, na jakie będziesz w stanie udzielić odpowiedzi po przeczytaniu rozdziału Jak można zidentyfikować w kodzie wąskie gardła związane z szybkością i pamięcią
RAM? Jak profilowane jest wykorzystanie pamięci i procesora? Jaka głębokość profilowania powinna zostać użyta? Jak można profilować aplikację działającą długoterminowo? Co się dzieje pod podszewką w przypadku użycia narzędzia CPython? Jak zapewnić poprawność kodu podczas dostrajania wydajności?
Profilowanie umożliwia znalezienie wąskich gardeł. Dzięki temu przy minimalnym nakładzie pracy możliwe jest uzyskanie największego praktycznego wzrostu wydajności. Choć można oczekiwać ogromnego wzrostu szybkości i zmniejszenia wykorzystania zasobów przy niewielkim nakładzie pracy, w praktyce celem jest uzyskanie kodu, który działa „wystarczająco szybko i niezawodnie”, aby spełnić nasze wymagania. Profilowanie pozwoli podjąć najbardziej pragmatyczne decyzje przy minimalnym wysiłku. Profilowany może być dowolny zasób (nie tylko procesor!), dla którego można dokonać pomiaru. W tym rozdziale przyjrzymy się zarówno wykorzystaniu pamięci, jak i czasu procesora. Podobne techniki możesz też zastosować do pomiaru przepustowości sieci i dyskowych operacji wejścia-wyjścia. Jeśli program działa zbyt wolno lub używane jest za dużo pamięci RAM, wskazane będzie poprawienie wszystkich odpowiedzialnych za to części kodu. Oczywiście możesz pominąć profilowanie i poprawić to, co, jak wierzysz, może stanowić problem. Trzeba jednak uważać, ponieważ często kończy się to „poprawieniem” niewłaściwej rzeczy. Zamiast kierować się intuicją, przed dokonaniem zmian w strukturze kodu lepiej przeprowadź profilowanie ze zdefiniowaną hipotezą.
29
Czasami pośpiech nie jest wskazany. Profilowanie przed dokonywaniem zmian pozwala szybko zidentyfikować wąskie gardła, które trzeba wyeliminować. Później możesz usunąć po prostu taką ich liczbę, jaka będzie wymagana, by osiągnąć żądaną wydajność. Jeśli pominiesz profilowanie i przejdziesz do optymalizowania, całkiem prawdopodobne jest to, że w dłuższej perspektywie będziesz musiał włożyć w to więcej pracy. Zawsze kieruj się wynikami profilowania.
Efektywne profilowanie Pierwszym zadaniem w procesie profilowania jest testowanie reprezentacyjnego systemu w celu określenia, które elementy pracują powoli (lub zużywają zbyt wiele pamięci RAM albo wymuszają za dużo operacji wejścia-wyjścia dysku lub sieci). Profilowanie zwykle powoduje obciążenie (zazwyczaj ma miejsce spowolnienie od dziesięciu do stu razy). Kod ma nadal być używany w sposób jak najbardziej zbliżony do tego, jaki wykorzystuje się w rzeczywistych warunkach. Wyodrębnij przypadek testowy i wyizoluj część systemu, która wymaga sprawdzenia. Najlepiej byłoby, gdyby ta część została już tak utworzona, aby znajdowała się we własnym zestawie modułów. Podstawowe techniki przedstawione w tym rozdziale jako pierwsze obejmują funkcję „magiczną” %timeit powłoki IPython, funkcję time.time() i dekorator czasu. Dzięki poznaniu tych metod zrozumiesz działanie instrukcji i funkcji. W dalszej części rozdziału zostanie omówiony moduł cProfile (podrozdział „Użycie modułu cProfile”). Dowiesz się, w jaki sposób za pomocą tego wbudowanego narzędzia zidentyfikować w kodzie funkcje, których wykonanie zajmuje najwięcej czasu. Umożliwi Ci to zaznajomienie się z ogólną prezentacją problemu, dzięki czemu będziesz mógł skupić swoją uwagę na krytycznych funkcjach. Dalej przyjrzymy się narzędziu line_profiler (podrozdział „Użycie narzędzia line_profiler do pomiarów dotyczących kolejnych wierszy kodu”), które przeprowadzi profilowanie wybranych funkcji dla kolejnych wierszy kodu. Wynik będzie obejmować liczbę wywołań każdego wiersza i wartość procentową czasu poświęconego na każdy wiersz. Dokładnie te informacje są niezbędne do zidentyfikowania tego, co działa wolno i z jakiego powodu. Gdy będziesz dysponował wynikami działania narzędzia line_profiler, uzyskasz informacje wymagane do zastosowania kompilatora omówionego w rozdziale 7. W rozdziale 6. (przykład 6.8) dowiesz się, jak użyć polecenia perf stat do przeanalizowania liczby instrukcji, które są ostatecznie wykonywane w procesorze, a także jak efektywnie wykorzystywać pamięci podręczne procesora. Pozwala to na dostrajanie operacji macierzowych na zaawansowanym poziomie. Po przeczytaniu tego rozdziału warto przyjrzeć się przykładowi 6.8. Po narzędziu line_profiler zostanie zaprezentowane narzędzie heapy (podrozdział „Inspekcja obiektów w stercie za pomocą narzędzia heapy”), które umożliwia śledzenie wszystkich obiektów w obrębie pamięci kodu Python. Jest to bardzo przydatne w przypadku identyfikowania dziwnych „przecieków” pamięci. Jeśli pracujesz z długo działającymi systemami, godne zainteresowania będzie narzędzie dowser (podrozdział „Użycie narzędzia dowser do generowania aktywnego wykresu dla zmiennych z utworzonymi instancjami”). Umożliwia ono analizowanie aktywnych obiektów w długoterminowym procesie za pośrednictwem interfejsu przeglądarki internetowej.
30
Rozdział 2. Użycie profilowania do znajdowania wąskich gardeł
Aby zilustrować, dlaczego wykorzystanie pamięci RAM jest duże, zostanie omówione narzędzie memory_profiler (podrozdział „Użycie narzędzia memory_profiler do diagnozowania wykorzystania pamięci”). Jest ono szczególnie przydatne podczas śledzenia na wykresie z etykietami wykorzystania pamięci RAM w czasie. Dzięki temu możesz wyjaśnić współpracownikom, dlaczego określone funkcje zużywają więcej pamięci RAM, niż oczekiwano. Niezależnie od wybranej metody profilowania kodu trzeba pamiętać o zapewnieniu w nim odpowiedniego zakresu testów jednostkowych. Testy jednostkowe ułatwiają popełnianie prostych pomyłek, a ponadto są pomocne w zapewnieniu możliwości odtworzenia wyników. Rezygnuj z nich tylko na własne ryzyko. Zawsze profiluj kod przed kompilowaniem lub modyfikowaniem algorytmów. Do określenia najbardziej efektywnych metod przyspieszania działania kodu niezbędny jest dowód.
W dalszej części rozdziału zamieszczono też wprowadzenie do kodu bajtowego Python w obrębie narzędzia CPython (podrozdział „Użycie modułu dis do sprawdzania kodu bajtowego narzędzia CPython”). Dzięki temu możesz zrozumieć, co się dzieje pod podszewką. W szczególności zaznajomienie się ze sposobem działania maszyny wirtualnej opartej na stosie kodu Python ułatwi Ci zrozumienie, dlaczego określone style kodowania cechują się mniejszą wydajnością od innych. Na końcu rozdziału przyjrzymy się sposobom integrowania testów jednostkowych podczas profilowania (podrozdział „Testowanie jednostkowe podczas optymalizacji w celu zachowania poprawności”), aby zachować poprawność kodu podczas dokonywania zmian mających na celu zwiększenie jego wydajności. Rozdział zostanie zakończony omówieniem strategii profilowania (podrozdział „Strategie udanego profilowania kodu”). Dzięki temu możliwe będzie niezawodne profilowanie kodu i gromadzenie właściwych danych na potrzeby testowania określonych hipotez. Dowiesz się, jak skalowanie częstotliwości procesora oraz funkcje takie jak TurboBoost mogą zafałszować wyniki profilowania, a także jak można je wyłączyć. Aby wykonać wszystkie te kroki, niezbędna jest prosta do analizowania funkcja. W następnym podrozdziale zostanie przedstawiony zbiór Julii. Jest to powiązana z procesorem funkcja, która w trochę większym stopniu korzysta z pamięci RAM. Ponadto przejawia działanie nieliniowe (z tego powodu nie można z łatwością przewidzieć wyników). Oznacza to, że zamiast analizowania w trybie offline, funkcja ta wymaga profilowania podczas działania.
Wprowadzenie do zbioru Julii Zbiór Julii (http://pl.wikipedia.org/wiki/Zbi%C3%B3r_Julii) to interesujący problem powiązany z procesorem, od którego warto zacząć. Zbiór ten jest sekwencją fraktalną, która generuje złożony obraz wyjściowy. Nazwa zbioru wywodzi się od matematyka Gastona Julii. Zamieszczony dalej kod jest trochę dłuższy od wersji, którą możesz sam utworzyć. Zawiera on komponent powiązany z procesorem oraz wyjątkowo jawny zbiór wejść. Konfiguracja taka umożliwia profilowanie zarówno wykorzystania procesora, jak i pamięci RAM. Dzięki temu można zrozumieć, jakie części kodu zużywają dwa spośród skromnych zasobów obliczeniowych. Ponieważ taka implementacja jest umyślnie niezoptymalizowana, możemy zidentyfikować
Wprowadzenie do zbioru Julii
31
operacje wykorzystujące pamięć i wolne instrukcje. W dalszej części rozdziału zostanie poprawiona wolna instrukcja logiczna oraz instrukcja, która wykorzystuje dużo pamięci. W rozdziale 7. znacząco skrócony zostanie ogólny czas wykonywania tej funkcji. Przeanalizujemy blok kodu, który w punkcie zespolonym c=-0.62772-0.42193j generuje zarówno wykres fałszywej skali szarości (rysunek 2.1), jak i wariant zbioru Julii z czystą skalą szarości (rysunek 2.3). Zbiór Julii jest tworzony przez obliczenie każdego piksela obrazu w wyizolowaniu. Jest to „kłopotliwy problem z równoległością”, gdyż między punktami nie są współużytkowane dane.
Rysunek 2.1. Wykres zbioru Julii z fałszywą skalą szarości podkreślającą szczegóły
Jeśli zostanie określony inny punkt c, zostanie uzyskany inny obraz. Wybrane położenie zawiera obszary szybkie do obliczenia oraz takie, których obliczenie wymaga więcej czasu. Jest to korzystne pod kątem przykładowej analizy.
32
Rozdział 2. Użycie profilowania do znajdowania wąskich gardeł
Problem jest interesujący, ponieważ każdy piksel jest obliczany przez zastosowanie pętli, która może być wykonywana nieokreśloną liczbę razy. W każdej iteracji sprawdzane jest, czy wartość danej współrzędnej zmierza do nieskończoności, czy wydaje się wstrzymywana przez atraktor. Współrzędne powodujące niewiele iteracji mają ciemny kolor (rysunek 2.1). Z kolei współrzędne, które powodują dużą liczbę iteracji, mają biały kolor. Białe obszary są trudniejsze do obliczenia i wygenerowanie ich zajmuje więcej czasu. Definiujemy zbiór współrzędnych z, które będą testowane. Obliczana funkcja określa pierwiastek kwadratowy liczby zespolonej z i dodaje punkt c: f(z) = z2 + c
Funkcja jest iterowana podczas testowania mającego na celu sprawdzenie, czy warunek zmierzania wstrzymuje użycie funkcji abs. Jeśli funkcja zmierzania ma wartość False, następuje wyjście z pętli i zarejestrowanie liczby iteracji wykonanych dla danej współrzędnej. Jeśli funkcja zmierzania nigdy nie ma wartości False, pętla jest kończona po liczbie iteracji określonej przez wartość maxiter. Wynik obliczenia liczby zespolonej z zostanie później przekształcony w kolorowy piksel, który reprezentuje położenie tej liczby zespolonej. W pseudokodzie może to wyglądać następująco: for z in coordinates: for iteration in range(maxiter): # ograniczona liczba iteracji dla punktu if abs(z) < 2.0: # czy warunek zmierzania został naruszony? z = z*z + c else: break # przechowanie liczby iteracji dla każdej liczby zespolonej z i wygenerowanie później wykresu
W celu objaśnienia tej funkcji spróbujmy użyć dwóch współrzędnych. Najpierw użyjemy współrzędnej, która zostanie umieszczona w lewym górnym narożniku wykresu w punkcie -1.8-1.8j. Zanim będzie możliwe wypróbowanie reguły aktualizacji, konieczne jest sprawdzenie warunku abs(z) < 2: z = -1.8-1.8j print abs(z) 2.54558441227
Jak widać, w przypadku lewej górnej współrzędnej test funkcji abs(z) da wartość False dla zerowej iteracji. Oznacza to, że nie jest stosowana reguła aktualizacji. Wartość zmiennej output dla tej współrzędnej wynosi 0. Przesuńmy się do środka wykresu w punkcie z = 0 + 0j i sprawdźmy kilka iteracji: c = -0.62772-0.42193j z = 0+0j for n in range(9): z = z*z + c print "{}: z={:33}, abs(z)={:0.2f}, 0: z= (-0.62772-0.42193j), 1: z= (-0.4117125265+0.1077777992j), 2: z=(-0.469828849523-0.510676940018j), 3: z=(-0.667771789222+0.057931518414j), 4: z=(-0.185156898345-0.499300067407j), 5: z=(-0.842737480308-0.237032296351j), 6: z=(0.026302151203-0.0224179996428j), 7: z= (-0.62753076355-0.423109283233j), 8: z=(-0.412946606356+0.109098183144j),
c={}".format(n, z, abs(z), c) abs(z)=0.76, c=(-0.62772-0.42193j) abs(z)=0.43, c=(-0.62772-0.42193j) abs(z)=0.69, c=(-0.62772-0.42193j) abs(z)=0.67, c=(-0.62772-0.42193j) abs(z)=0.53, c=(-0.62772-0.42193j) abs(z)=0.88, c=(-0.62772-0.42193j) abs(z)=0.03, c=(-0.62772-0.42193j) abs(z)=0.76, c=(-0.62772-0.42193j) abs(z)=0.43, c=(-0.62772-0.42193j)
Wprowadzenie do zbioru Julii
33
Jak widać, każda aktualizacja współrzędnej z dla tych kilku pierwszych iteracji powoduje uzyskanie wartości, dla której warunek abs(z) < 2 ma wartość True. Po wykonaniu dla tej współrzędnej 300 iteracji w dalszym ciągu test da wartość True. Nie jest możliwe określenie liczby iteracji niezbędnych do uzyskania dla warunku wartości False. Może się to zakończyć nieskończoną sekwencją. Klauzula zatrzymania z maksymalną liczbą iteracji (wartość maxiter) spowoduje zakończenie potencjalnie nieskończonej iteracji. Na rysunku 2.2 pokazano 50 pierwszych iteracji powyższej sekwencji. Dla punktu 0+0j (linia ciągła ze znacznikami w postaci okręgów) sekwencja wydaje się powtarzać co ósmą iterację. Każda sekwencja siedmiu obliczeń ma niewielkie odchylenie względem poprzedniej sekwencji. Nie jest możliwe stwierdzenie, czy ten punkt będzie bez końca iterowany w obrębie warunku granicznego przez długi czas, czy być może zaledwie przez kilka kolejnych iteracji. Linia kreskowana odcięcie prezentuje granicę w punkcie +2.
Rysunek 2.2. Dwa przykłady współrzędnych rozwijane dla zbioru Julii
W przypadku punktu -0.82+0j (linia kreskowana ze znacznikami w postaci rombów) widać, że po dziewiątej aktualizacji wynik bezwzględny przekroczył linię odcięcia +2, dlatego nastąpiło zatrzymanie aktualizowania tej wartości.
Obliczanie pełnego zbioru Julii W tym podrozdziale zostanie omówiony kod, który generuje zbiór Julii. Kod będzie analizowany w rozdziale na różne sposoby. Jak widać w przykładzie 2.1, na początku modułu importowany jest moduł time pierwszej metody profilowania i definiowanych jest kilka stałych współrzędnych. Przykład 2.1. Definiowanie stałych globalnych dla przestrzeni współrzędnych """Generator zbioru Julii bez opcjonalnego rysowania obrazów na bazie biblioteki PIL""" import time # obszar przestrzeni zespolonej do przeanalizowania x1, x2, y1, y2 = -1.8, 1.8, -1.8, 1.8 c_real, c_imag = -0.62772, -.42193
34
Rozdział 2. Użycie profilowania do znajdowania wąskich gardeł
Aby wygenerować wykres, tworzone są dwie listy danych wejściowych. Pierwsza lista to zs (współrzędne zespolone z), a druga to cs (zespolony warunek początkowy). Żadna z list nie zmienia się. Listę cs można zoptymalizować do postaci pojedynczej wartości c jako stałej. Powodem utworzenia dwóch list wejściowych jest chęć uzyskania rozsądnie prezentujących się danych, które będą używane podczas profilowania wykorzystania pamięci RAM w dalszej części rozdziału. Aby utworzyć listy zs i cs, konieczne jest określenie współrzędnych dla każdego punktu z. W przykładzie 2.2 te współrzędne są tworzone przy użyciu zmiennych xcoord i ycoord, a ponadto określono zmienne x_step i y_step. Szczegółowość takiej konfiguracji przydaje się przy przenoszeniu kodu do innych narzędzi (np. do narzędzi numpy) i środowisk opartych na kodzie Python, ponieważ ułatwia zdefiniowanie wszystkiego w bardzo przejrzysty sposób na potrzeby debugowania. Przykład 2.2. Definiowanie list współrzędnych jako wejść przykładowej funkcji obliczeniowej def calc_pure_python(desired_width, max_iterations): """Tworzenie listy współrzędnych zespolonych (zs) i parametrów zespolonych (cs), budowanie zbioru Julii i wyświetlanie danych""" x_step = (float(x2 - x1) / float(desired_width)) y_step = (float(y1 - y2) / float(desired_width)) x = [] y = [] ycoord = y2 while ycoord > y1: y.append(ycoord) ycoord += y_step xcoord = x1 while xcoord < x2: x.append(xcoord) xcoord += x_step # Utwórz listę współrzędnych i warunek początkowy dla każdej komórki # Zauważ, że warunek początkowy to stała, która z łatwością może zostać usunięta # Stała służy do symulowania rzeczywistego scenariusza z kilkoma wejściami # przekazanymi przykładowej funkcji zs = [] cs = [] for ycoord in y: for xcoord in x: zs.append(complex(xcoord, ycoord)) cs.append(complex(c_real, c_imag)) print "Długość dla x:", len(x) print "Łączna liczba elementów:", len(zs) start_time = time.time() output = calculate_z_serial_purepython(max_iterations, zs, cs) end_time = time.time() secs = end_time - start_time print "Działanie funkcji " + calculate_z_serial_purepython.func_name + " trwało", secs, "s" # Suma ta jest oczekiwana dla siatki 1000^2 z 300 iteracjami # Przechwytywane są drobne błędy, które mogą się pojawić # podczas przetwarzania ustalonego zbioru wejść assert sum(output) == 33219980
Po utworzeniu list zs i cs zwracane są informacje o ich wielkości, a ponadto obliczana jest lista output za pomocą funkcji calculate_z_serial_purepython. Na końcu sumowane są dane zmiennej output i instrukcji assert, które są zgodne z oczekiwaną wartością wyjściową. Jeden z autorów posłużył się tutaj tym kodem, aby zapewnić, że w książce nie pojawi się kod zawierający błędy. Obliczanie pełnego zbioru Julii
35
Ponieważ kod jest deterministyczny, możemy sprawdzić, czy funkcja działa zgodnie z oczekiwaniami, sumując wszystkie obliczone wartości. Przydaje się to jako kontrola poprawności. W przypadku wprowadzania zmian w kodzie numerycznym bardzo wskazane jest sprawdzenie, czy nie został uszkodzony algorytm. W idealnej sytuacji zostałyby użyte testy jednostkowe, a ponadto sprawdzona więcej niż jedna konfiguracja powiązana z problemem. W przykładzie 2.3 definiowana jest następnie funkcja calculate_z_serial_purepython, która rozszerza omówiony wcześniej algorytm. Na początku definiowana jest też lista output o takiej samej długości jak w przypadku list zs i cs. Możesz również zastanawiać się, dlaczego zamiast funkcji xrange używana jest funkcja range (tak właśnie jest w podrozdziale „Użycie narzędzia memory_profiler do diagnozowania wykorzystania pamięci”). W ten sposób można pokazać, jak rozrzutna potrafi być funkcja range! Przykład 2.3. Funkcja obliczeniowa powiązana z procesorem def calculate_z_serial_purepython(maxiter, zs, cs): """Obliczanie listy output przy użyciu reguły aktualizacji zbioru Julii""" output = [0] * len(zs) for i in range(len(zs)): n = 0 z = zs[i] c = cs[i] while abs(z) < 2 and n < maxiter: z = z * z + c n += 1 output[i] = n return output
W przykładzie 2.4 wywoływana jest funkcja obliczeniowa. Przez opakowanie jej za pomocą kodu kontrolnego __main__ w przypadku niektórych metod profilowania możemy bezpiecznie zaimportować moduł bez rozpoczynania obliczeń. Zauważ, że nie jest tutaj prezentowana metoda używana do generowania wykresu danych wyjściowych. Przykład 2.4. Funkcja main kodu if __name__ == "__main__": # Obliczanie zbioru Julii za pomocą czystego rozwiązania opartego na języku Python # z wykorzystaniem wartości domyślnych rozsądnych dla laptopa calc_pure_python(desired_width=1000, max_iterations=300)
Po uruchomieniu kodu zostaną uzyskane dane wyjściowe opisujące złożoność problemu: # uruchomienie powyższego kodu daje następujący wynik: Długość dla x: 1000 Łączna liczba elementów: 1000000 Działanie funkcji calculate_z_serial_purepython trwało 12.3479790688 s
W przypadku wykresu fałszywej skali szarości (rysunek 2.1) zmiany kolorów o wysokim kontraście pozwoliły nam zorientować się, gdzie koszt funkcji zmieniał się wolno lub szybko. Na rysunku 2.3 widoczna jest liniowa mapa kolorów: kolor czarny można szybko wygenerować, a generowanie koloru białego jest kosztowne. Uwidocznienie dwóch reprezentacji tych samych danych pozwala zauważyć, że przy odwzorowaniu liniowym traconych jest wiele szczegółów. Czasem podczas analizowania kosztu funkcji przydatne może być uwzględnienie różnych reprezentacji.
36
Rozdział 2. Użycie profilowania do znajdowania wąskich gardeł
Rysunek 2.3. Przykład wykresu zbioru Julii używający czystej skali szarości
Proste metody pomiaru czasu — instrukcja print i dekorator Po wykonaniu kodu z przykładu 2.4 uzyskano dane wyjściowe wygenerowane przez kilka instrukcji print. Na laptopie jednego z autorów wykonanie tego kodu przy użyciu narzędzia CPython 2.7 zajęło około 12 sekund. Warto zauważyć, że czas wykonania zawsze będzie trochę inny. Podczas pomiaru czasu wykonywania kodu musisz zaobserwować standardową zmienność, ponieważ w przeciwnym razie możesz niepoprawnie przypisać wzrost wydajności w kodzie po prostu losowej zmienności czasu wykonywania. W czasie działania kodu komputer będzie realizować inne zadania, takie jak uzyskiwanie dostępu do sieci, dysku lub pamięci RAM. Czynniki te mogą powodować zmiany czasu wykonywania programu. Laptop jednego z autorów to Dell E6420 z procesorem Intel Core I7-2720QM (2,20 GHz, pamięć podręczna 6 MB i poczwórny rdzeń) oraz pamięcią RAM 8 GB i systemem Ubuntu 13.10. W funkcji calc_pure_python (przykład 2.2) znajduje się kilka instrukcji print. Jest to najprostsza metoda pomiaru czasu wykonywania porcji kodu w obrębie funkcji. Jest to bardzo proste rozwiązanie, które pomimo szybkości i braku przejrzystości może okazać się bardzo pomocne w początkowej fazie analizowania porcji kodu. Użycie instrukcji print jest powszechne podczas debugowania i profilowania kodu. Choć metoda ta szybko staje się trudna w obsłudze, przydaje się w przypadku krótkich analiz.
Proste metody pomiaru czasu — instrukcja print i dekorator
37
Spróbuj uporządkować wyniki po zakończeniu analiz, ponieważ w przeciwnym razie standardowe wyjście stdout nie będzie przejrzyste. Metodą zapewniającą trochę większą czytelność jest użycie dekoratora. W tym przypadku powyżej interesującej nas funkcji dodajemy jeden wiersz kodu. Dekorator może być bardzo prosty i może replikować jedynie efekt działania instrukcji print. Później można go bardziej rozbudować. W przykładzie 2.5 definiowana jest nowa funkcja timefn, która pobiera funkcję wewnętrzną measure_time jako argument. Funkcja ta pobiera argumenty *args (zmienna liczba argumentów pozycyjnych) i **kwargs (zmienna liczba argumentów klucz/wartość), a ponadto przekazuje je funkcji fn w celu wykonania. W ramach wykonywania funkcji fn przechwytywana jest funkcja time.time(), a następnie za pomocą instrukcji print wyświetlane są wyniki wraz z nazwą funkcji (kod fn.func_name). Choć obciążenie wynikające z zastosowania tego dekoratora jest niewielkie, w przypadku wywołania funkcji fn miliony razy może ono stać się zauważalne. Aby ujawnić nazwę funkcji i notkę dokumentacyjną (ang. docstring) elementowi wywołującemu funkcji z dekoratorem, używa się kodu @wraps(fn) (w przeciwnym razie widoczna byłaby nazwa funkcji i notka dokumentacyjna dla dekoratora, a nie dekorowanej przez niego funkcji). Przykład 2.5. Definiowanie dekoratora do automatyzowania pomiarów czasu from functools import wraps def timefn(fn): @wraps(fn) def measure_time(*args, **kwargs): t1 = time.time() result = fn(*args, **kwargs) t2 = time.time() print ("@timefn: działanie funkcji " + fn.func_name + " trwało " + str(t2 - t1) + " s") return result return measure_time @timefn def calculate_z_serial_purepython(maxiter, zs, cs): ...
Po uruchomieniu tej wersji (zachowano instrukcje print z wcześniejszego kodu) widać, że czas wykonywania wersji z dekoratorem jest naprawdę niewiele krótszy niż w przypadku wywołania z funkcji calc_pure_python. Wynika to z obciążenia związanego z wywoływaniem funkcji (różnica jest znikoma): Długość elementu x: 1000 Łączna liczba elementów: 1000000 @timefn: działanie funkcji calculate_z_serial_purepython trwało 12.2218790054 s Działanie funkcji calculate_z_serial_purepython trwało 12.2219250043 s
Dodanie informacji o profilowaniu na pewno spowolni kod. Niektóre opcje profilowania zawierają mnóstwo informacji i powodują znaczny spadek szybkości. Konieczne będzie wypracowanie kompromisu pomiędzy szczegółowością profilowania i szybkością.
Użycie modułu timeit to kolejny sposób przeprowadzenia zwykłego pomiaru szybkości wykonywania funkcji powiązanej z procesorem. Ta metoda jest zwykle wykorzystywana przy określaniu czasu dla różnych typów prostych wyrażeń podczas eksperymentowania ze sposobami rozwiązania problemu.
38
Rozdział 2. Użycie profilowania do znajdowania wąskich gardeł
Zauważ, że moduł timeit tymczasowo wyłącza program czyszczący pamięć. Może on mieć wpływ na uzyskaną szybkość w przypadku rzeczywistych operacji, jeśli program byłby normalnie przez nie wywoływany. Pomoc z tym związana jest dostępna w dokumentacji języka Python (https://docs.python.org/2/library/timeit.html).
Z poziomu wiersza poleceń możesz uruchomić moduł timeit w następujący sposób: $ python -m timeit -n 5 -r 5 -s "import julia1" "julia1.calc_pure_python(desired_width=1000, max_iterations=300)"
Zauważ, że w ramach kroku konfiguracyjnego musisz zaimportować moduł przy użyciu opcji -s, ponieważ funkcja calc_pure_python znajduje się wewnątrz tego modułu. Moduł timeit zawiera kilka praktycznych wartości domyślnych w przypadku krótkich sekcji kodu, ale na potrzeby dłużej działających funkcji sensowne może być określenie liczby wykonania pętli, której wyniki są uśredniane dla każdego testu (-n 5), oraz liczby powtórzeń (-r 5). Jako odpowiedź zwracany jest wynik wykonania wszystkich powtórzeń. Domyślnie uruchomienie dla tej funkcji modułu timeit bez określania opcji -n i -r spowoduje wykonanie 10 razy pętli z pięcioma powtórzeniami. Całość operacji zajmie 6 minut. Nadpisanie wartości domyślnych może mieć sens, jeśli wyniki mają zostać uzyskane trochę szybciej. Interesują nas wyłącznie wyniki dla najlepszego przypadku, ponieważ przeciętny i najgorszy przypadek to prawdopodobnie rezultat ingerencji innych procesów. Wybór najlepszego spośród pięciu powtórzeń dla pięciu średnich wyników powinien dać nam dość pewny wynik: 5 loops, best of 5: 13.1 sec per loop
Spróbuj kilkakrotnie uruchomić test porównawczy, aby sprawdzić, czy uzyskiwane są zmienne wyniki. W celu ustalenia najkrótszego czasu przy stabilnym wyniku może być wymagana większa liczba powtórzeń. Ponieważ nie ma „właściwej” konfiguracji, jeśli występuje duża rozbieżność wyników pomiaru czasu, zwiększaj liczbę powtórzeń do momentu uzyskania stabilnego wyniku końcowego. Uzyskane dane pokazują, że ogólny koszt wywołania funkcji calc_pure_python to 13,1 sekundy (dla najlepszego przypadku), pojedyncze wywołania funkcji calculate_z_serial_purepython zajmują natomiast 12,2 sekundy, co zostało zmierzone przez dekorator @timefn. Różnica to głównie czas, jaki zajęło utworzenie list zs i cs. W obrębie powłoki IPython w ten sam sposób można użyć funkcji „magicznej” %timeit. Jeśli tworzysz kod interaktywnie w tej powłoce, a funkcje znajdują się w lokalnej przestrzeni nazw (prawdopodobnie z powodu użycia funkcji %run), możesz zastosować następujący kod: %timeit calc_pure_python(desired_width=1000, max_iterations=300)
Warto rozważyć zmienność obciążenia występującego w przypadku zwykłego komputera. Aktywnych jest wiele zadań w tle (np. usługa Dropbox, proces tworzenia kopii zapasowej), które w losowy sposób mogą wpływać na zasoby dyskowe i procesor. Skrypty na stronach internetowych mogą też wywoływać nieprzewidywalne wykorzystanie zasobów. Na rysunku 2.4 pokazano pojedynczy procesor obciążony w 100% przez część wcześniej wykonanych kroków pomiaru czasu. Inne rdzenie procesora tego samego komputera są nieznacznie obciążone przez inne zadania.
Proste metody pomiaru czasu — instrukcja print i dekorator
39
Rysunek 2.4. Narzędzie System Monitor w systemie Ubuntu, które prezentuje zmienne wykorzystanie procesora podczas pomiaru czasu dla przykładowej funkcji
Sporadycznie narzędzie System Monitor pokazuje dla komputera szczytową aktywność. Warto obserwować dane tego narzędzia, aby upewnić się, że nic innego nie oddziałuje na krytyczne zasoby (procesor, dysk, sieć).
Prosty pomiar czasu za pomocą polecenia time systemu Unix Na chwilę wyjdziemy trochę poza język Python, aby opisać użycie standardowego narzędzia dostępnego w systemach uniksowych. Następujące polecenie zarejestruje różne widoki czasu wykonywania programu, pomijając strukturę wewnętrzną kodu: $ /usr/bin/time -p python julia1_nopil.py Długość dla x: 1000 Łączna liczba elementów: 1000000 Działanie funkcji calculate_z_serial_purepython trwało 12.7298331261 s real 13.46 user 13.40 sys 0.04
Zauważ, że zamiast time podano dokładniejszą postać /usr/bin/time, aby skorzystać z systemowego narzędzia time, a nie z jego prostszej (i mniej przydatnej) wersji wbudowanej w powłokę. Jeśli spróbujesz użyć polecenia time --verbose i zostanie wygenerowany błąd, prawdopodobnie masz do czynienia z poleceniem time wbudowanym w powłokę, a nie z poleceniem systemowym. Użycie flagi przenośności -p powoduje uzyskanie następujących trzech wyników: real. Rejestruje aktualny czas lub czas, który upłynął. user. Rejestruje czas, jaki procesor poświęcił na realizowanie zadania poza funkcjami jądra. sys. Rejestruje czas, jaki upłynął w funkcjach na poziomie jądra.
Uwzględnienie wyników user i sys pozwala zorientować się, ile czasu minęło w procesorze. Różnica między tymi wynikami i wynikiem real daje możliwość określenia czasu, jaki upłynął podczas oczekiwania na operację wejścia-wyjścia. Różnica może też wskazywać, że system jest zajęty przetwarzaniem innych zadań, które zakłócają pomiary.
40
Rozdział 2. Użycie profilowania do znajdowania wąskich gardeł
Narzędzie time jest przydatne, ponieważ nie jest powiązane z językiem Python. Uwzględnia ono czas wymagany do uruchomienia programu wykonywalnego python, który może być znaczny, jeśli uruchamianych jest wiele nowych procesów (zamiast jednego długotrwałego procesu). Jeśli często używasz krótko działających skryptów, w przypadku których czas uruchamiania stanowi znaczącą część ogólnego czasu działania, narzędzie time może okazać się bardziej przydatną metodą pomiaru. Aby uzyskać jeszcze więcej danych wyjściowych, można dodać flagę --verbose: $ /usr/bin/time --verbose python julia1_nopil.py Długość dla x: 1000 Łączna liczba elementów: 1000000 Działanie funkcji calculate_z_serial_purepython trwało 12.3145110607 s Command being timed: "python julia1_nopil.py" User time (seconds): 13.46 System time (seconds): 0.05 Percent of CPU this job got: 99% Elapsed (wall clock) time (h:mm:ss or m:ss): 0:13.53 Average shared text size (kbytes): 0 Average unshared data size (kbytes): 0 Average stack size (kbytes): 0 Average total size (kbytes): 0 Maximum resident set size (kbytes): 131952 Average resident set size (kbytes): 0 Major (requiring I/O) page faults: 0 Minor (reclaiming a frame) page faults: 58974 Voluntary context switches: 3 Involuntary context switches: 26 Swaps: 0 File system inputs: 0 File system outputs: 1968 Socket messages sent: 0 Socket messages received: 0 Signals delivered: 0 Page size (bytes): 4096 Exit status: 0
W tym przypadku prawdopodobnie najbardziej przydatny wskaźnik to Major (requiring I/O) page faults, który określa, czy system operacyjny musi załadować z dysku strony danych, ponieważ nie ma ich już w pamięci RAM. Spowoduje to spadek szybkości. Ponieważ w przykładzie wymagania kodu i danych są niewielkie, nie występują błędy stronicowania. W przypadku procesu powiązanego z pamięcią lub kilku programów, które używają zmiennej i dużej ilości pamięci RAM, może to umożliwić zidentyfikowanie programu spowalnianego przez operacje dostępu do dysku na poziomie systemu operacyjnego, ponieważ części programu zostały przeniesione z pamięci RAM na dysk.
Użycie modułu cProfile Moduł cProfile to wbudowane narzędzie do profilowania w bibliotece standardowej. Jest ono podłączane do maszyny wirtualnej w module cProfile w celu pomiaru czasu, jaki zajmuje uruchomienie każdej napotkanej funkcji. Choć powoduje to znaczne obciążenie, pozwala uzyskać odpowiednio większą ilość informacji. Czasami dodatkowe informacje mogą doprowadzić do zaskakujących wniosków dotyczących kodu.
Użycie modułu cProfile
41
Moduł cProfile to jedno z trzech narzędzi profilujących w bibliotece standardowej. Dwa pozostałe narzędzia to hotshot i profile. Narzędzie hotshot ma postać eksperymentalnego kodu, a narzędzie profile to oryginalne narzędzie profilujące bazujące wyłącznie na kodzie Python. Moduł cProfile zawiera ten sam interfejs co narzędzie profile, a ponadto jest obsługiwany i pełni rolę domyślnego narzędzia profilującego. Jeśli ciekawi Cię historia tych bibliotek, zajrzyj na stronę Armina Rigo (https://mail.python.org/pipermail/python-dev/2005-), który w 2005 roku wystosował prośbę o dołączenie modułu cProfile do biblioteki standardowej. Dobrą praktyką w czasie profilowania jest utworzenie hipotezy dotyczącej szybkości części kodu przed rozpoczęciem profilowania go. Jeden z autorów woli wydrukować rozpatrywany fragment kodu i dołączyć do niego adnotacje. Wcześniejsze zdefiniowanie hipotezy oznacza, że możesz stwierdzić, jak bardzo się mylisz (i nadal będziesz!), a ponadto możesz poprawić intuicję odnośnie do konkretnych stylów tworzenia kodu. Nigdy nie należy unikać profilowania z powodu przeczucia (ostrzegamy Cię, ponieważ zostanie to źle przez Ciebie zrozumiane!). Zdecydowanie warto sformułować hipotezę przed rozpoczęciem profilowania, aby ułatwić sobie opanowanie umiejętności wykrywania w kodzie możliwych działań zmniejszających szybkość. Ponadto zawsze należy poprzeć dowodem podejmowane decyzje.
Zawsze kieruj się wynikami uzyskanymi przez pomiar i zaczynaj od podstawowego profilowania, aby upewnić się, że dotyczy ono właściwego obszaru. Nie ma nic bardziej upokarzającego niż zręczne optymalizowanie sekcji kodu tylko po to, aby uświadomić sobie (wiele godzin lub dni później), że pominięto najwolniejszą część procesu i w rzeczywistości w ogóle nie zajęto się zasadniczym problemem. A zatem jaka jest hipoteza w omawianym przykładzie? Wiemy, że funkcja calculate_z_serial_ purepython będzie prawdopodobnie najwolniejszym elementem kodu. W tej funkcji przepro-
wadzanych jest wiele operacji usuwania odniesień i tworzenia licznych odwołań do podstawowych operatorów arytmetycznych i funkcji abs. Okażą się one raczej tymi, które zużywają sporo zasobów procesora. Moduł cProfile zostanie użyty do uruchomienia wariantu kodu. Choć dane wyjściowe są skromne, pomagają zidentyfikować miejsce do dalszej analizy.
Flaga -s cumulative nakazuje modułowi cProfile sortowanie według łącznego czasu, jaki upłynął w obrębie każdej funkcji. Pozwala to nam uzyskać wgląd w najwolniejsze części sekcji kodu. Dane wyjściowe modułu są wyświetlane na ekranie bezpośrednio po standardowych wynikach polecenia print: $ python -m cProfile -s cumulative julia1_nopil.py ... 36221992 function calls in 19.664 seconds Ordered by: cumulative time ncalls tottime percall cumtime percall filename:lineno(function) 1 0.034 0.034 19.664 19.664 julia1_nopil.py:1() 1 0.843 0.843 19.630 19.630 julia1_nopil.py:23 (calc_pure_python) 1 14.121 14.121 18.627 18.627 julia1_nopil.py:9 (calculate_z_serial_purepython) 34219980 4.487 0.000 4.487 0.000 {abs} 2002000 0.150 0.000 0.150 0.000 {method 'append' of 'list' objects} 1 0.019 0.019 0.019 0.019 {range}
42
Rozdział 2. Użycie profilowania do znajdowania wąskich gardeł
1 2 4 1
0.010 0.000 0.000 0.000
0.010 0.000 0.000 0.000
0.010 0.000 0.000 0.000
0.010 0.000 0.000 0.000
{sum} {time.time} {len} {method 'disable' of '_lsprof.Profiler' objects}
Sortowanie według łącznego czasu pozwala zorientować się, w jakim miejscu upływa większość czasu wykonywania. Uzyskany wynik pokazuje, że 36 221 992 wywołania funkcji wystąpiły w czasie wynoszącym zaledwie ponad 19 sekund (tym razem uwzględnia to obciążenia związane z użyciem modułu cProfile). Wcześniej wykonanie kodu zajęło około 13 sekund. Po prostu dodano obciążenie wynoszące 5 sekund podczas pomiaru czasu wykonania każdej funkcji. Można zauważyć, że punkt wejścia do kodu skryptu julia1_cprofile.py w wierszu 1. zajmuje łącznie 19 sekund. A jest to jedynie odwołanie __main__ do funkcji calc_pure_python. W kolumnie ncalls jest wartość 1, która wskazuje, że wiersz ten jest wykonywany tylko raz. W obrębie funkcji calc_pure_python wywołanie funkcji calculate_z_serial_purepython zajmuje 18,6 sekundy. Obie funkcje są wywoływane tylko raz. Można wywnioskować, że w przybliżeniu jedna sekunda jest poświęcana na wiersze kodu w obrębie funkcji calc_pure_python, niezależnie od wywołania funkcji calculate_z_serial_purepython, która intensywnie korzysta z procesora. Nie można jednak stwierdzić przy użyciu modułu cProfile, jakie wiersze zajmują czas w obrębie funkcji. Czas poświęcany na wiersze kodu (bez wywoływania innych funkcji) wewnątrz funkcji calculate_z_serial_purepython wynosi 14,1 sekundy. Funkcja tworzy 34 219 980 wywołań funkcji abs, co w sumie zajmuje 4,4 sekundy, uwzględniając kilka innych wywołań, które nie zajmują wiele czasu. A co z wywołaniem {abs}? Jego wiersz służy do pomiaru czasu poszczególnych wywołań funkcji abs w obrębie funkcji calculate_z_serial_purepython. Choć czas przypadający na pojedyncze wywołanie jest nieistotny (został zarejestrowany jako 0,000 sekundy), łączny czas dla 34 219 980 wywołań to 4,4 sekundy. Nie możemy z góry przewidzieć, ile dokładnie zostanie wykonanych wywołań funkcji abs, ponieważ funkcja zbioru Julii cechuje się nieprzewidywalną dynamiką (z tego właśnie powodu jest to tak godne zainteresowania zagadnienie). W najlepszym razie możemy określić, że funkcja będzie wywoływana co najmniej 1 000 000 razy, gdy obliczenia są przeprowadzane dla iloczynu 1000*1000 pikseli. Co najwyżej, funkcja zostanie wywołana 300 000 000 razy, jeśli obliczenia dotyczą 1 000 000 pikseli przy maksymalnej liczbie iteracji wynoszącej 300. Oznacza to, że 34 miliony wywołań to w przybliżeniu 10% najgorszego wariantu. Jeśli przyjrzymy się oryginalnemu obrazowi skali szarości (rysunek 2.3) i wyobrazimy sobie, że jego białe części zostały ściśnięte w narożniku, możemy oszacować, że kosztowny biały obszar stanowi mniej więcej 10% reszty obrazu. W następnym wierszu profilowanych danych wyjściowych ({method 'append' of 'list' objects}) podano informację o utworzeniu 2 002 000 pozycji listy. Dlaczego jest 2 002 000 pozycji? Przed dalszą lekturą zastanów się, ile jest tworzonych pozycji listy.
Użycie modułu cProfile
43
Tworzenie takiej liczby pozycji ma miejsce w funkcji calc_pure_python na etapie konfigurowania. Listy zs i cs będą zawierać po 1000*1000 pozycji. Listy te są budowane przy użyciu listy 1000 współrzędnych x i 1000 współrzędnych y. Łącznie do dodania jest 2 002 000 wywołań. Godne uwagi jest to, że te dane wyjściowe modułu cProfile nie są porządkowane przez funkcje nadrzędne. Moduł podsumowuje koszt wszystkich funkcji w wykonywanym bloku kodu. Stwierdzenie, co ma miejsce w poszczególnych wierszach kodu, jest bardzo trudne w przypadku modułu cProfile, ponieważ uzyskiwane są jedynie informacje profilowania dla samych wywołań funkcji, a nie dla każdego wiersza w obrębie funkcji. Wewnątrz funkcji calculate_z_serial_purepython można teraz przeprowadzić obliczenie dla {abs} i {range}. W sumie te dwie funkcje powodują koszt wynoszący w przybliżeniu 4,5 sekundy. Wiemy, że łączny koszt funkcji calculate_z_serial_purepython to 18,6 sekundy. Ostatni wiersz danych wyjściowych profilowania odnosi się do narzędzia lsprof. Jest to oryginalna nazwa narzędzia, które rozwinęło się do postaci modułu cProfile. Nazwa może zostać zignorowana. Aby uzyskać większą kontrolę nad wynikami modułu cProfile, możesz utworzyć plik statystyk, a następnie dokonać jego analizy za pomocą języka Python: $ python -m cProfile -o profile.stats julia1.py
Po załadowaniu w następujący sposób pliku w interpreterze języka Python zostanie uzyskany taki sam jak wcześniej raport czasu łącznego: In [1]: In [2]: In [3]: Out[3]: In [4]: Tue Jan
import pstats p = pstats.Stats("profile.stats") p.sort_stats("cumulative")
p.print_stats() 7 21:00:56 2014 profile.stats 36221992 function calls in 19.983 seconds Ordered by: cumulative time ncalls tottime percall cumtime percall filename:lineno(function) 1 0.033 0.033 19.983 19.983 julia1_nopil.py:1() 1 0.846 0.846 19.950 19.950 julia1_nopil.py:23 (calc_pure_python) 1 13.585 13.585 18.944 18.944 julia1_nopil.py:9 (calculate_z_serial_purepython) 34219980 5.340 0.000 5.340 0.000 {abs} 2002000 0.150 0.000 0.150 0.000 {method 'append' of 'list' objects} 1 0.019 0.019 0.019 0.019 {range} 1 0.010 0.010 0.010 0.010 {sum} 2 0.000 0.000 0.000 0.000 {time.time} 4 0.000 0.000 0.000 0.000 {len} 1 0.000 0.000 0.000 0.000 {method 'disable' of '_lsprof.Profiler' objects}
W celu śledzenia tego, jakie funkcje są profilowane, możesz wyświetlić informacje o elemencie wywołującym. Na dwóch poniższych listingach możesz zobaczyć, że funkcja calculate_z_ serial_purepython ma największy koszt, a ponadto wywoływana jest z jednego miejsca. Jeśli ta funkcja zostałaby wywołana z wielu miejsc, listingi te mogłyby być pomocne przy zawężaniu położeń najbardziej kosztownych funkcji nadrzędnych: In [5]: p.print_callers() Ordered by: cumulative time Function
44
was called by... ncalls tottime cumtime
Rozdział 2. Użycie profilowania do znajdowania wąskich gardeł
julia1_nopil.py:1() julia1_nopil.py:23(calc_pure_python)
Użycie modułu cProfile
45
Ze względu na to, że moduł cProfile udostępnia sporo informacji, w celu wyświetlenia ich bez intensywnego korzystania z funkcji zawijania wierszy konieczny będzie szeroki ekran. Ponieważ jednak moduł jest wbudowany, stanowi wygodne narzędzie do szybkiego identyfikowania wąskich gardeł. Takie narzędzia jak line_profiler, heapy i memory_profiler, omówione w dalszej części rozdziału, ułatwią przejście do konkretnych wierszy, na które należy zwrócić uwagę.
Użycie narzędzia runsnake do wizualizacji danych wyjściowych modułu cProfile runsnake to narzędzie do wizualizacji statystyk profilowania utworzonych przez moduł cProfile.
Pozwala ono na szybkie zorientowanie się tylko przez przyjrzenie się wygenerowanemu diagramowi, jakie funkcje są najbardziej kosztowne. Użyj narzędzia runsnake do ogólnego zaznajomienia się z plikiem statystyk modułu cProfile, zwłaszcza wtedy, gdy analizowany jest nowy i duży kod bazowy. Pozwoli to wstępnie określić obszary, którymi należy się zająć. Narzędzie daje też możliwość ujawnienia obszarów, których kosztowności nie byłbyś w innym przypadku świadomy. Może to pomóc Ci zidentyfikować „szybkie wygrane”, którymi należy się zająć. Narzędzie może też zostać użyte podczas omawiania w zespole programistów mało wydajnych obszarów kodu, ponieważ ułatwia analizowanie wyników. Aby zainstalować narzędzie runsnake, wykonaj polecenie pip install runsnake. Zauważ, że wymaga ono pakietu wxPython, którego instalacja może być utrudniona w narzędziu virtualenv. Jeden z autorów zdecydował się więcej niż raz na zainstalowanie tego pakietu globalnie, zamiast podejmować próby uruchamiania go w narzędziu virtualenv, tylko po to, aby dokonać analizy pliku profilowania. Rysunek 2.5 przedstawia wykres wcześniejszych danych modułu cProfile. Inspekcja w formie wizualnej powinna ułatwić szybkie zrozumienie, że wykonanie funkcji calculate_z_serial_ purepython zajmuje większość czasu, a także tego, że jedynie część tego czasu wynika z wywoływania innych funkcji (spośród nich jedyną znaczącą jest funkcja abs). Możesz zauważyć, że znikoma ilość czasu związana jest z podprogramem konfiguracji, gdyż zdecydowana większość czasu wykonywania odnosi się do podprogramu obliczeniowego. W przypadku narzędzia runsnake możesz kliknąć funkcje i przejść do złożonych wywołań zagnieżdżonych. Narzędzie to okaże się bezcenne podczas omawiania w zespole przyczyn powolnego wykonywania w segmencie kodu.
Użycie narzędzia line_profiler do pomiarów dotyczących kolejnych wierszy kodu W opinii jednego z autorów narzędzie line_profiler Roberta Kerna oferuje największe możliwości identyfikowania w kodzie Python przyczyny problemów powiązanych z procesorem. Działanie narzędzia polega na profilowaniu wiersz po wierszu poszczególnych funkcji. Oznacza to, że należy zacząć od modułu cProfile i skorzystać z ogólnego widoku, który pozwoli określić, jakie funkcje mają być profilowane za pomocą narzędzia line_profiler. 46
Rozdział 2. Użycie profilowania do znajdowania wąskich gardeł
Rysunek 2.5. Wizualizacja pliku profilowania modułu cProfile przy użyciu narzędzia runsnake
Warto podczas modyfikowania kodu wyświetlać wersje danych wyjściowych tego narzędzia i tworzyć dla nich adnotacje, ponieważ dzięki temu uzyskuje się zapis zmian (pomyślnych lub nie), do których można szybko wrócić. Nie polegaj na pamięci przy wprowadzaniu zmian w poszczególnych wierszach. Aby zainstalować narzędzie line_profiler, wykonaj polecenie pip install line_profiler. Dekorator (@profile) służy do oznaczania wybranej funkcji. Skrypt kernprof.py jest używany do wykonywania kodu. Rejestrowany jest czas pracy procesora oraz inne statystyki dla każdego wiersza wybranej funkcji. Wymóg modyfikowania kodu źródłowego stanowi drobny kłopot, gdyż dodanie dekoratora spowoduje rozbicie testów jednostkowych, chyba że zostanie utworzony fikcyjny dekorator (więcej informacji zawiera punkt „Dekorator @profile bez operacji”).
Argument -l umożliwia profilowanie wiersz po wierszu, a argument -v powoduje zwrócenie szczegółowych danych wyjściowych. Bez argumentu -v zostaną uzyskane dane wyjściowe w postaci pliku .lprof, które możesz później poddać analizie przy użyciu modułu line_profiler. Przykład 2.6 prezentuje pełne uruchomienie funkcji powiązanej z procesorem. Przykład 2.6. Uruchomienie skryptu kernprof z danymi wyjściowymi dotyczącymi poszczególnych wierszy dla funkcji z dekoratorem w celu zarejestrowania czasu wykonywania przez procesor każdego wiersza kodu $ kernprof.py -l -v julia1_lineprofiler.py ... Wrote profile results to julia1_lineprofiler.py.lprof Timer unit: 1e-06 s
Użycie narzędzia line_profiler do pomiarów dotyczących kolejnych wierszy kodu
47
File: julia1_lineprofiler.py Function: calculate_z_serial_purepython at line 9 Total time: 100.81 s Line # Hits Per Hit % Time Line Contents ================================================== 9 @profile 10 def calculate_z_serial_purepython(maxiter, zs, cs): 11 """Obliczanie listy output przy użyciu reguły aktualizacji zbioru Julii""" 12 1 6870.0 0.0 output = [0] * len(zs) 13 1000001 0.8 0.8 for i in range(len(zs)): 14 1000000 0.8 0.8 n = 0 15 1000000 0.8 0.8 z = zs[i] 16 1000000 0.8 0.8 c = cs[i] 17 34219980 1.1 36.2 while abs(z) < 2 and n < maxiter: 18 33219980 1.0 32.6 z = z * z + c 19 33219980 0.8 27.2 n += 1 20 1000000 0.9 0.9 output[i] = n 21 1 4.0 0.0 return output
Zastosowanie skryptu kernprof.py powoduje znaczne wydłużenie czasu działania. W tym przykładzie wykonanie funkcji calculate_z_serial_purepython zajmuje około 100 sekund. Jest to spory skok w porównaniu z 13 i 19 sekundami odpowiednio w przypadku użycia prostych instrukcji print i modułu cProfile. Korzyścią jest to, że możliwe jest przeanalizowanie wiersz po wierszu tego, jak upływa czas w obrębie funkcji. Kolumna % Time jest najbardziej pomocna. Jak widać, 36% czasu zajmuje testowanie pętli while. Nie wiemy jednak, czy pierwsza instrukcja (abs(z) < 2) zajmuje więcej czasu niż druga (n < maxiter). Wewnątrz pętli widać, że aktualizacja liczby z również zajmuje sporo czasu. Nawet instrukcja n += 1 kosztuje wiele czasu! Mechanizm dynamicznego wyszukiwania języka Python działa w przypadku każdej pętli, nawet pomimo tego, że dla każdej zmiennej w każdej pętli używane są takie same typy. W tym przypadku kompilowanie i specjalizacja typów (rozdział 7.) zapewnią ogromną poprawę. Tworzenie listy output i aktualizacje w wierszu 20. zajmują stosunkowo mało czasu w porównaniu z pętlą while. Oczywistą metodą dalszego analizowania instrukcji pętli while jest jej rozbicie. Choć w społeczności związanej z językiem Python pojawiła się dyskusja dotycząca pomysłu ponownego tworzenia plików .pyc przy użyciu bardziej szczegółowych informacji na potrzeby wieloczęściowych instrukcji w postaci jednego wiersza, nieznane są nam żadne narzędzia produkcyjne, które oferują bardziej dokładną analizę niż narzędzie line_profiler. W przykładzie 2.7 dokonano rozbicia na kilka instrukcji logiki pętli while. Taka dodatkowa złożoność zwiększy czas działania funkcji, ponieważ będzie więcej wierszy kodu do wykonania. Może to jednak być pomocne w zrozumieniu kosztów związanych z wykonywaniem tej części kodu. Zanim przyjrzysz się kodowi, czy myślisz, że w ten sposób dowiemy się, jaki jest czas wykonywania fundamentalnych operacji? Czy inne czynniki mogą skomplikować analizę?
48
Rozdział 2. Użycie profilowania do znajdowania wąskich gardeł
Przykład 2.7. Rozbicie złożonej instrukcji pętli while na poszczególne instrukcje w celu zarejestrowania czasu wykonania dla każdej części oryginalnych segmentów kodu $ kernprof.py -l -v julia1_lineprofiler2.py ... Wrote profile results to julia1_lineprofiler2.py.lprof Timer unit: 1e-06 s File: julia1_lineprofiler2.py Function: calculate_z_serial_purepython at line 9 Total time: 184.739 s Line # Hits Per Hit % Time Line Contents =================================================== 9 @profile 10 def calculate_z_serial_purepython(maxiter, zs, cs): 11 """Obliczanie listy output przy użyciu reguły aktualizacji zbioru Julii""" 12 1 6831.0 0.0 output = [0] * len(zs) 13 1000001 0.8 0.4 for i in range(len(zs)): 14 1000000 0.8 0.4 n = 0 15 1000000 0.9 0.5 z = zs[i] 16 1000000 0.8 0.4 c = cs[i] 17 34219980 0.8 14.9 while True: 18 34219980 1.0 19.0 not_yet_escaped = abs(z) < 2 19 34219980 0.8 15.5 iterations_left = n < maxiter 20 34219980 0.8 15.1 if not_yet_escaped and iterations_left: 21 33219980 1.0 17.5 z = z * z + c 22 33219980 0.9 15.3 n += 1 23 else: 24 1000000 0.8 0.4 break 25 1000000 0.9 0.5 output[i] = n 26 1 5.0 0.0 return output
Wykonanie tej wersji kodu zajmuje 184 sekundy, w przypadku poprzedniej wersji było to natomiast 100 sekund. Inne czynniki utrudniły analizę. W tym przypadku kod jest spowalniany przez dodatkowe instrukcje, które muszą zostać wykonane 34 219 980 razy. Jeśli nie zostałby użyty skrypt kernprof.py do analizowania wiersz po wierszu efektu tej zmiany, z powodu braku niezbędnego dowodu być może zostałyby wyciągnięte inne wnioski na temat przyczyny spowolnienia. Na tym etapie sensowne jest cofnięcie się o krok, do wcześniejszej metody opartej na module timeit, aby sprawdzić czas wykonywania poszczególnych wyrażeń: >>> z = 0+0j # punkt w środku obrazu >>> %timeit abs(z) < 2 # testowane w obrębie powłoki IPython 10000000 loops, best of 3: 119 ns per loop >>> n = 1 >>> maxiter = 300 >>> %timeit n < maxiter 10000000 loops, best of 3: 77 ns per loop
Na podstawie tej prostej analizy można stwierdzić, że test logiki dla n jest prawie dwukrotnie szybszy niż wywołanie funkcji abs. Ponieważ wartości dla wyrażeń języka Python są określane zarówno od lewej do prawej strony, jak i oportunistycznie, sensowne jest umieszczenie najszybszego testu po lewej stronie równania. W przypadku jednego testu z każdej grupy 301 testów wykonywanych dla każdej współrzędnej test warunku n < maxiter da wartość False. Oznacza to, że interpreter języka Python nie będzie wymagał określenia wartości drugiej strony operatora and.
Użycie narzędzia line_profiler do pomiarów dotyczących kolejnych wierszy kodu
49
Do momentu określenia dla niego wartości nie wiemy, czy warunek abs(z) < 2 da wartość False. Wcześniejsze obserwacje dla tego obszaru przestrzeni zespolonej sugerują, że jest to wartość True dla około 10% czasu w przypadku wszystkich 300 iteracji. Aby dobrze zrozumieć złożoność dotyczącą czasu dla tej części kodu, sensowne byłoby kontynuowanie analizy numerycznej. W tej sytuacji zależy nam jednak na prostym sprawdzeniu, czy możliwe jest szybkie zwiększenie szybkości kodu. Możemy sformułować nową hipotezę, która brzmi: „Zmieniając kolejność operatorów w instrukcji while, osiągniemy solidne przyspieszenie”. Choć hipotezę tę można sprawdzić za pomocą skryptu kernprof.py, dodatkowe obciążenie związane z profilowaniem w ten sposób może spowodować zbyt dużo zamieszania. Możemy więc użyć wcześniejszej wersji kodu, uruchamiając test, który porównuje instrukcję while abs(z) < 2 and n < maxiter z instrukcją while n < maxiter and abs(z) < 2:. Wynik to dość trwały wzrost szybkości wynoszący około 0,4 sekundy. Oczywiście nie robi on wielkiego wrażenia, a ponadto w bardzo dużym stopniu powiązany jest z rozpatrywanym problemem; zastosowanie odpowiedniejszej metody do rozwiązania tego problemu (np. skorzystanie z narzędzia Cython lub PyPy w sposób opisany w rozdziale 7.) dałoby większe wzrosty szybkości. Możemy być pewni otrzymanego wyniku, ponieważ: Określono hipotezę prostą do sprawdzenia. Zmodyfikowano kod w taki sposób, aby była sprawdzana wyłącznie hipoteza (nigdy nie
sprawdzaj dwóch rzeczy jednocześnie!). Uzyskano wystarczający dowód na poparcie wyciągniętego wniosku.
Aby wszystko było kompletne, możemy uruchomić skrypt kernprof.py dla dwóch głównych funkcji z uwzględnieniem optymalizacji w celu potwierdzenia, że dysponujemy pełnym obrazem ogólnej złożoności kodu. Po zamianie miejscami w wierszu 17. dwóch składników testu pętli while w przykładzie 2.8 widać skromne skrócenie czasu wykonywania z 36,1% do 35,9% (taki wynik utrzymywał się dla powtarzanych uruchomień). Przykład 2.8. Zmiana miejsca występowania instrukcji pętli while w kodzie liczb zespolonych w celu nieznacznego przyspieszenia testu $ kernprof.py -l -v julia1_lineprofiler3.py ... Wrote profile results to julia1_lineprofiler3.py.lprof Timer unit: 1e-06 s File: julia1_lineprofiler3.py Function: calculate_z_serial_purepython at line 9 Total time: 99.7097 s Line # Hits Per Hit % Time Line Contents ================================================== 9 @profile 10 def calculate_z_serial_purepython(maxiter, zs, cs): 11 """Obliczanie listy output przy użyciu reguły aktualizacji zbioru Julii""" 12 1 6831.0 0.0 output = [0] * len(zs) 13 1000001 0.8 0.8 for i in range(len(zs)): 14 1000000 0.8 0.8 n = 0 15 1000000 0.9 0.9 z = zs[i] 16 1000000 0.8 0.8 c = cs[i]
50
Rozdział 2. Użycie profilowania do znajdowania wąskich gardeł
17 34219980 18 33219980 19 33219980 20 1000000 21 1
1.0 1.0 0.8 0.9 5.0
35.9 32.0 27.9 0.9 0.0
while n < maxiter and abs(z) < 2: z = z * z + c n += 1 output[i] = n return output
Zgodnie z oczekiwaniami na podstawie danych wyjściowych kodu z przykładu 2.9 widać, że wykonywanie funkcji calculate_z_serial_purepython zajmuje większość czasu (97%) działania jej funkcji nadrzędnej. Dla porównania kroki związane z tworzeniem listy zajmują znikomą ilość czasu. Przykład 2.9. Testowanie wiersz po wierszu czasu wykonywania programu konfiguracyjnego File: julia1_lineprofiler3.py Function: calc_pure_python at line 24 Total time: 195.218 s Line # Hits Per Hit % Time Line Contents ================================================== 24 @profile 25 def calc_pure_python(draw_output, desired_width, max_iterations): ... 44 1 1.0 0.0 zs = [] 45 1 1.0 0.0 cs = [] 46 1001 1.1 0.0 for ycoord in y: 47 1001000 1.1 0.5 for xcoord in x: 48 1000000 1.5 0.8 zs.append( complex(xcoord, ycoord)) 49 1000000 1.6 0.8 cs.append( complex(c_real, c_imag)) 50 51 1 51.0 0.0 print "Długość dla x:", len(x) 52 1 11.0 0.0 print "Łączna liczba elementów:", len(zs) 53 1 6.0 0.0 start_time = time.time() 54 1 191031307.0 97.9 output = calculate_z_serial_purepython (max_iterations, zs, cs) 55 1 4.0 0.0 end_time = time.time() 56 1 2.0 0.0 secs = end_time - start_time 57 1 58.0 0.0 print Działanie funkcji calculate_z_serial_purepython .func_name + " trwało", secs, "s" 58 # suma ta jest oczekiwana dla siatki 1000^2... 59 1 9799.0 0.0 assert sum(output) == 33219980
Użycie narzędzia memory_profiler do diagnozowania wykorzystania pamięci Tak jak narzędzie line_profiler Roberta Kerna dokonuje pomiaru wykorzystania procesora, tak moduł memory_profiler Fabiana Pedregosa i Philippe’a Gervaisa mierzy wykorzystanie pamięci dla kolejnych wierszy kodu. Zrozumienie cech kodu związanych z wykorzystaniem pamięci umożliwia zadanie sobie następujących dwóch pytań: Czy możliwe jest użycie mniejszej ilości pamięci RAM przez modyfikację funkcji pod kątem
bardziej efektywnego działania?
Czy możliwe jest za pomocą buforowania użycie większej ilości pamięci RAM i zyskanie
cykli procesora?
Użycie narzędzia memory_profiler do diagnozowania wykorzystania pamięci
51
Moduł memory_profiler działa w sposób bardzo podobny do narzędzia line_profiler, ale zapewnia znacznie mniejszą wydajność. Po zainstalowaniu pakietu psutil (opcjonalny, lecz zalecany) moduł memory_profiler będzie działać szybciej. Profilowanie pamięci bez trudu może sprawić, że kod będzie wykonywany od 10 do 100 razy wolniej. W praktyce moduł ten będzie używany sporadycznie, a narzędzie line_profiler (do profilowania wykorzystania procesora) częściej. Moduł memory_profiler zainstaluj za pomocą polecenia pip install memory_profiler (opcjonalnie użyj polecenia pip install psutil). Jak wspomniano, implementacja modułu memory_profiler nie zapewnia takiej wydajności jak implementacja narzędzia line_profiler. A zatem sensowne może być wykonywanie testów za pomocą modułu dla mniejszego problemu. Testy te zostaną zakończone w rozsądnym czasie. Całonocne uruchomienia mogą mieć sens w przypadku sprawdzania poprawności, ale do diagnozowania problemów i określania hipotez dotyczących rozwiązań niezbędne będą szybkie i rozsądne iteracje. W kodzie z przykładu 2.10 używana jest siatka 10001000. W przypadku laptopa jednego z autorów zgromadzenie statystyk zajęło około 1,5 godziny. Przykład 2.10. Wyniki modułu memory_profiler uzyskane dla obu głównych funkcji, które uwidaczniają nieoczekiwane użycie pamięci w funkcji calculate_z_serial_purepython $ python -m memory_profiler julia1_memoryprofiler.py ... Line # Mem usage Increment Line Contents ================================================ 9 89.934 MiB 0.000 MiB @profile 10 def calculate_z_serial_purepython(maxiter, zs, cs): 11 """Obliczanie listy output przy użyciu... 12 97.566 MiB 7.633 MiB output = [0] * len(zs) 13 130.215 MiB 32.648 MiB for i in range(len(zs)): 14 130.215 MiB 0.000 MiB n = 0 15 130.215 MiB 0.000 MiB z = zs[i] 16 130.215 MiB 0.000 MiB c = cs[i] 17 130.215 MiB 0.000 MiB while n < maxiter and abs(z) < 2: 18 130.215 MiB 0.000 MiB z = z * z + c 19 130.215 MiB 0.000 MiB n += 1 20 130.215 MiB 0.000 MiB output[i] = n 21 122.582 MiB -7.633 MiB return output Line # Mem usage Increment Line Contents ================================================ 24 10.574 MiB -112.008 MiB @profile 25 def calc_pure_python(draw_output, desired_width, max_iterations): 26 """Tworzenie listy liczb zespolonych... 27 10.574 MiB 0.000 MiB x_step = (float(x2 - x1) / ... 28 10.574 MiB 0.000 MiB y_step = (float(y1 - y2) / ... 29 10.574 MiB 0.000 MiB x = [] 30 10.574 MiB 0.000 MiB y = [] 31 10.574 MiB 0.000 MiB ycoord = y2 32 10.574 MiB 0.000 MiB while ycoord > y1: 33 10.574 MiB 0.000 MiB y.append(ycoord) 34 10.574 MiB 0.000 MiB ycoord += y_step 35 10.574 MiB 0.000 MiB xcoord = x1 36 10.582 MiB 0.008 MiB while xcoord < x2: 37 10.582 MiB 0.000 MiB x.append(xcoord) 38 10.582 MiB 0.000 MiB xcoord += x_step
52
Rozdział 2. Użycie profilowania do znajdowania wąskich gardeł
... 44 10.582 45 10.582 46 89.926 47 89.926 48 89.926 49 89.926 50 51 89.934 52 89.934 53 89.934 54 55 122.582 ...
MiB MiB MiB MiB MiB MiB
0.000 0.000 79.344 0.000 0.000 0.000
MiB MiB MiB MiB MiB MiB
MiB MiB MiB
0.008 MiB 0.000 MiB 0.000 MiB
MiB
32.648 MiB
zs = [] cs = [] for ycoord in y: for xcoord in x: zs.append(complex(xcoord, ycoord)) cs.append(complex(c_real, c_imag)) print "Długość dla x:", len(x) print "Łączna liczba elementów:", len(zs) start_time = time.time() output = calculate_z_serial... end_time = time.time()
Wymóg modyfikowania kodu źródłowego stanowi drobny kłopot. Podobnie jak w przypadku narzędzia line_profiler, dekorator (@profile) służy do oznaczenia wybranej funkcji. Dekorator spowoduje rozbicie testów jednostkowych, chyba że zostanie utworzony fikcyjny dekorator (więcej informacji zawiera punkt „Dekorator @profile bez operacji”).
Gdy zajmujesz się przydzielaniem pamięci, musisz mieć świadomość tego, że sytuacja nie jest tak klarowna jak w przypadku wykorzystania procesora. Ogólnie rzecz biorąc, bardziej efektywne jest nadmierne przypisanie pamięci do procesu w puli lokalnej, która może być używana w dogodnym momencie, ponieważ operacje przydziału pamięci zajmują stosunkowo dużo czasu. Ponadto czyszczenie pamięci nie następuje od razu, dlatego obiekty mogą być niedostępne, ale w dalszym ciągu przez jakiś czas mogą znajdować się w puli procesu czyszczenia pamięci. Efekt tego jest taki, że trudno w pełni zrozumieć, co dzieje się z wykorzystaniem i zwalnianiem pamięci w obrębie programu Python. Wynika to stąd, że wiersz kodu może nie przydzielić możliwej do określenia ilości pamięci, jaką ustalono poza obrębem procesu. Obserwowanie ogólnego trendu dla zestawu wierszy prawdopodobnie doprowadzi do lepszych wniosków niż monitorowanie zachowania tylko jednego wiersza. Przyjrzyjmy się danym wyjściowym modułu memory_profiler z przykładu 2.10. Wewnątrz funkcji calculate_z_serial_purepython w wierszu 12. widać, że przydzielenie 1 000 000 elementów powoduje dodanie do procesu około 7 MB pamięci RAM1. Nie oznacza to, że lista output na pewno ma wielkość wynoszącą 7 MB, ale jedynie to, że proces zwiększył wielkość w przybliżeniu o 7 MB w czasie wewnętrznej alokacji listy. W wierszu 13. widać, że w obrębie pętli proces powiększył się w przybliżeniu o kolejne 32 MB. Można to przypisać wywołaniu funkcji range (śledzenie pamięci RAM zostało obszerniej omówione w przykładzie 11.1; różnica w wielkościach 7 MB i 32 MB wynika z zawartości dwóch list). W procesie nadrzędnym w wierszu 46. widoczne jest, że alokacja list zs i cs powoduje wykorzystanie około 79 MB. I tym razem godne uwagi jest to, że niekoniecznie jest to rzeczywista wielkość tablic, ale jedynie wielkość, o jaką powiększył się proces po utworzeniu tych list.
1
Moduł memory_profiler mierzy wykorzystanie pamięci zgodnie z jednostką MiB (mebibajt) organizacji International Electrotechnical Commission. Jednostka ta odpowiada wartości 220 bajtów. Różni się trochę od powszechniejszej, lecz też bardziej niejednoznacznej jednostki MB (megabajt ma dwie ogólnie akceptowane definicje!). Jeden MiB odpowiada 1,048576 (lub w przybliżeniu 1,05) MB. Jeśli nie będzie mowy o bardzo specyficznych wielkościach, na potrzeby omówienia będziemy posługiwać się jednostką MiB.
Użycie narzędzia memory_profiler do diagnozowania wykorzystania pamięci
53
Inna metoda wizualizacji zmiany wykorzystania pamięci polega na próbkowaniu w czasie i prezentowaniu wyniku na wykresie. Narzędzie memory_profiler oferuje program o nazwie mprof, który po pierwsze, służy do próbkowania wykorzystania pamięci, a po drugie, do wizualizacji próbek. Ponieważ próbkowanie odbywa się w czasie, a nie w oparciu o wiersze kodu, ma znikomy wpływ na działanie kodu. Rysunek 2.6 utworzono za pomocą polecenia mprof run julia1_memoryprofiler.py. Tworzy ono plik statystyk, który następnie jest poddawany wizualizacji przy użyciu polecenia mprof plot. Dwie przykładowe funkcje są zawarte w nawiasach kwadratowych. Dzięki temu widoczne jest, w jakim momencie w czasie są one aktywowane, a ponadto możliwe jest obserwowanie wzrostu wykorzystania pamięci RAM w trakcie ich działania. W obrębie funkcji calculate_z_serial_ purepython widoczny jest ciągły wzrost wykorzystania pamięci RAM podczas jej wykonywania. Jest to spowodowane przez wszystkie niewielkie obiekty (typów int i float), które są tworzone.
Rysunek 2.6. Raport narzędzia memory_profiler wygenerowany przy użyciu programu mprof
Oprócz obserwowania zachowania na poziomie funkcji możesz dodać etykiety za pomocą menedżera kontekstów. Fragment kodu z przykładu 2.11 służy do wygenerowania wykresu z rysunku 2.7. Widoczna jest etykieta create_output_list, która pojawia się chwilę po funkcji calculate_z_serial_purepython, powodując przydzielenie procesowi większej ilości pamięci RAM. Dalej następuje wstrzymanie na sekundę. Funkcja time.sleep(1) to sztuczny dodatek, którego zadaniem jest ułatwienie zrozumienia wykresu. Po etykiecie create_range_of_zs widoczny jest duży i szybki wzrost wykorzystania pamięci RAM. W zmodyfikowanym kodzie z przykładu 2.11 etykieta ta będzie widoczna podczas tworzenia listy iterations. Zamiast funkcji xrange użyto funkcji range. Na diagramie powinno być wyraźnie widoczne, że instancja dużej listy 1 000 000 elementów jest tworzona tylko na potrzeby generowania indeksu. Ponadto jest to nieefektywne rozwiązanie, które nie będzie skalowane w przypadku list o większym rozmiarze (zabraknie pamięci RAM!). Sama alokacja pamięci użyta w celu utrzymania tej listy zajmie niewielką ilość czasu, co w przypadku tej funkcji nie da żadnych korzyści. 54
Rozdział 2. Użycie profilowania do znajdowania wąskich gardeł
Rysunek 2.7. Raport narzędzia memory_profiler z etykietami wygenerowanymi przy użyciu programu mprof Przykład 2.11. Użycie menedżera kontekstów do dodania etykiet do wykresu programu mprof @profile def calculate_z_serial_purepython(maxiter, zs, cs): """Obliczanie listy output przy użyciu reguły aktualizacji zbioru Julii""" with profile.timestamp("create_output_list"): output = [0] * len(zs) time.sleep(1) with profile.timestamp("create_range_of_zs"): iterations = range(len(zs)) with profile.timestamp("calculate_output"): for i in iterations: n = 0 z = zs[i] c = cs[i] while n < maxiter and abs(z) < 2: z = z * z + c n += 1 output[i] = n return output
W języku Python 3 zmianie ulega działanie funkcji range. Działa ona podobnie do funkcji xrange z języka Python 2. W języku Python 3 funkcja xrange została wycofana, a narzędzie konwersji 2to3 zajmuje się tą zmianą automatycznie.
W bloku etykiety calculate_output, którego działanie obejmuje większość wykresu, widoczny jest bardzo powolny liniowy wzrost wykorzystania pamięci RAM. Będzie to efektem użycia wszystkich liczb tymczasowych w pętlach wewnętrznych. Zastosowanie etykiet naprawdę pomaga zrozumieć, gdzie dokładnie jest wykorzystywana pamięć.
Użycie narzędzia memory_profiler do diagnozowania wykorzystania pamięci
55
Na koniec możemy zmienić wywołanie funkcji range w wywołanie funkcji xrange. Na rysunku 2.8 widać odpowiedni spadek wykorzystania pamięci RAM w pętli wewnętrznej.
Rysunek 2.8. Raport narzędzia memory_profiler prezentujący efekt zmiany funkcji range na funkcję xrange
Aby dokonać pomiaru ilości pamięci RAM używanej przez kilka instrukcji, można skorzystać z funkcji „magicznej” %memit powłoki IPython, która działa tak jak funkcja %timeit. W rozdziale 11. przyjrzymy się użyciu funkcji %memit do pomiaru ilości pamięci wykorzystywanej przez listy, a ponadto omówimy różne metody bardziej efektywnego użycia pamięci RAM.
Inspekcja obiektów w stercie za pomocą narzędzia heapy Projekt Guppy oferuje narzędzie do inspekcji sterty o nazwie heapy, które pozwala sprawdzić numer i wielkość każdego obiektu w stercie interpretera języka Python. Wgląd w interpreter i zrozumienie tego, co znajduje się w pamięci, okazuje się wyjątkowo przydatne w przypadku rzadkich, lecz trudnych sesji debugowania, gdy naprawdę niezbędne jest określenie liczby używanych obiektów, a także tego, czy są usuwane z pamięci w odpowiednim momencie. Jeśli masz do czynienia z kłopotliwym „przeciekiem” pamięci (prawdopodobnie spowodowanym odwołaniami do obiektów, które pozostają ukryte w złożonym systemie), narzędzie heapy jest tym, które pozwoli dotrzeć do źródła problemu. Jeśli dokonujesz przeglądu kodu w celu stwierdzenia, czy generuje przewidywaną liczbę obiektów, narzędzie to bardzo Ci się przyda. Wyniki mogą być zaskakujące i mogą zapewnić nowe możliwości optymalizacji. Aby skorzystać z narzędzia heapy, zainstaluj pakiet guppy za pomocą polecenia pip install guppy.
56
Rozdział 2. Użycie profilowania do znajdowania wąskich gardeł
Listing kodu z przykładu 2.12 to nieznacznie zmodyfikowana wersja kodu zbioru Julii. Obiekt sterty hpy dołączono do funkcji calc_pure_python. Zostanie wyświetlony stan sterty w trzech miejscach. Przykład 2.12. Użycie narzędzia heapy do sprawdzenia, jak liczba obiektów zmienia się podczas działania kodu def calc_pure_python(draw_output, desired_width, max_iterations): ... while xcoord < x2: x.append(xcoord) xcoord += x_step from guppy import hpy; hp = hpy() print "Stan narzędzia heapy po utworzeniu list y i x liczb zmiennoprzecinkowych" h = hp.heap() print h print zs = [] cs = [] for ycoord in y: for xcoord in x: zs.append(complex(xcoord, ycoord)) cs.append(complex(c_real, c_imag)) print "Stan narzędzia heapy po utworzeniu list zs i cs przy użyciu liczb zespolonych" h = hp.heap() print h print print "Długość dla x:", len(x) print "Łączna liczba elementów:", len(zs) start_time = time.time() output = calculate_z_serial_purepython(max_iterations, zs, cs) end_time = time.time() secs = end_time - start_time print Działanie funkcji calculate_z_serial_purepython.func_name + " trwało", secs, "s" print print "Stan narzędzia heapy po wywołaniu funkcji calculate_z_serial_purepython" h = hp.heap() print h print
Dane wyjściowe z przykładu 2.13 pokazują, że wykorzystanie pamięci staje się bardziej interesujące po utworzeniu list zs i cs. Z powodu 2 000 000 obiektów complex zużywających 64 000 000 bajtów wykorzystanie pamięci wzrosło w przybliżeniu o 80 MB. Liczby zespolone reprezentują większość wykorzystania pamięci na tym etapie. Jeśli miałoby zostać zoptymalizowane zużycie pamięci w kodzie tego programu, uzyskany wynik ujawniłby wszystko, ponieważ pozwala stwierdzić zarówno, ile obiektów jest przechowywanych, jak i jaka jest ich ogólna wielkość. Przykład 2.13. Sprawdzanie danych wyjściowych narzędzia heapy w celu stwierdzenia, jak się zwiększa liczba obiektów na każdym głównym etapie wykonywania kodu $ python julia1_guppy.py Stan narzędzia heapy po utworzeniu list y i x liczb zmiennoprzecinkowych Partition of a set of 27293 objects. Total size = 3416032 bytes. Index Count % Size % Cumulative % Kind (class / dict of class) 0 10960 40 1050376 31 1050376 31 str 1 5768 21 465016 14 1515392 44 tuple 2 199 1 210856 6 1726248 51 dict of type 3 72 0 206784 6 1933032 57 dict of module 4 1592 6 203776 6 2136808 63 types.CodeType 5 313 1 201304 6 2338112 68 dict (no owner) 6 1557 6 186840 5 2524952 74 function
Inspekcja obiektów w stercie za pomocą narzędzia heapy
57
7 8 9
Stan narzędzia heapy po utworzeniu list zs i cs przy użyciu liczb zespolonych Partition of a set of 2027301 objects. Total size = 83671256 bytes. Index Count % Size % Cumulative % Kind (class / dict of class) 0 2000000 99 64000000 76 64000000 76 complex 1 185 0 16295368 19 80295368 96 list 2 10962 1 1050504 1 81345872 97 str 3 5767 0 464952 1 81810824 98 tuple 4 199 0 210856 0 82021680 98 dict of type 5 72 0 206784 0 82228464 98 dict of module 6 1592 0 203776 0 82432240 99 types.CodeType 7 319 0 202984 0 82635224 99 dict (no owner) 8 1556 0 186720 0 82821944 99 function 9 199 0 177008 0 82998952 99 type
Długość dla x: 1000 Łączna liczba elementów: 1000000 Działanie funkcji calculate_z_serial_purepython trwało 13.2436609268 s Stan narzędzia heapy po wywołaniu funkcji calculate_z_serial_purepython Partition of a set of 2127696 objects. Total size = 94207376 bytes. Index Count % Size % Cumulative % Kind (class / dict of class) 0 2000000 94 64000000 68 64000000 68 complex 1 186 0 24421904 26 88421904 94 list 2 100965 5 2423160 3 90845064 96 int 3 10962 1 1050504 1 91895568 98 str 4 5767 0 464952 0 92360520 98 tuple 5 199 0 210856 0 92571376 98 dict of type 6 72 0 206784 0 92778160 98 dict of module 7 1592 0 203776 0 92981936 99 types.CodeType 8 319 0 202984 0 93184920 99 dict (no owner) 9 1556 0 186720 0 93371640 99 function
W trzeciej sekcji po obliczeniu wyniku dla zbioru Julii zostały użyte 94 MB. Oprócz liczb zespolonych w pamięci znajduje się obecnie duża kolekcja liczb całkowitych oraz więcej pozycji przechowywanych na listach. Ponieważ funkcja hpy.setrelheap() może posłużyć do utworzenia punktu kontrolnego konfiguracji pamięci, kolejne wywołania funkcji hpy.heap() spowodują wygenerowanie delty przy użyciu tego punktu. Dzięki temu możesz uniknąć sprawdzania wewnętrznych mechanizmów interpretera języka Python i wcześniejszej konfiguracji pamięci przed analizowanym miejscem programu.
Użycie narzędzia dowser do generowania aktywnego wykresu dla zmiennych z utworzonymi instancjami Narzędzie dowser Roberta Brewera podłącza się do przestrzeni nazw działającego kodu i zapewnia w przeglądarce internetowej za pośrednictwem interfejsu CherryPy w czasie rzeczywistym widok zmiennych z utworzonymi instancjami. Każdy śledzony obiekt ma powiązany wykres przebiegu w czasie, dlatego możesz sprawdzić, czy zwiększają się liczby określonych obiektów. Przydaje się to przy debugowaniu długotrwałych procesów.
58
Rozdział 2. Użycie profilowania do znajdowania wąskich gardeł
Jeśli dla długotrwałego procesu oczekujesz wystąpienia innego zachowania pamięci zależnie od działań podjętych w aplikacji (np. w przypadku serwera WWW możesz wysłać dane lub spowodować uruchomienie złożonych zapytań), może to zostać potwierdzone interaktywnie. Odpowiedni przykład zaprezentowano na rysunku 2.9.
Rysunek 2.9. Kilka wykresów przebiegu w czasie wyświetlonych za pośrednictwem interfejsu CherryPy przy użyciu narzędzia dowser
Aby można było skorzystać z narzędzia dowser, do kodu zbioru Julii zostanie dodana wygodna funkcja (zaprezentowana w przykładzie 2.14), która może uruchomić serwer interfejsu CherryPy. Przykład 2.14. Funkcja pomocnicza służąca do uruchomienia narzędzia dowser w aplikacji def launch_memory_usage_server(port=8080): import cherrypy import dowser cherrypy.tree.mount(dowser.Root()) cherrypy.config.update({ 'environment': 'embedded', 'server.socket_port': port }) cherrypy.engine.start()
Przed rozpoczęciem intensywnych obliczeń zostanie uruchomiony serwer interfejsu CherryPy (przykład 2.15). Po zakończeniu obliczeń konsola może pozostać otwarta dzięki funkcji time.sleep. W tym przypadku proces interfejsu CherryPy nadal działa, co pozwala kontynuować analizę stanu przestrzeni nazw. Przykład 2.15. Wywoływanie w aplikacji narzędzia dowser w odpowiednim momencie. Powoduje to uruchomienie serwera WWW ... for xcoord in x: zs.append(complex(xcoord, ycoord)) cs.append(complex(c_real, c_imag)) launch_memory_usage_server() ... output = calculate_z_serial_purepython(max_iterations, zs, cs) ... print "Oczekiwanie..." while True: time.sleep(1)
Użycie narzędzia dowser do generowania aktywnego wykresu dla zmiennych z utworzonymi instancjami
59
Odnośniki TRACE widoczne na rysunku 2.9 pozwalają wyświetlić zawartość każdego obiektu list (rysunek 2.10). Możliwe jest też dokładniejsze sprawdzenie każdego obiektu list. Choć przypomina to użycie interaktywnego debugera w środowisku IDE, możliwe jest do zrealizowania na wdrożonym serwerze bez użycia tego środowiska.
Rysunek 2.10. Milion pozycji na liście po zastosowaniu narzędzia dowser Preferujemy wyodrębnianie bloków kodu, które mogą być profilowane w kontrolowanych warunkach. Czasem jest to jednak niepraktyczne. W niektórych sytuacjach będzie po prostu wymagana prostsza diagnostyka. Obserwowanie śledzenia w czasie rzeczywistym działającego procesu może stanowić kompromis dla uzyskania niezbędnego dowodu bez uciekania się do zaawansowanej inżynierii oprogramowania.
Użycie modułu dis do sprawdzania kodu bajtowego narzędzia CPython Do tej pory dokonano przeglądu różnych metod pomiaru czasu wykonywania kodu Python (w przypadku wykorzystania procesora i pamięci RAM). Nie zajęliśmy się jednak jeszcze bazowym kodem bajtowym używanym przez maszynę wirtualną. Zrozumienie tego, co dzieje się pod podszewką, ułatwi zbudowanie modelu pamięciowego tego, co odbywa się w powolnych funkcjach. Ponadto okaże się pomocne podczas kompilowania kodu. Zajmijmy się zatem kodem bajtowym. Moduł dis umożliwia sprawdzanie bazowego kodu bajtowego, który jest uruchamiany w obrębie maszyny wirtualnej narzędzia CPython opartej na stosie. Zrozumienie tego, co się dzieje w maszynie wirtualnej uruchamiającej kod Python wyższego poziomu, pozwoli zorientować się, dlaczego niektóre style tworzenia kodu zapewniają większą szybkość niż inne. Ułatwi to również użycie narzędzia takiego jak Cython, które „wychodzi” poza kod Python i generuje kod C. Moduł dis jest wbudowany. Po przekazaniu do niego kodu lub modułu wyświetli on wyniki dezasemblacji. W przykładzie 2.16 przeprowadzana jest dezasemblacja pętli zewnętrznej funkcji powiązanej z procesorem. Należy spróbować dezasemblacji jednej z własnych funkcji, a następnie podjąć próbę dokładnego prześledzenia tego, jaka jest zgodność kodu poddanego dezasemblacji z danymi wyjściowymi po tej operacji. Czy możesz dopasować przedstawione poniżej dane wyjściowe modułu dis do oryginalnej funkcji?
60
Rozdział 2. Użycie profilowania do znajdowania wąskich gardeł
Przykład 2.16. Użycie wbudowanego modułu dis do zrozumienia bazowej maszyny wirtualnej opartej na stosie, która wykonuje kod Python In [1]: import dis In [2]: import julia1_nopil In [3]: dis.dis(julia1_nopil.calculate_z_serial_purepython) 11 0 LOAD_CONST 1 (0) 3 BUILD_LIST 1 6 LOAD_GLOBAL 0 (len) 9 LOAD_FAST 1 (zs) 12 CALL_FUNCTION 1 15 BINARY_MULTIPLY 16 STORE_FAST 3 (output) 12 19 SETUP_LOOP 123 (to 145) 22 LOAD_GLOBAL 1 (range) 25 LOAD_GLOBAL 0 (len) 28 LOAD_FAST 1 (zs) 31 CALL_FUNCTION 1 34 CALL_FUNCTION 1 37 GET_ITER >> 38 FOR_ITER 103 (to 144) 41 STORE_FAST 4 (i) 13 44 LOAD_CONST 1 (0) 47 STORE_FAST 5 (n) # ... # W celu utrzymania zwięzłości zostanie obcięta reszta pętli wewnętrznej! # ... 19 >> 131 LOAD_FAST 5 (n) 134 LOAD_FAST 3 (output) 137 LOAD_FAST 4 (i) 140 STORE_SUBSCR 141 JUMP_ABSOLUTE 38 >> 144 POP_BLOCK 20 >> 145 LOAD_FAST 3 (output) 148 RETURN_VALUE
Mimo swojej zwięzłości dane wyjściowe są dość zrozumiałe. Pierwsza kolumna zawiera numery wierszy powiązane z oryginalnym plikiem. W drugiej kolumnie znajduje się kilka symboli >>. Reprezentują one miejsca docelowe dla punktów skoku gdzieś w kodzie. Trzecia kolumna zawiera adres operacji wraz z nazwą operacji. Czwarta kolumna przechowuje parametry operacji. W piątej kolumnie znajdują się adnotacje ułatwiające dopasowanie kodu bajtowego do oryginalnych parametrów kodu Python. Aby dopasować kod bajtowy do odpowiedniego kodu Python, cofnij się do przykładu 2.3. Kod bajtowy umieszcza najpierw na stosie stałą wartość 0, a następnie tworzy listę jednoelementową. Dalej kod przeszukuje przestrzenie nazw w celu znalezienia funkcji len, umieszcza ją w stosie, ponownie przeszukuje przestrzenie nazw, aby znaleźć listę zs, po czym wstawia ją do stosu. W wierszu 12. kod bajtowy wywołuje funkcję len ze stosu, która używa odwołania do listy zs w stosie, a następnie dla dwóch ostatnich argumentów (długość listy zs i lista jednoelementowa) stosuje mnożenie binarne i wynik przechowuje na liście output. Jest to pierwszy wiersz funkcji języka Python, jaką się teraz zajmowaliśmy. Prześledź następny blok kodu bajtowego, aby zrozumieć działanie drugiego wiersza kodu Python (pętla zewnętrzna for). Punkty skoku (>>) dopasowują instrukcje, takie jak JUMP_ABSOLUTE i POP_JUMP_IF_FALSE. Przeanalizuj własną funkcję poddaną dezasemblacji i dopasuj punkty skoku do instrukcji skoku.
Użycie modułu dis do sprawdzania kodu bajtowego narzędzia CPython
61
Po wprowadzeniu do kodu bajtowego możemy zadać następujące pytanie: „Jak na kod bajtowy i czas wykonywania wpływa jawne tworzenie funkcji w porównaniu z użyciem w tym samym celu funkcji wbudowanych?”.
Różne metody, różna złożoność Powinna istnieć jedna, i najlepiej tylko jedna, oczywista metoda zrealizowania czegoś. Choć początkowo metoda ta może nie być oczywista, o ile nie jesteś Holendrem… — Tim Peters, The Zen of Python
Istnieją różne metody wyrażania pomysłów za pomocą języka Python. Choć generalnie powinno być jasne, jaka opcja jest najbardziej rozsądna, jeśli masz doświadczenie głównie ze starszą wersją języka Python lub innego języka programowania, możesz mieć na myśli inne rozwiązania. Niektóre z tych metod mogą zapewniać mniejszą wydajność od innych. W przypadku większości kodu prawdopodobnie bardziej zależy Ci na jego czytelności niż szybkości, aby zespół programistów mógł efektywnie tworzyć kod bez dłuższego zastanawiania się nad wydajnym, lecz zagmatwanym kodem. Czasami jednak wymagana będzie wydajność (bez utraty czytelności kodu). W tym przypadku niezbędne może być testowanie szybkości. Przyjrzyj się dwóm fragmentom kodu z przykładu 2.17. Choć oba realizują to samo zadanie, pierwszy z nich wygeneruje mnóstwo dodatkowego kodu bajtowego Python, co spowoduje większe obciążenie. Przykład 2.17. Naiwny i bardziej efektywny sposób rozwiązania tego samego problemu dotyczącego sumowania def fn_expressive(upper = 1000000): total = 0 for n in xrange(upper): total += n return total def fn_terse(upper = 1000000): return sum(xrange(upper)) print "Funkcje zwracają ten sam wynik:", fn_expressive() == fn_terse() Funkcje zwracają ten sam wynik: True
Obie funkcje obliczają sumę dla zakresu liczb całkowitych. Prosta zasada (musi jednak zostać poparta użyciem profilowania!) głosi, że większa liczba wierszy kodu bajtowego będzie wykonywana wolniej niż mniejsza liczba odpowiednich wierszy kodu bajtowego, który korzysta z wbudowanych funkcji. W przykładzie 2.18 użyto funkcji „magicznej” %timeit powłoki IPython do pomiaru najlepszego czasu wykonywania na podstawie zestawu uruchomień. Przykład 2.18. Użycie funkcji %timeit do testowania hipotezy określającej, że użycie funkcji wbudowanych powinno zapewnić większą szybkość niż w przypadku napisania własnych funkcji %timeit fn_expressive() 10 loops, best of 3: 42 ms per loop 100 loops, best of 3: 12.3 ms per loop %timeit fn_terse()
62
Rozdział 2. Użycie profilowania do znajdowania wąskich gardeł
Jeśli do sprawdzenia kodu dla każdej funkcji zostałby użyty moduł dis (przykład 2.19), okazałoby się, że maszyna wirtualna ma do wykonania 17 wierszy dla bardziej rozbudowanej funkcji i tylko 6 wierszy w przypadku bardzo czytelnej, lecz bardziej zwięzłej drugiej funkcji. Przykład 2.19. Użycie modułu dis do wyświetlenia liczby instrukcji kodu bajtowego objętych dwiema przykładowymi funkcjami import dis print fn_expressive.func_name dis.dis(fn_expressive) fn_expressive 2 0 LOAD_CONST 3 STORE_FAST 3 6 SETUP_LOOP 9 LOAD_GLOBAL 12 LOAD_FAST 15 CALL_FUNCTION 18 GET_ITER >> 19 FOR_ITER 22 STORE_FAST 4 25 LOAD_FAST 28 LOAD_FAST 31 INPLACE_ADD 32 STORE_FAST 35 JUMP_ABSOLUTE >> 38 POP_BLOCK 5 >> 39 LOAD_FAST 42 RETURN_VALUE print fn_terse.func_name dis.dis(fn_terse) fn_terse 8 0 LOAD_GLOBAL 3 LOAD_GLOBAL 6 LOAD_FAST 9 CALL_FUNCTION 12 CALL_FUNCTION 15 RETURN_VALUE
1 1 30 0 0 1
(0) (total) (to 39) (xrange) (upper)
16 2 1 2
(to 38) (n) (total) (n)
1 (total) 19 1 (total)
0 (sum) 1 (xrange) 0 (upper) 1 1
Różnica między dwoma blokami kodu jest ewidentna. Wewnątrz funkcji fn_expressive() utrzymywane są dwie zmienne lokalne, a ponadto wykonywana jest iteracja dla listy przy użyciu instrukcji for. Pętla for będzie sprawdzana w celu stwierdzenia, czy wyjątek StopIteration wystąpił w każdej pętli. Każda iteracja stosuje funkcję total.__add__, która sprawdzi typ drugiej zmiennej (n) w każdej iteracji. Wszystkie te sprawdzenia powodują niewielkie obciążenie pod kątem wydajności. W obrębie funkcji fn_terse() wywoływana jest zoptymalizowana funkcja wyrażeń listowych języka C, która potrafi wygenerować wynik końcowy bez tworzenia pośrednich obiektów Python. Choć jest to znacznie szybsze, każda iteracja w dalszym ciągu musi dokonywać sprawdzenia typów dodawanych razem obiektów (w rozdziale 4. przyjrzymy się metodom ustalania typu, dzięki czemu nie ma potrzeby sprawdzania go w każdej iteracji). Jak wcześniej wspomniano, konieczne jest profilowanie kodu. Jeśli będzie się polegać jedynie na heurystyce, bez wątpienia w pewnym momencie zostanie utworzony wolniejszy kod. Zdecydowanie warto dowiedzieć się, czy język Python oferuje krótszą, a jednocześnie nadal zrozumiałą metodę rozwiązania problemu. Jeśli tak będzie, bardziej prawdopodobne jest to, że kod okaże się bardziej czytelny dla innego programisty, czyli prawdopodobnie będzie działać szybciej.
Użycie modułu dis do sprawdzania kodu bajtowego narzędzia CPython
63
Testowanie jednostkowe podczas optymalizacji w celu zachowania poprawności Jeśli jeszcze nie stosujesz dla kodu testowania jednostkowego, prawdopodobnie w dłuższej perspektywie wpłynie to niekorzystnie na Twoją produktywność. Jeden z autorów (rumieni się) wstydzi się wspomnieć, że raz spędził cały dzień na optymalizowaniu swojego kodu przy wyłączonych testach jednostkowych, dlatego że były niewygodne, tylko po to, aby odkryć, że wynikowe znaczne przyspieszenie było spowodowane rozbiciem części ulepszanego przez niego algorytmu. Ani razu nie musisz popełniać tego błędu. Oprócz testowania jednostkowego należy też poważnie rozważyć użycie skryptu coverage.py. Umożliwia on stwierdzenie, jakie wiersze kodu są sprawdzane przez testy, a ponadto identyfikuje sekcje bez pokrycia. Skrypt pozwala szybko określić, czy testujesz kod, który zostanie zoptymalizowany. Dzięki temu wszelkie pomyłki, jakie mogą pojawić się w podczas procesu optymalizacji, zostaną szybko wychwycone.
Dekorator @profile bez operacji Testy jednostkowe nie powiodą się z wygenerowanym wyjątkiem NameError, jeśli w kodzie używany jest dekorator @profile z narzędzia line_profiler lub memory_profiler. Powodem jest to, że środowisko testów jednostkowych nie wprowadzi dekoratora @profile do lokalnej przestrzeni nazw. Problem ten rozwiązuje przedstawiony tutaj dekorator bez operacji. Najprościej, należy go dodać do testowanego bloku kodu i usunąć po zakończeniu testów. Dzięki dekoratorowi bez operacji możesz uruchamiać testy bez modyfikowania testowanego kodu. Oznacza to, że możesz wykonywać testy po każdej profilowanej optymalizacji. Dzięki temu nigdy nie zostaniesz zaskoczony niepoprawnym krokiem optymalizacji. Załóżmy, że istnieje trywialny moduł ex.py zaprezentowany w przykładzie 2.20. Moduł zawiera test (dla narzędzia nosetests) i funkcję, która była profilowana za pomocą narzędzia line_profiler lub memory_profiler. Przykład 2.20. Prosta funkcja i przypadek testowy, w którym ma zostać użyty dekorator @profile # ex.py import unittest @profile def some_fn(nbr): return nbr * 2 class TestCase(unittest.TestCase): def test(self): result = some_fn(2) self.assertEquals(result, 4)
Jeśli dla kodu modułu ex.py zostanie uruchomione narzędzie nosetests, zostanie wygenerowany wyjątek NameError: $ nosetests ex.py E ====================================================================== ERROR: Failure: NameError (name 'profile' is not defined) ... NameError: name 'profile' is not defined Ran 1 test in 0.001s FAILED (errors=1)
64
Rozdział 2. Użycie profilowania do znajdowania wąskich gardeł
Rozwiązanie polega na dodaniu dekoratora bez operacji na początku modułu kodu ex.py (dekorator możesz usunąć po zakończeniu profilowania). Jeśli dekorator @profile nie zostanie znaleziony w jednej z przestrzeni nazw (ponieważ nie jest używane narzędzie line_profiler lub memory_profiler), zostanie dodana nowo utworzona wersja dekoratora bez operacji. Jeśli narzędzie line_profiler lub memory_profiler wprowadziło do przestrzeni nazw nową funkcję, ta wersja dekoratora zostanie zignorowana. W przypadku narzędzia line_profiler możemy dodać kod z przykładu 2.21. Przykład 2.21. Poprawka dla narzędzia line_profiler dodająca dekorator @profile bez operacji do przestrzeni nazw podczas testowania jednostkowego # w przypadku narzędzia line_profiler if '__builtin__' not in dir() or not hasattr(__builtin__, 'profile'): def profile(func): def inner(*args, **kwargs): return func(*args, **kwargs) return inner
Funkcja __builtin__ test jest przeznaczona dla narzędzia nosetests, a test hasattr służy do identyfikowania faktu wprowadzenia dekoratora @profile do przestrzeni nazw. Tym razem możemy pomyślnie uruchomić narzędzie nosetests dla przykładowego kodu: $ kernprof.py -v -l ex.py Line # Hits Time Per %%HTMLit % Time Line Contents ============================================================== 11 @profile 12 def some_fn(nbr): 13 1 3 3.0 100.0 return nbr * 2 $ nosetests ex.py . Ran 1 test in 0.000s
W przypadku narzędzia memory_profiler używamy kodu z przykładu 2.22. Przykład 2.22. Poprawka dla narzędzia memory_profiler dodająca dekorator @profile bez operacji do przestrzeni nazw podczas testowania jednostkowego # dla narzędzia memory_profiler if 'profile' not in dir(): def profile(func): def inner(*args, **kwargs): return func(*args, **kwargs) return inner
Można oczekiwać wygenerowania następujących danych wyjściowych: python -m memory_profiler ex.py ... Line # Mem usage Increment Line Contents ================================================ 11 10.809 MiB 0.000 MiB @profile 12 def some_fn(nbr): 13 10.809 MiB 0.000 MiB return nbr * 2 $ nosetests ex.py . Ran 1 test in 0.000
Choć zrezygnowanie z użycia tych dekoratorów pozwoli zyskać kilka minut, po straceniu wielu godzin na przeprowadzenie niewłaściwej optymalizacji, która powoduje, że kod przestaje działać, postanowisz uwzględnić dekoratory w przepływie pracy.
Testowanie jednostkowe podczas optymalizacji w celu zachowania poprawności
65
Strategie udanego profilowania kodu Profilowanie wymaga trochę czasu i koncentracji. Oddzielenie sekcji przeznaczonej do testowania od głównego segmentu kodu pozwoli zwiększyć szanse na zrozumienie kodu. Aby zachować poprawność, możesz następnie wykonać dla kodu test jednostkowy, a ponadto przekazać do niego dane wygenerowane w rzeczywistych warunkach w celu zidentyfikowania niewydajnych instrukcji. Pamiętaj o wyłączeniu wszelkich akceleratorów bazujących na BIOS-ie, ponieważ spowodują one tylko niejasność uzyskanych wyników. W przypadku laptopa jednego z autorów funkcja Intel TurboBoost może tymczasowo przyspieszyć działanie procesora ponad jego normalną, maksymalną szybkość, jeśli jest wystarczająco chłodny. Oznacza to, że procesor w takim stanie może uruchomić ten sam blok kodu szybciej, niż wtedy, gdy układ jest nagrzany. System operacyjny może też kontrolować szybkość zegara. Laptop zasilany akumulatorowo prawdopodobnie będzie bardziej agresywnie kontrolować szybkość procesora niż laptop podłączony do zasilania sieciowego. Aby utworzyć bardziej stabilną konfigurację do testów porównawczych, wykonaj następujące czynności: Wyłącz w BIOS-ie funkcję TurboBoost. Wyłącz funkcję systemu operacyjnego, która nadpisuje funkcję SpeedStep (jeśli masz moż-
liwość zarządzania BIOS-em, znajdziesz w nim tę funkcję). Używaj wyłącznie zasilania sieciowego (nigdy akumulatorowego). Podczas eksperymentowania wyłącz narzędzia działające w tle, takie jak narzędzia two-
rzące kopie zapasowe i Dropbox. Wielokrotnie przeprowadzaj eksperymenty, aby uzyskać stabilny pomiar. Jeśli to możliwe, przejdź na poziom uruchamiania 1 (system Unix), aby nie działały żadne
inne zadania. By mieć całkowitą pewność wyników, zrestartuj komputer i ponownie przeprowadź eks-
perymenty. Spróbuj określić hipotezę dla oczekiwanego działania kodu, a następnie potwierdź (lub obal!) jej poprawność przy użyciu wyników kroku profilowania. Choć opcje wyboru nie zmienią się (decyzje możesz podjąć tylko na podstawie wyników profilowania), zwiększy się poziom intuicyjnego rozumienia kodu, co przyniesie pozytywne efekty w przyszłych projektach, ponieważ z większym prawdopodobieństwem podejmiesz decyzje zapewniające większą wydajność. Oczywiście decyzje te będą weryfikowane w trakcie działań z wykorzystaniem profilowania. Nie żałuj czasu na przygotowania. Jeśli spróbujesz przetestować kod pod kątem wydajności głęboko wewnątrz większego projektu bez oddzielenia od niego tego kodu, prawdopodobnie doświadczysz efektów ubocznych, które zaprzepaszczą cel starań. W przypadku wprowadzania bardziej szczegółowych zmian trudniejsze będzie użycie testu jednostkowego dla większego projektu. Może to dodatkowo komplikować działania. Efekty uboczne mogą obejmować inne wątki i procesy wpływające na wykorzystanie procesora i pamięci oraz na funkcjonowanie sieci i dysków. Spowoduje to, że wyniki nie będą do końca wiarygodne.
66
Rozdział 2. Użycie profilowania do znajdowania wąskich gardeł
W przypadku serwerów WWW skorzystaj z narzędzi dowser i dozer. Umożliwiają one wizualizację w czasie rzeczywistym działania obiektów w przestrzeni nazw. Jeśli to możliwe, zdecydowanie rozważ oddzielenie kodu przeznaczonego do przetestowania od głównej aplikacji internetowej, ponieważ znacznie przyspieszy to profilowanie. Upewnij się, że testy jednostkowe sprawdzają wszystkie ścieżki kodu w analizowanym kodzie. Wszystko, co nie jest testowane, ale jest używane w testach porównawczych, może spowodować subtelne błędy, które spowolnią działania. Użyj skryptu coverage.py, aby potwierdzić, że testy obejmują wszystkie ścieżki kodu. Utrudniony może być test jednostkowy skomplikowanej sekcji kodu, który generuje dużo numerycznych danych wyjściowych. Nie obawiaj się kierowania danych wyjściowych do pliku tekstowego wyników w celu przetworzenia go przez narzędzie diff lub użycia obiektu pickled. W przypadku problemów z optymalizacją numeryczną jeden z autorów lubi tworzyć długie pliki tekstowe z liczbami zmiennoprzecinkowymi i korzystać z narzędzia diff. Mniejsze błędy zaokrąglania pojawiają się natychmiast, nawet jeśli występują rzadko w danych wyjściowych. Jeśli kodu mogą dotyczyć problemy z zaokrąglaniem liczb z powodu niewielkich zmian, lepszym rozwiązaniem jest użycie dużej ilości danych wyjściowych, które mogą zostać użyte przed porównaniem i po nim. Przyczyną błędów zaokrąglania jest różnica w precyzji liczb zmiennopozycyjnych, jaka występuje między rejestrami procesora i główną pamięcią. Uruchomienie kodu z wykorzystaniem innej ścieżki kodu może spowodować subtelne błędy zaokrąglania, które później mogą wywołać niejasności. Lepiej mieć tego świadomość od razu, gdy takie błędy się pojawią. Oczywiście podczas profilowania i optymalizowania sensowne jest użycie narzędzia do kontrolowania kodu źródłowego. Stosowanie rozgałęzień nie jest kosztowne, a zapewnia spokój.
Podsumowanie Po zaznajomieniu się z technikami profilowania masz do dyspozycji wszystkie narzędzia, jakie są niezbędne do identyfikowania w kodzie wąskich gardeł związanych z wykorzystaniem procesora i pamięci RAM. W następnym rozdziale dowiesz się, jak w języku Python implementowane są najbardziej typowe kontenery. Dzięki temu będziesz w stanie podejmować rozsądne decyzje dotyczące reprezentowania większych kolekcji danych.
Podsumowanie
67
68
Rozdział 2. Użycie profilowania do znajdowania wąskich gardeł
ROZDZIAŁ 3.
Listy i krotki
Pytania, na jakie będziesz w stanie udzielić odpowiedzi po przeczytaniu rozdziału Kiedy przydają się listy i krotki? Jaka jest złożoność wyszukiwania w przypadku listy/krotki? Jak ta złożoność jest osiągana? Jakie są różnice między listami i krotkami? Na czym polega dołączanie do listy? Kiedy należy używać list i krotek?
Jedną z najważniejszych rzeczy związanych z pisaniem wydajnych programów jest zrozumienie tego, co zapewniają używane struktury danych. Okazuje się, że programowanie pod kątem wydajności w dużej mierze sprowadza się do określenia, jakie pytania próbujesz zadać w odniesieniu do danych. Wybór struktury danych pozwala na szybkie udzielenie odpowiedzi na te pytania. W rozdziale będzie mowa o rodzajach pytań, na jakie listy i krotki mogą szybko zapewnić odpowiedzi. Zostanie też wyjaśnione, w jaki sposób się to odbywa. Listy i krotki to klasa struktur danych nazywanych tablicami. Tablica to po prostu zwykła lista danych z pewnym wewnętrznym uporządkowaniem. Wiedza a priori o uporządkowaniu ma duże znaczenie: gdy wie się, że dane w tablicy znajdują się w konkretnym położeniu, można je pobrać w czasie O(1)! Ponadto tablice mogą być implementowane na wiele sposobów. Wytycza to inną linię podziału między listami i krotkami: listy są tablicami dynamicznymi, krotki natomiast to tablice statyczne. Rozszerzmy trochę powyższe informacje. Pamięć systemową komputera można traktować jako serię ponumerowanych pojemników, z których każdy może przechowywać numer. Numery mogą służyć do reprezentowania dowolnych używanych zmiennych (liczb całkowitych, liczb zmiennopozycyjnych, łańcuchów lub innych struktur danych), ponieważ są one prostymi odwołaniami do położenia danych w pamięci1. 1
W komputerach 64-bitowych pamięć o wielkości 12 kB zapewnia 725 pojemników, a 52 GB pamięci udostępnia 3 250 000 000 pojemników! 69
Gdy wymagane jest utworzenie tablicy (czyli listy lub krotki), najpierw musisz przydzielić blok pamięci systemowej (każda sekcja takiego bloku będzie używana jako wskaźnik do rzeczywistych danych wyrażany za pomocą liczby całkowitej). Wiąże się to z użyciem jądra, podprocesu systemu operacyjnego i zażądaniem zastosowania N ciągłych pojemników. Na rysunku 3.1 pokazano przykład struktury pamięci systemowej dla tablicy (w tym przypadku jest to lista) o wielkości wynoszącej 6. Zauważ, że w języku Python listy przechowują też informację o swojej wielkości, dlatego z sześciu przydzielonych bloków tylko pięć nadaje się do użycia. Pierwszy element określa długość tablicy.
Rysunek 3.1. Przykład struktury pamięci systemowej dla tablicy o wielkości równej 6
Aby wyszukać dowolny wybrany element listy, musisz po prostu wiedzieć, jaki to ma być element, a ponadto pamiętać, od jakiego pojemnika rozpoczynają się dane. Ponieważ wszystkie dane zajmują tyle samo miejsca (czyli jeden „pojemnik” lub, dokładniej ujmując, jeden wskaźnik do rzeczywistych danych wyrażany za pomocą liczby całkowitej), do wykonania obliczeń nie są wymagane żadne informacje o typie przechowywanych danych. Jeśli byłoby wiadomo, gdzie w pamięci rozpoczyna się lista N elementów, w jaki sposób można by było znaleźć dowolny element na liście?
Jeśli na przykład konieczne byłoby pobranie pierwszego elementu tablicy, po prostu należałoby przejść do pierwszego pojemnika w sekwencji M i odczytać znajdującą się w nim wartość. Jeśli z kolei niezbędny byłby piąty element tablicy, należałoby przejść do pojemnika na pozycji M+5 i odczytać jego zawartość. Ogólnie rzecz biorąc, w celu pobrania elementu i z tablicy należy przejść do pojemnika M+i. Oznacza to, że dzięki przechowywaniu danych w kolejnych pojemnikach i dysponowaniu wiedzą na temat uporządkowania danych możliwe jest zlokalizowanie danych przy użyciu informacji o tym, jaki pojemnik ma zostać sprawdzony w jednym kroku lub w czasie O(1) (niezależnie od wielkości tablicy; przykład 3.1). Przykład 3.1. Określanie czasu dla wyszukiwań list o różnej wielkości >>> %%timeit l = range(10) ...: l[5] ...: 10000000 loops, best of 3: 75.5 ns per loop >>> >>> %%timeit l = range(10000000) ...: l[100000] ...: 10000000 loops, best of 3: 76.3 ns per loop
70
Rozdział 3. Listy i krotki
Co będzie w przypadku tablicy o nieznanym uporządkowaniu, z której ma zostać pobrany konkretny element? Jeśli porządkowanie byłoby znane, można byłoby po prostu wyszukać tę konkretną wartość. Jednakże w tym przypadku konieczna jest operacja search. Najprostsze rozwiązanie tego problemu jest określane mianem wyszukiwania liniowego, w którym przeprowadzana jest iteracja każdego elementu tablicy, a ponadto sprawdzane jest, czy wartość jest żądaną (przykład 3.2). Przykład 3.2. Wyszukiwanie liniowe listy def linear_search(needle, array): for i, item in enumerate(array): if item == needle: return i return -1
W najgorszym przypadku algorytm ten ma wydajność O(n). Ma to miejsce przy szukaniu elementu, którego nie ma w tablicy. Aby stwierdzić, że szukany element nie znajduje się w tablicy, musimy najpierw sprawdzić to przy użyciu każdego z pozostałych elementów. Ostatecznie zostanie osiągnięta końcowa instrukcja return -1. Okazuje się, że algorytm ten to właśnie algorytm używający funkcji list.index(). Jedyną metodą zwiększenia szybkości jest zrozumienie, jak dane są umieszczane w pamięci, lub uporządkowanie przechowywanych pojemników danych. Problem ten, przez pominięcie oryginalnego uporządkowania danych i określenie innego bardziej nietypowego, rozwiązują w czasie O(1) tabele mieszające, które są fundamentalną strukturą danych obsługującą słowniki i zbiory. Ewentualnie, jeśli dane są sortowane w taki sposób, że każdy element jest większy (lub mniejszy) od swojego sąsiada po lewej (lub prawej) stronie, można zastosować specjalistyczne algorytmy wyszukiwania, które mogą skrócić czas wyszukiwania do czasu O(log n). Choć w przypadku wcześniej opisanych wyszukiwań o stałym czasie może się to wydać krok niemożliwy do wykonania, czasem jest to najlepsza opcja (zwłaszcza dlatego, że algorytmy wyszukiwania są bardziej elastyczne i umożliwiają definiowanie wyszukiwań przy użyciu kreatywnych sposobów). Dla następujących danych napisz algorytm, który znajduje indeks wartości 61: [9, 18, 18, 19, 29, 42, 56, 61, 88, 95]
Jak możesz przyspieszyć tę operację, gdy znany jest sposób uporządkowania danych? Wskazówka: Jeśli podzielisz tablicę na pół, stwierdzisz, że wszystkie wartości po lewej stronie są mniejsze od najmniejszego elementu zbioru po prawej stronie. Skorzystaj z tego!
Bardziej efektywne wyszukiwanie Jak wcześniej wspomniano, możliwe jest uzyskanie większej wydajności wyszukiwania, gdy najpierw dane zostaną tak posortowane, że wszystkie elementy po lewej stronie konkretnego elementu będą mniejsze (lub większe) od niego. Porównanie jest dokonywane za pomocą funkcji „magicznych” __eq__ i __lt__ obiektu, a w przypadku używania obiektów niestandardowych operacja ta może być definiowana przez użytkownika. Bez funkcji __eq__ i __lt__ obiekt niestandardowy będzie porównywany jedynie z obiektami tego samego typu, a porównanie będzie wykonywane z wykorzystaniem lokowania instancji w pamięci.
Bardziej efektywne wyszukiwanie
71
Dwa niezbędne składniki to algorytm sortowania i algorytm wyszukiwania. Listy języka Python mają wbudowany algorytm sortowania, który korzysta z algorytmu Timsort. Algorytm ten umożliwia sortowanie listy w czasie O(n) w najlepszym przypadku. W najgorszym przypadku jest to czas O(n log n). Taką wydajność algorytm osiąga przez wykorzystanie wielu typów algorytmów sortowania i użycie heurystyki, aby określić, jaki algorytm dla konkretnych danych sprawdzi się najlepiej (dokładniej rzecz biorąc, jest to kombinacja algorytmów sortowania ze wstawianiem i scalaniem). Po poddaniu listy sortowaniu można znaleźć żądany element przy użyciu wyszukiwania binarnego (przykład 3.3), które ma średnią złożoność O(log n). Taka wydajność jest osiągana przez sprawdzenie najpierw środka listy i porównanie wybranej wartości z żądaną. Jeśli wartość środkowego elementu jest mniejsza od żądanej wartości, rozpatrywana jest prawa połowa listy, po czym kontynuowane jest dzielenie listy na pół do momentu znalezienia wartości lub stwierdzenia, że wartość na pewno nie występuje na posortowanej liście. W rezultacie nie jest wymagane odczytanie wszystkich wartości na liście, co było niezbędne w przypadku wyszukiwania liniowego. Odczytywany jest jedynie niewielki podzbiór wartości. Przykład 3.3. Efektywne wyszukiwanie posortowanej listy — wyszukiwanie binarne def binary_search(needle, haystack): imin, imax = 0, len(haystack) while True: if imin >= imax: return -1 midpoint = (imin + imax) // 2 if haystack[midpoint] > needle: imax = midpoint elif haystack[midpoint] < needle: imin = midpoint+1 else: return midpoint
Metoda ta umożliwia znalezienie elementów na liście bez uciekania się do potencjalnie złożonego rozwiązania słownikowego. Szczególnie w przypadku przetwarzania listy danych, która sama w sobie jest posortowana, w celu znalezienia obiektu na liście i uzyskania złożoności wyszukiwania O(log n) bardziej efektywne będzie po prostu wyszukiwanie binarne zamiast przekształcania danych w słownik, a następnie wykonywania dla niego wyszukiwania. Choć wyszukiwanie w słowniku zajmuje czas O(1), przekształcenie w słownik zajmuje czas O(n), a ograniczenie słownika w postaci braku możliwości występowania powtarzających się kluczy może być niepożądane. Ponadto moduł bisect znacznie upraszcza ten proces, oferując proste metody dodawania elementów do listy podczas sortowania jej. Moduł zapewnia też znajdowanie elementów przy użyciu mocno zoptymalizowanego wyszukiwania binarnego. W tym celu moduł udostępnia alternatywne funkcje, które dodają element do poprawnie posortowanej listy. W przypadku zawsze sortowanej listy z łatwością można znaleźć szukane elementy (odpowiednie przykłady są dostępne w dokumentacji modułu bisect (https://docs.python.org/2/library/bisect.html)). Dodatkowo moduł ten pozwala bardzo szybko znaleźć element najbliższy szukanemu (przykład 3.4). Może to być bardzo przydatne przy porównywaniu dwóch zbiorów danych, które są do siebie podobne, lecz nie są identyczne. Przykład 3.4. Znajdowanie bliskich wartości na liście za pomocą modułu bisect import bisect import random def find_closest(haystack, needle): # funkcja bisect.bisect_left zwróci pierwszą wartość w tablicy haystack, # która jest większa od wartości needle
72
Rozdział 3. Listy i krotki
i = bisect.bisect_left(haystack, needle) if i == len(haystack): return i - 1 elif haystack[i] == needle: return i elif i > 0: j = i - 1 # ponieważ wiadomo, że wartość jest większa niż wartość needle (i odwrotnie w przypadku # wartości w j), nie jest konieczne użycie w tym miejscu wartości bezwzględnych if haystack[i] - needle > needle - haystack[j]: return j return i important_numbers = [] for i in xrange(10): new_number = random.randint(0, 1000) bisect.insort(important_numbers, new_number) # lista important_numbers będzie już uporządkowana, ponieważ wstawiono nowe elementy # za pomocą funkcji bisect.insort print important_numbers closest_index = find_closest(important_numbers, -250) print "Najbliższa wartość dla -250: ", important_numbers[closest_index] closest_index = find_closest(important_numbers, 500) print "Najbliższa wartość dla 500: ", important_numbers[closest_index] closest_index = find_closest(important_numbers, 1100) print "Najbliższa wartość dla 1100: ", important_numbers[closest_index]
Ogólnie rzecz biorąc, ma to związek z fundamentalną regułą pisania efektywnego kodu: wybierz właściwą strukturę danych i trzymaj się jej! Choć dla konkretnych operacji mogą istnieć bardziej efektywne struktury danych, koszt przekształcenia w nie może zniweczyć jakikolwiek wzrost wydajności.
Porównanie list i krotek Czym się różnią listy i krotki, jeśli korzystają z tej samej bazowej struktury danych? Oto podsumowanie głównych różnic:
1. Listy to tablice dynamiczne. Mogą się zmieniać i możliwa jest zmiana ich wielkości (zmiana liczby przechowywanych elementów).
2. Krotki są tablicami statycznymi. Krotki nie mogą się zmieniać, a zawarte w nich dane nie mogą zostać zmodyfikowane po utworzeniu krotki.
3. Krotki są buforowane przez środowisko uruchomieniowe interpretera języka Python.
Oznacza to, że nie jest wymagana komunikacja z jądrem w celu zarezerwowania pamięci każdorazowo, gdy ma zostać użyta.
Wymienione różnice określają ideową odmienność obu struktur: krotki służą do opisywania wielu właściwości jednej rzeczy, która nie ulega zmianie, listy natomiast mogą być używane do przechowywania kolekcji danych dotyczących różnych obiektów. Na przykład składniki numeru telefonu idealnie nadają się do tego, by zastosować krotkę: nie będą się zmieniać, a jeśli to nastąpi, powstała kombinacja będzie reprezentować nowy obiekt lub inny numer telefonu. Podobnie współczynniki wielomianu są odpowiednie dla krotki, ponieważ inne współczynniki reprezentują inny wielomian. Z kolei imiona osób czytających aktualnie tę książkę lepiej kwalifikują się do użycia listy. Choć takie dane nieustannie się zmieniają zarówno pod względem zawartości, jak i wielkości, zawsze reprezentują to samo.
Porównanie list i krotek
73
Godne uwagi jest to, że listy i krotki mogą korzystać z mieszanych typów. Jak się okaże, może to powodować całkiem spore obciążenie i ograniczać potencjalne optymalizacje. Obciążenie to może zostać wyeliminowane, jeśli zostanie wymuszone, aby wszystkie dane były tego samego typu. W rozdziale 6. będzie mowa o zmniejszaniu za pomocą narzędzia numpy zarówno ilości używanej pamięci, jak i obciążenia związanego z obliczeniami. Inne pakiety, takie jak blist i array, również mogą zredukować takie obciążenia w przypadku innych sytuacji niemających związku z obliczeniami numerycznymi. Nawiązuje to do głównej kwestii, jaka pojawia się w przypadku programowania pod kątem wydajności i zostanie omówiona w dalszych rozdziałach. Chodzi mianowicie o to, że podstawowy kod jest znacznie wolniejszy niż kod napisany specjalnie w celu rozwiązania konkretnego problemu. Poza tym niezmienność krotki, w przeciwieństwie do listy, której wielkość może być modyfikowana, pozwala uzyskać bardzo prostą strukturę danych. Oznacza to, że podczas przechowywania krotek w pamięci pamięć nie jest nadmiernie obciążana, a operacje na krotkach są dość przejrzyste. Jak się dowiesz, zmienność list wiąże się z większym wykorzystaniem pamięci niezbędnej do ich przechowywania, a ponadto z dodatkowymi obliczeniami wymaganymi do obsługi list. Czy dla podanych poniżej przykładowych zbiorów danych użyłbyś krotki, czy listy? Uzasadnij dlaczego.
1. Pierwsze 20 liczb pierwszych. 2. Nazwy języków programowania. 3. Wiek, waga i wzrost osoby. 4. Dzień i miejsce urodzenia osoby. 5. Wynik konkretnego zakładu. 6. Wyniki kolejnych serii zakładów. Rozwiązanie:
1. Krotka, ponieważ dane są statyczne i nie będą się zmieniać. 2. Lista, ponieważ zbiór danych nieustannie się zwiększa. 3. Lista, ponieważ wartości będą wymagać zaktualizowania. 4. Krotka, ponieważ informacje są statyczne i nie będą się zmieniać. 5. Krotka, ponieważ dane są statyczne. 6. Lista, ponieważ będzie obstawianych więcej zakładów (okazuje się, że możliwe
byłoby użycie listy krotek, bo choć nie będą się zmieniać metadane każdego poszczególnego zakładu, przy obstawianiu kolejnych zakładów konieczne będzie dodawanie dalszych metadanych).
Listy jako tablice dynamiczne Po utworzeniu listy w razie potrzeby możesz swobodnie zmienić jej zawartość: >>> >>> >>> [5,
numbers = [5, 8, 1, 3, 2, 6] numbers[2] = 2*numbers[0] # numbers 8, 10, 3, 2, 6]
Jak zostało wcześniej opisane, czas takiej operacji wynosi O(1), ponieważ od razu można znaleźć dane przechowywane w zerowym i drugim elemencie. 74
Rozdział 3. Listy i krotki
Dodatkowo możesz dołączyć do listy nowe dane i zwiększyć jej wielkość: >>> 6 >>> >>> [5, >>> 7
len(numbers) numbers.append(42) numbers 8, 10, 3, 2, 6, 42] len(numbers)
Jest to możliwe, ponieważ tablice dynamiczne obsługują operację resize, która zwiększa pojemność tablicy. Gdy po raz pierwszy do listy o wielkości N zostanie dołączony element, interpreter języka Python musi utworzyć nową listę, która jest wystarczająco duża, aby przechowywać pierwotną liczbę N elementów, a oprócz tego dołączone dane. Zamiast jednak przydzielać N+1 elementów, przydziela się w rzeczywistości M elementów, gdzie M > N. Ma to na celu zapewnienie dodatkowego miejsca dla przyszłych operacji dołączania. Dane ze starej listy są kopiowane do nowej listy, po czym stara lista jest usuwana. Idea tego jest taka, że jedna operacja dołączania stanowi prawdopodobnie początek wielu takich operacji. Żądając dodatkowego miejsca, możemy zmniejszyć liczbę koniecznych wystąpień operacji przydzielania, a tym samym całkowitą liczbę niezbędnych kopii w pamięci. Jest to dość istotne, gdyż kopie w pamięci powodują spore obciążenie, zwłaszcza gdy zaczyna się zwiększać wielkość listy. Na rysunku 3.2 pokazano, jak takie nadmierne przydzielanie przebiega w przypadku interpretera języka Python 2.7. Przykład 3.5 zawiera równanie określające ten wzrost wielkości.
Rysunek 3.2. Wykres prezentujący, ile dodatkowych elementów przydzielanych jest do listy o konkretnej wielkości
Listy jako tablice dynamiczne
75
Przykład 3.5. Równanie przydziału dla listy w przypadku interpretera języka Python 2.7 M = (N >> 3) + (N < 9 ? 3 : 6) N 0 1-4 5-8 9-16 17-25 26-35 36-46 … 991-1120 M 0 4 8 16 25 35 46 … 1120
Przy dołączaniu danych jest używane dodatkowe miejsce i zwiększana rzeczywista wielkość N listy. W rezultacie wielkość N zwiększa się podczas dołączania nowych danych do momentu wystąpienia warunku N == M. Gdy to nastąpi, nie będzie żadnego dodatkowego miejsca na wstawienie nowych danych, dlatego konieczne będzie utworzenie nowej listy, która zajmie więcej dodatkowego miejsca. Nowa lista zawiera dodatkową rezerwę określoną przez równanie z przykładu 3.5. W to miejsce kopiowane są stare dane. Na rysunku 3.3 dokonano wizualizacji opisanej sekwencji zdarzeń. Na rysunku prześledzono różne operacje wykonywane dla listy l w przykładzie 3.6.
Rysunek 3.3. Przykład sposobu zmiany listy w przypadku wielu operacji dołączania Przykład 3.6. Zmiana wielkości listy l = [1, 2] for i in range(3, 7): l.append(i)
76
Rozdział 3. Listy i krotki
Taka operacja dodatkowego przydziału ma miejsce przy pierwszym użyciu operatora append. W przypadku bezpośredniego utworzenia listy, jak w poprzednim przykładzie, przydzielana jest tylko wymagana liczba elementów.
Choć przeważnie ilość dodatkowej rezerwy, która jest przydzielana, jest dość mała, może ulec zwiększeniu. Efekt ten stanie się szczególnie dobrze widoczny przy utrzymywaniu wielu małych list lub w przypadku używania wyjątkowo dużej listy. Jeśli przechowywany jest milion list, z których każda zawiera 10 elementów, możemy przyjąć, że zostanie użyta ilość pamięci mieszcząca 10 milionów elementów. W rzeczywistości jednak, gdyby do utworzenia listy został użyty operator append, mogłoby zostać przydzielonych maksymalnie 16 milionów elementów. Podobnie dla dużej listy liczącej 100 milionów elementów w rzeczywistości zostałoby przydzielonych 112 500 007 elementów!
Krotki w roli tablic statycznych Krotki są niezmienne. Oznacza to, że inaczej niż w przypadku listy, po utworzeniu krotki nie można jej modyfikować ani zmieniać jej wielkości: >>> t = (1,2,3,4) >>> t[0] = 5 Traceback (most recent call last): File "", line 1, in TypeError: 'tuple' object does not support item assignment
Choć nie można zmieniać wielkości krotek, możliwe jest połączenie ze sobą dwóch krotek i utworzenie nowej krotki. Operacja ta przypomina operację resize wykonywaną dla list, z tym że nie ma możliwości przydziału żadnego dodatkowego miejsca dla wynikowej krotki: >>> >>> >>> (1,
t1 t2 t1 2,
= (1,2,3,4) = (5,6,7,8) + t2 3, 4, 5, 6, 7, 8)
Jeśli porównamy to z operacją append stosowaną dla list, stwierdzimy, że przydział w przypadku krotki odbywa się w czasie O(n), szybkość list natomiast wynosi O(1). Wynika to z tego, że operacje przydziału/kopiowania muszą mieć miejsce każdorazowo przy dodawaniu nowych danych do krotki. Dla porównania w przypadku list takie operacje są wykonywane tylko po wyczerpaniu dodatkowej rezerwy. W rezultacie nie występuje żadna wewnętrzna operacja podobna do operacji operatora append. Sumowanie dwóch krotek zawsze powoduje zwrócenie nowej krotki umieszczonej w nowym miejscu w pamięci. Zrezygnowanie z utrzymywania dodatkowej rezerwy na potrzeby zmiany wielkości zapewnia korzyść w postaci mniejszego zużycia zasobów. Lista licząca 100 milionów elementów, która została utworzona za pomocą operacji operatora append, w rzeczywistości zajmuje pamięć mieszczącą 112 500 007 elementów. Z kolei krotka przechowująca taką samą ilość danych zużyje tylko ilość pamięci, jaka mieści dokładnie 100 milionów elementów. Powoduje to, że w przypadku danych statycznych krotki zużywają mniej pamięci i są preferowane. Co więcej, jeśli nawet lista zostanie utworzona bez operatora append (stąd też nie pojawi się dodatkowa rezerwa wprowadzana przez ten operator), w dalszym ciągu będzie ona zajmować w pamięci więcej miejsca niż krotka z tymi samymi danymi. Wynika to stąd, że w celu
Krotki w roli tablic statycznych
77
efektywnej zmiany swojej wielkości listy muszą śledzić więcej informacji o swoim bieżącym stanie. Choć te dodatkowe informacje zajmują niewiele miejsca (odpowiednik jednego dodatkowego elementu), sumarycznie mogą okazać się pokaźne, gdy używanych jest kilka milionów list. Inną korzyścią wynikającą ze statyczności krotek jest proces, który interpreter języka Python realizuje w tle. Mowa mianowicie o buforowaniu zasobów. W przypadku tego języka ma miejsce czyszczenie pamięci. Oznacza to, że gdy zmienna nie jest już używana, interpreter języka Python zwalnia pamięć zajmowaną przez nią, zwracając ją systemowi operacyjnemu do wykorzystania w innych aplikacjach (lub przez inne zmienne). Jednak w przypadku krotek liczących od 1 do 20 elementów, gdy nie są one już używane, zajmowane przez nie miejsce nie jest od razu zwracane systemowi, lecz jest zachowywane na potrzeby przyszłego wykorzystania. Oznacza to, że gdy w przyszłości niezbędna okaże się nowa krotka o takiej wielkości, nie będzie trzeba komunikować się z systemem operacyjnym w celu znalezienia obszaru w pamięci, w którym zostaną umieszczone dane, ponieważ będzie już istnieć rezerwa wolnej pamięci. Choć może się to wydać niewielką korzyścią, jest to jedna ze znakomitych cech krotek. Można je tworzyć z łatwością i szybko, gdyż umożliwiają one uniknięcie konieczności komunikowania się z systemem operacyjnym, co może zająć programowi dość sporo czasu. W przykładzie 3.7 pokazano, że tworzenie instancji listy może być 5,1 razy wolniejsze niż w przypadku krotki. Jeśli operacja jest wykonywana w obrębie szybkiej pętli, wartość ta może dość szybko się powiększyć! Przykład 3.7. Porównanie czasu tworzenia instancji list i krotek >>> %timeit l = [0,1,2,3,4,5,6,7,8,9] 1000000 loops, best of 3: 285 ns per loop >>> %timeit t = (0,1,2,3,4,5,6,7,8,9) 10000000 loops, best of 3: 55.7 ns per loop
Podsumowanie Listy i krotki to szybkie i mało obciążające obiekty, które są używane, gdy dane są już wewnętrznie uporządkowane. Takie uporządkowanie umożliwia uniknięcie problemu z wyszukiwaniem w przypadku tych struktur. Jeśli uporządkowanie jest znane wcześniej, czas wyszukiwań wynosi O(1). Dzięki temu eliminuje się kosztowne czasowo wyszukiwanie liniowe z czasem O(n). Choć możliwa jest zmiana wielkości list, konieczne jest właściwe zrozumienie tego, jaka jest skala nadmiernego przydziału, aby mieć pewność, że zbiór danych nadal może zmieścić się w pamięci. Z kolei krotki mogą być szybko tworzone bez towarzyszącego listom dodatkowego obciążenia. Kosztem jest jednak brak możliwości ich modyfikowania. W podrozdziale „Czy listy języka Python są wystarczająco dobre?” omówiono, w jaki sposób wstępnie przydzielać listy w celu częściowego zmniejszenia obciążenia związanego z częstym dołączaniem danych do list języka Python. Ponadto przeanalizowane zostały niektóre inne metody optymalizacji, które mogą być pomocne w radzeniu sobie z tymi problemami. W następnym rozdziale zajmiemy się właściwościami obliczeniowymi słowników, które przy dodatkowym obciążeniu pozwalają rozwiązać problemy z wyszukiwaniem w przypadku nieuporządkowanych danych.
78
Rozdział 3. Listy i krotki
ROZDZIAŁ 4.
Słowniki i zbiory
Pytania, na jakie będziesz w stanie udzielić odpowiedzi po przeczytaniu rozdziału Kiedy przydają się słowniki i zbiory? Pod jakim względem słowniki i zbiory są jednakowe? Jakie jest obciążenie powodowane zastosowaniem słownika? Jak można zoptymalizować wydajność słownika? W jaki sposób w języku Python używane są słowniki do śledzenia przestrzeni nazw?
Zbiory i słowniki to idealne struktury danych, używane dla danych bez wewnętrznego uporządkowania, ale udostępniające unikalny obiekt umożliwiający odwoływanie się do nich (obiekt odwołania to zwykle łańcuch, ale może to być obiekt dowolnego typu pozwalającego na zastosowanie mieszania). Taki obiekt nazywany jest „kluczem”, a dane to „wartość”. Słowniki i zbiory są niemal identyczne, z tym wyjątkiem, że zbiory w rzeczywistości nie zawierają wartości. Zbiór to po prostu kolekcja unikalnych kluczy. Jak wskazuje nazwa, zbiory bardzo przydają się do realizowania operacji na zbiorach. Typ umożliwiający mieszanie (ang. hashable) to typ implementujący funkcję „magiczną” __hash__ oraz funkcję __eq__ lub __cmp__. Wszystkie wbudowane typy języka Python implementują już te funkcje, a wszystkie klasy użytkownika mają wartości domyślne. Więcej informacji zawiera punkt „Funkcje mieszania i entropia”.
W poprzednim rozdziale wspomniano, że w przypadku list/krotek bez wewnętrznego uporządkowania (z wykorzystaniem operacji wyszukiwania) jesteśmy ograniczeni do czasu wyszukiwania wynoszącego co najwyżej O(log n). Słowniki i zbiory zapewniają wyszukiwania z czasem O(n) bazujące na arbitralnym indeksie. Ponadto, podobnie do list/krotek, słowniki i zbiory zapewniają czas wstawiania wynoszący O(1)1. Jak się okaże w podrozdziale „Jak działają
1
Jak wspomniano w punkcie „Funkcje mieszania i entropia”, słowniki i zbiory są w dużym stopniu zależne od funkcji mieszania. Jeśli funkcja mieszania dla konkretnego typu danych nie zapewnia czasu O(1), dowolny słownik lub zbiór, który zawiera ten typ, nie będzie już gwarantować czasu O(1).
79
słowniki i zbiory?”, taka szybkość jest osiągana przez użycie w roli bazowej struktury danych tabeli mieszającej z adresowaniem otwartym. Z użyciem słowników i zbiorów związany jest jednak koszt. Przede wszystkim zajmują one więcej miejsca w pamięci. Ponadto, choć złożoność operacji wstawiania/wyszukiwania wynosi O(1), rzeczywista szybkość zależy w dużej mierze od używanej funkcji mieszania. Jeśli określanie wartości przez tę funkcję jest powolne, podobnie będzie również w przypadku dowolnych operacji wykonywanych na słownikach lub zbiorach. Przyjrzyjmy się przykładowi. Załóżmy, że przechowywane mają być informacje kontaktowe dla każdej osoby z książki telefonicznej. Informacje te mają być przechowywane w postaci, która w przyszłości ułatwi udzielenie odpowiedzi na następujące pytanie: „Jaki jest numer telefonu Jana Nowaka?”. W przypadku list numery telefonów i nazwiska byłyby przechowywane kolejno, a w celu znalezienia wymaganego numeru byłaby przeszukiwana cała lista (przykład 4.1). Przykład 4.1. Wyszukiwanie w książce telefonicznej za pomocą listy def find_phonenumber(phonebook, name): for n, p in phonebook: if n == name: return p return None phonebook = [ ("Jan Nowak", "555-555-5555"), ("Albert Einstein", "212-555-5555"), ] print "Numer telefonu Jana Nowaka to", find_phonenumber(phonebook, "Jan Nowak")
Możliwe jest również użycie sortowania listy i modułu bisect do uzyskania wydajności O(log n).
Użycie słownika zapewnia jednak uzyskanie po prostu „indeksu” w postaci nazwisk i „wartości” jako numerów telefonów (przykład 4.2). Umożliwia to wyszukiwanie niezbędnej wartości i pobranie bezpośredniego odwołania do niej zamiast konieczności odczytywania każdej wartości ze zbioru danych. Przykład 4.2. Wyszukiwanie w książce telefonicznej za pomocą słownika phonebook = { "Jan Nowak": "555-555-5555", "Albert Einstein" : "212-555-5555", } print "Numer telefonu Jana Nowaka to", phonebook["Jan Nowak"]
W przypadku pokaźnych książek telefonicznych dość znaczna jest różnica między czasem O(1) wyszukiwania w słowniku i czasem O(n) (lub w najlepszym razie czasem O(log n), gdy zostanie użyty moduł bisect) wyszukiwania liniowego w obrębie listy. Utwórz skrypt, który mierzy wydajność dla metody opartej na liście i module bisect oraz zastosowania słownika do znajdowania numeru w książce telefonicznej. Jak wygląda skala pomiaru czasu przy zwiększającej się wielkości książki telefonicznej?
80
Rozdział 4. Słowniki i zbiory
Z kolei aby udzielić odpowiedzi na następujące pytanie: „Ile w mojej książce telefonicznej znajduje się unikalnych imion?”, można skorzystać ze zbiorów. Jak wspomniano, zbiór to prosta kolekcja unikalnych kluczy. Właśnie ta właściwość zostanie wymuszona dla przykładowych danych, inaczej niż w przypadku metody opartej na liście, w przypadku której taka właściwość musi być wymuszana niezależnie od struktury danych przez porównanie każdego imienia ze wszystkimi pozostałymi. Zostało to zilustrowane w przykładzie 4.3. Przykład 4.3. Znajdowanie unikalnych imion za pomocą list i zbiorów def list_unique_names(phonebook): unique_names = [] for name, phonenumber in phonebook: # first_name, last_name = name.split(" ", 1) for unique in unique_names: # if unique == first_name: break else: unique_names.append(first_name) return len(unique_names) def set_unique_names(phonebook): unique_names = set() for name, phonenumber in phonebook: # first_name, last_name = name.split(" ", 1) unique_names.add(first_name) # return len(unique_names) phonebook = [ ("Jan Nowak", "555-555-5555"), ("Albert Einstein", "212-555-5555"), ("Jan Kowalski", "202-555-5555"), ("Albert Rutherford", "647-555-5555"), ("Edyta Barska", "301-555-5555"), ] print "Liczba unikalnych imion przy zastosowaniu metody opartej na zbiorze:", set_unique_names(phonebook) print "Liczba unikalnych imion przy zastosowaniu metody opartej na liście:", list_unique_names(phonebook)
Konieczne jest sprawdzenie wszystkich pozycji w książce telefonicznej, dlatego czas działania używanej pętli wynosi O(n). W tym przypadku niezbędne jest porównanie bieżącego imienia ze wszystkimi już napotkanymi imionami. Jeśli imię okaże się nowym unikalnym imieniem, zostanie dodane do listy unikalnych imion. Dalej kontynuowane jest przetwarzanie listy. Krok ten jest wykonywany dla każdej pozycji w książce telefonicznej. Zamiast iterowania w przypadku metody opartej na zbiorze wszystkich napotkanych już unikalnych imion można po prostu dodać bieżące imię do zbioru unikalnych imion. Ponieważ zbiory zapewniają unikalność kluczy, które zawierają, próba dodania elementu znajdującego się już w zbiorze spowoduje, że element ten po prostu nie zostanie dodany. Co więcej, czas takiej operacji wynosi O(1). Pętla wewnętrzna algorytmu listy dokonuje iteracji elementów listy unique_names, która na początku jest pusta, a następnie powiększa się. W najgorszym przypadku, gdy wszystkie imiona będą unikalne, lista osiągnie wielkość książki telefonicznej. Może to być postrzegane jako wykonywanie wyszukiwania liniowego każdego imienia w książce telefonicznej dla listy, która cały czas się powiększa. A zatem cały algorytm jest wykonywany w czasie O(n log n), ponieważ pętla zewnętrzna generuje czas O(n), pętla wewnętrzna natomiast jest wykonywana w czasie O(log n). Jak działają słowniki i zbiory?
81
Z kolei algorytm zbioru nie ma pętli wewnętrznej. set.add to proces z czasem O(1), który kończy się po wykonaniu ustalonej liczby operacji, niezależnie od tego, jak duża jest książka telefoniczna (związanych jest z tym kilka drobnych zastrzeżeń, które zostaną przedstawione podczas omawiania implementowania słowników i zbiorów). Oznacza to, że jedynym zmiennym elementem złożoności tego algorytmu jest pętla wykonywana dla książki telefonicznej. Dzięki temu czas działania algorytmu wynosi O(n). Przy pomiarze czasu dla tych dwóch algorytmów używanych dla książki telefonicznej liczącej 10 000 pozycji i 7422 unikalnych imion widoczne jest, jak znaczna może być różnica między czasami O(n) i O(n log n): >>> %timeit list_unique_names(large_phonebook) 1 loops, best of 3: 2.56 s per loop >>> %timeit set_unique_names(large_phonebook) 100 loops, best of 3: 9.57 ms per loop
Innymi słowy, algorytm zbioru zapewnił 267-krotne przyspieszenie! Ponadto przy zwiększaniu się książki telefonicznej wzrasta przyrost szybkości (dla książki telefonicznej liczącej 100 tysięcy pozycji i 15 574 unikalne imiona uzyskuje się 557-krotne przyspieszenie).
Jak działają słowniki i zbiory? Słowniki i zbiory używają tabel mieszających do osiągnięcia czasu O(1) dla swoich operacji wyszukiwania i wstawiania. Taka wydajność jest wynikiem bardzo mądrego wykorzystania funkcji mieszania w celu przekształcenia dowolnego klucza (np. łańcucha lub obiektu) w indeks listy. Funkcja mieszania i lista mogą być później stosowane do szybkiego określenia bez wyszukiwania, gdzie znajduje się dowolna wybrana porcja danych. Przekształcanie klucza danych w coś, co może być używane podobnie jak indeks listy, pozwala uzyskać wydajność taką samą jak w przypadku listy. Ponadto zamiast konieczności odwoływania się do danych przy użyciu indeksu liczbowego, który sam implikuje określone uporządkowanie danych, możemy odwołać się do danych za pomocą dowolnego klucza.
Wstawianie i pobieranie Aby od podstaw utworzyć tabelę mieszającą, zaczynamy od przydzielonej pamięci, podobnie jak w przypadku tablic. Jeśli do tablicy mają zostać wstawione dane, po prostu znajdujemy najmniejszy nieużywany pojemnik i wstawiamy do niego dane (w razie potrzeby zmieniając wielkość pojemnika). W przypadku tabel mieszających konieczne jest najpierw określenie umiejscowienia danych w ciągłym obszarze pamięci. Umiejscowienie danych jest zależne od dwóch właściwości wstawianych danych: wartości klucza poddanej mieszaniu oraz tego, jak wartość porównuje się z innymi obiektami. Wynika to stąd, że po wstawieniu danych klucz jest najpierw poddawany mieszaniu i maskowaniu, aby został przekształcony w efektywny indeks w tablicy2. Maska zapewnia, że wartość mieszania, która może być wartością dowolnej liczby całkowitej, mieści się w przydzielonej liczbie pojemników. A zatem jeśli przydzielono 8 bloków pamięci, a wartość mieszania to 28975, 2
Maska to liczba binarna, która obcina wartość liczbową. Oznacza to, że 0b1111101 & 0b111 = 0b101 = 5 reprezentuje operację maskowania (0b111) liczby 0b1111101. Operacja taka może też być traktowana jako pobieranie określonej liczby najmniej znaczących cyfr liczby.
82
Rozdział 4. Słowniki i zbiory
pod uwagę brany jest pojemnik o indeksie 28975 & 0b111 = 7. Jeśli jednak słownik powiększył się i wymaga 512 bloków pamięci, maska przyjmie postać 0b111111111 (w tym przypadku pod uwagę będzie brany pojemnik o indeksie 28975 & 0b11111111). Konieczne jest teraz sprawdzenie, czy taki pojemnik nie jest już używany. Jeśli okaże się pusty, można wstawić klucz i wartość do takiego bloku pamięci. Klucz jest przechowywany, dzięki czemu możliwe jest zapewnienie pobrania poprawnej wartości w operacjach wyszukiwania. Jeśli pojemnik jest używany, a jego wartość jest równa wartości, która ma zostać wstawiona (porównanie realizowane jest za pomocą wbudowanego narzędzia cmp), para złożona z klucza i wartości znajduje się już w tabeli mieszającej, dlatego możliwe jest zwrócenie pary. Jeśli jednak wartości nie są zgodne, niezbędne będzie znalezienie nowego miejsca, w którym zostaną umieszczone dane. W celu znalezienia nowego indeksu oblicza się go za pomocą prostej funkcji liniowej. Jest to metoda nazywana sondowaniem. Mechanizm sondowania języka Python korzysta z bitów o wyższej pozycji oryginalnej wartości mieszania (jak wspomniano, dla tabeli o długości 8 dla początkowego indeksu rozpatrywano jedynie 3 ostatnie bity wartości mieszania, korzystając z wartości maski mask = 0b111 = bin(8 - 1)). Użycie bitów o wyższej pozycji zapewnia każdej wartości mieszania inną kolejność następnych możliwych wartości mieszania, co jest pomocne w uniknięciu przyszłych kolizji. Wybierając algorytm do generowania nowego indeksu, masz dużą swobodę działania. Dość ważne jest jednak to, że schemat sprawdza każdy możliwy indeks w celu równomiernego rozmieszczania danych w tabeli. O tym, jak dobrze dane są rozmieszczane w tabeli mieszającej, decyduje współczynnik obciążenia, który jest powiązany z entropią funkcji mieszania. Pseudokod z przykładu 4.4 ilustruje obliczenie indeksów wartości mieszania używanych w narzędziu CPython 2.7. Przykład 4.4. Sekwencja wyszukiwania w słowniku def index_sequence(key, mask=0b111, PERTURB_SHIFT=5): perturb = hash(key) # i = perturb & mask yield i while True: i = ((i >= PERTURB_SHIFT yield i & mask
Funkcja hash zwraca liczbę całkowitą, sam kod C w narzędziu CPython korzysta natomiast z liczby całkowitej bez znaku. Z tego powodu ten pseudokod nie powiela w 100% działania w narzędziu CPython. Jest to jednak dobre przybliżenie. Takie sondowanie stanowi modyfikację naiwnej metody sondowania liniowego. W przypadku sondowania liniowego po prostu uzyskiwane są wartości dla i = (5 * i + 1) & mask, gdzie i jest inicjowane jako wartość mieszania klucza, a wartość 5 nie ma znaczenia w niniejszym omówieniu3. Godne uwagi jest to, że sondowanie liniowe dotyczy jedynie kilku ostatnich bajtów wartości mieszania, a reszta jest pomijana (czyli dla słownika z 8 elementami pod uwagę będą brane tylko 3 ostatnie bity, ponieważ w tym przypadku maska to 0x111). Oznacza to, że jeśli mieszanie dwóch elementów daje te same trzy ostatnie cyfry binarne, nie tylko nie wystąpi kolizja, ale też identyczna będzie kolejność sondowanych indeksów. W celu rozwiązania tego problemu we wprowadzającym zamieszanie schemacie używanym w języku Python uwzględniana zacznie być większa liczba bitów z wartości mieszania elementów. 3
Wartość 5 wynika z właściwości generatora kongruencji liniowej LCG (Linear Congruential Generator), który używany jest w przypadku generowania liczb losowych.
Jak działają słowniki i zbiory?
83
Podobna procedura ma miejsce podczas przeprowadzania wyszukiwań dotyczących konkretnego klucza. Dany klucz jest przekształcany w indeks, który jest sprawdzany. Jeśli klucz w tym indeksie jest zgodny (jak wcześniej wspomniano, oryginalny klucz jest też przechowywany podczas wykonywania operacji wstawiania), możliwe jest zwrócenie tej wartości. W przeciwnym razie dalej tworzone są nowe indeksy przy użyciu tego samego schematu do momentu znalezienia danych lub natrafienia na pusty pojemnik. W drugim przypadku można wywnioskować, że dane nie istnieją w tabeli. Na rysunku 4.1 zilustrowano proces dodawania danych do tabeli mieszającej. W przykładzie zdecydowano się na utworzenie funkcji mieszania, która po prostu używa pierwszej litery z wprowadzonych danych. Jest to osiągane przez zastosowanie funkcji ord języka Python dla pierwszej litery podanych danych w celu uzyskania liczbowej reprezentacji tej litery (jak wspomniano, funkcje mieszania muszą zwracać liczby całkowite). Jak się okaże w punkcie „Funkcje mieszania i entropia”, język Python zapewnia funkcje mieszania dla większości swoich typów. Dzięki temu, z wyjątkiem ekstremalnych sytuacji, nie będzie trzeba samodzielnie dbać o taką funkcję.
Rysunek 4.1. Wynikowa tabela mieszająca po wykonaniu operacji wstawiania z kolizjami
Wstawienie klucza Barcelona powoduje kolizję. Nowy indeks jest obliczany za pomocą schematu z przykładu 4.4. Słownik ten może też zostać utworzony w języku Python za pomocą kodu z przykładu 4.5. Przykład 4.5. Niestandardowa funkcja mieszania class City(str): def __hash__(self): return ord(self[0]) # Tworzony jest słownik, w którym do miast przypisywane są dowolne wartości data = { City("Rzym"): 4, City("San Francisco"): 3, City("Nowy Jork"): 5, City("Barcelona"): 2, }
84
Rozdział 4. Słowniki i zbiory
W tym przypadku klucze Barcelona i Rzym powodują kolizję wartości mieszania (na rysunku 4.1 pokazano wynik takiej operacji wstawiania). Coś takiego ma miejsce, ponieważ dla słownika z czterema elementami używana jest wartość maski 0b111. W rezultacie klucz Barcelona podejmie próbę użycia indeksu ord("B") & 0b111 = 66 & 0b111 = 0b1000010 & 0b111 = 0b010 = 2. Podobnie klucz Rzym spróbuje zastosować indeks ord("R") & 0b111 = 82 & 0b111 = 0b1010010 & 0b111 = 0b010 = 2. Przeanalizuj poniższe problemy. Oto omówienie kolizji wartości mieszania: 1. Znajdowanie elementu. Jak będzie wyglądać wyszukiwanie dla klucza Johannesburg w przypadku użycia słownika utworzonego w przykładzie 4.5? Jakie indeksy zostaną sprawdzone? 2. Usuwanie elementu. Jak będzie obsługiwane usuwanie klucza Rzym w przypadku użycia słownika utworzonego w przykładzie 4.5? Jak będą obsługiwane kolejne wyszukiwania dla kluczy Rzym i Barcelona? 3. Kolizje wartości mieszania. Biorąc pod uwagę słownik utworzony w przykładzie 4.5, ilu możesz spodziewać się kolizji wartości mieszania dla 500 miast dodanych do tabeli mieszającej, których nazwy rozpoczynają się dużą literą? Co będzie w przypadku 1000 miast? Czy przychodzi Ci na myśl sposób zmniejszenia liczby kolizji? Dla 500 miast będą istnieć w przybliżeniu 474 elementy słownika, które kolidowały z poprzednią wartością (500–26), gdy z każdą wartością mieszania powiązanych jest 500:26 = 19,2 miasta. W przypadku 1000 miast kolizja występowałaby dla 974 elementów, a z każdą wartością mieszania powiązanych byłoby 1000:26 = 38,4 miasta. Wynika to stąd, że wartość mieszania po prostu bazuje na wartości liczbowej pierwszej litery, która może być jedną z liter od A do Z. Powoduje to, że dozwolonych jest tylko 26 niezależnych wartości mieszania. Oznacza to, że wyszukiwanie w takiej tabeli wymagałoby aż 38 kolejnych operacji wyszukiwania w celu znalezienia poprawnej wartości. Aby to poprawić, konieczne jest zwiększenie liczby możliwych wartości mieszania przez uwzględnienie w tej wartości innych aspektów związanych z miastami. Domyślna funkcja mieszania używana dla łańcucha rozpatruje każdy znak, aby zmaksymalizować liczbę możliwych wartości. Dokładniejsze objaśnienie zamieszczono w punkcie „Funkcje mieszania i entropia”.
Usuwanie Po usunięciu wartości z tabeli mieszającej nie można po prostu zapisać wartości NULL w danym pojemniku pamięci. Wynika to z tego, że wartości NULL zostały użyte jako wartość pomocnicza podczas sondowania pod kątem kolizji wartości mieszania. W efekcie konieczne jest zapisanie specjalnej wartości, która wskazuje, że pojemnik jest pusty. Przy zajmowaniu się kolizją wartości mieszania w dalszym ciągu mogą za tym pojemnikiem występować wartości, które należy uwzględnić. W takich pustych miejscach możliwy jest później zapis. Po zmianie wielkości tabeli mieszającej są one usuwane.
Zmiana wielkości Wstawienie większej liczby elementów do tabeli mieszającej powoduje, że trzeba zmienić wielkość tej tabeli, by można było uwzględnić nowe elementy. Może się okazać, że tabela wypełniona nie więcej niż w dwóch trzecich swojej pojemności będzie się cechować optymalnym Jak działają słowniki i zbiory?
85
wykorzystaniem miejsca, a jednocześnie nadal będzie mieć odpowiedni limit spodziewanej liczby kolizji. A zatem po osiągnięciu punktu krytycznego tabela zwiększy się. Aby tak się stało, przydzielana jest większa tabela (czyli w pamięci rezerwowanych jest więcej pojemników), maska jest modyfikowana w celu dopasowania do nowej tabeli, a wszystkie elementy starej tabeli są ponownie wstawiane do nowej tabeli. Wymaga to ponownego obliczenia indeksów, ponieważ zmodyfikowana maska zmieni wynikowy indeks. W rezultacie zmiana wielkości dużych tabel mieszania może być dość kosztowną operacją! Ze względu jednak na to, że taka operacja zmiany wielkości jest wykonywana tylko wtedy, gdy tabela jest zbyt mała, w przeciwieństwie do każdej operacji wstawiania, zamortyzowany koszt wstawiania nadal wynosi O(1). Domyślnie najmniejsza wielkość słownika lub zbioru wynosi 8 (oznacza to, że jeśli przechowywane są tylko trzy wartości, interpreter języka Python w dalszym ciągu przydzieli 8 elementów). Przy zmianie wielkości słownika/zbioru liczba pojemników zwiększa się czterokrotnie do momentu osiągnięcia 50 000 elementów, a później przyrost wielkości jest dwukrotny. W związku z tym możliwe są następujące wielkości: 8, 32, 128, 512, 2048, 8192, 32768, 131072, 262144, ...
Godne uwagi jest to, że zmiana wielkości może mieć na celu zmniejszenie lub zwiększenie tabeli mieszającej. Oznacza to, że jeśli zostanie usunięta wystarczająca liczba elementów tabeli mieszającej, jej wielkość może zostać przeskalowana w dół. Zmiana wielkości ma jednak miejsce tylko podczas operacji wstawiania.
Funkcje mieszania i entropia Obiekty w języku Python przeważnie mogą być poddawane mieszaniu, ponieważ są już z nimi powiązane wbudowane funkcje __hash__ i __cmp__. W przypadku typów liczbowych (int i float) wartość mieszania jest po prostu oparta na wartości bitowej liczby, którą one reprezentują. Krotki i łańcuchy mają wartość mieszania, która bazuje na ich zawartości. Z kolei listy nie obsługują mieszania, ponieważ ich wartości mogą się zmieniać. Jako że wartości listy mogą się zmieniać, a tym samym wartość mieszania, która reprezentuje listę, może to zmienić względne umiejscowienie danego klucza w tabeli mieszającej4. Klasy definiowane przez użytkownika również są wyposażone w domyślne funkcje mieszania i porównywania. Domyślna funkcja __hash__ po prostu zwraca umiejscowienie obiektu w pamięci podane przez wbudowaną funkcję id. Podobnie operator __cmp__ porównuje wartość liczbową umiejscowienia obiektu w pamięci. Jest to przeważnie akceptowalne, ponieważ dwie instancje klasy są generalnie różne i nie powinny kolidować ze sobą w tabeli mieszającej. Jednakże w niektórych sytuacjach wskazane będzie użycie obiektów set lub dict do ujednoznacznienia elementów. Przyjrzyj się następującej definicji klasy: class Point(object): def __init__(self, x, y): self.x, self.y = x, y
4
Więcej informacji na ten temat dostępnych jest pod adresem: https://wiki.python.org/moin/DictionaryKeys.
86
Rozdział 4. Słowniki i zbiory
Jeśli dla wielu obiektów Point zostałyby utworzone instancje z tymi samymi wartościami x i y, wszystkie byłyby niezależnymi obiektami w pamięci, czyli znajdowałyby się w niej w różnych miejscach. Spowodowałoby to zapewnienie im wszystkim różnych wartości mieszania. Oznacza to, że umieszczenie wszystkich tych obiektów w obiekcie set sprawiłoby, że miałyby one osobne pozycje: >>> p1 = Point(1,1) >>> p2 = Point(1,1) >>> set([p1, p2]) set([, ]) >>> Point(1,1) in set([p1, p2]) False
Aby temu zaradzić, możesz utworzyć niestandardową funkcję mieszania, która bazuje na rzeczywistej zawartości obiektu, a nie na jego umiejscowieniu w pamięci. Funkcja mieszania może być dowolna, dopóki niezmiennie dla jednego obiektu zapewnia taki sam wynik (pojawiają się też kwestie związane z entropią funkcji mieszania, które zostaną omówione w dalszej części rozdziału). Następująca ponowna definicja klasy Point da oczekiwane wyniki: class Point(object): def __init__(self, x, y): self.x, self.y = x, y def __hash__(self): return hash((self.x, self.y)) def __eq__(self, other): return self.x == other.x and self.y == other.y
Pozwala to utworzyć pozycje w zbiorze lub słowniku indeksowane za pomocą właściwości obiektu Point, a nie adresu pamięci obiektu, dla którego utworzono instancję: >>> p1 = Point(1,1) >>> p2 = Point(1,1) >>> set([p1, p2]) set([]) >>> Point(1,1) in set([p1, p2]) True
Jak wspomniano we wcześniejszej uwadze poświęconej kolizji wartości mieszania, niestandardowa funkcja mieszania powinna być uważnie wybrana, aby równomiernie rozprowadzała wartości mieszania w celu uniknięcia kolizji. Występowanie wielu kolizji spowoduje zmniejszenie wydajności tabeli mieszającej. Jeśli większość kluczy powoduje kolizje, konieczne będzie ciągłe „sondowanie” innych wartości. W efekcie znalezienie żądanego klucza może wymagać przejścia potencjalnie dużej części słownika. W najgorszym razie, gdy wszystkie klucze w słowniku będą ze sobą kolidować, wydajność wyszukiwań w słowniku będzie wynosić O(n). Będzie to oznaczać taką samą wydajność jak w przypadku przeszukiwania listy. Jeśli w słowniku przechowywanych jest 5000 wartości, a ponadto niezbędne jest utworzenie funkcji mieszania dla obiektu, który ma zostać użyty jako klucz, słownik będzie przechowywany w tabeli mieszającej o wielkości 32 768. Oznacza to, że tylko 15 ostatnich bitów wartości mieszania używanych jest do tworzenia indeksu (dla tabeli mieszającej o takiej wielkości maska to bin(32758-1) = 0b111111111111111). Pojęcie określające „stopień rozłożenia używanej funkcji mieszania” nazywane jest entropią funkcji mieszania. Oto definicja entropii:
S p(i) log( p(i)) i
Jak działają słowniki i zbiory?
87
Gdzie: p(i) to prawdopodobieństwo tego, że funkcja mieszania zapewni wartość mieszania i. Entropia jest maksymalizowana, gdy każda wartość mieszania ma równe prawdopodobieństwo wybrania. Funkcja mieszania, która maksymalizuje entropię, nosi nazwę idealnej funkcji mieszania, ponieważ gwarantuje minimalną liczbę kolizji. W przypadku nieskończenie dużego słownika idealna jest funkcja mieszania używana dla liczb całkowitych. Wynika to stąd, że wartość mieszania dla liczby całkowitej to po prostu liczba całkowita! Dla takiego słownika wartość maski jest nieskończona, dlatego rozpatrywane są wszystkie bity wartości mieszania. Oznacza to, że dla danych dwóch dowolnych liczb można zagwarantować, że ich wartości mieszania nie będą jednakowe. Jeśli jednak słownik stałby się skończony, taka gwarancja nie byłaby dłużej możliwa. Na przykład w przypadku słownika z czterema elementami używana maska to 0b111. A zatem wartość mieszania dla liczby 5 wynosi 5 & 0b111 = 5, a wartość mieszania dla liczby 501 to 501 & 0b111 = 5. Oznacza to, że ich pozycje będą kolidować. W celu znalezienia maski dla słownika z dowolną liczbą N elementów najpierw znajdowana jest minimalna liczba pojemników, jakie słownik musi mieć, aby nadal pozostawał zapełniony w dwóch trzecich (N * 5 / 3). Później określana jest najmniejsza wielkość słownika, jaka pozwoli pomieścić tę liczbę elementów (8; 32; 128; 512; 2048 itd.), oraz znajdowana jest liczba bitów niezbędna do przechowania takiej liczby elementów. Jeśli na przykład N wynosi 1039, musi istnieć co najmniej 1731 pojemników. Oznacza to, że wymagany jest słownik z 2048 pojemnikami. A zatem maska to bin(2048 - 1) = 0b11111111111.
Przy korzystaniu ze skończonego słownika nie istnieje jedna, najlepsza funkcja mieszania, którą możesz zastosować. Jednakże wcześniejsze ustalenie, jaki będzie używany zakres wartości, a także jak duży będzie słownik, ułatwia dokonanie dobrego wyboru. Jeśli na przykład przechowuje się wszystkie 676 kombinacji dwóch małych liter jako klucze w słowniku (aa, ab, ac itd.), odpowiednią funkcją mieszania będzie funkcja zaprezentowana w przykładzie 4.6. Przykład 4.6. Optymalna funkcja mieszania twoletter_hash def twoletter_hash(key): offset = ord('a') k1, k2 = key return (ord(k2) - offset) + 26 * (ord(k1) - offset)
W przypadku maski 0b1111111111 (słownik liczący 676 wartości będzie utrzymywany w tabeli mieszającej o długości 2048 z maską bin(2048-1) = 0b11111111111) funkcja nie powoduje żadnych kolizji wartości mieszania dla żadnej kombinacji dwóch małych liter. W przykładzie 4.7 bardzo wyraźnie przedstawiono konsekwencje użycia złej funkcji mieszania dla klasy definiowanej przez użytkownika. W tym przypadku kosztem zastosowania takiej funkcji (okazuje się, że jest to najgorsza z możliwych funkcja mieszania!) jest 21,8 razy wolniejsze wyszukiwanie. Przykład 4.7. Pomiar czasu dla dobrych i złych funkcji mieszania import string import timeit class BadHash(str): def __hash__(self): return 42
88
Rozdział 4. Słowniki i zbiory
class GoodHash(str): def __hash__(self): """ Jest to nieznacznie zoptymalizowana wersja funkcji twoletter_hash """ return ord(self[1]) + 26 * ord(self[0]) - 2619 baddict = set() gooddict = set() for i in string.ascii_lowercase: for j in string.ascii_lowercase: key = i + j baddict.add(BadHash(key)) gooddict.add(GoodHash(key)) badtime = timeit.repeat( "key in baddict", setup = "from __main__ import baddict, BadHash; key = BadHash('zz')", repeat = 3, number = 1000000, ) goodtime = timeit.repeat( "key in gooddict", setup = "from __main__ import gooddict, GoodHash; key = GoodHash('zz')", repeat = 3, number = 1000000, ) print "Minimalny czas wyszukiwania dla słownika baddict: ", min(badtime) print "Minimalny czas wyszukiwania dla słownika gooddict: ", min(goodtime) # Wyniki: # Minimalny czas wyszukiwania dla słownika baddict: 16,3375990391 # Minimalny czas wyszukiwania dla słownika gooddict: 0,748275995255
1. Wykaż, że dla nieskończonego słownika (czyli dla nieskończonej maski) użycie wartości liczby całkowitej jako jego wartości mieszania nie spowoduje żadnych kolizji. 2. Wykaż, że funkcja mieszania podana w przykładzie 4.6 nadaje się idealnie dla tabeli mieszającej o wielkości 1024. Dlaczego nie jest ona idealna dla mniejszych tabel mieszających?
Słowniki i przestrzenie nazw Wyszukiwanie w słowniku jest szybkie. Niemniej jednak niepotrzebne wykonanie takiej operacji spowoduje spowolnienie kodu, tak jak wszelkie nieistotne wiersze. Jednym z obszarów, w których się to objawia, jest zarządzanie przestrzenią nazw w języku Python. W tym obszarze intensywnie wykorzystuje się słowniki do wykonywania operacji wyszukiwania. Każdorazowo, gdy zmienna, funkcja lub moduł są wywoływane w kodzie Python, używana jest hierarchia, która określa, gdzie interpreter tego języka powinien szukać tych obiektów. Najpierw interpreter szuka w obrębie tablicy locals(), która zawiera elementy dla wszystkich zmiennych lokalnych. Interpreter języka Python bardzo stara się zapewnić szybkie wyszukiwanie zmiennych lokalnych. Jest to jedyna część procesu, która nie wymaga wyszukiwania w słowniku. Jeśli obiekt nie istnieje w tej tablicy, przeszukiwany jest następnie słownik globals(). Jeśli tutaj też obiekt nie zostanie znaleziony, zostanie przeszukany obiekt __builtin__. Godne uwagi jest to, że choć tablice locals() i globals() to jawne słowniki, a __builtin__ to z technicznego punktu widzenia obiekt modułu, w przypadku przeszukiwania tego obiektu pod kątem danej właściwości po prostu ma miejsce wyszukiwanie słownikowe w obrębie jego odwzorowania tablicy locals() (dotyczy to wszystkich obiektów modułu i obiektów klasy!). Słowniki i przestrzenie nazw
89
Aby stało się to bardziej zrozumiałe, przyjrzyjmy się prostemu przykładowi wywoływania funkcji zdefiniowanych w różnych zasięgach (przykład 4.8). Za pomocą modułu dis możemy dokonać dezasemblacji funkcji (przykład 4.9), aby lepiej zrozumieć przebieg operacji wyszukiwania w przestrzeniach nazw. Przykład 4.8. Wyszukiwanie w przestrzeniach nazw import math from math import sin def test1(x): """ >>> %timeit test1(123456) 1000000 loops, best of 3: 381 ns per loop """ return math.sin(x) def test2(x): """ >>> %timeit test2(123456) 1000000 loops, best of 3: 311 ns per loop """ return sin(x) def test3(x, sin=math.sin): """ >>> %timeit test3(123456) 1000000 loops, best of 3: 306 ns per loop """ return sin(x)
Przykład 4.9. Wyszukiwanie w przestrzeniach nazw po dokonanej dezasemblacji >>> dis.dis(test1) 9 0 LOAD_GLOBAL 3 LOAD_ATTR 6 LOAD_FAST 9 CALL_FUNCTION 12 RETURN_VALUE >>> dis.dis(test2) 15 0 LOAD_GLOBAL 3 LOAD_FAST 6 CALL_FUNCTION 9 RETURN_VALUE >>> dis.dis(test3) 21 0 LOAD_FAST 3 LOAD_FAST 6 CALL_FUNCTION 9 RETURN_VALUE
0 (math) 1 (sin) 0 (x) 1
# Wyszukiwanie w słowniku # Wyszukiwanie w słowniku # Wyszukiwanie lokalne
0 (sin) # Wyszukiwanie w słowniku 0 (x) # Wyszukiwanie lokalne 1 1 (sin) # Wyszukiwanie lokalne 0 (x) # Wyszukiwanie lokalne 1
Pierwsza funkcja test1 tworzy wywołanie funkcji sin przez jawne użycie biblioteki math. Jest to też widoczne w wygenerowanym kodzie bajtowym: najpierw musi zostać załadowane odwołanie do modułu math, a następnie dla tego modułu przeprowadzane jest wyszukiwanie atrybutów do momentu uzyskania odwołania do funkcji sin. Odbywa się to za pomocą dwóch operacji wyszukiwania w słowniku. Pierwsza z nich znajduje moduł math, a druga funkcję sin w module. Z kolei funkcja test2 jawnie importuje funkcję sin z modułu math, a następnie funkcja jest bezpośrednio dostępna w obrębie globalnej przestrzeni nazw. Oznacza to, że możesz uniknąć wyszukiwania modułu math i kolejnego wyszukiwania atrybutów. Niemniej jednak nadal konieczne jest znalezienie funkcji sin w globalnej przestrzeni nazw. Jest to jeszcze jeden powód, dla którego należy wyraźnie określić, jakie funkcje są importowane z modułu.
90
Rozdział 4. Słowniki i zbiory
Takie podejście nie tylko zwiększa czytelność kodu, gdyż programista wie dokładnie, jakie funkcje są wymagane ze źródeł zewnętrznych, ale też przyspiesza wykonywanie kodu! Funkcja test3 definiuje funkcję sin jako argument słowa kluczowego z wartością domyślną, która jest odwołaniem do funkcji sin w obrębie modułu math. Choć w dalszym ciągu konieczne jest znalezienie odwołania do tej funkcji w module, trzeba to zrobić tylko wtedy, gdy funkcja test3 zostanie zdefiniowana jako pierwsza. Później odwołanie do funkcji sin jest przechowywane w definicji funkcji jako zmienna lokalna w postaci domyślnego argumentu słowa kluczowego. Jak wcześniej wspomniano, do znalezienia zmiennych lokalnych nie trzeba przeprowadzać wyszukiwania w słowniku. Zmienne te są przechowywane w bardzo niewielkiej tablicy, która cechuje się bardzo krótkimi czasami wyszukiwania. Z tego powodu znajdowanie funkcji przebiega dość szybko! Choć takie efekty są interesującym wynikiem sposobu zarządzania przestrzeniami nazw w języku Python, funkcja test3 zdecydowanie nie jest odpowiednia dla tego języka. Okazuje się na szczęście, że takie dodatkowe wyszukiwania w słowniku zaczynają powodować spadek wydajności tylko wtedy, gdy są bardzo często wywoływane (tzn. w najbardziej wewnętrznym bloku bardzo szybkiej pętli, tak jak w przykładzie zbioru Julii). Mając to na uwadze, bardziej przejrzystym rozwiązaniem byłoby ustawienie przed uruchomieniem pętli zmiennej lokalnej z odwołaniem globalnym. Choć w dalszym ciągu konieczne będzie wyszukiwanie globalne każdorazowo przy wywołaniu funkcji, wszystkie wywołania jej w pętli będą przebiegać szybciej. Przemawia za tym to, że nawet minutowe spowolnienia w kodzie mogą zostać spotęgowane, jeśli kod jest uruchamiany miliony razy. Nawet jeśli wyszukiwanie w słowniku zajmuje zaledwie kilkaset nanosekund, w przypadku wykonywania miliony razy pętli dla tej operacji czas szybko może się wydłużyć. Jak widać w przykładzie 4.10, przyspieszenie 9,4% uzyskiwane jest już tylko przez samo utworzenie funkcji sin jako lokalnej względem intensywnej obliczeniowo pętli, która ją wywołuje. Przykład 4.10. Efekty powolnych wyszukiwań w przestrzeniach nazw w pętlach from math import sin def tight_loop_slow(iterations): """ >>> %timeit tight_loop_slow(10000000) 1 loops, best of 3: 2.21 s per loop """ result = 0 for i in xrange(iterations): # to wywołanie funkcji sin wymaga wyszukiwania globalnego result += sin(i) def tight_loop_fast(iterations): """ >>> %timeit tight_loop_fast(10000000) 1 loops, best of 3: 2.02 s per loop """ result = 0 local_sin = sin for i in xrange(iterations): # to wywołanie funkcji local_sin wymaga wyszukiwania lokalnego result += local_sin(i)
Słowniki i przestrzenie nazw
91
Podsumowanie Słowniki i zbiory zapewniają znakomity sposób przechowywania danych, które mogą być indeksowane według klucza. Metoda użycia takiego klucza z wykorzystaniem funkcji mieszania może w dużym stopniu wpłynąć na wynikową wydajność struktury danych. Co więcej, zaznajomienie się ze sposobem działania słowników pozwala lepiej zrozumieć nie tylko to, jak zorganizować dane, ale też jak uporządkować kod. Wynika to stąd, że słowniki stanowią nieodłączną część wewnętrznych funkcji języka Python. W następnym rozdziale zostaną omówione generatory, które umożliwiają zapewnienie danych kodowi przy większej kontroli uporządkowania i bez konieczności uprzedniego przechowywania w pamięci pełnych zbiorów danych. Pozwala to uniknąć wielu możliwych przeszkód, które mogą wystąpić podczas używania dowolnej z wewnętrznych struktur danych języka Python.
92
Rozdział 4. Słowniki i zbiory
ROZDZIAŁ 5.
Iteratory i generatory
Pytania, na jakie będziesz w stanie udzielić odpowiedzi po przeczytaniu rozdziału W jaki sposób generatory oszczędzają pamięć? W jakich sytuacjach najlepiej skorzystać z generatora? W jaki sposób użyć narzędzia itertools do tworzenia złożonych przepływów pracy
generatora? Kiedy wartościowanie leniwe jest korzystne, a kiedy nie?
Gdy wiele osób mających doświadczenie z innym językiem zaczyna się uczyć języka Python, zaskakuje je różnica w zapisie pętli for. Oznacza to, że zamiast zapisu: # Inne języki for (i=0; i mean + 3 * standard_deviation: return day return False
Elementem związanym z tą funkcją, który może wydać się dziwny, jest dodatkowy zestaw nawiasów okrągłych w definicji parametrów. Nie jest to żadna literówka, lecz wynik użycia tej funkcji, która pobiera dane wejściowe z generatora groupby. Jak wspomniano, generator ten zwraca krotki, które stają się parametrami właśnie funkcji check_anomaly. W efekcie w celu właściwego wyodrębnienia klucza i danych grupy konieczne jest rozszerzenie krotek. Ponieważ używany jest iterator ifilter, innym sposobem poradzenia sobie z tym bez konieczności rozszerzania krotek wewnątrz definicji funkcji jest zdefiniowanie iteratora istarfilter. Iterator wykonuje działania podobne do działań iteratora istarmap w przypadku iteratora imap (więcej informacji zawiera dokumentacja narzędzia itertools). Na koniec możemy połączyć w łańcuch generatory, aby uzyskać dni z danymi wykazującymi nieprawidłowości (przykład 5.5). Przykład 5.5. Połączenie generatorów w łańcuch from itertools import ifilter, imap data = read_data(data_filename) data_day = day_grouper(data) anomalous_dates = ifilter(None, imap(check_anomaly, data_day)) # first_anomalous_date, first_anomalous_data = anomalous_dates.next() print "Pierwsza data z nieprawidłowościami: ", first_anomalous_date
Wartościowanie leniwe generatora
99
Iterator ifilter usunie wszystkie elementy, które nie są zgodne z danym filtrem. Domyślnie iterator (ma to miejsce w momencie przekazania wartości None do pierwszego parametru) odfiltruje wszystkie elementy, dla których zostanie określona wartość False. Tym sposobem nie będą uwzględniane żadne dni, w przypadku których funkcja check_anomaly nie stwierdzi nieprawidłowości. Metoda ta w bardzo prosty sposób pozwala uzyskać listę dni wykazujących nieprawidłowości bez konieczności ładowania całego zbioru danych. Godne uwagi jest to, że powyższy kod w rzeczywistości nie przeprowadza żadnego obliczenia. Konfiguruje jedynie potok w celu wykonania obliczenia. Plik w ogóle nie zostanie wczytany do momentu użycia funkcji anomalous_dates.next() lub zastosowania iteracji dla generatora anomalous_dates. Okazuje się, że analiza jest przeprowadzana dopiero po zażądaniu nowej wartości z generatora anomalous_dates. A zatem jeśli pełny zbiór danych zawiera pięć nieprawidłowych dat, ale w kodzie pobierana jest jedna data, po czym następuje zatrzymanie w celu zażądania nowych wartości, plik zostanie wczytany tylko do miejsca, w którym wystąpią dane dla danego dnia. Jest to określane mianem wartościowania leniwego. W jego przypadku wykonywane są wyłącznie jawnie zażądane obliczenia. Jeśli istnieje warunek wczesnego zakończenia, może to znacząco skrócić ogólny czas działania kodu. Inna subtelność związana z organizowaniem analizy w ten sposób umożliwia wykonywanie bez trudu bardziej wymagających obliczeń bez konieczności przebudowywania dużych porcji kodu. Aby na przykład uzyskać ruchome okno dla jednego dnia, zamiast używać grupowania według dni, można utworzyć nową funkcję day_grouper: from datetime import datetime def rolling_window_grouper(data, window_size=3600): window = tuple(islice(data, 0, window_size)) while True: current_datetime = datetime.fromtimestamp(window[0][0]) yield (current_datetime, window) window = window[1:] + (data.next(),)
Zastępujemy teraz po prostu wywołanie funkcji day_grouper z przykładu 5.5 wywołaniem funkcji rolling_window_grouper i uzyskujemy żądany wynik. W przypadku tej wersji widoczne jest również bardzo wyraźnie zapewnienie pamięci przez tę i poprzednią metodę. Jako stan ta metoda będzie przechowywać tylko dane odpowiadające oknu (w obu przypadkach jest to jeden dzień lub 3600 punktów danych). By to zmienić, należy wielokrotnie otworzyć plik i użyć różnych deskryptorów cyklu życia do wskazania dokładnie tych danych, które mają zostać użyte (lub skorzystać z modułu linecache). Jest to jednak wymagane tylko wtedy, gdy przykładowy podzbiór zbioru danych nadal nie mieści się w pamięci. Końcowa uwaga: w funkcji rolling_window_grouper wykonywanych jest wiele operacji pop i append dla listy window. Możliwe jest zoptymalizowanie tego w znacznym stopniu przez zastosowanie obiektu deque w module collections. Obiekt ten zapewnia operacje dołączania i usuwania o czasie O(1), które są wykonywane dla elementów znajdujących się na początku lub końcu listy (zwykłe listy cechują się czasem O(1) dla operacji dołączania i usuwania wykonywanych dla końca listy oraz czasem O(n) dla tych samych operacji dotyczących początku listy). Za pomocą obiektu deque możliwe jest dołączenie nowych danych po prawej stronie (lub na końcu) listy. Metoda deque.popleft() pozwala usunąć dane po lewej stronie (lub na początku) listy bez konieczności przydzielania dodatkowego miejsca lub wykonywania czasochłonnych operacji o czasie O(n).
100
Rozdział 5. Iteratory i generatory
Podsumowanie Zdefiniowanie algorytmu znajdującego nieprawidłowości za pomocą iteratorów pozwala przetwarzać znacznie więcej danych, niż może zmieścić się w pamięci. Co więcej, możliwe jest wykonanie tego w krótszym czasie niż w przypadku zastosowania list, ponieważ eliminowane są wszystkie kosztowne operacje append. Ze względu na to, że iteratory są typem podstawowym języka Python, zawsze należy używać ich przy próbie zmniejszenia poziomu wykorzystania pamięci przez aplikację. Korzyści w postaci wartościowania leniwego wyników sprawiają, że przetwarzane są tylko niezbędne dane, a ponadto oszczędzana jest pamięć, ponieważ nie są w niej przechowywane wcześniejsze wyniki, dopóki nie zostanie to jawnie zażądane. W rozdziale 11. będzie mowa o innych metodach możliwych do wykorzystania w przypadku bardziej specyficznych problemów. Ponadto w rozdziale tym zostaną zaprezentowane nowe sposoby analizowania trudności, gdy pojawi się kłopot z pamięcią RAM. Inną korzyścią z rozwiązywania problemów za pomocą iteratorów jest przygotowanie kodu do użycia z wykorzystaniem wielu procesorów lub komputerów (więcej o tym w rozdziałach 9. i 10.). Jak wspomniano w podrozdziale „Iteratory dla szeregów nieskończonych”, podczas stosowania iteratorów zawsze trzeba pamiętać o różnych stanach wymaganych do działania algorytmu. Po stwierdzeniu, w jaki sposób ma zostać przygotowany stan niezbędny do uruchomienia algorytmu, nie będzie mieć znaczenia to, gdzie on działa. Tego rodzaju paradygmat można zauważyć na przykład w przypadku modułów multiprocessing i ipython, które do uruchamiania zadań równoległych używają funkcji podobnej do funkcji map.
Podsumowanie
101
102
Rozdział 5. Iteratory i generatory
ROZDZIAŁ 6.
Obliczenia macierzowe i wektorowe
Pytania, na jakie będziesz w stanie udzielić odpowiedzi po przeczytaniu rozdziału Jakie są wąskie gardła w obliczeniach wektorowych? Jakich narzędzi można użyć do określenia, jak efektywnie procesor wykonuje
obliczenia?
Dlaczego narzędzie numpy lepiej radzi sobie z obliczeniami numerycznymi niż czysty
kod Python?
Czym są liczniki cache-misses i page-faults? Jak można śledzić przydziały pamięci w utworzonym kodzie?
Niezależnie od tego, jaki problem ma zostać rozwiązany za pomocą komputera, w pewnym momencie zetkniesz się z obliczeniami wektorowymi. Stanowią one integralną część tego, jak komputer działa, a także w jaki sposób próbuje skrócić czas działania programów aż do poziomu kondensatorów. Komputer potrafi jedynie przetwarzać liczby. Oznacza to, że uzyskanie informacji o sposobie jednoczesnego wykonywania kilku takich obliczeń pozwoli przyspieszyć program. W tym rozdziale spróbujemy objaśnić część złożoności tego problemu. W tym celu skoncentrujemy się na dość prostym zadaniu matematycznym, rozwiązując równanie dyfuzji i analizując to, co dzieje się na poziomie procesora. Zrozumienie, jak różny kod Python wpływa na działanie procesora i jak efektywnie badać ten wpływ, pozwoli Ci opanować umiejętność analizowania również innych problemów. Najpierw zaprezentujemy problem i przedstawimy szybkie rozwiązanie bazujące na czystym kodzie Python. Po zidentyfikowaniu problemów dotyczących pamięci i podjęciu próby ich rozwiązania za pomocą czystego kodu Python skorzystamy z narzędzia numpy i zastanowimy się, jak i dlaczego powoduje ono przyspieszenie wykonania kodu. Później zaczniemy wprowadzać zmiany w algorytmie i przygotowywać kod pod kątem rozwiązania problemu. Dzięki usunięciu części ogólnych elementów używanych bibliotek możliwe będzie dodatkowe zwiększenie szybkości. Na końcu zostanie zastosowanych kilka dodatkowych modułów, które ułatwią tego rodzaju przetwarzanie w praktyce. Jednocześnie przypomnimy ostrzeżenie dotyczące optymalizowania przed profilowaniem. 103
Wprowadzenie do problemu Niniejszy podrozdział ma za zadanie pomóc Ci w lepszym zrozumieniu równań, które będą rozwiązywane w rozdziale. Przed lekturą reszty rozdziału absolutnie nie musisz opanować treści tego podrozdziału. Jeśli zamierzasz go pominąć, pamiętaj o przeanalizowaniu algorytmu z przykładów 6.1 i 6.2, by zrozumieć kod, który będzie optymalizowany. Jeśli jednak postanowisz przeczytać ten podrozdział, a nawet będziesz oczekiwać dodatkowego objaśnienia, przeczytaj rozdział 17. trzeciej edycji książki Numerical Recipes autorstwa Williama Pressa i innych (wydawnictwo Cambridge University Press).
Aby objaśnić przedstawione w rozdziale obliczenia macierzowe i wektorowe, wielokrotnie zostanie użyty przykład dyfuzji płynów. Dyfuzja to jeden z mechanizmów powodujących ruch płynów i podejmujących próby jednakowego ich zmieszania. W podrozdziale zostaną przybliżone pojęcia matematyczne związane z równaniem dyfuzji. Choć może wydać się to skomplikowane, nie ma powodu do obaw! Aby całe zagadnienie stało się bardziej zrozumiałe, szybko je uprościmy. Opanowanie w podstawowym zakresie końcowego równania, które będzie rozwiązywane, okaże się przydatne w trakcie lektury rozdziału, ale nie jest niezbędne. W kolejnych rozdziałach skupimy się przede wszystkim na różnych wariantach kodu, a nie na samym równaniu. Jeśli zrozumiesz równanie, będzie Ci po prostu łatwiej zapoznać się ze sposobami optymalizowania kodu. Tak jest w większości przypadków. Zrozumienie tego, na czym kod bazuje, oraz niuansów algorytmu pozwoli Ci dokładniej poznać możliwe metody optymalizacji. Prostym przykładem dyfuzji jest rozprzestrzenianie się barwnika w wodzie. Jeśli w wodzie o temperaturze pokojowej umieścisz kilka kropli barwnika, będzie on powoli przemieszczać się aż do momentu, gdy całkowicie wymiesza się z wodą. Ponieważ woda nie jest mieszana ani nie jest na tyle ciepła, aby wywołać prądy konwekcyjne, dyfuzja będzie podstawowym procesem, który spowoduje wymieszanie się dwóch płynów. W przypadku numerycznego rozwiązywania równań związanych z procesem dyfuzji wybieramy żądany warunek początkowy, a ponadto mamy możliwość rozwijania go w miarę upływu czasu w celu stwierdzenia, jaką postać przyjmie na późniejszym etapie (rysunek 6.2). Gdy już to wiadomo, najważniejszą do naszych celów informacją na temat dyfuzji jest jej definicja. Równanie dyfuzji, definiowane jako częściowe równanie różnicowe o jednym wymiarze (1W), jest zapisywane w następującej postaci:
2 u x,t D 2 u x,t t x W powyższym równaniu u to wektor reprezentujący masy poddawane dyfuzji. Na przykład może istnieć wektor o wartościach 0 (w przypadku samej wody), 1 (dla samego barwnika) oraz wartościach pośrednich odpowiadających wymieszaniu wody i barwnika. Ogólnie rzecz biorąc, będzie to macierz dwuwymiarowa (2W) lub trójwymiarowa (3W), która reprezentuje rzeczywistą powierzchnię lub objętość płynu. Tym sposobem wektor u może być macierzą 3W, która reprezentuje płyn w szklance. Zamiast wyznaczenia drugiej pochodnej w kierunku x konieczne byłoby wyznaczenie jej dla wszystkich osi. Ponadto symbol D w równaniu to wielkość fizyczna, która reprezentuje symulowane właściwości płynu. Duża wartość wielkości D
104
Rozdział 6. Obliczenia macierzowe i wektorowe
odzwierciedla płyn, który z dużą łatwością może podlegać dyfuzji. Choć dla uproszczenia na potrzeby używanego kodu zostanie ustawione D o wartości 1, w dalszym ciągu wielkość ta będzie uwzględniana w obliczeniach. Równanie dyfuzji jest też nazywane równaniem przewodnictwa cieplnego. W tym przypadku u reprezentuje temperaturę obszaru, a D opisuje stopień przewodnictwa cieplnego materiału. Rozwiązanie równania pozwala stwierdzić, jakie jest przewodnictwo cieplne. Umożliwia nie tylko określenie, jak kilka kropli barwnika przemieszcza się w wodzie, ale i sprawdzenie, jaka jest dyfuzja w radiatorze ciepła generowanego przez procesor.
Dla równania dyfuzji, które jest ciągłe w czasie i przestrzeni, dokonamy aproksymacji przy użyciu objętości i czasów dyskretnych. W tym celu zostanie użyta metoda Eulera. Polega ona po prostu na pobraniu pochodnej i zapisaniu jej jako różnicy w następujący sposób:
u x , t dt u x , t u x,t t dt W powyższym równaniu dt oznacza stałą liczbę reprezentującą krok czasowy lub rozwiązanie w czasie, dla którego równanie ma zostać rozwiązane. Można to przyrównać do liczby klatek na sekundę filmu, który próbujesz stworzyć. Gdy liczba klatek będzie wzrastać (lub dt zmniejszać się), uzyskamy bardziej przejrzysty obraz tego, co się dzieje. Okazuje się, że gdy dt zmierza do zera, aproksymacja Eulera staje się dokładna (zauważ jednak, że ta dokładność może być osiągnięta wyłącznie teoretycznie, ponieważ komputer cechuje się tylko skończoną precyzją, a ponadto błędy numeryczne szybko wpłyną znacząco na wyniki). Możliwe jest zatem zmodyfikowanie tego równania w celu stwierdzenia, jakie jest u(x, t+dt) dla danego u(x,t). Oznacza to, że możemy zacząć od pewnego stanu początkowego u(x,0) (reprezentuje szklankę wody w momencie umieszczenia w niej kropli barwnika) i wykorzystać opisane mechanizmy do „rozwinięcia” tego stanu, aby sprawdzić, jak będzie się prezentować w przyszłych momentach czasu (u(x,dt)). Tego typu problem jest określany mianem problemu wartości początkowej lub problemu Cauchy’ego. Stosując podobny zabieg dla pochodnej osi x z wykorzystaniem aproksymacji różnic skończonych, uzyskujemy następujące równanie końcowe:
u x , t dt u x , t dt D
u x dx , t u x dx , t 2 u x , t dx 2
W tym przypadku, podobnie do tego, jak dt reprezentuje liczbę klatek na sekundę, dx reprezentuje rozdzielczość obrazów. Mniejsza wartość dx oznacza mniejszy obszar reprezentowany przez każdą komórkę w macierzy. Dla uproszczenia po prostu zostanie ustawione D o wartości 1 oraz dx o wartości 1. Te dwie wartości stają się bardzo istotne podczas przeprowadzania właściwych symulacji fizycznych. Ponieważ jednak równanie dyfuzji jest tu rozwiązywane tylko do celów demonstracyjnych, w tym przypadku nie mają one żadnego znaczenia. Korzystając z przedstawionego równania, można rozwiązać prawie każdy problem dotyczący dyfuzji. Istnieją jednak pewne kwestie z nim związane. Przede wszystkim, jak wcześniej wspomniano, indeks przestrzenny w u (tj. parametr x) będzie reprezentowany jako indeksy w macierzy. Co się stanie przy próbie znalezienia wartości w x-dx, gdy x znajduje się na początku macierzy? Problem ten nazywany jest warunkiem brzegowym. Mogą istnieć trwałe warunki
Wprowadzenie do problemu
105
brzegowe z następującą informacją: „każda wartość spoza granic używanej macierzy zostanie ustawiona na 0 (lub dowolną inną wartość)”. Alternatywnie mogą istnieć okresowe warunki brzegowe informujące o tym, że wartości będą „przekręcane”. Oznacza to, że jeśli jeden z wymiarów macierzy ma długość N, wartość w tym wymiarze w indeksie –1 jest taka sama jak w wymiarze N-1, a wartość w wymiarze N jest identyczna z wartością w indeksie 0. Inaczej mówiąc, przy próbie uzyskania dostępu do wartości w indeksie i zostanie otrzymana wartość w indeksie (i%N). Inną kwestią jest to, jak będzie przechowywanych wiele składników czasu wektora u. Dla każdej wartości czasu, dla której są przeprowadzane obliczenia, może istnieć jedna macierz. Wydaje się, że jako minimum będą wymagane dwie macierze: po jednej dla bieżącego stanu płynu i jego następnego stanu. Jak się okaże, w przypadku tej konkretnej kwestii pojawiają się czynniki mające bardzo duży wpływ na wydajność. A zatem jak w praktyce wygląda rozwiązywanie tego problemu? Przykład 6.1 zawiera pseudokod, który prezentuje sposób użycia do tego przedstawionego równania. Przykład 6.1. Pseudokod dla dyfuzji jednowymiarowej # Tworzenie warunków początkowych u = vector of length N for i in range(N): u = 0 if there is water, 1 if there is dye # Rozwijanie warunków początkowych D = 1 t = 0 dt = 0.0001 while True: print "Bieżący czas: %f" % t unew = vector of size N # Aktualizowanie kroku dla każdej komórki for i in range(N): unew[i] = u[i] + D * dt * (u[(i+1)%N] + u[(i-1)%N] - 2 * u[i]) # Przeniesienie zaktualizowanego rozwiązania do wektora u u = unew visualize(u)
Powyższy kod pobiera warunki początkowe dla barwnika w wodzie i informuje, jak układ ten będzie wyglądał z biegiem czasu przy interwale wynoszącym 0,0001 sekundy. Wyniki działania kodu pokazano na rysunku 6.1, na którym zaprezentowano przemieszczanie się w miarę upływu czasu bardzo skoncentrowanej kropli barwnika (reprezentowana na rysunku przez najwyżej umieszczoną funkcję w kształcie kapelusza). Na wykresach można zaobserwować, jak z czasem barwnik staje się dobrze wymieszany, i moment, gdy wszędzie występuje jego podobne stężenie. Na potrzeby rozdziału zostanie uzyskane rozwiązanie dwuwymiarowej wersji poprzedniego równania. Oznacza to jedynie, że zamiast wektora (lub inaczej mówiąc, macierzy z jednym indeksem) będzie przetwarzana macierz dwuwymiarowa. Jedyna zmiana w równaniu (a tym samym w poniższym kodzie) wiąże się z koniecznością pobrania drugiej pochodnej dla kierunku y. Oznacza to po prostu, że pierwotne równanie, które zostało użyte, przyjmie obecnie następującą postać:
2 2 u x, y,t D 2 u x, y,t 2 u x, y,t t y x
106
Rozdział 6. Obliczenia macierzowe i wektorowe
Rysunek 6.1. Przykład dyfuzji jednowymiarowej
To numeryczne równanie dyfuzji w wersji dwuwymiarowej wyrażane jest w postaci pseudokodu z przykładu 6.2. W tym celu można użyć tych samych metod co wcześniej. Przykład 6.2. Algorytm do obliczania dyfuzji dwuwymiarowej for i in range(N): for j in range(M): unew[i][j] = u[i][j] + dt * ( \ (u[(i+1)%N][j] + u[(i-1)%N][j] - 2 * u[i][j]) + \ # d^2 u / dx^2 (u[i][(j+1)%M] + u[j][(j-1)%M] - 2 * u[i][j]) \ # d^2 u / dy^2 )
Można teraz zebrać to wszystko i utworzyć w języku Python pełny kod dyfuzji dwuwymiarowej, który posłuży jako baza do testów porównawczych opisanych w dalszej części książki. Choć kod wygląda na bardziej skomplikowany, wyniki przypominają te uzyskane dla kodu dyfuzji jednowymiarowej (zaprezentowano je na rysunku 6.2). Aby poszerzyć wiedzę związaną z tematami poruszanymi w tym podrozdziale, zajrzyj na stronę serwisu Wikipedia poświęconą równaniu dyfuzji (http://en.wikipedia.org/wiki/Diffusion_equation), a także przeczytaj rozdział 7. książki Numerical methods for complex systems autorstwa S. V. Gurevicha (http://pauli.uni-muenster.de/tp/fileadmin/lehre/NumMethoden/WS0910/ScriptPDE/Heat.pdf).
Czy listy języka Python są wystarczająco dobre? Użyjmy pseudokodu z przykładu 6.1 i tak go zmodyfikujmy, aby łatwiej było analizować wydajność jego działania. Pierwszym krokiem jest utworzenie funkcji przetwarzania, która pobiera macierz i zwraca jej stan po przetwarzaniu. Prezentuje to przykład 6.3.
Czy listy języka Python są wystarczająco dobre?
107
Rysunek 6.2. Przykład dyfuzji dla dwóch zbiorów warunków początkowych Przykład 6.3. Dyfuzja dwuwymiarowa przy użyciu czystego kodu Python grid_shape = (1024, 1024) def evolve(grid, dt, D=1.0): xmax, ymax = grid_shape new_grid = [[0.0,] * ymax for x in xrange(xmax)] for i in xrange(xmax): for j in xrange(ymax): grid_xx = grid[(i+1)%xmax][j] + grid[(i-1)%xmax][j] - 2.0 * grid[i][j] grid_yy = grid[i][(j+1)%ymax] + grid[i][(j-1)%ymax] - 2.0 * grid[i][j] new_grid[i][j] = grid[i][j] + D * (grid_xx + grid_yy) * dt return new_grid
Zamiast wstępnie przydzielać listę new_grid, można utworzyć ją w pętli for za pomocą metod append. Choć ten wariant byłby znacznie szybszy od pierwszego z podanych, wnioski w dalszym ciągu byłyby właściwe. Zdecydowaliśmy się na wstępne przydzielenie listy, ponieważ jest bardziej obrazowa.
Zmienna globalna grid_shape określa wielkość obszaru, który będzie symulowany. Jak wyjaśniono w podrozdziale „Wprowadzenie do problemu”, używane są okresowe warunki brzegowe (z tego właśnie powodu dla indeksów stosowana jest operacja modulo). Aby faktycznie użyć tego kodu, konieczne jest zainicjowanie siatki i wywołanie dla niej funkcji evolve. Kod z przykładu 6.4 to bardzo ogólna procedura inicjowania, która będzie wielokrotnie wykorzystywana w rozdziale (parametry wydajnościowe tego kodu nie będą analizowane, ponieważ wymaga on tylko jednokrotnego uruchomienia w przeciwieństwie do wielokrotnie wywoływanej funkcji evolve).
108
Rozdział 6. Obliczenia macierzowe i wektorowe
Przykład 6.4. Inicjalizacja dyfuzji dwuwymiarowej przy użyciu czystego kodu Python def run_experiment(num_iterations): # Ustawianie warunków początkowych xmax, ymax = grid_shape grid = [[0.0,] * ymax for x in xrange(xmax)] block_low = int(grid_shape[0] * .4) block_high = int(grid_shape[0] * .5) for i in xrange(block_low, block_high): for j in xrange(block_low, block_high): grid[i][j] = 0.005 # Rozwijanie warunków początkowych start = time.time() for i in range(num_iterations): grid = evolve(grid, 0.1) return time.time() – start
Używane tutaj warunki początkowe są takie same jak w przykładzie z kwadratami (rysunek 6.2). Wartości liczby dt i elementów siatki zostały tak dobrane, aby były wystarczająco małe w celu zapewnienia stabilności algorytmu. W trzeciej edycji książki Numerical Recipes (http://www.nr.com/) autorstwa Williama Pressa i innych można znaleźć obszerniejsze omówienie właściwości zbieżności tego algorytmu.
Problemy z przesadną alokacją Używając narzędzia line_profiler dla funkcji przetwarzania czystego kodu Python, można rozpocząć analizowanie tego, co wpływa na możliwy wolny czas działania. Po przyjrzeniu się danym wyjściowym tego narzędzia do profilowania (przykład 6.5) stwierdzimy, że większość czasu działania funkcji zajmuje obliczenie pochodnej i aktualizowanie siatki1. Właśnie tego oczekiwaliśmy, gdyż jest to problem powiązany wyłącznie z procesorem. Jakikolwiek czas poświęcony na coś innego niż rozwiązywanie tego problemu oznacza oczywiście, że pojawia się potrzeba zastosowania optymalizacji. Przykład 6.5. Profilowanie dyfuzji dwuwymiarowej przy użyciu czystego kodu Python $ kernprof.py -lv diffusion_python.py Wrote profile results to diffusion_python.py.lprof Timer unit: 1e-06 s File: diffusion_python.py Function: evolve at line 8 Total time: 16.1398 s Line # Hits Time Per Hit % Time Line Contents ============================================================== 8 @profile 9 def evolve(grid, dt, D=1.0): 10 10 39 3.9 0.0 xmax, ymax = grid_shape # 11 2626570 2159628 0.8 13.4 new_grid = ... 12 5130 4167 0.8 0.0 for i in xrange(xmax): # 13 2626560 2126592 0.8 13.2 for j in xrange(ymax): 14 2621440 4259164 1.6 26.4 grid_xx = ... 15 2621440 4196964 1.6 26.0 grid_yy = ... 16 2621440 3393273 1.3 21.0 new_grid[i][j] = ... 17 10 10 1.0 0.0 return grid # 1
Są to dane wyjściowe kodu z przykładu 6.3, które zostały przycięte do marginesów strony. Jak wcześniej wspomniano, aby możliwe było profilowanie przez skrypt kernprof.py, konieczne jest zastosowanie dla funkcji dekoratora @profile (więcej informacji zawiera podrozdział „Użycie narzędzia line_profiler do pomiarów dotyczących kolejnych wierszy kodu”).
Czy listy języka Python są wystarczająco dobre?
109
Instrukcja zajmuje tak wiele czasu dla jednego trafienia z powodu konieczności pobrania zmiennej grid_shape z lokalnej przestrzeni nazw (więcej informacji zawiera podrozdział „Słowniki i przestrzenie nazw”). Z tym wierszem powiązanych jest 5130 trafień. Oznacza to, że dla przetwarzanej siatki xmax ma wartość 512. Wynika to z wykonania 512 operacji sprawdzania dla każdej wartości w zakresie funkcji xrange oraz jednej takiej operacji dotyczącej warunku zakończenia pętli. Wszystko to zostało powtórzone 10 razy. Z tym wierszem powiązanych jest 10 trafień. Oznacza to, że funkcja była profilowana w ramach 10 uruchomień. Dane wyjściowe pokazują też jednak, że 20% czasu zajęło przydzielanie listy new_grid. Jest to marnotrawstwo, ponieważ właściwości tej listy nie zmieniają się. Niezależnie od tego, jakie wartości są wysyłane do funkcji evolve, lista new_grid zawsze będzie mieć taką samą postać i wielkość, a ponadto będzie zawierać jednakowe wartości. Prosta optymalizacja polegałaby na jednorazowym przydzieleniu tej listy i wykorzystaniu jej ponownie. Tego rodzaju optymalizacja przypomina przemieszczenie powtarzającego się kodu poza obręb szybkiej pętli: from math import sin def loop_slow(num_iterations): """ >>> %timeit loop_slow(int(1e4)) 100 loops, best of 3: 2.67 ms per loop """ result = 0 for i in xrange(num_iterations): result += i * sin(num_iterations) # return result def loop_fast(num_iterations): """ >>> %timeit loop_fast(int(1e4)) 1000 loops, best of 3: 1.38 ms per loop """ result = 0 factor = sin(num_iterations) for i in xrange(num_iterations): result += i return result * factor
Wartość funkcji sin(num_iterations) nie zmienia wydajności pętli, dlatego nie jest za każdym razem obliczana. Jak ilustruje to przykład 6.6, możliwa jest do przeprowadzenia transformacja podobna do użytej w kodzie dyfuzji. W tym przypadku instancja listy new_grid z przykładu 6.4 zostanie utworzona i wysłana do funkcji evolve. Funkcja zadziała tak samo jak wcześniej, czyli wczyta listę grid i zapisze ją w liście new_grid. Następnie można po prostu zamienić listę new_grid na listę grid i wznowić kontynuowanie działań. Przykład 6.6. Dyfuzja dwuwymiarowa przy użyciu czystego kodu Python po zmniejszeniu liczby alokacji pamięci def evolve(grid, dt, out, D=1.0): xmax, ymax = grid_shape for i in xrange(xmax): for j in xrange(ymax): grid_xx = grid[(i+1)%xmax][j] + grid[(i-1)%xmax][j] - 2.0 * grid[i][j] grid_yy = grid[i][(j+1)%ymax] + grid[i][(j-1)%ymax] - 2.0 * grid[i][j] out[i][j] = grid[i][j] + D * (grid_xx + grid_yy) * dt def run_experiment(num_iterations):
110
Rozdział 6. Obliczenia macierzowe i wektorowe
# Ustawianie warunków początkowych xmax,ymax = grid_shape next_grid = [[0.0,] * ymax for x in xrange(xmax)] grid = [[0.0,] * ymax for x in xrange(xmax)] block_low = int(grid_shape[0] * .4) block_high = int(grid_shape[0] * .5) for i in xrange(block_low, block_high): for j in xrange(block_low, block_high): grid[i][j] = 0.005 start = time.time() for i in range(num_iterations): evolve(grid, 0.1, next_grid) grid, next_grid = next_grid, grid return time.time() - start
Na podstawie profilowania wierszy zmodyfikowanej wersji kodu z przykładu 6.72 można stwierdzić, że ta niewielka zmiana zapewniła przyspieszenie wynoszące 21%. Prowadzi to do wniosku podobnego do tego, który wynikał z omówienia operacji dołączania wykonywanych dla list (punkt „Listy jako tablice dynamiczne” z rozdziału 3.), a mianowicie, że alokacje pamięci oznaczają koszt. Każdorazowo, gdy żądana jest pamięć w celu zapisania zmiennej lub listy, interpreter języka Python musi poświęcić czas na komunikację z systemem operacyjnym w celu przydzielenia nowego miejsca w pamięci, a następnie na wykonanie iteracji dla nowo przydzielonego obszaru, co jest niezbędne do zainicjowania go przy użyciu danej wartości. Gdy tylko jest to możliwe, ponowne wykorzystanie już przydzielonego miejsca zapewni wzrost wydajności. Trzeba jednak zachować ostrożność przy wprowadzaniu takich zmian. Choć przyspieszenie może być znaczące, jak zawsze należy przeprowadzić profilowanie, aby upewnić się, że osiągamy oczekiwane wyniki, a nie tylko wprowadzamy nieporządek w kodzie podstawowym. Przykład 6.7. Profilowanie wierszy kodu Python dyfuzji po zmniejszeniu liczby alokacji pamięci $ kernprof.py -lv diffusion_python_memory.py Wrote profile results to diffusion_python_memory.py.lprof Timer unit: 1e-06 s File: diffusion_python_memory.py Function: evolve at line 8 Total time: 13.3209 s Line # Hits Time Per Hit % Time Line Contents ============================================================== 8 @profile 9 def evolve(grid, dt, out, D=1.0): 10 10 15 1.5 0.0 xmax, ymax = grid_shape 11 5130 3853 0.8 0.0 for i in xrange(xmax): 12 2626560 1942976 0.7 14.6 for j in xrange(ymax): 13 2621440 4059998 1.5 30.5 grid_xx = ... 14 2621440 4038560 1.5 30.3 grid_yy = ... 15 2621440 3275454 1.2 24.6 out[i][j] = ...
Fragmentacja pamięci Z kodem Python utworzonym w przykładzie 6.6 w dalszym ciągu związany jest problem, który nieodłącznie towarzyszy użyciu języka Python do wykonywania tego rodzaju operacji na wektorach. Rzecz w tym, że język ten nie obsługuje we własnym zakresie wektoryzacji. Wynika to z dwóch powodów: listy języka Python przechowują wskaźniki do samych danych, 2
Profilowany tutaj kod to kod z przykładu 6.6, przycięty do marginesów strony.
Fragmentacja pamięci
111
a ponadto kod bajtowy Python nie jest zoptymalizowany pod kątem wektoryzacji. Oznacza to, że pętle for nie mogą „przewidzieć”, kiedy użycie wektoryzacji byłoby korzystne. To, że listy języka Python przechowują wskaźniki, oznacza, że zamiast utrzymywać same interesujące nas dane, listy zawierają położenia określające, gdzie dane mogą zostać znalezione. W większości zastosowań jest to dobre, ponieważ umożliwia przechowywanie w obrębie listy danych dowolnego żądanego typu. Gdy jednak pojawiają się operacje wektorowe i macierzowe, jest to przyczyną dużego spadku wydajności. Zmniejszenie wydajności ma miejsce, ponieważ przy każdorazowym pobieraniu elementu z macierzy grid konieczne jest wykonywanie wielu wyszukiwań. Na przykład kod grid[5][2] wymaga przeprowadzenia najpierw wyszukiwania na liście grid indeksu 5. Powoduje to zwrócenie wskaźnika określającego miejsce przechowywania danych w tym położeniu. Dalej wymagane jest kolejne wyszukiwanie na liście elementu o indeksie 2 dotyczące zwróconego obiektu. Po uzyskaniu tego odwołania określone zostanie miejsce przechowywania rzeczywistych danych. Obciążenie związane z jednym takim wyszukiwaniem nie jest duże i w większości sytuacji może zostać zignorowane. Jeśli jednak żądane dane zostałyby umieszczone w jednym ciągłym bloku w pamięci, możliwe byłoby przemieszczenie wszystkich danych w jednej operacji, a nie w dwóch dla każdego elementu. Jest to jedna z głównych kwestii związanych z fragmentacją danych: gdy taka sytuacja występuje, konieczne jest przemieszczanie każdej porcji danych osobno, a nie w postaci jednego bloku. Oznacza to, że generowane jest większe obciążenie związane z transferami z pamięci. Ponadto procesor jest zmuszony do czekania podczas przesyłania danych. Okaże się, jak jest to ważne, podczas analizowania licznika cache-misses za pomocą narzędzia perf. Problem z przekazywaniem właściwych danych do procesora (gdy ich wymaga) jest związany z tak zwanym wąskim gardłem Von Neumanna. Odnosi się do faktu, że istnieje ograniczona przepustowość między pamięcią i procesorem, co jest spowodowane warstwową architekturą pamięci wykorzystywaną w nowoczesnych komputerach. Jeśli możliwe byłoby przemieszczanie danych nieskończenie szybko, obecność jakiejkolwiek pamięci podręcznej byłaby zbędna, ponieważ procesor byłby w stanie natychmiast pobierać dowolne wymagane dane. Byłby to stan, w którym nie istniałoby wąskie gardło. Ponieważ dane nie mogą być przemieszczane nieskończenie szybko, konieczne jest wstępne pobieranie ich z pamięci RAM i przechowywanie w mniejszych, lecz szybszych pamięciach podręcznych procesora, aby, przy odrobinie szczęścia, procesor mógł znaleźć potrzebną porcję danych w miejscu, z którego dane mogą być szybko wczytane. Choć jest to skrajnie wyidealizowany sposób postrzegania architektury, w dalszym ciągu widocznych jest kilka związanych z nią problemów. Jak stwierdzić, jakie dane będą potrzebne w przyszłości? Procesor sprawdza się dobrze w przypadku mechanizmów nazywanych przewidywaniem rozgałęzień i potokowaniem. Mechanizmy te podejmują jeszcze podczas przetwarzania bieżącej instrukcji próbę przewidzenia następnej instrukcji i załadowania do pamięci podręcznej odpowiednich porcji danych z pamięci. Niemniej jednak do zminimalizowania wpływu wąskiego gardła na wydajność najważniejsza jest wiedza na temat sposobu przydzielania pamięci i przeprowadzania obliczeń dla danych. Dość trudne może być sprawdzenie, jak dobrze zawartość pamięci jest przenoszona do procesora. Jednakże w systemie Linux narzędzie perf może być używane do uzyskania zadziwiającej ilości informacji o tym, jak procesor radzi sobie z działającym programem. Narzędzie
112
Rozdział 6. Obliczenia macierzowe i wektorowe
to można uruchomić na przykład dla czystego kodu Python z przykładu 6.6, aby sprawdzić, jak efektywnie procesor wykonuje kod. Wyniki zaprezentowano w przykładzie 6.8. Zauważ, że dane wyjściowe w tym i dalszych przykładach zastosowania narzędzia perf zostały przycięte do marginesów strony. Usunięte dane zawierały wariancje dla każdego pomiaru wskazujące, w jakim stopniu wartości zmieniły się w ciągu wielu testów porównawczych. Przydaje się to do stwierdzenia, jak bardzo mierzona wartość jest zależna od rzeczywistych parametrów wydajnościowych programu w porównaniu z innymi właściwościami systemu (np. innymi działającymi programami, które korzystają z zasobów systemowych). Przykład 6.8. Liczniki wydajności dla dyfuzji dwuwymiarowej przy użyciu czystego kodu Python po zmniejszeniu liczby alokacji pamięci $ perf stat -e cycles,stalled-cycles-frontend,stalled-cycles-backend,instructions,\ cache-references,cache-misses,branches,branch-misses,task-clock,faults,\ minor-faults,cs,migrations -r 3 python diffusion_python_memory.py Performance counter stats for 'python diffusion_python_memory.py' (3 runs): 329,155,359,015 cycles # 3.477 GHz 76,800,457,550 stalled-cycles-frontend # 23.33% frontend cycles idle 46,556,100,820 stalled-cycles-backend # 14.14% backend cycles idle 598,135,111,009 instructions # 1.82 insns per cycle # 0.13 stalled cycles per insn 35,497,196 cache-references # 0.375 M/sec 10,716,972 cache-misses # 30.191 % of all cache refs 133,881,241,254 branches # 1414.067 M/sec 2,891,093,384 branch-misses # 2.16% of all branches 94678.127621 task-clock # 0.999 CPUs utilized 5,439 page-faults # 0.057 K/sec 5,439 minor-faults # 0.057 K/sec 125 context-switches # 0.001 K/sec 6 CPU-migrations # 0.000 K/sec 94.749389121 seconds time elapsed
Narzędzie perf Poświęćmy chwilę na zrozumienie różnych liczników wydajności zapewnianych przez narzędzie perf oraz ich związku z przykładowym kodem. Licznik task-clock informuje o liczbie cykli zegarowych, jakie zajmuje wykonywane zadanie. Jest to coś innego niż całkowity czas działania, ponieważ jeśli działanie programu zajęło 1 sekundę, ale zostały użyte dwa procesory, licznik task-clock będzie miał wartość 1000 (przeważnie jest ona wyrażana w milisekundach). W wygodny dla nas sposób narzędzie perf przeprowadza obliczenia i obok tego licznika podaje informację o liczbie wykorzystanych procesorów. W powyższych danych wyjściowych wartość licznika nie wynosi dokładnie 1, ponieważ wystąpiły okresy, w których proces korzystał z innych podsystemów w celu wykonania instrukcji (na przykład podczas przydzielania pamięci). Liczniki context-switches i CPU-migrations informują o sposobie wstrzymania pracy programu w celu poczekania na zakończenie operacji jądra (np. operacji wejścia-wyjścia), umożliwienia działania innym aplikacjom lub przekazania wykonywania do innego rdzenia procesora. Gdy wystąpi przełączenie kontekstu, wykonywanie programu jest wstrzymywane, a inny program może rozpocząć działanie. Jest to bardzo czasochłonne zadanie, które powinno zostać w jak największym stopniu zminimalizowane. Nie mamy jednak zbyt dużej kontroli nad momentem wystąpienia tego zdarzenia. Jądro decyduje o tym, kiedy programy mogą zostać przełączone. Możliwe są jednak działania, które zapobiegną przemieszczaniu naszego programu
Fragmentacja pamięci
113
przez jądro. Ogólnie rzecz biorąc, w momencie wykonywania operacji wejścia-wyjścia (np. odczytu z pamięci, dysku lub sieci) jądro wstrzymuje program. Jak się okaże w kolejnych rozdziałach, możliwe jest użycie procedur asynchronicznych w celu zapewnienia, że program w dalszym ciągu będzie korzystał z procesora nawet podczas oczekiwania na operacje wejścia-wyjścia. Pozwala to zachować działanie programu bez przełączania kontekstu. Ponadto istnieje możliwość ustawienia dla programu wartości za pomocą narzędzia nice, aby określić dla programu priorytet i uniemożliwić jądru użycie dla niego przełączania kontekstu. Wartość licznika CPU-migrations jest rejestrowana po zatrzymaniu programu i wznowieniu go w innym procesorze niż ten, który był używany wcześniej, aby wszystkie procesory miały taki sam poziom wykorzystania. Może to być postrzegane jako szczególnie niewłaściwe przełączanie kontekstu, ponieważ nie tylko program jest tymczasowo wstrzymywany, ale też tracone są wszelkie dane znajdujące się w pamięci podręcznej L1 (jak wspomniano, każdy proces zawiera własną tego typu pamięć). Licznik page-fault stanowi część nowoczesnego schematu przydzielania pamięci w systemie Unix. Po przydzieleniu pamięci jądro nie robi wiele, z wyjątkiem przekazania programowi odwołania do pamięci. Później jednak w momencie pierwszego użycia pamięci system operacyjny zgłasza niewielkie przerwanie błędu stronicowania, które powoduje wstrzymanie działającego programu i odpowiednie przydzielenie pamięci. Jest to nazywane systemem przydzielania leniwego. Choć w porównaniu z wcześniejszymi systemami alokacji pamięci metoda ta jest dość zoptymalizowana, niewielkie błędy stronicowania stanowią kosztowną operację, ponieważ większość operacji realizowanych jest poza zasięgiem działającego programu. Istnieje również główny błąd stronicowania, który występuje w momencie zażądania przez program danych z urządzenia (dysku, urządzenia sieciowego itp.), z którego nie został jeszcze wykonany odczyt. Tego rodzaju operacje są jeszcze bardziej kosztowne, gdyż nie tylko powodują przerwanie programu, ale również uwzględniają odczyt z dowolnego urządzenia, na którym znajdują się dane. Tego typu błąd stronicowania nie ma zwykle wpływu na funkcjonowanie powiązania z procesorem. Może jednak być źródłem problemu w przypadku dowolnego programu, który wykonuje operacje odczytu/zapisu z wykorzystaniem dysku lub sieci. Gdy odwołamy się do danych znajdujących się w pamięci, pokonają one drogę przez różne warstwy pamięci (omówienie tego zagadnienia zawiera podrozdział „Warstwy komunikacji” w rozdziale 1.). Każdorazowo przy odwołaniu do danych zawartych w pamięci podręcznej zwiększa się wartość licznika cache-references. Jeśli takich danych nie będzie jeszcze w pamięci podręcznej i wymagane będzie pobranie ich z pamięci RAM, zmieni się wartość licznika cache-miss. Nie dojdzie do tego, jeżeli odczytywane są dane wcześniej wczytywane (takie dane nadal są w pamięci podręcznej) lub zlokalizowane w pobliżu danych niedawno używanych (dane są wysyłane z pamięci RAM do pamięci podręcznej w porcjach). Chybienia w pamięci podręcznej mogą być źródłem spowolnienia w przypadku działania powiązanego z procesorem, ponieważ w takiej sytuacji nie tylko konieczne jest oczekiwanie na pobranie danych z pamięci RAM, ale też przerywany jest przepływ potoku wykonywania (więcej o tym wkrótce). W dalszej części rozdziału zostanie omówiona metoda zredukowania tego efektu przez optymalizację układu danych w pamięci. Licznik instructions informuje o liczbie instrukcji wydawanych procesorowi przez kod. Ze względu na potokowanie jednocześnie może działać kilka takich instrukcji. Właśnie o tym informuje adnotacja insns per cycle. W celu lepszej obsługi potokowania liczniki stalled-cycles-frontend i stalled-cycles-backend przekazują informacje o liczbie cykli, przez jakie program oczekiwał na wypełnienie przodu lub tyłu potoku. Może to wystąpić z powodu chybienia w pamięci 114
Rozdział 6. Obliczenia macierzowe i wektorowe
podręcznej, niepoprawnie przewidzianego rozgałęzienia lub konfliktu zasobów. Przód potoku odpowiada za pobieranie następnej instrukcji z pamięci i dekodowanie jej do postaci poprawnej operacji, tył potoku jest natomiast odpowiedzialny za samo uruchomienie operacji. W przypadku potokowania procesor jest w stanie wykonywać bieżącą operację podczas pobierania i przygotowywania następnej operacji. Licznik branches określa moment działania kodu, w którym zmienia się przepływ wykonywania. Pomyśl o instrukcjach if..then. Zależnie od wyniku wykonania instrukcji warunkowej będzie wykonywana jedna lub druga sekcja kodu. Zasadniczo jest to rozgałęzienie w wykonywaniu kodu — następna instrukcja w programie może być jedną z dwóch. Aby to zoptymalizować, zwłaszcza w odniesieniu do potoku, procesor próbuje odgadnąć, w jakim kierunku podąży rozgałęzienie, oraz wcześniej załadować odpowiednie instrukcje. Gdy rezultat tego przewidywania okaże się niepoprawny, zostaną uzyskane wartości liczników stalled-cycles i branch-miss. Chybienia rozgałęzień mogą być dość zawiłe i powodować wiele dziwnych wyników (na przykład niektóre pętle będą działać znacznie szybciej dla list sortowanych niż dla list niesortowanych, po prostu dlatego, że w pierwszym przypadku będzie mniej chybień rozgałęzień). Aby zaznajomić się z bardziej szczegółowym objaśnieniem tego, co się dzieje na poziomie procesora w przypadku różnych liczników wydajności, zajrzyj do znakomitego przewodnika Computer Architecture Tutorial autorstwa Gurpura M. Prabhu (http://www.cs.iastate.edu/~prabhu/Tutorial/title.html). Omówiono w nim problemy na bardzo niskim poziomie, dzięki czemu można dobrze zrozumieć, co ma miejsce pod podszewką podczas działania kodu.
Podejmowanie decyzji z wykorzystaniem danych wyjściowych narzędzia perf Liczniki wydajności przedstawione w przykładzie 6.8 pozwalają stwierdzić, że w czasie działania kodu procesor odwoływał się 35 497 196 razy do pamięci podręcznej L1/L2. Spośród tych odwołań 10 716 972 (lub 30,191%) stanowiły żądania danych, których nie było w pamięci w momencie żądania, i wymagały pobrania. Ponadto można zauważyć, że w każdym cyklu procesora możliwe jest wykonanie średnio 1,82 instrukcji. Pozwala to określić całkowity wzrost szybkości wynikający ze stosowania potokowania, wykonywania nieuporządkowanego i hiperwątkowości (lub dowolnej innej funkcji procesora, która umożliwia uruchomienie więcej niż jednej instrukcji w ciągu cyklu procesora). Fragmentacja zwiększa liczbę transferów z pamięci do procesora. Ponadto, ze względu na to, że w momencie zażądania obliczenia w pamięci podręcznej procesora nie ma gotowych wielu porcji danych, nie będzie możliwa wektoryzacja obliczeń. Jak wspomniano w podrozdziale „Warstwy komunikacji” w rozdziale 1., wektoryzacja obliczeń (lub spowodowanie jednoczesnego wykonywania przez procesor wielu obliczeń) może wystąpić tylko w przypadku wypełnienia pamięci podręcznej procesora wszystkimi odpowiednimi danymi. Ponieważ magistrala może przesyłać wyłącznie ciągłe obszary pamięci, będzie to możliwe tylko wtedy, gdy dane siatki będą sekwencyjnie przechowywane w pamięci RAM. Ze względu na to, że lista przechowuje wskaźniki do danych, a nie rzeczywiste dane, faktyczne wartości w siatce są porozrzucane po całej pamięci, przez co nie mogą być wszystkie od razu skopiowane.
Fragmentacja pamięci
115
Skalę tego problemu można zmniejszyć przez zastosowanie modułu array zamiast list. Obiekty tego modułu przechowują dane w pamięci sekwencyjnie, dlatego wycinek obiektu array w rzeczywistości reprezentuje ciągły obszar w pamięci. Nie rozwiązuje to jednak całkowicie problemu. Choć dane są przechowywane w pamięci sekwencyjnie, interpreter języka Python w dalszym ciągu nie ma informacji o sposobie wektoryzowania pętli. Pożądane jest, aby każda pętla, która w danym momencie wykonuje operację arytmetyczną dla jednego elementu tablicy, korzystała z porcji danych. Jak jednak wcześniej wspomniano, interpreter języka Python jest pozbawiony możliwości takiej optymalizacji kodu bajtowego (po części z powodu wyjątkowo dynamicznej natury języka). Dlaczego sekwencyjne przechowywanie żądanych danych w pamięci nie zapewnia automatycznie wektoryzacji? Po przyjrzeniu się kodowi maszynowemu uruchamianemu przez procesor zobaczysz, że operacje wektoryzowane (np. mnożenie dwóch tablic) korzystają z innej części procesora, a także odmiennych instrukcji niż operacje niewektoryzowane. Aby interpreter języka Python użył tych specjalnych instrukcji, niezbędny jest moduł stworzony w tym celu. Wkrótce zostanie wyjaśnione, jak narzędzie numpy zapewnia dostęp do tych wyspecjalizowanych instrukcji.
Co więcej, z powodu szczegółów implementacji, użycie typu array podczas tworzenia list danych, które wymagają przeprowadzenia iteracji, w rzeczywistości przebiega wolniej niż zwykłe utworzenie listy. Wynika to z tego, że obiekt array zawiera bardzo niskopoziomową reprezentację przechowywanych liczb, którą przed zwróceniem użytkownikowi trzeba przekształcić w wersję zgodną z językiem Python. To dodatkowe obciążenie występuje każdorazowo podczas indeksowania typu array. Taka decyzja związana z implementacją spowodowała, że obiekt array stał się mniej odpowiedni do operacji matematycznych, a bardziej do efektywnego przechowywania w pamięci danych o stałym typie.
Wprowadzenie do narzędzia numpy Aby poradzić sobie z fragmentacją stwierdzoną za pomocą narzędzia perf, konieczne jest znalezienie pakietu, który potrafi efektywnie wektoryzować operacje. Na szczęście narzędzie numpy oferuje wszystkie potrzebne funkcje. Przechowuje ono dane w ciągłych porcjach w pamięci i obsługuje operacje wektoryzowane wykonywane na danych. W efekcie wszelkie obliczenia arytmetyczne wykonywane na tablicach narzędzia numpy korzystają z porcji danych bez konieczności jawnego stosowania pętli dla każdego elementu. Taka metoda nie tylko znacznie ułatwia operacje arytmetyczne na macierzach, ale też skraca czas ich wykonywania. Przyjrzyjmy się przykładowi: from array import array import numpy def norm_square_list(vector): """ >>> vector = range(1000000) >>> %timeit norm_square_list(vector_list) 1000 loops, best of 3: 1.16 ms per loop """ norm = 0 for v in vector: norm += v*v return norm def norm_square_list_comprehension(vector): """ >>> vector = range(1000000)
116
Rozdział 6. Obliczenia macierzowe i wektorowe
>>> %timeit norm_square_list_comprehension(vector_list) 1000 loops, best of 3: 913 µs per loop """ return sum([v*v for v in vector]) def norm_squared_generator_comprehension(vector): """ >>> vector = range(1000000) >>> %timeit norm_square_generator_comprehension(vector_list) 1000 loops, best of 3: 747 μs per loop """ return sum(v*v for v in vector) def norm_square_array(vector): """ >>> vector = array('l', range(1000000)) >>> %timeit norm_square_array(vector_array) 1000 loops, best of 3: 1.44 ms per loop """ norm = 0 for v in vector: norm += v*v return norm def norm_square_numpy(vector): """ >>> vector = numpy.arange(1000000) >>> %timeit norm_square_numpy(vector_numpy) 10000 loops, best of 3: 30.9 µs per loop """ return numpy.sum(vector * vector) # def norm_square_numpy_dot(vector): """ >>> vector = numpy.arange(1000000) >>> %timeit norm_square_numpy_dot(vector_numpy) 10000 loops, best of 3: 21.8 µs per loop """ return numpy.dot(vector, vector) #
Wiersz tworzy dwie niejawne pętle dla obiektu vector (jedna służy do mnożenia, a druga do sumowania). Choć pętle te przypominają pętle użyte w funkcji norm_square_list_comprehension, są wykonywane z wykorzystaniem zoptymalizowanego kodu numerycznego narzędzia numpy. Jest to preferowana metoda tworzenia norm wektora w narzędziu numpy za pomocą wektoryzowanej operacji numpy.dot. W celu zilustrowania tego udostępniono mniej efektywny kod funkcji norm_square_numpy. Prostszy kod narzędzia numpy działa 37,54 razy szybciej niż kod funkcji norm_square_list oraz 29,5 razy szybciej od „zoptymalizowanego” wyrażenia listowego języka Python. Różnica w szybkości między czystą metodą wykonywania pętli i metodą wyrażenia listowego uwidacznia korzyść wynikającą z dodatkowych obliczeń realizowanych w tle zamiast jawnego wykonywania ich w kodzie Python. Przeprowadzanie obliczeń za pomocą już wbudowanych mechanizmów języka Python pozwala uzyskać szybkość wykonywania rodzimego kodu C, na którym kod Python bazuje. Po części to samo rozumowanie umożliwia uzasadnienie tak znacznego przyspieszenia w kodzie narzędzia numpy. Zamiast używać bardzo uogólnionej struktury listy, korzystamy z precyzyjnie dostrojonego obiektu utworzonego specjalnie na potrzeby przetwarzania tablic liczb. Oprócz uproszczonych i wyspecjalizowanych mechanizmów obiekt numpy zapewnia również lokalizację w pamięci oraz wektoryzowane operacje. Jest to niezmiernie ważne podczas przeprowadzania obliczeń numerycznych. Procesor jest wyjątkowo szybki. Przeważnie najlepszym
Fragmentacja pamięci
117
sposobem szybkiego zoptymalizowania kodu będzie po prostu przekazanie procesorowi żądanych przez niego danych w krótszym czasie. Uruchomienie każdej z zaprezentowanych wcześniej funkcji za pomocą narzędzia perf pokazuje, że funkcje z obiektem array i czyste funkcje Python wymagają w przybliżeniu 11011 instrukcji, wersja funkcji narzędzia numpy wykorzystuje natomiast mniej więcej 3109 instrukcji. Ponadto w przypadku funkcji z obiektem array i czystych funkcji Python wystąpiło w przybliżeniu 80% chybień w pamięci podręcznej, a dla funkcji narzędzia numpy wartość ta wyniosła około 55%. W kodzie funkcji norm_square_numpy podczas wykonywania operacji vector * vector występuje niejawna pętla, którą zajmie się narzędzie numpy. Pętla ta jest tą samą pętlą, która jawnie została utworzona w innych przykładach: dla wszystkich elementów obiektu vector wykonywana jest pętla, a ponadto każdy z nich jest mnożony przez samego siebie. Ponieważ jednak jest to realizowane nie w obrębie kodu Python, ale za pomocą odpowiednio poinstruowanego narzędzia numpy, narzędzie to może wykorzystać wszystkie żądane optymalizacje. W tle używa bardzo zoptymalizowanego kodu C, który został stworzony specjalnie z myślą o skorzystaniu z dowolnej wektoryzacji obsługiwanej przez procesor. Ponadto tablice narzędzia numpy są reprezentowane w pamięci sekwencyjnie jako niskopoziomowe typy numeryczne. Dzięki temu mają one takie same wymagania dotyczące miejsca jak obiekty array (z modułu array). Dodatkową korzyścią jest to, że problem można ponownie sformułować przy użyciu iloczynu skalarnego obsługiwanego przez narzędzie numpy. Powoduje to, że do obliczenia żądanej wartości używana jest jedna operacja zamiast uzyskiwania najpierw iloczynu dwóch wektorów, a następnie ich sumowania. Jak widać na rysunku 6.3, pod względem czasu działania funkcja norm_square_numpy_dot pozostawia daleko w tyle wszystkie pozostałe. Wynika to z jej specjalizacji, a także z braku konieczności przechowywania wartości pośredniej operacji vector * vector, co miało miejsce w przypadku funkcji norm_square_numpy.
Rysunek 6.3. Czas działania różnych funkcji potęgowanych przy użyciu normy dla wektorów o różnej długości
118
Rozdział 6. Obliczenia macierzowe i wektorowe
Zastosowanie narzędzia numpy w przypadku problemu dotyczącego dyfuzji Wykorzystując zdobyte informacje o narzędziu numpy, z łatwością można przystosować czysty kod Python do obsługi wektoryzacji. Jedyną nową funkcją, która wymaga zaprezentowania, jest funkcja roll narzędzia numpy. Choć zapewnia ona to samo co użyty wcześniej zabieg indeksowania z wykorzystaniem operacji modulo, dotyczy to całej tablicy narzędzia numpy. Zasadniczo funkcja dokonuje wektoryzacji następującej operacji ponownego indeksowania: >>> import numpy as np >>> np.roll([1,2,3,4], 1) array([4, 1, 2, 3]) >>> np.roll([[1,2,3],[4,5,6]], 1, axis=1) array([[3, 1, 2], [6, 4, 5]])
Funkcja roll tworzy nową tablicę narzędzia numpy, co może zostać potraktowane zarówno jako jej zaleta, jak i wada. Wadą jest poświęcenie czasu na przydzielenie nowego miejsca, które musi zostać następnie wypełnione odpowiednimi danymi. Z kolei po utworzeniu takiej nowej obróconej tablicy możliwe będzie dość szybkie wektoryzowanie operacji dotyczących tablicy. Ponadto nie wystąpią chybienia w pamięci podręcznej procesora. Może to mieć znaczny wpływ na szybkość rzeczywistego obliczenia, które musi zostać przeprowadzone dla siatki. W dalszej części rozdziału metoda ta zostanie zmodyfikowana w taki sposób, że ta sama korzyść zostanie uzyskana bez potrzeby ciągłego przydzielania większej ilości pamięci. Dzięki tej dodatkowej funkcji możemy zmodyfikować kod Python dyfuzji z przykładu 6.6, korzystając z prostszych i wektoryzowanych tablic narzędzia numpy. Dodatkowo do osobnej funkcji wydzielane jest obliczenie pochodnych grid_xx i grid_yy. Przykład 6.9 prezentuje początkową wersję kodu dyfuzji bazującego na narzędziu numpy. Przykład 6.9. Początkowa wersja kodu dyfuzji bazującego na narzędziu numpy import numpy as np grid_shape = (1024, 1024) def laplacian(grid): return np.roll(grid, +1, 0) + np.roll(grid, -1, 0) + \ np.roll(grid, +1, 1) + np.roll(grid, -1, 1) - 4 * grid def evolve(grid, dt, D=1): return grid + dt * D * laplacian(grid) def run_experiment(num_iterations): grid = np.zeros(grid_shape) block_low = int(grid_shape[0] * .4) block_high = int(grid_shape[0] * .5) grid[block_low:block_high, block_low:block_high] = 0.005 start = time.time() for i in range(num_iterations): grid = evolve(grid, 0.1) return time.time() – start
Od razu widać, że powyższy kod jest znacznie krótszy. Zwykle jest to dobry wskaźnik wydajności. Wiele znacznych poprawek jest dokonywanych poza obrębem interpretera języka Python, a prawdopodobnie także wewnątrz modułu, który został stworzony specjalnie pod kątem wydajności i rozwiązania konkretnego problemu (niemniej jednak zawsze powinno to być sprawdzane!). Jednym z przyjętych tutaj założeń jest to, że narzędzie numpy korzysta z lepszego sposobu zarządzania pamięcią, aby szybciej przekazać procesorowi wymagane Zastosowanie narzędzia numpy w przypadku problemu dotyczącego dyfuzji
119
przez niego dane. Ponieważ jednak to, czy faktycznie tak będzie, czy nie, zależy od samej implementacji narzędzia numpy, przeprowadźmy profilowanie kodu, aby stwierdzić, czy przyjęta hipoteza jest poprawna. Przykład 6.10 prezentuje wyniki. Przykład 6.10. Liczniki wydajności dla dwuwymiarowej dyfuzji z wykorzystaniem narzędzia numpy $ perf stat -e cycles,stalled-cycles-frontend,stalled-cycles-backend,instructions,\ cache-references,cache-misses,branches,branch-misses,task-clock,faults,\ minor-faults,cs,migrations -r 3 python diffusion_numpy.py Performance counter stats for 'python diffusion_numpy.py' (3 runs): 10,194,811,718 cycles # 3.332 GHz 4,435,850,419 stalled-cycles-frontend # 43.51% frontend cycles idle 2,055,861,567 stalled-cycles-backend # 20.17% backend cycles idle 15,165,151,844 instructions # 1.49 insns per cycle # 0.29 stalled cycles per insn 346,798,311 cache-references # 113.362 M/sec 519,793 cache-misses # 0.150 % of all cache refs 3,506,887,927 branches # 1146.334 M/sec 3,681,441 branch-misses # 0.10% of all branches 3059.219862 task-clock # 0.999 CPUs utilized 751,707 page-faults # 0.246 M/sec 751,707 minor-faults # 0.246 M/sec 8 context-switches # 0.003 K/sec 1 CPU-migrations # 0.000 K/sec 3.061883218 seconds time elapsed
Wyniki pokazują, że prosta zmiana dokonana za pomocą narzędzia numpy dała 40-krotne przyspieszenie w porównaniu z implementacją czystego kodu Python ze zmniejszoną liczbą przydziałów pamięci (przykład 6.8). Jak zostało to osiągnięte? Przede wszystkim zawdzięczamy to wektoryzacji zapewnianej przez to narzędzie. Choć bazująca na nim wersja kodu wydaje się wykonywać mniej instrukcji w ciągu cyklu, każda z tych instrukcji realizuje znacznie więcej pracy. Oznacza to, że jedna wektoryzowana instrukcja może mnożyć cztery (lub więcej) liczby w tablicy, zamiast wymagać czterech niezależnych instrukcji mnożenia. Generalnie umożliwia to wykorzystanie mniejszej łącznej liczby instrukcji niezbędnych do rozwiązania tego samego problemu. Istnieje też kilka innych czynników mających wpływ na wersję kodu narzędzia numpy, która wymaga mniejszej całkowitej liczby instrukcji służących do rozwiązania problemu dotyczącego dyfuzji. Jeden z nich ma związek z pełnym interfejsem API języka Python, który jest dostępny podczas wykonywania czystego kodu Python, lecz niekoniecznie w przypadku wersji kodu narzędzia numpy (na przykład siatki z czystym kodem Python mogą być dołączane do takiego kodu, ale nie do kodu narzędzia numpy). Nawet pomimo tego, że ta (lub inna) funkcjonalność nie jest jawnie używana, pojawia się obciążenie związane z udostępnianiem systemu, w którym może ona być dostępna. Ponieważ narzędzie numpy może przyjmować, że przechowywane dane zawsze będą liczbami, wszystko, co dotyczy tablic, może być optymalizowane pod kątem operacji wykonywanych dla liczb. Przy omawianiu kompilatora Cython (zajrzyj do podrozdziału „Cython”) w dalszym ciągu będziemy eliminować funkcje niezbędne do poprawienia wydajności. W tym przypadku możliwe jest nawet usunięcie sprawdzania powiązań list w celu przyspieszenia wyszukiwania w ich obrębie. Liczba instrukcji zwykle nie musi być skorelowana z wydajnością. Program z mniejszą liczbą instrukcji może nie wykonywać ich efektywnie lub mogą one okazać się wolne. Widoczne jest jednak, że oprócz zredukowania liczby instrukcji wersja kodu narzędzia numpy znacznie zmniejszyła też nieefektywność w postaci chybień w pamięci podręcznej (0,15% chybień zamiast 30,2%). 120
Rozdział 6. Obliczenia macierzowe i wektorowe
Jak wyjaśniono w podrozdziale „Fragmentacja pamięci”, chybienia w pamięci podręcznej spowalniają obliczenia, ponieważ procesor musi czekać na pobranie danych z wolniejszej pamięci, a nie od razu mieć do nich dostęp w swojej pamięci podręcznej. Okazuje się, że fragmentacja pamięci jest na tyle dominującym czynnikiem wpływającym na wydajność, że w przypadku wyłączenia wektoryzacji w narzędziu numpy3 i pozostawienia reszty bez zmian nadal zauważalny będzie wzrost szybkości w porównaniu z wersją czystego kodu Python (przykład 6.11). Przykład 6.11. Liczniki wydajności dla dwuwymiarowej dyfuzji z wykorzystaniem narzędzia numpy bez wektoryzacji $ perf stat -e cycles,stalled-cycles-frontend,stalled-cycles-backend,instructions,\ cache-references,cache-misses,branches,branch-misses,task-clock,faults,\ minor-faults,cs,migrations -r 3 python diffusion_numpy.py Performance counter stats for 'python diffusion_numpy.py' (3 runs): 48,923,515,604 cycles # 3.413 GHz 24,901,979,501 stalled-cycles-frontend # 50.90% frontend cycles idle 6,585,982,510 stalled-cycles-backend # 13.46% backend cycles idle 53,208,756,117 instructions # 1.09 insns per cycle # 0.47 stalled cycles per insn 83,436,665 cache-references # 5.821 M/sec 1,211,229 cache-misses # 1.452 % of all cache refs 4,428,225,111 branches # 308.926 M/sec 3,716,789 branch-misses # 0.08% of all branches 14334.244888 task-clock # 0.999 CPUs utilized 751,185 page-faults # 0.052 M/sec 751,185 minor-faults # 0.052 M/sec 24 context-switches # 0.002 K/sec 5 CPU-migrations # 0.000 K/sec 14.345794896 seconds time elapsed
Wyniki pokazują, że dominującym czynnikiem związanym z 40-krotnym przyspieszeniem przy zastosowaniu narzędzia numpy nie jest wektoryzowany zestaw instrukcji, lecz lokalizacja w pamięci i zmniejszona fragmentacja pamięci. Na podstawie wcześniejszego eksperymentu można stwierdzić, że wektoryzacja decyduje o tak dużym przyspieszeniu4 zaledwie w około 15%. Fakt, że problemy z pamięcią stanowią dominujący czynnik, który spowalnia kod, nie jest zbyt dużym zaskoczeniem. Komputery są bardzo dobrze zaprojektowane do tego, aby wykonały dokładnie te obliczenia, które są od nich wymagane na potrzeby rozwiązania danego problemu, czyli mnożenie i dodawanie liczb. Wąskim gardłem jest przekazanie tych liczb do procesora na tyle szybko, aby mógł przeprowadzić obliczenia tak szybko, jak potrafi.
Przydziały pamięci i operacje wewnętrzne Aby zoptymalizować efekty dominowania przez pamięć, spróbujmy użyć tej samej metody co w przykładzie 6.6. Umożliwia ona zmniejszenie liczby przydziałów dokonywanych w kodzie narzędzia numpy. Przydziały są trochę gorsze od wcześniej omówionych chybień w pamięci podręcznej. Zamiast po prostu znaleźć właściwe dane w pamięci RAM, jeśli nie było ich w pamięci podręcznej, operacja przydziału musi też skierować do systemu operacyjnego żądanie 3
W tym celu narzędzie numpy jest kompilowane z wykorzystaniem flagi -O0. Na potrzeby tego eksperymentu została skompilowana wersja 1.8.0 narzędzia numpy za pomocą następującego polecenia: $ OPT='-O0' FOPT='-O0' BLAS=None LAPACK=None ATLAS=None python setup.py build.
4
W bardzo dużym stopniu jest to zależne od tego, jaki procesor jest używany.
Zastosowanie narzędzia numpy w przypadku problemu dotyczącego dyfuzji
121
dotyczące dostępnej porcji danych, a następnie zarezerwować ją. Takie żądanie generuje znacznie większe obciążenie niż zwykłe wypełnienie pamięci podręcznej. Wypełnienie po chybieniu w pamięci podręcznej ma postać sprzętowej funkcji zoptymalizowanej na płycie głównej, przydzielanie pamięci natomiast wymaga do zakończenia działania komunikacji z innym procesem, czyli jądrem. W celu usunięcia alokacji z przykładu 6.9 na początku kodu dokonamy wstępnej alokacji miejsca początkowego, a następnie będą używane wyłącznie operacje wewnętrzne. Operacje te (np. +=, *= itp.) jako swojego wyjścia używają ponownie jednego z wejść. Oznacza to, że do przechowania wyniku obliczenia nie jest wymagane przydzielenie miejsca. Aby wyraźnie to pokazać, przyjrzymy się temu, jak zmienia się identyfikator id tablicy numpy podczas wykonywania dla niej operacji (przykład 6.12). Korzystanie z tego identyfikatora stanowi dobry sposób śledzenia tego działania dla tablic narzędzia numpy, ponieważ pozwala on określić, do jakiej sekcji pamięci następuje odwołanie. Jeśli dwie tablice narzędzia numpy mają taki sam identyfikator id, odwołują się do tej samej sekcji pamięci5. Przykład 6.12. Operacje wewnętrzne, które zmniejszają liczbę przydziałów pamięci >>> import numpy as np >>> array1 = np.random.random((10,10)) >>> array2 = np.random.random((10,10)) >>> id(array1) 140199765947424 # >>> array1 += array2 >>> id(array1) 140199765947424 # >>> array1 = array1 + array2 >>> id(array1) 140199765969792 #
Te dwa identyfikatory id są identyczne, ponieważ wykonywana jest operacja wewnętrzna. Oznacza to, że nie zmienia się adres pamięci tablicy array1. Po prostu modyfikowane są zawarte w niej dane. W tym miejscu adres pamięci zmienił się. Podczas wykonywania operacji array1 + array2 przydzielany jest nowy adres pamięci, który jest wypełniany wynikiem obliczenia. Zapewnia to jednak korzyści w sytuacji, gdy trzeba zachować oryginalne dane (oznacza to, że instrukcja array3 = array1 + array2 pozwala nadal używać tablic array1 i array2, operacje wewnętrzne natomiast usuwają część oryginalnych danych). Co więcej, widoczne jest oczekiwane spowolnienie spowodowane operacją, która nie jest wewnętrzna. W przypadku niewielkich tablic numpy to obciążenie może stanowić nawet 50% całkowitego czasu obliczeniowego. Choć przy większych obliczeniach przyspieszenie jest wyrażane raczej w przedziale kilku procent, w dalszym ciągu oznacza to mnóstwo czasu, jeśli obliczenia są wykonywane miliony razy. W przykładzie 6.13 widoczne jest, że w przypadku niewielkich tablic użycie operacji wewnętrznych daje przyspieszenie wynoszące 20%. Margines ten zwiększy się przy większych tablicach, ponieważ przydziały pamięci staną się intensywniejsze.
5
Nie jest to całkowitą prawdą, gdyż dwie tablice narzędzia numpy mogą odwoływać się do tej samej sekcji pamięci, lecz używać różnych informacji do reprezentowania tych samych danych w różny sposób. Takie dwie tablice będą mieć inne identyfikatory id. Ze strukturą identyfikatorów id tablic narzędzia numpy związanych jest wiele subtelności, których omówienie wykracza poza zakres treści rozdziału.
122
Rozdział 6. Obliczenia macierzowe i wektorowe
Przykład 6.13. Operacje wewnętrzne, które zmniejszają liczbę przydziałów pamięci >>> %%timeit array1, array2 = # ... array1 = array1 + array2 ... 100000 loops, best of 3: 3.03 >>> %%timeit array1, array2 = ... array1 += array2 ... 100000 loops, best of 3: 2.42
np.random.random((10,10)), np.random.random((10,10))
us per loop np.random.random((10,10)), np.random.random((10,10)) us per loop
Zauważ, że zamiast funkcji %timeit używana jest funkcja %%timeit umożliwiająca określenie kodu do przygotowania eksperymentu, w którym nie jest ustalany przedział czasu. Wadą jest to, że choć zmodyfikowanie kodu z przykładu 6.9 w celu użycia operacji wewnętrznych nie jest zbyt skomplikowane, powoduje, że wynikowy kod staje się trochę trudniejszy do zrozumienia. W przykładzie 6.14 widoczne są wyniki takiej refaktoryzacji. Tworzone są instancje wektorów grid i next_grid, a ponadto są one nieustannie wzajemnie zamieniane. Wektor grid zawiera znane bieżące informacje o systemie. Po uruchomieniu funkcji evolve wektor next_grid przechowuje zaktualizowane informacje. Przykład 6.14. Zamienianie większości operacji narzędzia numpy na operacje wewnętrzne def laplacian(grid, out): np.copyto(out, grid) out *= -4 out += np.roll(grid, +1, 0) out += np.roll(grid, -1, 0) out += np.roll(grid, +1, 1) out += np.roll(grid, -1, 1) def evolve(grid, dt, out, D=1): laplacian(grid, out) out *= D * dt out += grid def run_experiment(num_iterations): next_grid = np.zeros(grid_shape) grid = np.zeros(grid_shape) block_low = int(grid_shape[0] * .4) block_high = int(grid_shape[0] * .5) grid[block_low:block_high, block_low:block_high] = 0.005 start = time.time() for i in range(num_iterations): evolve(grid, 0.1, next_grid) grid, next_grid = next_grid, grid # return time.time() - start
Ponieważ dane wyjściowe funkcji evolve są przechowywane w wektorze wyjściowym next_grid, konieczna jest zamiana tych dwóch zmiennych w taki sposób, aby przy następnej iteracji pętli wektor grid zawierał najbardziej aktualne informacje. Taka operacja zamiany nie jest zbyt kosztowna, ponieważ modyfikowane są tylko odwołania do danych, nie one same. Trzeba pamiętać, że ze względu na to, że każda operacja ma być wewnętrzna, każdorazowo przy wykonywaniu operacji wektorowej musi ona znajdować się we własnym wierszu kodu. Może to sprawić, że coś tak prostego jak A = A*B+C może stać się dość zawiłe. Ponieważ w języku Python duży nacisk kładziony jest na czytelność kodu, należy zapewnić, że dokonane zmiany zaowocują wystarczającym przyspieszeniem, aby zostały uznane za uzasadnione.
Zastosowanie narzędzia numpy w przypadku problemu dotyczącego dyfuzji
123
Porównanie liczników wydajności z przykładów 6.15 i 6.10 pozwala stwierdzić, że usunięcie nieuzasadnionych alokacji przyspieszyło kod o 29%. Po części wynika to ze zmniejszenia liczby chybień w pamięci podręcznej, ale w większym stopniu jest spowodowane zredukowaniem liczby błędów stronicowania. Przykład 6.15. Liczniki wydajności dla narzędzia numpy w przypadku wewnętrznych operacji pamięciowych $ perf stat -e cycles,stalled-cycles-frontend,stalled-cycles-backend,instructions,\ cache-references,cache-misses,branches,branch-misses,task-clock,faults,\ minor-faults,cs,migrations -r 3 python diffusion_numpy_memory.py Performance counter stats for 'python diffusion_numpy_memory.py' (3 runs): 7,864,072,570 cycles # 3.330 GHz 3,055,151,931 stalled-cycles-frontend # 38.85% frontend cycles idle 1,368,235,506 stalled-cycles-backend # 17.40% backend cycles idle 13,257,488,848 instructions # 1.69 insns per cycle # 0.23 stalled cycles per insn 239,195,407 cache-references # 101.291 M/sec 2,886,525 cache-misses # 1.207 % of all cache refs 3,166,506,861 branches # 1340.903 M/sec 3,204,960 branch-misses # 0.10% of all branches 2361.473922 task-clock # 0.999 CPUs utilized 6,527 page-faults # 0.003 M/sec 6,527 minor-faults # 0.003 M/sec 6 context-switches # 0.003 K/sec 2 CPU-migrations # 0.001 K/sec 2.363727876 seconds time elapsed
Optymalizacje selektywne: znajdowanie tego, co wymaga poprawienia Po przyjrzeniu się kodowi z przykładu 6.14 można odnieść wrażenie, że udało nam się poradzić sobie z większością problemów: zmniejszyliśmy obciążenie procesora za pomocą narzędzia numpy i zredukowaliśmy liczbę alokacji wymaganych do rozwiązania problemu. Niemniej jednak zawsze możliwe jest przeprowadzenie dodatkowych analiz. Jeśli zostanie wykonane profilowanie wierszy tego kodu (przykład 6.16), okaże się, że większość działań jest realizowana w obrębie funkcji laplacian. 93% czasu, jaki zajmuje działanie funkcji evolve, jest związane z przetwarzaniem w obrębie funkcji laplacian. Przykład 6.16. Profilowanie wierszy pokazuje, że wykonywanie funkcji laplacian zajmuje zdecydowanie zbyt dużo czasu Wrote profile results to diffusion_numpy_memory.py.lprof Timer unit: 1e-06 s File: diffusion_numpy_memory.py Function: laplacian at line 8 Total time: 3.67347 s Line # Hits Time Per Hit % Time Line Contents ============================================================== 8 @profile 9 def laplacian(grid, out): 10 500 162009 324.0 4.4 np.copyto(out, grid) 11 500 111044 222.1 3.0 out *= -4 12 500 464810 929.6 12.7 out += np.roll(grid, +1, 13 500 432518 865.0 11.8 out += np.roll(grid, -1, 14 500 1261692 2523.4 34.3 out += np.roll(grid, +1, 15 500 1241398 2482.8 33.8 out += np.roll(grid, -1,
124
Rozdział 6. Obliczenia macierzowe i wektorowe
0) 0) 1) 1)
File: diffusion_numpy_memory.py Function: evolve at line 17 Total time: 3.97768 s Line # Hits Time Per Hit % Time Line Contents ============================================================== 17 @profile 18 def evolve(grid, dt, out, D=1): 19 500 3691674 7383.3 92.8 laplacian(grid, out) 20 500 111687 223.4 2.8 out *= D * dt 21 500 174320 348.6 4.4 out += grid
Może być wiele powodów, dla których funkcja laplacian jest taka wolna. Istnieją jednak dwie główne, bardziej ogólne kwestie, które należy rozważyć. Po pierwsze, wygląda na to, że wywołania funkcji np.roll powodują przydzielanie nowych wektorów (można to zweryfikować, sprawdzając dokumentację funkcji). Oznacza to, że nawet pomimo tego, że w opisanej wcześniej refaktoryzacji usunięto siedem alokacji pamięci, nadal występują cztery nierozstrzygnięte alokacje. Co więcej, np.roll to bardzo uogólniona funkcja, która zawiera wiele kodu służącego do obsługi specjalnych przypadków. Ponieważ wiadomo dokładnie, co ma zostać osiągnięte (czyli po prostu przemieszczenie pierwszej kolumny danych, aby była ostatnią w każdym wymiarze), możliwe jest zmodyfikowanie tej funkcji w celu wyeliminowania większości nieuzasadnionego kodu. Możliwe jest nawet połączenie logiki funkcji np.roll z operacją dodawania, która ma miejsce w przypadku obróconych danych. Ma to na celu utworzenie bardzo wyspecjalizowanej funkcji roll_add, która realizuje dokładnie to, czego żądamy, przy użyciu najmniejszej liczby alokacji i minimalnej ilości dodatkowej logiki. Przykład 6.17 pokazuje, jak mogłaby wyglądać taka refaktoryzacja. Wymagane jest jedynie utworzenie nowej funkcji roll_add i zapewnienie użycia jej przez funkcję laplacian. Ponieważ narzędzie numpy obsługuje wyszukane indeksowanie, implementowanie takiej funkcji wymaga jedynie tego, aby nie pomieszać indeksów. Jak jednak wcześniej wspomniano, choć kod ten może być bardziej wydajny, będzie znacznie mniej czytelny. Przykład 6.17. Tworzenie własnej funkcji obrotu import numpy as np def roll_add(rollee, shift, axis, out): """ Dla danej macierzy, macierzy wyjściowej oraz parametrów rollee i out funkcja przeprowadzi następujące obliczenie: >>> out += np.roll(rollee, shift, axis=axis) Jest to realizowane przy następujących założeniach: * parametr rollee jest dwuwymiarowy * parametr shift będzie przyjmować wyłącznie wartości +1 lub –1 * parametr axis będzie przyjmować wyłącznie wartości 0 lub 1 (jest to również implikowane przez pierwsze założenie) Przy korzystaniu z tych założeń możliwe jest przyspieszenie tej funkcji przez uniknięcie dodatkowych mechanizmów używanych przez narzędzie numpy do uogólniania funkcji obrotu, a ponadto przez sprawienie, że operacja ta sama w sobie będzie wewnętrzna """ if shift == 1 and axis == 0: out[1:, :] += rollee[:-1, :] out[0 , :] += rollee[-1, :] elif shift == -1 and axis == 0: out[:-1, :] += rollee[1:, :] out[-1 , :] += rollee[0, :] elif shift == 1 and axis == 1: out[:, 1:] += rollee[:, :-1] out[:, 0 ] += rollee[:, -1]
Zastosowanie narzędzia numpy w przypadku problemu dotyczącego dyfuzji
125
elif shift == -1 and axis == 1: out[:, :-1] += rollee[:, 1:] out[:, -1] += rollee[:, 0] def test_roll_add(): rollee = np.asarray([[1,2],[3,4]]) for shift in (-1, +1): for axis in (0, 1): out = np.asarray([[6,3],[9,2]]) expected_result = np.roll(rollee, shift, axis=axis) + out roll_add(rollee, shift, axis, out) assert np.all(expected_result == out) def laplacian(grid, out): np.copyto(out, grid) out *= -4 roll_add(grid, +1, 0, out) roll_add(grid, -1, 0, out) roll_add(grid, +1, 1, out) roll_add(grid, -1, 1, out)
Zwróć uwagę na dodatkowe działania, oprócz przeprowadzenia pełnych testów, wykonane w celu zapewnienia dla funkcji informacyjnego opisu jej działania. Przy takim postępowaniu ważne jest utrzymanie czytelności kodu. Opisane kroki zdecydowanie zapewniają, że kod zawsze będzie działać zgodnie z oczekiwaniami. Ponadto w przyszłości programiści będą mogli modyfikować kod, wiedząc, jakie operacje są przez ten kod wykonywane, a także kiedy coś nie działa.
Po przyjrzeniu się licznikom wydajności z przykładu 6.18 dotyczącego zmodyfikowanego kodu stwierdzimy, że choć kod jest znacznie szybszy od kodu z przykładu 6.14 (okazuje się, że 70% szybszy), większość liczników ma mniej więcej takie same wartości. Wartość licznika page-faults zmniejszyła się, ale nie o 70%. Podobnie wartości liczników cache-misses i cache-references są mniejsze, lecz nie na tyle, aby miało to wpływ na całkowite przyspieszenie. W tym przypadku jednym z najważniejszych liczników jest licznik instructions. Określa on liczbę instrukcji procesora, które musiały zostać wykonane w celu uruchomienia programu. Innymi słowy, licznik informuje o liczbie operacji, które musiał wykonać procesor. Wydaje się, że modyfikacja dostosowywanej funkcji roll_add spowodowała zredukowanie łącznej liczby niezbędnych instrukcji około 2,86 razy. Wynika to z tego, że zamiast bazować na wszystkich mechanizmach narzędzia numpy w celu obrotu macierzy, można utworzyć krótszy i prostszy mechanizm, który może skorzystać z założeń dotyczących danych (czyli z tego, że dane są dwuwymiarowe, a ponadto że obrót będzie wykonywany tylko o wartość 1). Omawianie tego zagadnienia dotyczącego eliminowania zbędnych mechanizmów zarówno w przypadku narzędzia numpy, jak i kodu Python będzie kontynuowane w podrozdziale „Cython”. Przykład 6.18. Liczniki wydajności dla narzędzia numpy w przypadku wewnętrznych operacji w pamięci i niestandardowej funkcji laplacian $ perf stat -e cycles,stalled-cycles-frontend,stalled-cycles-backend,instructions,\ cache-references,cache-misses,branches,branch-misses,task-clock,faults,\ minor-faults,cs,migrations -r 3 python diffusion_numpy_memory2.py Performance counter stats for 'python diffusion_numpy_memory2.py' (3 runs): 4,303,799,244 cycles # 3.108 GHz 2,814,678,053 stalled-cycles-frontend # 65.40% frontend cycles idle 1,635,172,736 stalled-cycles-backend # 37.99% backend cycles idle 4,631,882,411 instructions # 1.08 insns per cycle # 0.61 stalled cycles per insn
126
Rozdział 6. Obliczenia macierzowe i wektorowe
272,151,957 2,835,948 621,565,054 2,905,879 1384.555494 5,559 5,559 6 3 1.386148918
cache-references cache-misses branches branch-misses task-clock page-faults minor-faults context-switches CPU-migrations seconds time elapsed
# 196.563 M/sec # 1.042 % of all cache refs # 448.928 M/sec # 0.47% of all branches # 0.999 CPUs utilized # 0.004 M/sec # 0.004 M/sec # 0.004 K/sec # 0.002 K/sec
Moduł numexpr: przyspieszanie i upraszczanie operacji wewnętrznych Mankamentem optymalizacji operacji wektorowych przez narzędzie numpy jest to, że jednocześnie występuje ona tylko dla jednej operacji. Oznacza to, że podczas wykonywania operacji A*B+C z wykorzystaniem wektorów narzędzia numpy najpierw realizowana jest cała operacja A*B, a dane są przechowywane w tymczasowym wektorze. Ten nowy wektor jest następnie sumowany z C. Dość wyraźnie prezentuje to wersja kodu dyfuzji z przykładu 6.14 z używanymi operacjami wewnętrznymi. Istnieje jednak wiele modułów, które mogą być pomocne w tym przypadku. numexpr to moduł, który może pobrać całe wyrażenie wektorowe i skompilować je do postaci bardzo efektywnego kodu zoptymalizowanego pod kątem minimalizacji chybień w pamięci podręcznej i używanego miejsca tymczasowego. Ponadto w celu maksymalizacji przyspieszenia wyrażenia mogą korzystać z wielu rdzeni procesora (więcej informacji zamieszczono w rozdziale 9.) oraz wyspecjalizowanych instrukcji przeznaczonych dla układów Intela. Bardzo prosta jest modyfikacja kodu w celu zastosowania modułu numexpr. Wymagane jest jedynie przebudowanie wyrażeń do postaci łańcuchów z odwołaniami do zmiennych lokalnych. Wyrażenia są kompilowane w tle (i buforowane, aby wywołania tego samego wyrażenia nie powodowały identycznego obciążenia związanego z kompilacją) i uruchamiane za pomocą zoptymalizowanego kodu. Przykład 6.19 prezentuje, jak łatwe jest modyfikowanie funkcji evolve w celu użycia modułu numexpr. W tym przypadku decydujemy się na zastosowanie parametru out funkcji evaluate, aby moduł numexpr nie przydzielał nowego wektora, do którego zostanie zwrócony wynik obliczenia. Przykład 6.19. Użycie modułu numexpr do dodatkowego zoptymalizowania dużych operacji macierzowych from numexpr import evaluate def evolve(grid, dt, next_grid, D=1): laplacian(grid, next_grid) evaluate("next_grid*D*dt+grid", out=next_grid)
Istotną właściwością modułu numexpr jest uwzględnianie przez niego pamięci podręcznych procesora. Moduł dokładnie przemieszcza dane, tak aby różne pamięci podręczne procesora zawierały poprawne dane w celu zminimalizowania chybień w pamięci podręcznej. Po uruchomieniu narzędzia perf dla zaktualizowanego kodu (przykład 6.20) zauważalne będzie przyspieszenie. Jeśli jednak sprawdzimy wydajność dla mniejszej siatki 512×512 (rysunek 6.4 na końcu rozdziału), okaże się, że szybkość zmniejszyła się o około 15%. Z czego to wynika?
Moduł numexpr: przyspieszanie i upraszczanie operacji wewnętrznych
127
Przykład 6.20. Liczniki wydajności dla narzędzia numpy w przypadku wewnętrznych operacji w pamięci, niestandardowej funkcji laplacian i modułu numexpr $ perf stat -e cycles,stalled-cycles-frontend,stalled-cycles-backend,instructions,\ cache-references,cache-misses,branches,branch-misses,task-clock,faults,\ minor-faults,cs,migrations -r 3 python diffusion_numpy_memory2_numexpr.py Performance counter stats for 'python diffusion_numpy_memory2_numexpr.py' (3 runs): 5,940,414,581 cycles # 1.447 GHz 3,706,635,857 stalled-cycles-frontend # 62.40% frontend cycles idle 2,321,606,960 stalled-cycles-backend # 39.08% backend cycles idle 6,909,546,082 instructions # 1.16 insns per cycle # 0.54 stalled cycles per insn 261,136,786 cache-references # 63.628 M/sec 11,623,783 cache-misses # 4.451 % of all cache refs 627,319,686 branches # 152.851 M/sec 8,443,876 branch-misses # 1.35% of all branches 4104.127507 task-clock # 1.364 CPUs utilized 9,786 page-faults # 0.002 M/sec 9,786 minor-faults # 0.002 M/sec 8,701 context-switches # 0.002 M/sec 60 CPU-migrations # 0.015 K/sec 3.009811418 seconds time elapsed
Większość dodatkowych mechanizmów wprowadzanych przez moduł numexpr do programu ma związek z pamięciami podręcznymi. Gdy siatka jest niewielka, a wszystkie dane niezbędne do obliczeń mieszczą się w pamięci podręcznej, te dodatkowe mechanizmy po prostu spowodują dodanie większej liczby instrukcji, które nie będą korzystne pod kątem wydajności. Poza tym kompilowanie operacji wektorowej zakodowanej w postaci łańcucha generuje duże obciążenie. Gdy łączny czas działania programu jest niewielki, obciążenie to może być dość wyraźne. Jednak przy zwiększaniu wielkości siatki należy spodziewać się, że moduł numexpr lepiej wykorzysta pamięć podręczną niż samo narzędzie numpy. Ponadto moduł numexpr do przeprowadzania swoich obliczeń używa wielu rdzeni i próbuje wypełnić pamięci podręczne każdego rdzenia. W przypadku niewielkiej siatki dodatkowe obciążenie związane z zarządzaniem wieloma rdzeniami eliminuje wszelki możliwy wzrost szybkości. Komputer, na którym uruchomiono kod, był wyposażony w pamięć podręczną o wielkości 20 480 kB (procesor Intel® Xeon® E5-2680). Ponieważ przetwarzane są dwie tablice (po jednej dla danych wejściowych i wyjściowych), z łatwością można przeprowadzić obliczenie dla wielkości siatki, która spowoduje wypełnienie pamięci podręcznej. Liczba elementów siatki możliwa do przechowywania ma łączną wielkość 20 480 kB/64 bity = 2 560 000. Ze względu na to, że używane są dwie siatki, liczba ta jest dzielona między dwa obiekty (w efekcie każdy obiekt może zawierać co najwyżej 2 560 000/2 = 1 280 000 elementów). Zastosowanie pierwiastka kwadratowego dla tej liczby daje wielkość siatki, która korzysta z takiej liczby elementów. Podsumowując, oznacza to, że w przybliżeniu dwie tablice dwuwymiarowe o wielkości 1131×1131 zapełniłyby pamięć podręczną ( 20480 kB / 64 bity / 2 1131 ). W praktyce jednak nie uda się całkowicie wypełnić pamięci podręcznej na własne potrzeby (inne programy zapełnią części tej pamięci), dlatego realistyczna jest możliwość pomieszczenia w niej dwóch tablic 800×800. Po przyjrzeniu się tabelom 6.1 i 6.2 można stwierdzić, że gdy wielkość siatki zwiększa się z 512×512 do 1024×1024, kod modułu numexpr zaczyna przewyższać pod względem wydajności czysty kod narzędzia numpy.
128
Rozdział 6. Obliczenia macierzowe i wektorowe
Tabela 6.1. Łączny czas działania wszystkich schematów dla różnych wielkości siatki i 500 iteracji funkcji evolve Metoda
256256
512512
10241024
20482048
40964096
Kod Python
2,32 s
9,49 s
39,00 s
155,02 s
617,35 s
Kod Python + pamięć
2,56 s
10,26 s
40,87 s
162,88 s
650,26 s
Narzędzie numpy
0,07 s
0,28 s
1,61 s
11,28 s
45,47 s
Narzędzie numpy + pamięć
0,05 s
0,22 s
1,05 s
6,95 s
28,14 s
Narzędzie numpy + pamięć + laplacian
0,03 s
0,12 s
0,53 s
2,68 s
10,57 s
Narzędzie numpy + pamięć + laplacian + numexpr
0,04 s
0,13 s
0,50 s
2,42 s
9,54 s
Narzędzie numpy + pamięć + scipy
0,05 s
0,19 s
1,22 s
6,06 s
30,31 s
Tabela 6.2. Przyspieszenie porównane z szybkością prostego kodu Python (przykład 6.3) dla wszystkich schematów i zmiennych wielkości siatki przy liczbie iteracji funkcji evolve większej niż 500 Metoda
256256
512512
10241024
20482048
40964096
Kod Python
0,00 razy
0,00 razy
0,00 razy
0,00 razy
0,00 razy
Kod Python + pamięć
0,90 razy
0,93 razy
0,95 razy
0,95 razy
0,95 razy
Narzędzie numpy
32,33 razy
33,56 razy
24,25 razy
13,74 razy
13,58 razy
Narzędzie numpy + pamięć
42,63 razy
42,75 razy
37,13 razy
22,30 razy
21,94 razy
Narzędzie numpy + pamięć + laplacian
77,98 razy
78,91 razy
73,90 razy
57,90 razy
58,43 razy
Narzędzie numpy + pamięć + laplacian + numexpr
65,01 razy
74,27 razy
78,27 razy
64,18 razy
64,75 razy
Narzędzie numpy + pamięć + scipy
42,43 razy
51,28 razy
32,09 razy
25,58 razy
20,37 razy
Przestroga: weryfikowanie „optymalizacji” (biblioteka scipy) Nauką, jaką należy wynieść z lektury tego rozdziału, jest sposób postępowania w przypadku każdej optymalizacji: profilowanie kodu w celu zorientowania się w tym, co ma miejsce, określenie możliwego rozwiązania dla części kodu o małej wydajności, a następnie profilowanie w celu upewnienia się, że wprowadzona poprawka rzeczywiście przyniosła efekty. Choć brzmi to zrozumiale, wszystko szybko może się skomplikować, tak jak to było w przypadku modułu numexpr, którego wydajność w dużym stopniu zależała od wielkości rozpatrywanej siatki. Oczywiście proponowane rozwiązania nie zawsze działają zgodnie z oczekiwaniami. Podczas tworzenia kodu na potrzeby tego rozdziału jeden z autorów stwierdził, że funkcja laplacian okazała się najwolniejszą funkcją, a ponadto przyjął hipotezę, że biblioteka scipy może być znacznie szybsza. Taki wniosek wynika z faktu, że operatory Laplace’a stanowią typową operację w analizie obrazów i prawdopodobnie dysponują bardzo dobrze zoptymalizowaną biblioteką, która przyspieszy wywołania. Ponieważ biblioteka scipy oferuje podmoduł do przetwarzania obrazów, oznacza to, że szczęście nam sprzyja! Implementacja była dość prosta (przykład 6.21) i wymagała zastanowienia się przez chwilę nad zawiłościami stosowania okresowych warunków brzegowych (lub warunków „opakowania”, jak to jest określane w przypadku biblioteki scipy).
Przestroga: weryfikowanie „optymalizacji” (biblioteka scipy)
129
Przykład 6.21. Użycie filtru laplace biblioteki scipy from scipy.ndimage.filters import laplace def laplacian(grid, out): laplace(grid, out, mode='wrap')
Łatwość implementacji jest dość ważna i przed rozważeniem kwestii wydajności z pewnością sprawi, że ta metoda będzie uznawana za lepszą. Jednakże po przeprowadzeniu testu porównawczego dla kodu biblioteki scipy (przykład 6.22) odkryliśmy następującą rewelację: metoda ta nie oferuje żadnego znacznego przyspieszenia w porównaniu z kodem, na którym bazuje (przykład 6.14). Okazuje się, że w miarę zwiększania się wielkości siatki metoda ta zaczyna cechować się coraz gorszą wydajnością (przyjrzyj się rysunkowi 6.4 na końcu rozdziału). Przykład 6.22. Liczniki wydajności dla dyfuzji w przypadku funkcji laplace biblioteki scipy $ perf stat -e cycles,stalled-cycles-frontend,stalled-cycles-backend,instructions,\ cache-references,cache-misses,branches,branch-misses,task-clock,faults,\ minor-faults,cs,migrations -r 3 python diffusion_scipy.py Performance counter stats for 'python diffusion_scipy.py' (3 runs): 6,573,168,470 cycles # 2.929 GHz 3,574,258,872 stalled-cycles-frontend # 54.38% frontend cycles idle 2,357,614,687 stalled-cycles-backend # 35.87% backend cycles idle 9,850,025,585 instructions # 1.50 insns per cycle # 0.36 stalled cycles per insn 415,930,123 cache-references # 185.361 M/sec 3,188,390 cache-misses # 0.767 % of all cache refs 1,608,887,891 branches # 717.006 M/sec 4,017,205 branch-misses # 0.25% of all branches 2243.897843 task-clock # 0.994 CPUs utilized 7,319 page-faults # 0.003 M/sec 7,319 minor-faults # 0.003 M/sec 12 context-switches # 0.005 K/sec 1 CPU-migrations # 0.000 K/sec 2.258396667 seconds time elapsed
Porównanie liczników wydajności wersji kodu biblioteki scipy z licznikami niestandardowej funkcji laplacian (przykład 6.18) pozwala uzyskać informacje wskazujące przyczynę braku przyspieszenia spodziewanego po wprowadzonych modyfikacjach. Najbardziej wyróżniające się liczniki to page-faults i instructions. Wartości obydwu są znacznie większe w przypadku wersji kodu biblioteki scipy. Wzrost wartości licznika page-faults pokazuje, że choć funkcja laplacian biblioteki scipy obsługuje operacje wewnętrzne, nadal przydziela wiele pamięci. Okazuje się, że wartość licznika page-faults w wersji kodu biblioteki scipy jest większa niż dla pierwszej modyfikacji kodu narzędzia numpy (przykład 6.15). Najważniejszy jest jednak licznik instructions. Informuje on o tym, że kod biblioteki scipy żąda, aby procesor wykonał ponad dwa razy więcej działań niż w przypadku kodu niestandardowej funkcji laplacian. Nawet pomimo tego, że instrukcje te są bardziej zoptymalizowane (potwierdza to większa wartość licznika insns per cycle, która informuje o liczbie instrukcji, jakie procesor może wykonać w jednym cyklu zegarowym), dodatkowa optymalizacja nie zaradzi samej liczbie dodanych instrukcji. Po części może to być spowodowane tym, że kod biblioteki scipy utworzono jako bardzo ogólny. Z tego powodu może on przetwarzać wszelkiego rodzaju dane wejściowe przy użyciu różnych warunków brzegowych (wymagają one dodatkowego kodu, a tym samym większej liczby instrukcji). Można to stwierdzić na podstawie dużej wartości licznika branches, czyli liczby rozgałęzień wymaganych przez kod biblioteki scipy.
130
Rozdział 6. Obliczenia macierzowe i wektorowe
Podsumowanie Analizując ponownie dokonane optymalizacje, można odnieść wrażenie, że podążono dwiema podstawowymi ścieżkami: skrócono czas przekazywania danych procesorowi i zmniejszono liczbę działań, jakie procesor musiał wykonać. W tabelach 6.1 i 6.2 dokonano porównania wyników osiągniętych po zastosowaniu różnych optymalizacji z wynikami oryginalnej implementacji czystego kodu Python dla zbioru danych o zmiennej wielkości. Na rysunku 6.4 w wersji graficznej zaprezentowano rezultat tego porównania. Widoczne są trzy pasma wydajności, które odpowiadają dwóm wymienionym metodom. Pasmo przebiegające wzdłuż dolnej części rysunku prezentuje niewielki wzrost wydajności w odniesieniu do implementacji czystego kodu Python w przypadku początkowych działań poczynionych w celu zmniejszenia liczby alokacji pamięci. Środkowe pasmo pokazuje, co się stało po zastosowaniu narzędzia numpy i dalszym zredukowaniu liczby alokacji. Z kolei górne pasmo ilustruje wyniki osiągnięte przez ograniczenie liczby podejmowanych działań. Ważnym wnioskiem jest to, że zawsze należy zadbać o wszystkie działania administracyjne, jakie kod musi wykonać podczas inicjowania. Może to obejmować przydzielanie pamięci lub wczytywanie konfiguracji z pliku, a nawet wstępne obliczanie wybranych wartości, które będą potrzebne w trakcie trwania cyklu życia programu. Jest to istotne z dwóch powodów. Po pierwsze, zmniejszana jest całkowita liczba koniecznych uruchomień tych zadań przez jednorazowe wykonanie ich na początku. Ponadto wiadomo, że w przyszłości możliwe będzie użycie tych zasobów bez generowania zbyt dużego obciążenia. Po drugie, nie jest zakłócany przepływ programu. Umożliwia to bardziej efektywne potokowanie i zapewnia wypełnienie pamięci podręcznych bardziej trafnymi danymi. Dowiedziałeś się też więcej o znaczeniu lokalizacji danych, a także o tym, jak istotny jest prosty sposób przekazywania danych procesorowi. Pamięci podręczne procesora mogą być dość złożone. Często najlepszym rozwiązaniem jest zezwolenie różnym mechanizmom zaprojektowanym pod kątem ich optymalizacji na zajęcie się wszystkim. Jednakże decydujące znaczenie może mieć zrozumienie zachodzących procesów, a ponadto realizowanie wszystkich możliwych operacji w celu zoptymalizowania sposobu obsługi pamięci. Na przykład dzięki opanowaniu zasad działania pamięci podręcznych wiemy, że spadek wydajności prowadzący do zaniku przyspieszenia niezależnie od wielkości siatki (rysunek 6.4) prawdopodobnie można przypisać wypełnieniu pamięci podręcznej L3 przez dane siatki. Gdy do tego dojdzie, przestaną być widoczne korzyści wynikające ze stosowania warstwowej architektury pamięci jako rozwiązania problemu wąskiego gardła Von Neumanna. Kolejny ważny wniosek dotyczy użycia bibliotek zewnętrznych. Język Python jest znakomity ze względu na jego łatwość użycia i czytelność kodu, co umożliwia szybkie tworzenie i debugowanie kodu. Jednakże zasadnicze znaczenie ma dostrojenie wydajności do bibliotek zewnętrznych. Mogą one być wyjątkowo szybkie, ponieważ mogą być tworzone za pomocą języków niskopoziomowych. Ponieważ jednak komunikują się z interpreterem języka Python przy użyciu interfejsu, możliwe jest też szybkie napisanie kodu, który będzie korzystać z tych bibliotek. Pokazaliśmy też, jak ważną rolę przed rozpoczęciem eksperymentów odgrywa wykonywanie testów porównawczych i definiowanie hipotez dotyczących wydajności. Sformułowanie hipotezy przed uruchomieniem testu porównawczego umożliwia określenie warunków informujących o tym, czy optymalizacja faktycznie zadziałała. Czy dana zmiana mogła skrócić Podsumowanie
131
Rysunek 6.4. Zestawienie przyspieszeń wynikających z użycia metod wypróbowanych w rozdziale
czas działania? Czy spowodowała ona zredukowanie liczby alokacji? Czy mniejsza jest liczba chybień w pamięci podręcznej? Optymalizacja niekiedy może być sztuką. Wynika to z ogromnej złożoności systemów komputerowych. Ponadto niezmiernie pomocna może być analiza ilościowa zachodzących procesów. Ostatnia uwaga dotycząca optymalizacji odnosi się do tego, że trzeba szczególnie zadbać o to, by przeprowadzane optymalizacje mogły zostać uogólnione dla różnych komputerów (przyjmowane założenia i uzyskiwane wyniki testów porównawczych mogą być zależne od architektury używanego komputera, sposobu skompilowania stosowanych modułów itp.). Ponadto podczas dokonywania optymalizacji niezmiernie ważne jest uwzględnienie innych programistów oraz tego, jak zmiany wpłyną na czytelność kodu. Na przykład rozwiązanie zastosowane w przykładzie 6.17 okazało się potencjalnie niejasne. W związku z tym postaraliśmy się o pełne udokumentowanie kodu i przetestowanie go, aby optymalizacje okazały się pomocne nie tylko dla nas, ale również dla innych osób z zespołu. W następnym rozdziale zostanie przedstawiony sposób tworzenia własnych modułów zewnętrznych, które mogą być dokładnie dostrajane pod kątem rozwiązywania konkretnych problemów przy jednoczesnym zapewnieniu jeszcze większej efektywności. Jest to możliwe dzięki zastosowaniu metody pisania programów wykorzystującej szybkie tworzenie prototypów. Polega ona na rozwiązaniu najpierw problemu z powolnym kodem, następnie zidentyfikowaniu elementów o małej wydajności, a na końcu znalezieniu sposobów na przyspieszenie ich działania. Częste profilowanie i podejmowanie próby optymalizacji jedynie sekcji kodu, w przypadku których wiadomo, że są powolne, pozwala zaoszczędzić czas, a jednocześnie sprawić, że programy będą działać tak szybko, jak to możliwe.
132
Rozdział 6. Obliczenia macierzowe i wektorowe
ROZDZIAŁ 7.
Kompilowanie do postaci kodu C
Pytania, na jakie będziesz w stanie udzielić odpowiedzi po przeczytaniu rozdziału Jak możesz sprawić, że kod Python będzie działać jako kod niższego poziomu? Jaka jest różnica między kompilatorem JIT i kompilatorem AOT? Jakie zadania mogą być wykonywane przez skompilowany kod Python szybciej
niż w przypadku zwykłego kodu Python? Dlaczego adnotacje typu zwiększają szybkość skompilowanego kodu Python? Jak możesz utworzyć moduły dla kodu Python za pomocą języka C lub Fortran? Jak możesz użyć w kodzie Python bibliotek języka C lub Fortran?
Najprostszym sposobem przyspieszenia kodu jest ograniczenie liczby operacji, jakie będzie wykonywać. Zakładając, że zostały już wybrane dobre algorytmy i zmniejszono ilość przetwarzanych danych, najprostsza metoda, by wykonywać mniejszą liczbę instrukcji, polega na skompilowaniu kodu do postaci kodu maszynowego. W tym zakresie język Python oferuje kilka opcji obejmujących narzędzia do kompilowania oparte na czystym kodzie C, takie jak Cython, Shed Skin i Pythran, kompilowanie bazujące na kompilatorze LLVM za pośrednictwem narzędzia Numba oraz zastępczą maszynę wirtualną PyPy, która zawiera wbudowany kompilator JIT (Just in Time). Przy podejmowaniu decyzji dotyczącej ścieżki, jaka zostanie obrana, konieczne jest zrównoważenie wymagań związanych z łatwością dostosowania kodu i impetu zespołu. Każde z wymienionych narzędzi dodaje nową zależność do szeregu używanych narzędzi. Dodatkowo narzędzie Cython wymaga pisania w języku nowego typu (hybrydzie języków Python i C), co wiąże się z koniecznością zdobycia nowej umiejętności. Wykorzystanie nowego języka narzędzia Cython może mieć negatywny wpływ na impet zespołu, ponieważ jego członkowie bez znajomości języka C mogą mieć problem z obsługą takiego kodu. Jednak w praktyce jest to raczej niewielki kłopot, gdyż kod narzędzia Cython będzie używany tylko w dobrze wybranych, niewielkich obszarach kodu.
133
Godne uwagi jest to, że przeprowadzanie profilowania kodu w odniesieniu do pamięci i procesora prawdopodobnie spowoduje, że zaczniesz się zastanawiać nad optymalizacjami algorytmicznymi wyższego poziomu, które mogą zostać zastosowane. Takie zmiany algorytmiczne (czyli użycie dodatkowej logiki w celu uniknięcia obliczeń lub buforowanie eliminujące ponowne obliczenia) mogą pomóc Ci zapobiegać wykonywaniu w kodzie zbędnych działań. Ekspresywność kodu Python ułatwia zauważenie takich możliwości algorytmicznych. W podrozdziale „Technika głębokiego uczenia prezentowana przez firmę RadimRehurek.com” z rozdziału 12. Radim Řehůřek wyjaśnia, jak implementacja kodu Python może wygrać z czystą implementacją stworzoną w języku C. W rozdziale dokonamy przeglądu następujących narzędzi: Cython — jest to najczęściej używane narzędzie do kompilowania do postaci kodu C, które
uwzględnia zarówno narzędzie numpy, jak i zwykły kod Python (wymagana jest znajomość języka C). Shed Skin — zautomatyzowany konwerter Python-C przeznaczony dla kodu, który nie
bazuje na narzędziu numpy.
Numba — nowy kompilator stworzony z myślą o kodzie bazującym na narzędziu numpy. Pythran — nowy kompilator przeznaczony zarówno dla kodu bazującego na narzędziu
numpy, jak i innego kodu. PyPy — stabilny kompilator JIT dla kodu, który nie bazuje na narzędziu numpy. Kod taki
zastępuje zwykły plik wykonywalny Python. W dalszej części rozdziału przyjrzymy się interfejsom funkcji zewnętrznych, które umożliwiają skompilowanie kodu C do postaci modułów rozszerzeń dla języka Python. Wbudowany interfejs API tego języka jest używany razem z narzędziami ctypes i cffi (twórców kompilatora PyPy) oraz z konwerterem Fortran-Python f2py.
Jakie wzrosty szybkości są możliwe? Jeśli problem związany jest z metodą kompilowania, całkiem prawdopodobne są wzrosty szybkości wynoszące rząd wielkości lub więcej. Przyjrzymy się tutaj różnym metodom osiągania przyspieszeń wynoszących jeden lub dwa rzędy wielkości dla pojedynczego rdzenia, a także w przypadku zastosowania wielu rdzeni za pośrednictwem interfejsu OpenMP. Kod Python, który zwykle będzie działać szybciej po skompilowaniu, prawdopodobnie służy do zastosowań matematycznych, a ponadto zawiera raczej wiele pętli wielokrotnie powtarzających te same operacje. W obrębie tych pętli możliwe jest tworzenie wielu obiektów tymczasowych. Mało prawdopodobne jest to, że kod wywołujący biblioteki zewnętrzne (np. wyrażenia regularne, operacje na łańcuchach, wywołania bibliotek bazy danych) wykaże jakiekolwiek przyspieszenie po skompilowaniu. Programy powiązane z operacjami wejścia-wyjścia również raczej nie pozwolą osiągnąć znacznych przyspieszeń. Jeśli kod Python koncentruje się na wywoływaniu wektoryzowanych funkcji narzędzia numpy, wcale może nie działać szybciej po skompilowaniu. Inaczej będzie tylko wtedy, gdy kompilowany kod to głównie kod Python (a ponadto prawdopodobnie w sytuacji, kiedy w kodzie jest wykonywana pętla). W rozdziale 6. omówiono operacje narzędzia numpy. Okazuje się, że w ich przypadku kompilowanie nie będzie pomocne, ponieważ nie występuje wiele obiektów pośrednich. 134
Rozdział 7. Kompilowanie do postaci kodu C
Ogólnie rzecz biorąc, bardzo mało prawdopodobne jest to, że skompilowany kod będzie w ogóle szybszy od funkcji napisanej w języku C. Niemniej jednak taki kod nie będzie też działać znacznie wolniej. Całkiem możliwe jest to, że kod C wygenerowany z kodu Python będzie działać tak samo szybko jak ręcznie napisana funkcja C, chyba że programista używający języka C dysponuje szczególnie dużą wiedzą na temat metod dostrajania kodu C do architektury docelowej platformy sprzętowej. W przypadku kodu stworzonego z myślą o operacjach matematycznych możliwe jest, że ręcznie stworzona funkcja języka Fortran przewyższy odpowiadającą jej funkcję C. Jednakże i tym razem będzie to raczej wymagać wiedzy eksperckiej. Generalnie rzecz biorąc, wynik kompilacji (uzyskany prawdopodobnie z wykorzystaniem narzędzia Cython, Pythran lub Shed Skin) będzie zbliżony do wyniku dla ręcznie napisanego kodu C w stopniu wymaganym przez większość programistów. Podczas profilowania algorytmu i korzystania z niego trzeba pamiętać o diagramie z rysunku 7.1. Trochę czasu poświęconego na zrozumienie kodu poprzez jego profilowanie powinno dać możliwość podjęcia lepszych decyzji na poziomie algorytmicznym. Skoncentrowanie się w dalszej kolejności na kompilatorze powinno zaowocować dodatkowym przyspieszeniem. Prawdopodobnie możliwe będzie dalsze dostrajanie algorytmu, ale nie należy być zaskoczonym coraz mniejszymi przyspieszeniami wynikającymi z coraz większej ilości włożonej pracy. Trzeba samemu stwierdzić, kiedy dodatkowe starania przestaną być opłacalne.
Rysunek 7.1. Trochę czasu poświęconego na profilowanie i kompilowanie zapewnia spore korzyści, ale dalsze działania zwykle są coraz mniej opłacalne
Jakie wzrosty szybkości są możliwe?
135
Jeśli używasz kodu Python i całej grupy dołączonych bibliotek bez narzędzia numpy, podstawowymi opcjami wyboru będą narzędzia Cython, Shed Skin i PyPy. Jeśli używasz narzędzia numpy, odpowiednimi propozycjami są narzędzia Cython, Numba i Pythran. Obsługują one język Python 2.7, a część z nich jest też zgodna z językiem Python w wersji 3.2 lub nowszej. Niektóre z przedstawionych dalej przykładów wymagają ogólnej znajomości kompilatorów kodu C oraz samego kodu C. W przypadku braku takiej wiedzy przed zagłębieniem się w tę tematykę powinieneś w podstawowym zakresie poznać język C i skompilować działający program napisany w tym języku.
Porównanie kompilatorów JIT i AOT Omawiane narzędzia można podzielić na dwie grupy: służące do wcześniejszego kompilowania (kompilatory AOT: Cython, Shed Skin, Pythran) oraz do kompilowania przy pierwszej próbie użycia kodu (kompilatory JIT: Numba, PyPy). Kompilowanie za pomocą kompilatora AOT (Ahead of Time) powoduje utworzenie biblioteki statycznej przeznaczonej dla używanej platformy sprzętowej. Po pobraniu narzędzia numpy, scipy lub scikit-learn nastąpi skompilowanie przez jedno z nich części biblioteki z wykorzystaniem kompilatora Cython na danej platformie sprzętowej (ewentualnie w przypadku użycia dystrybucji takiej jak Continuum Anaconda, zostanie zastosowana wcześniej zbudowana biblioteka kompilowana). Dzięki kompilacji kodu przed jego użyciem uzyskuje się bibliotekę, która może od razu zostać zastosowana przy rozwiązywaniu problemu. Kompilowanie za pomocą kompilatora JIT (Just in Time) w dużym stopniu (lub całkowicie) eliminuje początkowe działania. Kompilator może rozpocząć kompilację tylko odpowiednich części kodu w momencie ich użycia. Oznacza to wystąpienie problemu zimnego startu. Polega on na tym, że może wystąpić sytuacja, gdy większość kodu programu została już skompilowana, a aktualnie używana porcja kodu jeszcze nie, więc w momencie rozpoczynania uruchamiania kodu w czasie trwania kompilacji będzie on działać bardzo wolno. Jeśli ma to miejsce każdorazowo przy uruchamianiu skryptu, który jest uaktywniany wielokrotnie, związany z tym spadek wydajności może stać się znaczny. Ponieważ problem ten dotyczy kompilatora PyPy, korzystanie z niego w przypadku krótkich, lecz często wykonywanych skryptów może okazać się niepożądane. W tym miejscu omówienia widać, że wcześniejsze kompilowanie daje korzyść w postaci najlepszych przyspieszeń, ale często wymaga też największego nakładu pracy. Kompilatory JIT oferują duże przyspieszenia przy bardzo małej liczbie ręcznie wprowadzanych zmian, ale mogą powodować opisany problem. Przy wyborze właściwej technologii na potrzeby konkretnego zastosowania konieczne będzie rozważenie tych kwestii.
Dlaczego informacje o typie ułatwiają przyspieszenie działania kodu? W języku Python typy są dynamicznie określane. Zmienna może odwoływać się do obiektu dowolnego typu, a dowolny wiersz kodu może zmienić typ przywoływanego obiektu. Utrudnia to maszynie wirtualnej optymalizację metody wykonywania kodu na poziomie kodu maszynowego,
136
Rozdział 7. Kompilowanie do postaci kodu C
ponieważ nie dysponuje ona informacją o tym, jaki podstawowy typ danych będzie używany dla przyszłych operacji. Utrzymywanie kodu w uogólnionej postaci powoduje, że będzie on dłużej wykonywany. W poniższym przykładzie v identyfikuje liczbę zmiennoprzecinkową lub parę takich liczb, które reprezentują liczbę zespoloną complex. Oba warunki mogą wystąpić w tej samej pętli w różnym czasie lub w powiązanych kolejnych sekcjach kodu: v = -1.0 print type(v), abs(v) 1.0 v = 1-1j print type(v), abs(v) 1.41421356237
Funkcja abs działa różnie w zależności od bazowego typu danych. Funkcja ta użyta dla liczby całkowitej lub zmiennoprzecinkowej po prostu powoduje przekształcenie wartości ujemnej w wartość dodatnią. W przypadku liczby zespolonej funkcja abs pobiera pierwiastek kwadratowy sumy elementów podniesionych do kwadratu:
abs c c.real 2 c.imag 2 Kod maszynowy dla przykładu liczby zespolonej complex uwzględnia więcej instrukcji i do wykonania wymaga więcej czasu. Przed wywołaniem funkcji abs dla zmiennej interpreter języka Python musi najpierw poszukać typu zmiennej, a następnie zdecydować, jaką wersję funkcji wywołać. Związane z tym obciążenie zwiększa się w przypadku wykonywania wielu powtarzanych wywołań. W obrębie kodu Python każdy podstawowy obiekt, taki jak liczba całkowita, zostanie opakowany za pomocą obiektu języka Python wyższego poziomu (np. za pomocą obiektu int w przypadku liczby całkowitej). Tego rodzaju obiekt oferuje dodatkowe funkcje, takie jak __hash__ (ułatwia przechowywanie) i __str__ (obsługuje wyświetlanie łańcuchów). Wewnątrz sekcji kodu powiązanego z procesorem częstą sytuacją jest to, że typy zmiennych nie zmieniają się. Daje to możliwość zastosowania kompilacji statycznej i szybszego wykonywania kodu. Jeśli wymaganych jest jedynie wiele pośrednich operacji matematycznych, nie są potrzebne funkcje wyższego poziomu, a ponadto mogą być zbędne mechanizmy służące do zliczania odwołań. W tym przypadku można po prostu przejść do poziomu kodu maszynowego i przeprowadzić szybko obliczenia przy użyciu kodu maszynowego i bajtów, a nie poprzez modyfikowanie obiektów wyższego poziomu języka Python, z czym wiąże się większe obciążenie. W tym celu wcześniej określane są typy obiektów, aby możliwe było wygenerowanie poprawnego kodu C.
Użycie kompilatora kodu C W dalszych przykładach zostaną zastosowane kompilatory gcc i g++ z zestawu narzędziowego GNU C Compiler. Jeśli poprawnie skonfigurujesz środowisko, możesz skorzystać z alternatywnego kompilatora (np. icc Intela lub cl Microsoftu). Narzędzie Cython korzysta z kompilatora gcc, a narzędzie Shed Skin używa kompilatora g++.
Użycie kompilatora kodu C
137
Kompilator gcc stanowi znakomity wybór w przypadku większości platform, ponieważ jest dobrze obsługiwany i dość zaawansowany. Często możliwe jest uzyskanie większej wydajności za pomocą dostrojonego kompilatora (np. w przypadku urządzeń Intela kompilator icc tej firmy może wygenerować szybszy kod niż kompilator gcc), ale wiąże się to z koniecznością poszerzenia wiedzy specjalistycznej i uzyskania informacji o sposobie dostosowywania flag dla alternatywnego kompilatora. Języki C i C++ często są używane do kompilacji statycznej w miejsce innych języków, takich jak Fortran, ze względu na ich wszechobecność i bogatą gamę bibliotek pomocniczych. Kompilator i konwerter (w tym przypadku konwerterem jest narzędzie Cython i inne podobne) mają możliwość analizowania kodu z adnotacją w celu określenia, czy mogą zostać zastosowane kroki optymalizacji statycznej (np. wstawianie funkcji i rozwijanie pętli). Agresywna analiza pośredniego drzewa składni abstrakcyjnej (przeprowadzana przez narzędzia Pythran, Numba i PyPy) zapewnia możliwości łączenia wiedzy o tym, jak w języku Python wyrażane są informacje o najlepszej metodzie wykorzystania napotkanych wzorców w celu przekazania ich bazowemu kompilatorowi.
Analiza przykładu zbioru Julii W rozdziale 2. dokonano profilowania generatora zbioru Julii. Użyty kod generuje obraz wyjściowy z wykorzystaniem liczb całkowitych i liczb zespolonych. Obliczenia obrazu są powiązane z procesorem. Główne obciążenie związane z wykonywaniem kodu miało postać powiązanej z procesorem pętli wewnętrznej, która oblicza listę output. Lista może mieć postać kwadratowej tablicy pikseli, w której każda wartość reprezentuje koszt wygenerowania piksela. Kod funkcji wewnętrznej został zaprezentowany w przykładzie 7.1. Przykład 7.1. Analiza powiązanego z procesorem kodu funkcji zbioru Julii def calculate_z_serial_purepython(maxiter, zs, cs): """Obliczanie listy output za pomocą reguły aktualizacji zbioru Julii""" output = [0] * len(zs) for i in range(len(zs)): n = 0 z = zs[i] c = cs[i] while n < maxiter and abs(z) < 2: z = z * z + c n += 1 output[i] = n return output
W przypadku laptopa jednego z autorów obliczenie oryginalnego zbioru Julii dla siatki 1000×1000 przy wartości maxit równej 300 zajęło w przybliżeniu 11 sekund (użyto implementacji czystego kodu Python wykonywanego za pomocą narzędzia CPython 2.7).
138
Rozdział 7. Kompilowanie do postaci kodu C
Cython Cython (http://cython.org/) to kompilator, który przekształca kod Python z adnotacją typu w skompilowany moduł rozszerzenia. Adnotacje typu przypominają te stosowane w języku C. Takie rozszerzenie może być importowane za pomocą narzędzia import jako zwykły moduł języka Python. Choć rozpoczęcie działań nie przysparza trudności, wiąże się z tym konieczność poszerzania wiedzy w coraz większym stopniu wraz z każdym dodatkowym poziomem złożoności i optymalizacji. Przez jednego z autorów narzędzie to jest wykorzystywane do przekształcania funkcji wymagających wielu obliczeń w szybszy kod. Wybrał to narzędzie z powodu jego powszechnego użycia, dojrzałości i obsługi interfejsu OpenMP. W przypadku standardu OpenMP możliwe jest radzenie sobie z problemami dotyczącymi przetwarzania równoległego poprzez zastosowanie modułów obsługujących wieloprocesorowość, które są uruchamiane w wielu procesorach jednego komputera. Wątki są ukrywane przed kodem Python. Działają za pośrednictwem wygenerowanego kodu C. Kompilator Cython (opublikowany w 2007 r.) wywodzi się z kompilatora Pyrex (wprowadzonego w 2002 r.). Cython rozszerza możliwości pierwotnych zastosowań kompilatora Pyrex. Biblioteki, które używają kompilatora Cython, to: scipy, scikit-learn, lxml i zmq. Kompilator Cython może być stosowany za pośrednictwem skryptu setup.py do kompilacji modułu. Może też zostać użyty interaktywnie w powłoce IPython, co umożliwia „magiczne” polecenie. Adnotacja typów jest zwykle przeprowadzana przez programistę, choć możliwa jest pewna forma zautomatyzowanego tworzenia adnotacji.
Kompilowanie czystego kodu Python za pomocą narzędzia Cython Prosta metoda rozpoczęcia tworzenia kompilowanego modułu rozszerzenia uwzględnia trzy pliki. W przypadku użycia zbioru Julii jako przykładu są to następujące pliki: Plik wywołującego kodu Python (spora część wcześniej przedstawionego kodu zbioru Julii). Nowy plik .pyx z funkcją do skompilowania. Plik setup.py, który zawiera instrukcje wywołujące kompilator Cython do utworzenia mo-
dułu rozszerzenia. Przy użyciu tej metody wywoływany jest skrypt setup.py w celu wykorzystania kompilatora Cython do skompilowania pliku .pyx do postaci skompilowanego modułu. W systemach uniksowych skompilowany moduł będzie prawdopodobnie plikiem .so. W systemie Windows powinien to być plik .pyd (biblioteka języka Python przypominająca bibliotekę DLL). W przypadku przykładu zbioru Julii zostaną zastosowane następujące pliki: julia1.py. Służy do zbudowania list wejściowych i wywołania funkcji obliczeniowej. cythonfn.pyx. Zawiera funkcję powiązaną z procesorem, dla której można utworzyć adnotacje. setup.py. Zawiera instrukcje procesu budowania.
Wynikiem uruchomienia skryptu setup.py jest możliwy do zaimportowania moduł. W skrypcie julia1.py z przykładu 7.2 wymagane jest jedynie wprowadzenie kilku drobnych zmian w celu zaimportowania nowego modułu za pomocą instrukcji import i wywołania funkcji.
Cython
139
Przykład 7.2. Importowanie nowo skompilowanego modułu do głównego kodu ... import calculate # zgodnie z definicją w skrypcie setup.py ... def calc_pure_python(desired_width, max_iterations): # ... start_time = time.time() output = calculate.calculate_z(max_iterations, zs, cs) end_time = time.time() secs = end_time - start_time print "Czas trwania:", secs, "s" ...
W przykładzie 7.3 zaczniemy od czystego kodu Python bez adnotacji typu. Przykład 7.3. Niezmieniony czysty kod Python z pliku cythonfn.pyx (ze zmienionym rozszerzeniem na .py) dla skryptu setup.py kompilatora Cython # cythonfn.pyx def calculate_z(maxiter, zs, cs): """Obliczanie listy output za pomocą reguły aktualizacji zbioru Julii""" output = [0] * len(zs) for i in range(len(zs)): n = 0 z = zs[i] c = cs[i] while n < maxiter and abs(z) < 2: z = z * z + c n += 1 output[i] = n return output
Skrypt setup.py z przykładu 7.4 jest krótki. Zdefiniowano w nim sposób przekształcenia pliku cythonfn.pyx w plik calculate.so. Przykład 7.4. Skrypt setup.py przekształca plik cythonfn.pyx w kod C, który ma zostać skompilowany przez kompilator Cython from distutils.core import setup from distutils.extension import Extension from Cython.Distutils import build_ext setup( cmdclass = {'build_ext': build_ext}, ext_modules = [Extension("calculate", ["cythonfn.pyx"])] )
Po uruchomieniu skryptu setup.py z przykładu 7.5 z argumentem build_ext kompilator Cython poszuka pliku cythonfn.pyx i utworzy plik calculate.so. Przykład 7.5. Uruchamianie skryptu setup.py w celu zbudowania nowo skompilowanego modułu $ python setup.py build_ext --inplace running build_ext cythoning cythonfn.pyx to cythonfn.c building 'calculate' extension gcc -pthread -fno-strict-aliasing -DNDEBUG -g -fwrapv -O2 -Wall -Wstrict-prototypes -fPIC -I/usr/include/python2.7 -c cythonfn.c -o build/temp.linux-x86_64-2.7/cythonfn.o gcc -pthread -shared -Wl,-O1 -Wl,-Bsymbolic-functions -Wl, -Bsymbolic-functions -Wl,-z, relro build/temp.linux-x86_64-2.7/cythonfn.o -o calculate.so
140
Rozdział 7. Kompilowanie do postaci kodu C
Pamiętaj o tym, że jest to krok wykonywany ręcznie. Gdy zaktualizujesz plik .pyx lub setup.py i zapomnisz ponownie uruchomić polecenie do budowania, nie będzie dostępny zaktualizowany moduł .so do zaimportowania. Jeśli nie masz pewności, czy kod został skompilowany, sprawdź znacznik czasu pliku .so. W razie wątpliwości usuń wygenerowane pliki kodu C oraz plik .so, a następnie zbuduj je ponownie.
Argument --inplace nakazuje kompilatorowi Cython zbudowanie skompilowanego modułu w bieżącym katalogu, a nie w osobnym katalogu build. Po zakończeniu procesu budowania dostępny będzie plik cythonfn.c, który jest raczej mało czytelny, a także plik calculate.so. Po uruchomieniu kodu z pliku julia1.py importowany jest skompilowany moduł. Na laptopie jednego z autorów zbiór Julii został obliczony w czasie wynoszącym 8,9 sekundy, a nie w bardziej typowym czasie równym 11 sekund. Jest to niewielki wzrost wydajności kosztem znikomego nakładu pracy.
Użycie adnotacji kompilatora Cython do analizowania bloku kodu W poprzednim przykładzie pokazano, że możliwe jest szybkie zbudowanie skompilowanego modułu. W przypadku intensywnych pętli i operacji matematycznych już samo to często prowadzi do wzrostu szybkości. Oczywiście nie należy po omacku przeprowadzać optymalizacji. Konieczne jest stwierdzenie, jaka część kodu jest wolna, aby możliwe było zdecydowanie o tym, co wymaga większego nakładu pracy. Kompilator Cython oferuje opcję tworzenia adnotacji, która zapewnia plik wyjściowy HTML możliwy do wyświetlenia w przeglądarce. Do wygenerowania adnotacji używane jest polecenie cython -a cythonfn.pyx, które generuje plik wyjściowy cythonfn.html. Po wyświetleniu w przeglądarce zawartość pliku przypomina widoczną na rysunku 7.2. Podobny rysunek jest dostępny w dokumentacji kompilatora Cython (http://docs.cython.org/src/quickstart/cythonize.html).
Rysunek 7.2. Kolorem wyróżniono dane wyjściowe funkcji bez adnotacji uzyskane za pomocą kompilatora Cython
Dwukrotne kliknięcie każdego wiersza powoduje jego rozwinięcie i wyświetlenie wygenerowanego kodu C. Intensywniejszy żółty kolor oznacza więcej wywołań w obrębie maszyny wirtualnej języka Python, bardziej białe wiersze natomiast wskazują na kod C, który w mniejszym stopniu przypomina kod Python. Celem jest usunięcie jak największej liczby żółtych wierszy i zakończenie działań z jak najmniejszą liczbą białych wierszy.
Cython
141
Choć bardziej żółte wiersze oznaczają więcej wywołań w obrębie maszyny wirtualnej, niekoniecznie spowoduje to wolniejsze działanie kodu. Każde wywołanie w maszynie wirtualnej wiąże się z obciążeniem, ale dla wszystkich takich wywołań będzie ono znaczne tylko w przypadku wywołań występujących wewnątrz dużych pętli. Wywołania poza obrębem dużych pętli (np. wiersz kodu używany do utworzenia listy output na początku funkcji) nie są kosztowne w porównaniu z kosztem obliczeń w pętli wewnętrznej. Nie marnuj czasu na wiersze, które nie powodują spowolnienia kodu. W przykładzie wiersze z największą liczbą wywołań w obrębie maszyny wirtualnej języka Python (najbardziej żółte) mają numery 4 i 8. Na podstawie wyników dotychczasowych operacji profilowania można stwierdzić, że wiersz 8. zostanie prawdopodobnie wywołany ponad 30 milionów razy, dlatego jest znakomitym kandydatem do tego, by się na nim skoncentrować. Wiersze 9., 10. i 11. są prawie żółte. Ponadto wiadomo, że znajdują się w środku intensywnej pętli wewnętrznej. Ogólnie rzecz biorąc, odpowiadają za sporą część czasu wykonywania funkcji. Z tego powodu w pierwszej kolejności trzeba się nimi zająć. Jeśli musisz przypomnieć sobie, ile czasu trwało wykonywanie tej sekcji kodu, zajrzyj do podrozdziału „Użycie narzędzia line_profiler do pomiarów dotyczących kolejnych wierszy kodu” z rozdziału 2. Wiersze 6. i 7. są mniej żółte. Ponieważ są wywoływane tylko milion razy, mają znacznie mniejszy wpływ na końcową szybkość. Oznacza to, że później można skupić uwagę na nich. Okazuje się, że ponieważ są one obiektami list, właściwie nic nie można zrobić, aby skrócić czas dostępu do nich. Jak wspomniano w podrozdziale „Cython i numpy”, wyjątkiem jest operacja polegająca na zastąpieniu obiektów list tablicami narzędzia numpy, które zapewnią niewielki przyrost szybkości. Aby lepiej zrozumieć żółte obszary, możesz rozwinąć każdy wiersz przez dwukrotne kliknięcie. Na rysunku 7.3 widać, że do utworzenia listy output iterowana jest długość elementu zs. Powoduje to utworzenie obiektów języka Python, w przypadku których maszyna wirtualna tego języka zlicza odwołania. Choć te wywołania są kosztowne, tak naprawdę nie mają wpływu na czas wykonywania tej funkcji.
Rysunek 7.3. Kod C ukryty w wierszu kodu Python 142
Rozdział 7. Kompilowanie do postaci kodu C
Aby poprawić czas wykonywania funkcji, konieczne jest rozpoczęcie deklarowania typów obiektów, które są uwzględniane w pętlach wewnętrznych generujących duże obciążenie. Dzięki temu pętle te mogą tworzyć mniej dość kosztownych wywołań kierowanych do maszyny wirtualnej języka Python. W ten sposób oszczędza się czas. Ogólnie rzecz biorąc, do wierszy kodu, które prawdopodobnie zajmują najwięcej czasu procesora, zaliczają się następujące: wiersze znajdujące się w intensywnych pętlach wewnętrznych, wiersze usuwające odwołania do elementów obiektów list, array lub np.array, wiersze wykonujące operacje matematyczne.
Jeśli nie wiesz, jakie wiersze są najczęściej wykonywane, wykorzystaj narzędzie do profilowania line_profiler, które omówiono w podrozdziale „Użycie narzędzia line_profiler do pomiarów dotyczących kolejnych wierszy kodu” z rozdziału 2. Dowiesz się, jakie wiersze są najczęściej wykonywane, a także które z nich powodują największe obciążenie wewnątrz maszyny wirtualnej języka Python. Dzięki temu uzyskasz wyraźny dowód na to, jakie wiersze wymagają uwagi w celu osiągnięcia najlepszego przyrostu szybkości.
Dodawanie adnotacji typu Na rysunku 7.2 pokazano, że prawie każdy wiersz funkcji jest wywoływany w maszynie wirtualnej języka Python. Wszystkie obliczenia numeryczne również są wywoływane w tej maszynie, ponieważ używane są obiekty języka Python wyższego poziomu. Konieczne jest przekształcenie tych obiektów w lokalne obiekty języka C, a następnie, po przeprowadzeniu kodowania numerycznego, przekształcenie wyniku z powrotem w obiekt języka Python. W przykładzie 7.6 widoczny jest sposób dodawania typów podstawowych za pomocą składni słowa kluczowego cdef. Przykład 7.6. Dodawanie typów podstawowych języka C w celu rozpoczęcia przyspieszania działania skompilowanej funkcji. W tym celu używany jest w większym stopniu język C, a w mniejszym kod wykorzystujący maszynę wirtualną języka Python def calculate_z(int maxiter, zs, cs): """Obliczanie listy output za pomocą reguły aktualizacji zbioru Julii""" cdef unsigned int i, n cdef double complex z, c output = [0] * len(zs) for i in range(len(zs)): n = 0 z = zs[i] c = cs[i] while n < maxiter and abs(z) < 2: z = z * z + c n += 1 output[i] = n return output
Godne uwagi jest to, że takie typy będą zrozumiałe dla kompilatora Cython, lecz nie dla interpretera języka Python. Kompilator Cython używa tych typów do przekształcania kodu Python w obiekty języka C, które nie muszą być wywoływane w stosie języka Python. Oznacza to, że operacje są szybsze, ale towarzyszy temu utrata elastyczności i szybkości tworzenia kodu. Cython
143
Dodawane są następujące typy: int dla liczby całkowitej ze znakiem, unsigned int dla liczby całkowitej, która może być tylko dodatnia, double complex dla liczb zespolonych podwójnej precyzji.
Słowo kluczowe cdef umożliwia zadeklarowanie zmiennych wewnątrz zawartości funkcji. Musi ono być deklarowane na początku funkcji, ponieważ jest to wymóg specyfikacji języka C. Podczas dodawania adnotacji kompilatora Cython dodajesz kod inny niż kod Python do pliku .pyx. Oznacza to, że rezygnujesz z interaktywności tworzenia kodu Python w interpreterze. Z myślą o osobach zaznajomionych z pisaniem kodu w języku C powracamy do cyklu tworzenie kodu – kompilowanie – uruchamianie – debugowanie.
Możesz zastanawiać się, czy możliwe jest dodanie adnotacji typu do przekazywanych list. Choć używane jest słowo kluczowe list, w omawianym przykładzie nie ma to praktycznie żadnego znaczenia. Obiekty list nadal muszą być sprawdzane na poziomie interpretera języka Python w celu wyodrębnienia ich zawartości. Jest to bardzo wolna operacja. Przypisywanie typów niektórym podstawowym obiektom odzwierciedlane jest w danych wyjściowych widocznych na rysunku 7.4. Co ważne, wiersze 11. i 12., czyli dwa z najczęściej wywoływanych wierszy kodu, zmieniły teraz kolor z żółtego na biały. Wskazuje to, że nie są one już wywoływane w maszynie wirtualnej języka Python. W porównaniu z poprzednim przykładem można spodziewać się znacznego wzrostu szybkości. Wiersz 10. jest wywoływany ponad 30 milionów razy, dlatego nadal warto się na nim koncentrować.
Rysunek 7.4. Pierwsze adnotacje typu
Po skompilowaniu zakończenie wykonywania tej wersji kodu zajmuje 4,3 sekundy. Po wprowadzeniu zaledwie kilku zmian w funkcji uzyskujemy szybkość dwukrotnie większą niż w przypadku oryginalnego kodu Python. Godne uwagi jest to, że wzrost szybkości wynika z tego, że więcej często wykonywanych operacji kierowanych jest do poziomu kodu C (w tym przypadku są to aktualizacje do wartości zmiennych z i n). Oznacza to, że kompilator kodu C może optymalizować sposób przetwarzania przez funkcje niskiego poziomu bajtów, które reprezentują te zmienne, bez wywoływania funkcji w stosunkowo wolnej maszynie wirtualnej języka Python. 144
Rozdział 7. Kompilowanie do postaci kodu C
Na rysunku 7.4 widać, że pętla while nadal w pewnym stopniu generuje koszty (ma kolor żółty). Kosztowne wywołanie w maszynie wirtualnej języka Python dotyczy funkcji abs na potrzeby liczby zespolonej z. Kompilator Cython nie zapewnia wbudowanej funkcji abs dla liczb zespolonych. Zamiast niej można udostępnić własne lokalne rozszerzenie. Jak wspomniano wcześniej w rozdziale, użycie funkcji abs dla liczby zespolonej uwzględnia obliczenie pierwiastka kwadratowego sumy kwadratów składowych rzeczywistych i urojonych. W teście pożądane jest sprawdzenie, czy pierwiastek kwadratowy wyniku jest mniejszy niż 2. Zamiast wyznaczania pierwiastka kwadratowego można obliczyć kwadrat drugiej strony porównania. Oznacza to, że < 2 zostanie przekształcone w < 4. Dzięki temu eliminuje się konieczność obliczania pierwiastka kwadratowego jako ostatniej części funkcji abs. Rozpoczęto od postaci:
c.real 2 c.imag 2 4 Operację uproszczono do następującej postaci:
c.real 2 c.imag 2 4 Jeśli w poniższym kodzie zostałaby zachowana operacja sqrt, w dalszym ciągu byłby zauważalny wzrost szybkości wykonywania. Jednym z sekretów optymalizowania kodu jest sprawienie, aby realizował jak najmniej działań. Dzięki usunięciu stosunkowo kosztownej operacji po zastanowieniu się nad ostatecznym celem funkcji kompilator kodu C będzie mógł wykonać to, z czym sobie dobrze radzi, zamiast próbować „odgadnąć”, jakiego efektu końcowego oczekuje programista. Tworzenie równoważnego, lecz bardziej wyspecjalizowanego kodu do rozwiązania tego samego problemu, jest określane mianem redukowania mocy (ang. strength reduction). Kosztem mniejszej elastyczności (i być może czytelności) zyskuje się krótszy czas wykonywania. To matematyczne rozwinięcie prowadzi do przykładu 7.7, w którym dość kosztowna funkcja
abs została zastąpiona uproszczonym wierszem rozszerzonych działań matematycznych.
Przykład 7.7. Rozwijanie funkcji abs za pomocą kompilatora Cython def calculate_z(int maxiter, zs, cs): """Obliczanie listy output za pomocą reguły aktualizacji zbioru Julii""" cdef unsigned int i, n cdef double complex z, c output = [0] * len(zs) for i in range(len(zs)): n = 0 z = zs[i] c = cs[i] while n < maxiter and (z.real * z.real + z.imag * z.imag) < 4: z = z * z + c n += 1 output[i] = n return output
Tworzenie adnotacji dla kodu pozwala nieznacznie poprawić wydajność instrukcji while w wierszu 10. (rysunek 7.5). Obecnie instrukcja obejmuje mniej wywołań w wirtualnej maszynie języka Python. Choć skala wzrostu szybkości, jaki zostanie uzyskany, nie jest od razu oczywista, wiadomo, że wiersz ten jest wywoływany ponad 30 milionów razy. Oznacza to, że przewidywane jest odpowiednie zwiększenie wydajności. Cython
145
Rysunek 7.5. Rozszerzone działania matematyczne pozwalające na ostateczną wygraną w procesie optymalizacji
Ta zmiana ma diametralne znaczenie. Przez zmniejszenie liczby wywołań w najbardziej wewnętrznej pętli znacząco skracany jest czas obliczeniowy funkcji. Czas wykonania nowej wersji kodu wynosi zaledwie 0,25 sekundy, co oznacza niesamowite 40-krotne przyspieszenie w porównaniu z oryginalną wersją kodu. Kompilator Cython obsługuje kilka metod kompilowania do postaci kodu C. Niektóre z nich są prostsze od opisanej tutaj metody tworzenia pełnej adnotacji typu. Aby ułatwić sobie rozpoczęcie korzystania z kompilatora Cython, należy zaznajomić się z trybem czystego kodu Python, a ponadto przyjrzeć się narzędziu pyximport, które ułatwia zaprezentowanie tego kompilatora współpracownikom.
Aby dla omawianej porcji kodu uzyskać dodatkowy możliwy wzrost wydajności, możesz wyłączyć sprawdzanie ograniczeń dla każdego zastąpienia odwołania na liście. Celem sprawdzania ograniczeń jest zapewnienie, że program nie będzie korzystał z danych poza obrębem przydzielonej tablicy. W przypadku kodu C z łatwością można przypadkowo uzyskać dostęp do pamięci poza granicami tablicy, co spowoduje nieoczekiwane wyniki (i prawdopodobnie błąd segmentacji!). Domyślnie kompilator Cython chroni programistę przed przypadkowym adresowaniem poza granicami listy. Choć taka ochrona wiąże się z niewielkim wykorzystaniem czasu procesorowego, występuje w zewnętrznej pętli funkcji. Z tego powodu sumarycznie nie powoduje znacznego wydłużenia czasu wykonywania. Zwykle bezpieczne jest wyłączenie sprawdzania ograniczeń, o ile nie przeprowadzasz własnych obliczeń związanych z adresowaniem tablicy. W tym przypadku konieczne będzie zadbanie o to, aby nie zostały przekroczone granice listy. Kompilator Cython oferuje zestaw flag, które mogą być określane na różne sposoby. Najprostszy polega na dodaniu ich jako jednowierszowych komentarzy na początku pliku .pyx. Do zmiany tych ustawień możliwe jest też użycie dekoratora lub flagi czasu kompilowania. W celu wyłączenia sprawdzania granic dodajemy dyrektywę kompilatora Cython w obrębie komentarza na początku pliku .pyx. #cython: boundscheck=False def calculate_z(int maxiter, zs, cs):
146
Rozdział 7. Kompilowanie do postaci kodu C
Jak widać, wyłączenie sprawdzania ograniczeń spowoduje tylko nieznaczne skrócenie czasu, ponieważ ma to miejsce w pętli zewnętrznej, a nie wewnętrznej, co jest kosztowniejsze. W przypadku omawianego przykładu nie zapewni to żadnego skrócenia czasu. Spróbuj wyłączyć sprawdzanie ograniczeń i przepełnienia, jeśli kod powiązany z procesorem znajduje się w pętli, która często zastępuje odwołania dla elementów.
Shed Skin Shed Skin (http://code.google.com/p/shedskin/) to eksperymentalny kompilator Python-C++, który współdziała z językiem Python w wersjach 2.4 – 2.7. Kompilator używa inferencji typów do automatycznego sprawdzenia programu Python w celu tworzenia adnotacji typów stosowanych dla każdej zmiennej. Taki kod z adnotacjami jest następnie przekształcany w kod C, aby można go było skompilować za pomocą standardowego kompilatora (np. g++). Automatyczna introspekcja to bardzo interesująca funkcja kompilatora Shed Skin. Użytkownik musi jedynie zapewnić przykład prezentujący sposób wywołania funkcji z wykorzystaniem właściwego rodzaju danych, a kompilator sam określi resztę. Zaletą inferencji typów jest to, że programista nie musi jawnie określać typów. Aby tak było, analizator musi mieć możliwość zidentyfikowania typów dla każdej zmiennej w programie. W bieżącej wersji kompilatora tysiące wierszy kodu Python mogą być automatycznie przekształcane do postaci kodu C. Kompilator korzysta z narzędzia Boehma oczyszczającego pamięć, które umożliwia dynamiczne zarządzanie pamięcią. Narzędzie to jest używane także w przypadku kompilatorów Mono i GNU Compiler for Java. Wadą kompilatora Shed Skin jest to, że dla standardowych bibliotek stosuje zewnętrzne implementacje. Wszystko, co nie zostało zaimplementowane (dotyczy to również narzędzia numpy), nie będzie obsługiwane. Projekt kompilatora Shed Skin zawiera ponad 75 przykładów, w tym wiele modułów matematycznych utworzonych w czystym kodzie Python, a nawet w pełni działający emulator Commodore 64. Każdy z przykładowych kodów działa znacznie szybciej po skompilowaniu za pomocą kompilatora Shed Skin (nawet w porównaniu z uruchomieniem w obrębie narzędzia CPython). Kompilator Shed Skin może tworzyć odrębne programy wykonywalne, które nie zależą od używanej instalacji interpretera języka Python lub modułów rozszerzeń wykorzystywanych wraz z instrukcją import w zwykłym kodzie Python. Skompilowane moduły zarządzają własną pamięcią. Oznacza to, że pamięć z procesu kodu Python jest kopiowana, a wyniki są z powrotem kopiowane — nie występuje żadne jawne współużytkowanie pamięci. W przypadku dużych bloków pamięci (np. dużej macierzy) koszt wykonywania operacji kopiowania może być znaczny. Przyjrzymy się temu na końcu tego podrozdziału. Kompilator Shed Skin zapewnia podobny zestaw korzyści co kompilator PyPy (więcej informacji zamieszczono w podrozdziale „PyPy”). Oznacza to, że kompilator PyPy może być łatwiejszy w użyciu, ponieważ nie wymaga żadnych kroków kompilacji. Sposób automatycznego dodawania adnotacji typu przez kompilator Shed Skin może być interesujący dla niektórych użytkowników. Jeśli ponadto zamierzasz modyfikować wynikowy kod C, wygenerowany Shed Skin
147
kod C może być bardziej czytelny niż kod C utworzony przez kompilator Cython. Podejrzewamy, że kod z automatyczną inferencją typów będzie szczególnie interesujący dla innych twórców kompilatorów w społeczności.
Tworzenie modułu rozszerzenia W przedstawionym tutaj przykładzie zostanie zbudowany moduł rozszerzenia. Za pomocą instrukcji import można zaimportować wygenerowany moduł tak, jak to miało miejsce w przypadku przykładów dotyczących kompilatora Cython. Moduł ten może też zostać skompilowany w postaci odrębnego programu wykonywalnego. W przykładzie 7.8 zamieszczono kod w osobnym module. Zawiera on zwykły kod Python, dla którego w żaden sposób nie są tworzone adnotacje typu. Zauważ również, że dodano test __main__, który powoduje, że moduł ten ma niezależną postać na potrzeby analizy typów. Kompilator Shed Skin może użyć tego bloku __main__, który zapewnia przykładowe argumenty, do identyfikacji typów przekazywanych do funkcji calculate_z, a także do określenia typów wykorzystywanych wewnątrz funkcji powiązanej z procesorem. Przykład 7.8. Przenoszenie funkcji powiązanej z procesorem do osobnego modułu (jak w przypadku kompilatora Cython) w celu umożliwienia działania systemu automatycznej inferencji typów kompilatora Shed Skin # shedskinfn.py def calculate_z(maxiter, zs, cs): """Obliczanie listy output za pomocą reguły aktualizacji zbioru Julii""" output = [0] * len(zs) for i in range(len(zs)): n = 0 z = zs[i] c = cs[i] while n < maxiter and abs(z) < 2: z = z * z + c n += 1 output[i] = n return output if __name__ == "__main__": # Tworzenie trywialnego przykładu za pomocą poprawnych typów w celu umożliwienia # wywołania funkcji przez inferencję typów, aby kompilator Shed Skin mógł analizować typy output = calculate_z(1, [0j], [0j])
Moduł ten można zaimportować w zwykły sposób (przykład 7.9) zarówno przed skompilowaniem go, jak i po kompilacji. Ponieważ kod nie jest modyfikowany (inaczej niż w przypadku kompilatora Cython), przed kompilacją możliwe jest wywołanie oryginalnego modułu Python. Jeśli kod nie zostanie skompilowany, nie uzyska się przyrostu szybkości, ale możliwe będzie przeprowadzenie debugowania w uproszczony sposób za pomocą zwykłych narzędzi powiązanych z językiem Python. Przykład 7.9. Importowanie modułu zewnętrznego w celu umożliwienia kompilatorowi Shed Skin skompilowania tylko tego modułu ... import shedskinfn ... def calc_pure_python(desired_width, max_iterations): #... start_time = time.time()
148
Rozdział 7. Kompilowanie do postaci kodu C
output = shedskinfn.calculate_z(max_iterations, zs, cs) end_time = time.time() secs = end_time - start_time print "Czas trwania:", secs, "s" ...
Jak zaprezentowano w przykładzie 7.10, możliwe jest sprawienie, by kompilator Shed Skin udostępnił dane wyjściowe z adnotacją związane z jego analizą. Umożliwia to polecenie shedskin -ann shedskinfn.py, które generuje plik shedskinfn.ss.py. W przypadku kompilowania modułu rozszerzenia konieczne jest jedynie zainicjowanie analizy za pomocą fikcyjnej funkcji __main__. Przykład 7.10. Sprawdzanie danych wyjściowych z adnotacją kompilatora Shed Skin w celu stwierdzenia, jakie typy zostały przez niego zidentyfikowane # shedskinfn.ss.py def calculate_z(maxiter, zs, cs):
# maxiter: [int], # zs: [list(complex)], # cs: [list(complex)] """Obliczanie listy output za pomocą reguły aktualizacji zbioru Julii""" output = [0] * len(zs) # [list(int)] for i in range(len(zs)): # [__iter(int)] n = 0 # [int] z = zs[i] # [complex] c = cs[i] # [complex] while n < maxiter and abs(z) < 2: # [complex] z = z * z + c # [complex] n += 1 # [int] output[i] = n # [int] return output # [list(int)] if __name__ == "__main__": # [] # Tworzenie trywialnego przykładu za pomocą poprawnych typów w celu umożliwienia # wywołania funkcji przez inferencję typów, aby kompilator Shed Skin mógł analizować typy output = calculate_z(1, [0j], [0j]) # [list(int)]
Po przeanalizowaniu typów __main__ wewnątrz funkcji calculate_z mogą być identyfikowane zmienne, takie jak z i c, na podstawie obiektów, z którymi prowadzą one interakcję. Moduł jest kompilowany przy użyciu polecenia shedskin --extmod shedskinfn.py. Generowane są następujące pliki: shedskinfn.hpp (plik nagłówkowy C++), shedskinfn.cpp (plik źródłowy C++), Makefile.
Uruchomienie programu make powoduje wygenerowanie pliku shedskinfn.so. Instrukcja import shedskinfn pozwala użyć tego pliku w kodzie Python. Czas wykonywania skryptu julia1.py z wykorzystaniem pliku shedskinfn.so wynosi 0,4 sekundy. Jest to ogromna poprawa wydajności w porównaniu z wersją bez kompilacji, która wymagała bardzo niewielkiego nakładu pracy. Tak jak w przypadku kompilatora Cython w przykładzie 7.7, możliwe jest również rozwinięcie funkcji abs. Po uruchomieniu tej wersji kodu (ze zmodyfikowanym tylko jednym wierszem funkcji abs) i użyciu kilku dodatkowych flag (--nobounds --nowrap) ostatecznie uzyskujemy czas wykonywania wynoszący 0,3 sekundy. Choć jest to czas trochę dłuższy (o 0,05 sekundy) niż w przypadku wersji z kompilatorem Cython, nie było konieczne podawanie wszystkich informacji o typach. Oznacza to, że eksperymentowanie z wykorzystaniem kompilatora Shed Skin jest bardzo łatwe. Kompilator PyPy uruchamia tę samą wersję kodu z podobną szybkością.
Shed Skin
149
To, że w przypadku omawianego przykładu kompilatory Cython, PyPy i Shed Skin korzystają z podobnych środowisk wykonawczych, nie oznacza, że uzyskany wynik może zostać uogólniony. Aby w realizowanym projekcie osiągnąć najlepsze czasy wykonywania, trzeba sprawdzić różne narzędzia i przeprowadzić własne eksperymenty.
Kompilator Shed Skin umożliwia określenie dodatkowych flag dotyczących kompilacji, takich jak -ffast-math lub -O3. W dwóch krokach (w pierwszym gromadzone są statystyki dotyczące wykonywania, a w drugim wygenerowany kod jest optymalizowany na podstawie uzyskanych statystyk) można dodać optymalizację PGO (Profile-Guided Optimization) w celu podjęcia próby osiągnięcia dodatkowego wzrostu szybkości. Optymalizacja PGO nie spowodowała jednak przyspieszenia wykonywania kodu dla przykładu zbioru Julii. W praktyce optymalizacja ta często zapewnia niewielki rzeczywisty wzrost wydajności lub żaden. Należy zauważyć, że domyślnie liczby całkowite są 32-bitowe. Jeśli wymagane są większe zakresy z 64-bitowymi liczbami całkowitymi, podaj flagę --long. Należy też unikać przydzielania małych obiektów (np. nowych krotek) w obrębie pętli wewnętrznych, ponieważ proces czyszczenia pamięci nie obsługuje ich tak efektywnie, jak można byłoby oczekiwać.
Koszt związany z kopiami pamięci W przykładzie kompilator Shed Skin kopiuje do swojego środowiska obiekty list języka Python, upraszczając dane do postaci podstawowych typów języka C. Kompilator przekształca następnie wynik funkcji języka C na końcu jej wykonywania z powrotem w obiekt list języka Python. Takie przekształcenia i kopiowania zajmują czas. Czy może to oznaczać brakujący czas wynoszący 0,05 sekundy, o którym wspomniano przy okazji poprzedniego wyniku? Aby określić jedynie koszt związany z kopiowaniem danych do/z funkcji za pośrednictwem kompilatora Shed Skin, można zmodyfikować plik shedskinfn.py w celu usunięcia kodu odpowiedzialnego za realizowanie rzeczywistych operacji. Następujący wariant funkcji calculate_z to właśnie to, co jest potrzebne: def calculate_z(maxiter, zs, cs): """Obliczanie listy output za pomocą reguły aktualizacji zbioru Julii""" output = [0] * len(zs) return output
W przypadku wykonywania skryptu julia1.py za pomocą tej funkcji szkieletowej czas wynosi w przybliżeniu 0,05 sekundy (oczywiście skrypt nie oblicza poprawnego wyniku!). Czas ten stanowi koszt kopiowania 2 milionów liczb zespolonych do funkcji calculate_z oraz ponownego kopiowania z niej miliona liczb całkowitych. Zasadniczo kompilatory Shed Skin i Cython generują ten sam kod maszynowy. Różnica w szybkości wykonywania wynika z tego, że kompilator Shed Skin działa w niezależnym obszarze pamięci, oraz z obciążenia związanego z koniecznością kopiowania danych. Z drugiej strony w przypadku kompilatora Shed Skin nie ma potrzeby tworzenia na początku adnotacji, co zapewnia dość znaczne oszczędności czasu.
150
Rozdział 7. Kompilowanie do postaci kodu C
Cython i numpy Obiekty listy (więcej informacji zamieszczono w rozdziale 3.) powodują obciążenie w przypadku każdej operacji zastępowania odwołania, ponieważ przywoływane przez nie obiekty mogą znajdować się w dowolnym miejscu w pamięci. Dla porównania, obiekty tablicy przechowują typy podstawowe w ciągłych blokach pamięci RAM, co pozwala na szybsze adresowanie. Język Python oferuje moduł array, który zapewnia jednowymiarowe przechowywanie typów podstawowych (w tym liczb całkowitych, liczb zmiennoprzecinkowych i łańcuchów Unicode). Moduł numpy.array narzędzia numpy umożliwia wielowymiarowe przechowywanie oraz oferuje szerszą gamę typów podstawowych, w tym liczby zespolone. W przypadku iterowania obiektu array w sposób możliwy do przewidzenia kompilator może zostać poinstruowany w celu uniknięcia żądania od interpretera języka Python, by obliczył odpowiedni adres. Zamiast tego interpreter może zająć się następnym elementem podstawowym w sekwencji, co polega na bezpośrednim przejściu do jego adresu pamięci. Ponieważ dane są rozmieszczone w ciągłym bloku, trywialnym zadaniem jest obliczenie za pomocą przesunięcia adresu następnego elementu kodu C. Dzięki temu nie ma potrzeby instruowania narzędzia CPython, aby obliczyło taki sam wynik, co wiązałoby się z użyciem wolnego wywołania w obrębie maszyny wirtualnej. Należy zauważyć, że jeśli zostanie uruchomiona wersja kodu narzędzia numpy bez żadnych adnotacji kompilatora Cython (czyli kod po prostu zostanie wykonany jako zwykły skrypt Python), zajmie to około 71 sekund. Jest to zdecydowanie gorszy wynik niż dla wersji kodu ze zwykłym obiektem list języka Python, którego wykonanie zajęło około 11 sekund. Spowolnienie jest spowodowane obciążeniem wynikającym z zastępowania odwołań dla poszczególnych elementów list narzędzia numpy. Nie zostało przewidziane używanie tych list w ten sposób, nawet mimo tego, że dla początkujących programistów może się to wydać intuicyjną metodą obsługi operacji. Kompilowanie kodu eliminuje to obciążenie. W odniesieniu do tego kompilator Cython oferuje dwie specjalne postaci składni. Starsze wersje kompilatora udostępniają specjalny typ dostępu dla tablic narzędzia numpy, a później za pośrednictwem interfejsu memoryview wprowadzono ogólny protokół interfejsu bufora. Zapewnia on ten sam niskopoziomowy dostęp do dowolnego obiektu, który implementuje interfejs bufora, uwzględniając tablice narzędzia numpy i języka Python. Dodatkową korzyścią oferowaną przez interfejs bufora jest to, że umożliwia łatwe współużytkowanie bloków pamięci z innymi bibliotekami języka C bez potrzeby przekształcania ich z obiektów języka Python w inną postać. Blok kodu z przykładu 7.11 przypomina trochę oryginalną implementację, z tym wyjątkiem, że zostały dodane adnotacje interfejsu memoryview. Drugi argument funkcji to double complex[:] zs. Oznacza to, że używany jest obiekt liczb zespolonych o podwójnej precyzji, który korzysta z protokołu bufora (określony za pomocą znaków []) zawierającego jednowymiarowy blok danych (określony przy użyciu dwukropka :).
Cython i numpy
151
Przykład 7.11. Wersja kodu z adnotacjami narzędzia numpy dla funkcji obliczającej zbiór Julii # cython_np.pyx import numpy as np cimport numpy as np def calculate_z(int maxiter, double complex[:] zs, double complex[:] cs): """Obliczanie listy output za pomocą reguły aktualizacji zbioru Julii""" cdef unsigned int i, n cdef double complex z, c cdef int[:] output = np.empty(len(zs), dtype=np.int32) for i in range(len(zs)): n = 0 z = zs[i] c = cs[i] while n < maxiter and (z.real * z.real + z.imag * z.imag) < 4: z = z * z + c n += 1 output[i] = n return output
Oprócz podawania argumentów wejściowych przy użyciu składni adnotacji bufora tworzone są też adnotacje dla zmiennej output przez przypisanie jej obiektu tablicy jednowymiarowej array narzędzia numpy za pośrednictwem funkcji empty. Wywołanie tej funkcji spowoduje przydzielenie bloku pamięci, ale nie zainicjuje pamięci przy użyciu rozsądnych wartości, dlatego może ona zawierać cokolwiek. Ponieważ zawartość takiej tablicy zostanie nadpisana w pętli wewnętrznej, nie będzie konieczne ponowne przypisywanie tablicy przy użyciu wartości domyślnej. Jest to trochę szybsze niż przydzielanie i ustawianie zawartości tablicy za pomocą wartości domyślnej. Używając szybszej i bardziej jawnej wersji matematycznej, rozwinięto również wywołanie funkcji abs. Czas działania tej wersji wynosi 0,23 sekundy, czyli jest to wynik nieznacznie lepszy niż w przypadku oryginalnej wersji kodu używającego kompilatora Cython, która bazuje na czystym kodzie Python z przykładu 7.7 zbioru Julii. Czysta wersja kodu powoduje obciążenie każdorazowo przy zastępowaniu odwołania dla obiektu complex kodu Python, ale operacje te występują w pętli zewnętrznej, dlatego nie mają dużego udziału w czasie wykonywania. Po pętli zewnętrznej tworzone są macierzyste wersje zmiennych, które działają z „szybkością kodu C”. Pętla wewnętrzna, zarówno w przypadku przykładowego kodu narzędzia numpy, jak i wcześniejszego przykładu czystego kodu Python, realizuje te same działania dla tych samych danych. Oznacza to, że różnica w czasie wykonywania wynika z operacji zastępowania odwołań w pętli zewnętrznej oraz tworzenia tablic output.
Przetwarzanie równoległe rozwiązania na jednym komputerze z wykorzystaniem interfejsu OpenMP W ramach ostatniego kroku rozwijania omawianej wersji kodu przyjrzyjmy się użyciu rozszerzeń języka C++ interfejsu OpenMP do zastosowania przetwarzania równoległego dla trudnego, ale umożliwiającego takie rozwiązanie problemu. Jeśli problem, który rozpatrujesz, jest podobny, możesz szybko skorzystać z wielu rdzeni obecnych w komputerze. OpenMP (Open Multi-Processing) to dobrze zdefiniowany interfejs API dla wielu platform, który obsługuje wykonywanie równoległe i współużytkowanie pamięci dla kodu utworzonego przy użyciu języków C, C++ i Fortran. Interfejs ten wbudowany jest w większość nowoczesnych kompilatorów kodu C. Jeśli kod C zostanie właściwe napisany, przetwarzanie równoległe występuje na poziomie kompilatora. Oznacza to stosunkowo niewielki nakład pracy dla programisty, który korzysta z kompilatora Cython. 152
Rozdział 7. Kompilowanie do postaci kodu C
W przypadku tego kompilatora interfejs OpenMP może zostać dodany za pomocą operatora prange (parallel range) i przez dodanie do skryptu setup.py dyrektywy kompilatora -fopenmp. Działania w obrębie pętli tego operatora mogą być wykonywane równolegle, ponieważ wyłączana jest blokada GIL (Global Interpreter Lock). Przykład 7.12 prezentuje zmodyfikowaną wersję kodu z obsługą operatora prange. Instrukcja with nogil: określa blok, w którym wyłączana jest blokada GIL. Wewnątrz tego bloku operator prange umożliwia pętli for przetwarzania równoległego interfejsu OpenMP niezależne obliczenie każdej wartości zmiennej i. Przykład 7.12. Dodawanie operatora prange w celu zastosowania przetwarzania równoległego za pomocą interfejsu OpenMP # cython_np.pyx from cython.parallel import prange import numpy as np cimport numpy as np def calculate_z(int maxiter, double complex[:] zs, double complex[:] cs): """Obliczanie listy output za pomocą reguły aktualizacji zbioru Julii""" cdef unsigned int i, length cdef double complex z, c cdef int[:] output = np.empty(len(zs), dtype=np.int32) length = len(zs) with nogil: for i in prange(length, schedule="guided"): z = zs[i] c = cs[i] output[i] = 0 while output[i] < maxiter and (z.real * z.real + z.imag * z.imag) < 4: z = z * z + c output[i] += 1 return output
Podczas wyłączania blokady GIL nie można przetwarzać zwykłych obiektów języka Python (np. list). Konieczne jest przetwarzanie wyłącznie obiektów podstawowych i obiektów, które obsługują interfejs memoryview. W przypadku przetwarzania równoległego zwykłych obiektów języka Python wymagane byłoby rozwiązanie problemów towarzyszących zarządzaniu pamięcią, których blokada GIL celowo unika. Kompilator Cython nie zapobiega modyfikowaniu obiektów języka Python. Jeśli sam to zrobisz, spowoduje to tylko problemy i zamieszanie!
Aby skompilować plik cython_np.pyx, konieczne jest zmodyfikowanie skryptu setup.py w sposób pokazany w przykładzie 7.13. Po modyfikacji skrypt instruuje kompilator kodu C o użyciu flagi -fopenmp jako argumentu podczas kompilacji w celu włączenia interfejsu OpenMP i połączenia z jego bibliotekami. Przykład 7.13. Dodawanie do skryptu setup.py flag kompilatora i programu konsolidującego interfejsu OpenMP dla kompilatora Cython #setup.py from distutils.core import setup from distutils.extension import Extension from Cython.Distutils import build_ext setup( cmdclass = {'build_ext': build_ext}, ext_modules = [Extension("calculate", ["cython_np.pyx"], extra_compile_args=['-fopenmp'], extra_link_args=['-fopenmp'])] )
Cython i numpy
153
Operator prange kompilatora Cython umożliwia wybranie różnych metod szeregowania. W przypadku opcji static obciążenie jest równomiernie rozkładane między dostępne procesory. Część obliczeń wymaga więcej czasu, a część nie. Jeśli kompilator Cython zostanie poinstruowany, aby równomiernie szeregować porcje zadań między procesorami przy użyciu opcji static, wyniki dla części obliczeń zostaną uzyskane szybciej niż dla innych. Szybsze wątki przejdą następnie w stan bezczynności. Dzięki opcjom szeregowania dynamic i guided można zmniejszyć skalę tego problemu przez dynamiczne przydzielanie zadań w mniejszych porcjach podczas wykonywania kodu. Dzięki temu w przypadku zmiennego czasu obliczeń obciążenie jest równomiernie rozkładane między procesorami. Właściwy wybór dla utworzonego kodu będzie zmieniał się w zależności od natury obciążenia. Zastosowanie interfejsu OpenMP i opcji schedule="guided" pozwala skrócić czas wykonywania w przybliżeniu do 0,07 sekundy. Szeregowanie guided spowoduje dynamiczne przydzielanie zadań, dzięki czemu mniej wątków będzie oczekiwać na nowe zadania. Używając instrukcji #cython: boundscheck=False, można też wyłączyć dla omawianego przykładu sprawdzanie ograniczeń, ale nie spowodowałoby to skrócenia czasu wykonywania.
Numba Narzędzie Numba (http://numba.pydata.org/) firmy Continuum Analytics to kompilator JIT specjalizujący się w kodzie narzędzia numpy, który dokonuje kompilacji tego kodu w czasie wykonywania za pośrednictwem kompilatora LLVM (a nie, tak jak we wcześniej prezentowanych przykładach, za pomocą kompilatora g++ lub gcc). Numba nie wymaga kroku prekompilacji, dlatego po uruchomieniu dla nowego kodu kompiluje każdą funkcję z adnotacją, która jest wymagana przez używany sprzęt. Zaletą jest to, że kompilatorowi udostępniany jest dekorator, który informuje go o tym, jakimi funkcjami ma się zająć, po czym Numba zaczyna realizować swoje zadania. Kompilator Numba przeznaczony jest do stosowania dla każdego standardowego kodu narzędzia numpy. Numba to krócej istniejący projekt (w książce użyto wersji 0.13), a związany z nim interfejs API może się nieznacznie zmieniać z każdą wersją. Z tego powodu na chwilę obecną należy traktować go jako bardziej przydatny w środowisku badawczym. Jeśli korzystasz z tablic narzędzia numpy i kodu bez wektoryzacji, który dokonuje iteracji dla wielu elementów, kompilator Numba powinien umożliwić Ci uzyskanie szybkiego efektu optymalizacji bez większego nakładu pracy. Mankamentem związanym z użyciem kompilatora Numba jest łańcuch narzędzi. Korzysta on z kompilatora LLVM, a ponadto ma wiele zależności. Zalecamy zastosowanie dystrybucji Continuum Anaconda, ponieważ zapewnia wszystkie składniki. W przeciwnym razie instalowanie kompilatora Numba w nowym środowisku może być bardzo czasochłonnym zadaniem. Przykład 7.14 prezentuje dodanie dekoratora @jit do podstawowej funkcji zbioru Julii. Nie jest wymagane nic więcej. To, że kompilator numba został zaimportowany, oznacza, że mechanizmy kompilatora LLVM zostaną uruchomione w czasie wykonywania w celu skompilowania tej funkcji w tle.
154
Rozdział 7. Kompilowanie do postaci kodu C
Przykład 7.14. Zastosowanie dekoratora @jit dla funkcji from numba import jit ... @jit() def calculate_z_serial_purepython(maxiter, zs, cs, output):
Po usunięciu dekoratora @jit będzie to jedynie wersja kodu narzędzia numpy z demonstracją zbioru Julii obsługiwaną przez interpreter języka Python 2.7. Wykonanie takiego kodu zajmie 71 sekund. Dodanie tego dekoratora powoduje skrócenie czasu wykonywania do 0,3 sekundy. Jest to czas bardzo zbliżony do wyniku osiągniętego w przypadku kompilatora Cython, lecz bez całego nakładu pracy związanego z tworzeniem adnotacji. Jeśli ta sama funkcja zostanie uruchomiona drugi raz w tej samej sesji interpretera języka Python, zadziała jeszcze szybciej. Nie ma potrzeby kompilowania funkcji docelowej w drugim przejściu, jeśli jednakowe są typy argumentów. W efekcie ogólny czas wykonywania jest krótszy. W przypadku drugiego uruchomienia wynik kompilatora Numba odpowiada wcześniej uzyskanemu wynikowi zastosowania kompilatora Cython z narzędziem numpy (a zatem przy znikomym nakładzie pracy kompilator Numba okazał się równie szybki jak Cython!). Kompilator PyPy ma takie same wymagania związane z uruchamianiem. W przypadku debugowania za pomocą kompilatora Numba warto zauważyć, że można go poinstruować w celu pokazania typu zmiennej, którą się zajmuje w obrębie skompilowanej funkcji. W przykładzie 7.15 widać, że zmienna zs jest rozpoznawana przez kompilator JIT jako tablica liczb zespolonych. Przykład 7.15. Debugowanie identyfikowanych typów print("Zmienna zs ma typ:", numba.typeof(zs)) array(complex128, 1d, C))
Kompilator Numba obsługuje też inne formy introspekcji, takie jak inspect_types, która umożliwia przegląd skompilowanego kodu w celu stwierdzenia, gdzie zostały zidentyfikowane informacje o typach. W przypadku braku typów możliwe jest doprecyzowanie, jak wyrażono funkcję, aby ułatwić kompilatorowi Numba określenie większej liczby możliwości inferencji typów. Płatna wersja kompilatora Numba, czyli NumbaPro (http://docs.continuum.io/numbapro/), oferuje eksperymentalną obsługę operatora przetwarzania równoległego prange z wykorzystaniem interfejsu OpenMP. Dostępna jest również eksperymentalna obsługa układów GPU. Projekt ten ma na celu uproszczenie przekształcania wolniejszego kodu Python z pętlami bazującego na narzędziu numpy w bardzo szybki kod, który może być wykonywany w procesorze lub układzie GPU. Kompilator NumbaPro jest wart uwagi.
Pythran Pythran (http://pythonhosted.org/pythran/) to kompilator Python-C++ przeznaczony dla podzbioru instrukcji języka Python, który oferuje częściową obsługę narzędzia numpy. Kompilator ten działa trochę podobnie do kompilatorów Numba i Cython. Po utworzeniu przez programistę adnotacji argumentów funkcji kompilator Pythran zajmuje się dalej dodatkowymi adnotacjami typu i specjalizacją kodu. Wykorzystuje możliwości związane z wektoryzacją i przetwarzaniem równoległym opartym na interfejsie OpenMP. Działa wyłącznie w przypadku języka Python 2.7.
Pythran
155
Bardzo interesującą funkcją kompilatora Pythran jest to, że próbuje automatycznie wykryć możliwości przetwarzania równoległego (np. w sytuacji, gdy używasz instrukcji map) i przekształcić kod w kod przetwarzania równoległego bez konieczności wykonywania przez programistę dodatkowych działań. Możliwe jest też określenie przy użyciu dyrektyw pragma omp sekcji przetwarzania równoległego. Pod tym względem sposób działania kompilatora Pythran bardzo przypomina obsługę interfejsu OpenMP przez kompilator Cython. W tle kompilator Pythran pobiera zarówno zwykły kod Python, jak i kod narzędzia numpy, a następnie próbuje agresywnie kompilować je do postaci bardzo szybkiego kodu C++, który zapewnia wyniki jeszcze lepsze od uzyskanych dla kompilatora Cython. Należy zauważyć, że projekt ten jest stosunkowo nowy i mogą w nim występować błędy. Godne uwagi jest także to, że zespół programistów jest bardzo przyjaźnie nastawiony i zwykle usuwa zgłoszone błędy w ciągu kilku godzin. Ponownie przyjrzyj się równaniu dyfuzji z przykładu 6.9. Część obliczeniową funkcji wyodrębniono do osobnego modułu, aby mogła zostać skompilowana do postaci biblioteki binarnej. Przydatną funkcją kompilatora Pythran jest to, że przy użyciu tego kompilatora nie jest tworzony kod niezgodny z językiem Python. Przypomnij sobie, że w przypadku kompilatora Cython konieczne było tworzenie plików .pyx z dołączonym kodem Python, który nie mógł być bezpośrednio uruchomiony przez interpreter języka Python. W przypadku kompilatora Pythran dodawane są jednowierszowe komentarze, które mogą zostać przez niego wykryte. Oznacza to, że jeśli zostanie usunięty generowany moduł kompilowany .so, można po prostu uruchomić kod przy użyciu interpretera języka Python. Jest to znakomita możliwość w odniesieniu do debugowania. W przykładzie 7.16 zaprezentowano równanie przewodnictwa cieplnego. Funkcja evolve zawiera jednowierszowy komentarz, który dołącza dla niej informacje o typach (ponieważ jest to komentarz, uruchomienie kodu bez kompilatora Pythran sprawi, że interpreter języka Python po prostu go zignoruje). Po załadowaniu kompilator Pythran wykryje ten komentarz i dokona propagacji informacji o typach (bardzo podobnie jak w przypadku narzędzia Shed Skin) w każdej powiązanej funkcji. Przykład 7.16. Dodawanie jednowierszowego komentarza w celu dołączenia punktu wejścia do funkcji evolve() import numpy as np def laplacian(grid): return np.roll(grid, +1, 0) + np.roll(grid, -1, 0) + np.roll(grid, +1, 1) + np.roll(grid, -1, 1) - 4 * grid #pythran export evolve(float64[][], float) def evolve(grid, dt, D=1): return grid + dt * D * laplacian(grid)
Moduł ten można skompilować za pomocą polecenia pythran diffusion_numpy.py, które zwróci plik diffusion_numpy.so. Z poziomu funkcji testowej można zaimportować ten nowy moduł i wywołać funkcję evolve. Na laptopie jednego z autorów bez zainstalowanego kompilatora Pythran czas wykonywania tej funkcji dla siatki 8192×8192 wyniósł 3,8 sekundy. W przypadku kompilatora Pythran czas zmniejszył się do 1,5 sekundy. Oczywiście jeśli kompilator Pythran obsługuje wymagane funkcje, może zapewnić naprawdę imponujące wzrosty wydajności przy bardzo niewielkim nakładzie pracy.
156
Rozdział 7. Kompilowanie do postaci kodu C
Przyczyną przyspieszenia jest to, że kompilator Pythran używa własnej wersji funkcji roll, która cechuje się mniejszą funkcjonalnością. Oznacza to, że przeprowadza kompilację do postaci mniej złożonego kodu, który może działać szybciej. Ponadto kod ten jest mniej elastyczny niż kod narzędzia numpy (twórcy kompilatora Pythran zwracają uwagę na to, że implementuje on tylko niektóre elementy narzędzia numpy). Pod względem wyników Pythran może jednak prześcignąć inne wcześniej omówione narzędzia. Zastosujmy teraz tę samą metodę dla przykładu rozszerzonych operacji matematycznych w przypadku zbioru Julii. Samo dodanie jednowierszowej adnotacji do funkcji calculate_z powoduje skrócenie czasu wykonywania do 0,29 sekundy, czyli wyniku trochę gorszego niż w przypadku wyniku kompilatora Cython. Dodanie jednowierszowej deklaracji interfejsu OpenMP na początku pętli zewnętrznej powoduje skrócenie czasu wykonywania do 0,1 sekundy. Czas ten nie odbiega zbytnio od najlepszego wyniku uzyskanego dla interfejsu OpenMP kompilatora Cython. Kod z adnotacją został zaprezentowany w przykładzie 7.17. Przykład 7.17. Dodawanie adnotacji do funkcji calculate_z dla kompilatora Pythran z obsługą interfejsu OpenMP #pythran export calculate_z(int, complex[], complex[], int[]) def calculate_z(maxiter, zs, cs, output): #omp parallel for schedule(guided) for i in range(len(zs)):
Przedstawione dotychczas technologie uwzględniają użycie kompilatora, który towarzyszy zwykłemu interpreterowi CPython. Przyjrzymy się teraz narzędziu PyPy, które oferuje całkowicie nowy interpreter.
PyPy PyPy (http://pypy.org/) to alternatywna implementacja języka Python, która obejmuje śledzący kompilator JIT. Implementacja jest zgodna z językiem Python 2.7. Dostępna jest też eksperymentalna wersja dla języka Python 3.2. Docelowo narzędzie PyPy zastępuje narzędzie CPython, oferując wszystkie wbudowane moduły. Projekt składa się z łańcucha narzędziowego RPython Translation Toolchain, który służy do budowania interpretera PyPy (i może zostać wykorzystany do tworzenia innych interpreterów). Kompilator JIT w interpreterze PyPy jest bardzo wydajny. Odpowiednie przyspieszenia można zauważyć przy niewielkim lub żadnym nakładzie pracy programisty. W podrozdziale „Interpreter PyPy zapewniający powodzenie systemów przetwarzania danych i systemów internetowych” z rozdziału 12. opisano historię dużego wdrożenia zakończonego sukcesem, które bazowało na interpreterze PyPy. Interpreter PyPy uruchamia bez żadnych modyfikacji prezentację zbioru Julii bazującą na czystym kodzie Python. W przypadku narzędzi CPython i PyPy czas wykonywania tego kodu wynosi odpowiednio 11 sekund i 0,3 sekundy. Oznacza to, że interpreter PyPy osiąga wynik bardzo zbliżony do wyniku uzyskanego dla przykładowego kodu, dla którego zastosowano kompilator Cython (przykład 7.7). Nie jest z tym związany zupełnie żaden nakład pracy, co naprawdę robi wrażenie! Jak zaobserwowano przy okazji omawiania kompilatora Numba, jeśli obliczenia są przeprowadzane ponownie w tej samej sesji, drugie i kolejne uruchomienia są szybsze od pierwszego, ponieważ w ich przypadku kompilacja została już wykonana.
PyPy
157
Interesujące jest to, że interpreter PyPy obsługuje wszystkie wbudowane moduły. Oznacza to, że moduł multiprocessing działa tak jak w przypadku narzędzia CPython. Jeśli zajmujesz się problemem wykorzystującym moduły z dołączonymi bibliotekami, który może być przetwarzany równolegle za pomocą modułu multiprocessing, oczekuj, że dostępne będą wszystkie przyrosty szybkości, jakich możesz się spodziewać. Szybkość interpretera PyPy zwiększała się z czasem. Wykres na rysunku 7.6 uzyskany z serwisu speed.pypy.org pozwala zorientować się w dojrzałości interpretera. Pokazane testy szybkości reprezentują szeroki zestaw przypadków użycia, a nie tylko operacje matematyczne. Oczywiste jest to, że interpreter PyPy oferuje większą wydajność niż narzędzie CPython.
Rysunek 7.6. Każda nowa wersja interpretera PyPy oferuje zwiększenie szybkości
Różnice związane z czyszczeniem pamięci Interpreter PyPy korzysta z procesu czyszczenia pamięci innego typu niż narzędzie CPython. Może to spowodować w kodzie pewne nieoczywiste zmiany w działaniu. Narzędzie CPython używa zliczenia odwołań, interpreter PyPy natomiast korzysta ze zmodyfikowanej metody zaznaczania i usuwania, która może znacznie później oczyścić nieużywany obiekt. Oba warianty są poprawnymi implementacjami specyfikacji języka Python. Trzeba tylko być świadomym tego, że niektóre modyfikacje kodu mogą być niezbędne podczas czyszczenia. Niektóre metody tworzenia kodu omawiane w odniesieniu do narzędzia CPython zależą do działania licznika odwołań. Dotyczy to zwłaszcza opróżniania plików bez ich jawnego zamknięcia, gdy wykonywane są dla nich operacje otwierania i zapisywania. W przypadku interpretera PyPy ten sam kod zostanie uruchomiony, ale aktualizacje pliku mogą zostać w późniejszym czasie umieszczone na dysku przy następnym uruchomieniu procesu czyszczenia pamięci. Alternatywną formą, która sprawdza się zarówno dla interpretera PyPy, jak i interpretera języka Python, jest użycie menedżera kontekstu, korzystającego z instrukcji with do otwierania i automatycznego zamykania plików. Na stronie Differences between PyPy and CPython (różnice między interpreterem PyPy i narzędziem CPython) witryny internetowej interpretera PyPy podano odpowiednie szczegóły (http://pypy.readthedocs.org/en/latest/cpython_differences.html). 158
Rozdział 7. Kompilowanie do postaci kodu C
Uruchamianie interpretera PyPy i instalowanie modułów Jeśli nigdy nie uruchamiałeś alternatywnego interpretera języka Python, powinno Ci pomóc zaznajomienie się z krótkim przykładem. Zakładając, że pobrałeś i rozpakowałeś interpreter PyPy, zobaczysz strukturę folderów zawierającą katalog bin. W celu uruchomienia interpretera PyPy użyj polecenia z przykładu 7.18. Przykład 7.18. Uruchamianie interpretera PyPy w celu stwierdzenia, że implementuje język Python 2.7.3 $ ./bin/pypy Python 2.7.3 (84efb3ba05f1, Feb 18 2014, 23:00:21) [PyPy 2.3.0-alpha0 with GCC 4.6.3] on linux2 Type "help", "copyright", "credits" or "license" for more information. And now for something completely different: `` (not thread-safe, but well, nothing is)''
Zauważ, że interpreter PyPy 2.3 działa jako interpreter języka Python 2.7.3. Konieczne jest teraz skonfigurowanie narzędzia pip. Pożądane będzie zainstalowanie narzędzia ipython (zwróć uwagę, że narzędzie IPython jest uruchamiane z tą samą instalacją interpretera języka Python 2.7.3, o której wcześniej wspomniano). Kroki przedstawione w przykładzie 7.19 są takie same jak te, które zostałyby wykonane w przypadku narzędzia CPython, gdyby zainstalowano narzędzie pip bez korzystania z istniejącej dystrybucji lub menedżera pakietów. Przykład 7.19. Instalowanie narzędzia pip dla interpretera PyPy w celu zainstalowania modułów zewnętrznych, takich jak IPython $ mkdir sources # tworzenie lokalnego katalogu pobierania $ cd sources # pobieranie dystrybucji i narzędzia pip $ curl -O http://python-distribute.org/distribute_setup.py $ curl -O https://raw.github.com/pypa/pip/master/contrib/get-pip.py # uruchamianie za pomocą polecenia pypy plików instalacyjnych dla pobranych plików $ ../bin/pypy ./distribute_setup.py ... $ ../bin/pypy get-pip.py ... $ ../bin/pip install ipython ... $ ../bin/ipython Python 2.7.3 (84efb3ba05f1, Feb 18 2014, 23:00:21) Type "copyright", "credits" or "license" for more information. IPython 2.0.0—An enhanced Interactive Python. ? -> Introduction and overview of IPython's features. %quickref -> Quick reference. help -> Python's own help system. object? -> Details about 'object', use 'object??' for extra details.
Zauważ, że interpreter PyPy nie obsługuje projektów takich jak narzędzie numpy w przydatny sposób (istnieje warstwa pomostowa udostępniana przez narzędzie cpyext (https://bitbucket. org/pypy/compatibility/wiki/c-api), ale jest ono zbyt wolne, aby mogło okazać się wartościowe w przypadku narzędzia numpy). Z tego powodu nie należy oczekiwać dużego wsparcia narzędzia numpy ze strony interpretera PyPy. Oferuje on eksperymentalny port narzędzia numpy o nazwie numpypy (instrukcje instalacji są dostępne na blogu jednego z autorów książki http:// ianozsvald.com/2014/01/14/installing-the-numpy-module-in-pypy/), który jednak nie zapewnia na razie żadnych godnych uwagi wzrostów szybkości1.[BS1] 1
Może się to zmienić do końca roku 2014 (więcej informacji pod adresem http://bit.ly/numpypy).
PyPy
159
Jeśli wymagasz innych pakietów, wszystko, co ma postać czystego kodu Python, prawdopodobnie zostanie zainstalowane, a to, co bazuje na bibliotekach rozszerzeń języka C, raczej nie będzie działać w przydatny sposób. Interpreter PyPy nie zawiera procesu czyszczącego pamięć ze zliczaniem odwołań. Wszystko, co zostanie skompilowane dla narzędzia CPython, będzie korzystać z wywołań biblioteki, które obsługują proces czyszczący pamięć narzędzia CPython. Interpreter PyPy zapewnia obejście tego problemu, ale wiąże się ono ze znacznym dodatkowym obciążeniem. W praktyce podejmowanie próby wymuszania współpracy starszych bibliotek rozszerzeń bezpośrednio z interpreterem PyPy nie ma żadnej wartości. W przypadku tego interpretera należy w miarę możliwości spróbować usunąć wszelki kod rozszerzenia C (taki kod może istnieć tylko w celu przyspieszenia kodu Python, za co obecnie ma być odpowiedzialny interpreter PyPy). Na stronie wiki dla interpretera PyPy utrzymywana jest lista zgodnych modułów (https://bitbucket.org/pypy/compatibility/wiki/Home). Inną wadą interpretera PyPy jest to, że może zużywać wiele pamięci RAM. W tym zakresie każda kolejna wersja interpretera jest lepsza, ale w praktyce może on używać więcej pamięci RAM niż narzędzie CPython. Ponieważ jednak pamięć RAM jest dosyć tania, sensowne jest podjęcie próby poświęcenia jej dla zwiększonej wydajności. Niektórzy użytkownicy zgłosili również mniejsze zużycie pamięci RAM podczas korzystania z interpretera PyPy. Jak zawsze, jeśli jest to ważna kwestia, przeprowadź eksperyment przy użyciu reprezentatywnych danych. Choć interpreter PyPy jest powiązany z blokadą GIL (Global Interpreter Lock), zespół programistów realizuje projekt o nazwie STM (Software Transactional Memory), który ma na celu podjęcie próby usunięcia wymogu stosowania blokady GIL. Projekt STM przypomina trochę transakcje bazy danych. Jest to mechanizm kontroli współbieżności stosowany dla operacji dostępu do pamięci. Mechanizm ten może wycofać zmiany, jeśli w tym samym obszarze pamięci wystąpią operacje powodujące konflikt. Celem integracji projektu STM jest umożliwienie systemom o wysokim stopniu współbieżności dysponowania pewną formą kontroli współbieżności. Będzie się to wiązać z utratą części efektywności w przypadku operacji, ale w zamian poprawi się wydajność programistów, którzy nie będą zmuszeni do zajmowania się wszystkimi aspektami kontroli jednoczesnego dostępu. Na potrzeby profilowania polecane narzędzia to jitviewer (https://bitbucket.org/pypy/jitviewer) i logparser (http://morepypy.blogspot.co.uk/2009/11/hi-all-this-week-i-worked-on-improving.html).
Kiedy stosować poszczególne technologie? Jeśli realizujesz projekt o charakterze numerycznym, użycie każdej z opisanych technologii może okazać się przydatne. W tabeli 7.1 podsumowano główne opcje. Tabela 7.1. Podsumowanie opcji kompilatorów
Dojrzałość
Cython
Shed Skin
Numba
Pythran
PyPy
T
T
-
-
T
Rozpowszechnienie
T
-
-
-
-
Obsługa narzędzia numpy
T
-
T
T
-
Zmiany w kodzie niepowodujące rozdzielania
-
T
T
T
T
Wymóg znajomości języka C
T
-
-
-
-
Obsługa interfejsu OpenMP
T
-
T
T
T
160
Rozdział 7. Kompilowanie do postaci kodu C
Jeśli problem mieści się w ograniczonym zakresie obsługiwanych funkcji, kompilator Pythran oferuje prawdopodobnie największe przyrosty szybkości w przypadku problemów rozwiązywanych za pomocą narzędzia numpy przy najmniejszym nakładzie pracy. Kompilator ten zapewnia też kilka prostych w użyciu opcji przetwarzania równoległego interfejsu OpenMP. Ponadto jest stosunkowo nowym projektem. Kompilator Numba może oferować szybkie przyrosty szybkości przy niewielkim nakładzie pracy, ale ma zbyt wiele ograniczeń, które mogą sprawić, że nie będzie dobrze działać dla napisanego kodu. Kompilator ten także jest stosunkowo nowym projektem. Kompilator Cython oferuje prawdopodobnie najlepsze wyniki w przypadku najszerszej grupy problemów, ale wymaga większego nakładu pracy, a ponadto ponieważ korzysta z kombinacji kodu Python i adnotacji kodu C, jego obsługa jest utrudniona. Kompilator PyPy stanowi mocną propozycję, jeśli ma zostać przeprowadzona kompilacja do kodu C, a ponadto nie jest używane narzędzie numpy ani żadna inna biblioteka zewnętrzna. Shed Skin może okazać się przydatny, gdy kod ma zostać skompilowany do postaci kodu C, a ponadto nie używasz narzędzia numpy lub innych bibliotek zewnętrznych. Jeśli wdrażasz narzędzie produkcyjne, prawdopodobnie pożądane będzie pozostanie przy dobrze znanych narzędziach. Kompilator Cython powinien być podstawową opcją wyboru. Możesz przeczytać treść podrozdziału „Technika głębokiego uczenia prezentowana przez firmę RadimRehurek.com” z rozdziału 12. W konfiguracjach produkcyjnych używa się też kompilatora PyPy (więcej informacji zawiera podrozdział „Interpreter PyPy zapewniający powodzenie systemów przetwarzania danych i systemów internetowych” z rozdziału 12.). Jeśli masz do czynienia z niewielkimi wymaganiami numerycznymi, zauważ, że interfejs buforu kompilatora Cython akceptuje macierze array.array. Jest to prosta metoda przekazywania bloku danych kompilatorowi Cython w celu przeprowadzenia szybkiego przetwarzania numerycznego bez konieczności dodawania narzędzia numpy jako zależności projektu. Generalnie rzecz biorąc, kompilatory Pythran i Numba to dość nowe projekty, ale bardzo obiecujące. Z kolei kompilator Cython jest bardzo dojrzały. Kompilator PyPy jest uważany za dość dojrzały i zdecydowanie powinien być wykorzystywany w przypadku długotrwałych procesów. Na zajęciach prowadzonych w 2014 r. przez jednego z autorów zdolny student zaimplementował wersję kodu C algorytmu zbioru Julii. Był rozczarowany, gdy stwierdził, że kod wykonywany był wolniej niż wersja skompilowana za pomocą kompilatora Cython. Okazało się, że student użył 32-bitowych liczb zmiennoprzecinkowych dla komputera 64-bitowego (takie liczby 32-bitowe są wolniej przetwarzane na komputerze 64-bitowym niż 64-bitowe liczby o podwójnej precyzji). Pomimo tego, że student był dobrym programistą używającym języka C, nie wiedział, że coś takiego mogło spowodować zmniejszenie szybkości. Po zmodyfikowaniu kodu okazało się, że wersja kodu bazująca na kompilatorze C, choć znacznie krótsza od wersji automatycznie wygenerowanej za pomocą kompilatora Cython, działała w przybliżeniu z tą samą szybkością. Pisanie czystego kodu C, porównywanie jego szybkości i określanie sposobu jego modyfikacji zajęło więcej czasu niż użycie od razu kompilatora Cython. Jest to jedynie anegdota. Nie sugerujemy, że kompilator Cython wygeneruje najlepszy kod. Kompetentni programiści tworzący kod w języku C mogą prawdopodobnie stwierdzić, jak sprawić, że ich kod będzie działać szybciej od wersji wygenerowanej przez kompilator Cython.
Kiedy stosować poszczególne technologie?
161
Godne uwagi jest jednak to, że nie będzie bezpieczne przyjęcie, że ręcznie napisany kod C będzie szybszy od przekształconego kodu Python. Zawsze konieczne jest wykonywanie testów porównawczych i podejmowanie decyzji na podstawie uzyskanego dowodu. Kompilatory języka C naprawdę dobrze radzą sobie z przekształcaniem kodu w dość wydajny kod maszynowy. Z kolei język Python sprawdza się naprawdę nieźle w roli języka pozwalającego na opisanie problemu w zrozumiały sposób. Z głową połącz ze sobą te dwie mocne strony.
Inne przyszłe projekty Na stronie kompilatorów PyData (http://compilers.pydata.org/) znajduje się lista kompilatorów i narzędzi o dużej wydajności. Theano (http://deeplearning.net/software/theano/) to język wysokiego poziomu, który umożliwia wyrażanie operatorów matematycznych w tablicach wielowymiarowych. Język ten jest silnie zintegrowany z narzędziem numpy, a ponadto może eksportować kod skompilowany dla procesorów i układów GPU. Co interesujące, okazał się przydatny członkom społeczności zajmującej się sztuczną inteligencją z wykorzystaniem techniki głębokiego uczenia. Kompilator Parakeet (http://www.parakeetpython.com/) koncentruje się na kompilowaniu operacji obejmujących tablice narzędzia numpy o dużej gęstości, które korzystają z podzbioru instrukcji języka Python. Obsługuje też układy GPU. PyViennaCL (http://viennacl.sourceforge.net/pyviennacl.html) to powiązanie języka Python z biblioteką algebry liniowej i obliczeń numerycznych ViennaCL. Obsługuje procesory i układy GPU z wykorzystaniem narzędzia numpy. Biblioteka ViennaCL napisana w języku C++ generuje kod dla interfejsów CUDA, OpenCL i OpenMP. Obsługuje ona operacje gęstej i rzadkiej algebry liniowej, bibliotekę BLAS i moduły rozwiązujące. Nuitka (http://nuitka.net/pages/overview.html) to kompilator kodu Python, który ma być alternatywą dla zwykłego interpretera CPython, oferując opcję tworzenia skompilowanych plików wykonywalnych. W pełni obsługuje język Python 2.7, choć w naszych testach nie zapewnił żadnych zauważalnych przyrostów szybkości w przypadku prostych testów numerycznych kodu Python. Pyston (https://github.com/dropbox/pyston) to najnowsza branżowa technologia. Korzysta z kompilatora LLVM i jest obsługiwana przez interfejs Dropbox. Z powodu braku obsługi modułów rozszerzenia kompilatora Pyston może dotyczyć ten sam problem co kompilatora PyPy, ale w ramach projektu planowane jest podjęcie próby rozwiązania go. W przeciwnym razie mało prawdopodobne jest, że obsługa narzędzia numpy będzie praktycznym rozwiązaniem. Społeczność programistów nie może raczej narzekać na niedobór opcji kompilacji. Choć wszystkie wymagają kompromisów, oferują też mnóstwo możliwości, dzięki czemu w złożonych projektach może być wykorzystana pełna moc procesorów i architektur wielordzeniowych.
Uwaga dotycząca układów GPU Układy graficzne GPU (Graphics Processing Unit) to obecnie modna technologia. Zdecydowaliśmy się jednak nie omawiać jej co najmniej do następnego wydania książki. Wynika to stąd, że w branży zachodzą szybkie zmiany, a ponadto całkiem prawdopodobne jest to, że wszystko, co zawarliśmy w tej książce, ulegnie zmianie, gdy będziesz w trakcie jej czytania. A na poważnie, nie chodzi o to, że zmiany mogą wymagać wiersze utworzonego kodu, ale o to, że wraz z rozwojem architektur konieczna może okazać się znacząca zmiana sposobu, w jaki będziesz rozwiązywać problemy. 162
Rozdział 7. Kompilowanie do postaci kodu C
Jeden z autorów zajmował się problemem z fizyki, korzystając przez rok z układu GPU NVIDIA GTX 480 oraz języka Python i środowiska PyCUDA. Po upływie roku została wykorzystana pełna moc układu GPU i system działał 25 razy szybciej niż ta sama funkcja na komputerze z procesorem 4-rdzeniowym. Wariant kodu dla tego procesora został napisany w języku C przy użyciu biblioteki przetwarzania równoległego. Z kolei wariant kodu dla układu GPU był tworzony głównie w języku C architektury CUDA opakowanym w środowisku PyCUDA w celu obsługi danych. Niedługo później pojawiły się w sprzedaży układy GPU z serii GTX 5xx. W efekcie zmianie uległo wiele optymalizacji dotyczących serii 4xx. Efekty prawie rocznej pracy ostatecznie zostały zaprzepaszczone na rzecz łatwiejszego do utrzymania rozwiązania w postaci kodu C, który był wykonywany z wykorzystaniem procesorów. Choć jest to pojedynczy przykład, zwraca uwagę na zagrożenie wynikające z tworzenia niskopoziomowego kodu dla architektury CUDA (lub OpenCL). Biblioteki bazujące na układach GPU i oferujące funkcje wyższego poziomu ze znacznie większym prawdopodobieństwem będą mogły nadawać się do ogólnego zastosowania (np. biblioteki, które zapewniają interfejsy do analizy obrazu lub transkodowania wideo). Zachęcamy do rozważenia tych kwestii przed sprawdzeniem opcji tworzenia kodu bezpośrednio dla układów GPU. Projekty, których celem jest automatyczne zarządzanie układami GPU, obejmują narzędzia Numba, Parakeet i Theano.
Oczekiwania dotyczące przyszłego projektu kompilatora Wśród obecnych opcji kompilatorów dostępnych jest kilka komponentów technologii o dużych możliwościach. Osobiście życzyłbym sobie uogólnienia mechanizmu tworzenia adnotacji kompilatora Shed Skin, aby mógł współpracować z innymi narzędziami (na przykład generując dane wyjściowe zgodne z kompilatorem Cython w celu wygładzenia krzywej uczenia podczas rozpoczynania korzystania z tego kompilatora, a zwłaszcza w przypadku używania narzędzia numpy). Kompilator Cython jest dojrzały i integruje się silnie z językiem Python i narzędziem numpy. Jeśli krzywa uczenia oraz wymagania dotyczące obsługi nie byłyby tak bardzo zniechęcające, więcej osób zastosowałoby ten kompilator. W dłuższej perspektywie czasowej życzeniem byłoby pojawienie się rozwiązania przypominającego kompilatory Numba i PyPy, które oferuje działanie w stylu kompilatora JIT zarówno w przypadku zwykłego kodu Python, jak i kodu narzędzia numpy. Obecnie nie zapewnia tego żadne narzędzie. Narzędzie rozwiązujące ten problem byłoby mocnym kandydatem do zastąpienia zwykłego interpretera CPython, który aktualnie jest używany przez wszystkich, bez konieczności modyfikowania kodu przez projektantów. Przyjazna rywalizacja i duży rynek otwarty na nowe pomysły sprawiają, że nasz ekosystem staje się naprawdę wartościowym miejscem.
Interfejsy funkcji zewnętrznych Czasem zautomatyzowane rozwiązania po prostu nie są odpowiednie, dlatego sam musisz napisać niestandardowy kod w języku C lub Fortran. Może to wynikać z tego, że metody kompilacji nie znajdują pewnych potencjalnych optymalizacji lub wymagane jest wykorzystanie funkcji bibliotek albo języka, które są niedostępne w języku Python. We wszystkich takich przypadkach niezbędne będzie zastosowanie interfejsów funkcji zewnętrznych, które zapewniają dostęp do kodu pisanego i kompilowanego przy użyciu innego języka. Interfejsy funkcji zewnętrznych
163
W pozostałej części rozdziału podejmiemy próbę użycia zewnętrznej biblioteki do rozwiązania równania dyfuzji dwuwymiarowej w taki sam sposób jak w rozdziale 62. Przykład 7.20 prezentuje kod tej biblioteki, który może reprezentować zainstalowaną bibliotekę lub być kodem utworzonym własnoręcznie. Metody, którym się przyjrzymy, znakomicie nadają się do pobrania niewielkich części kodu i przemieszczenia ich do innego języka w celu przeprowadzenia specjalistycznych optymalizacji bazujących na języku. Przykład 7.20. Przykładowy kod C służący do rozwiązywania problemu dyfuzji dwuwymiarowej void evolve(double in[][512], double out[][512], double D, double dt) { int i, j; double laplacian; for (i=1; i >>> >>> >>>
from countmemaybe import KMinValues kmv1 = KMinValues(k=1024) kmv2 = KMinValues(k=1024) for i in xrange(0,50000): # kmv1.add(str(i)) ...: >>> for i in xrange(25000, 75000): # kmv2.add(str(i)) ...: >>> print len(kmv1) 50416 >>> print len(kmv2) 52439 >>> print kmv1.cardinality_intersection(kmv2) 25900.2862992 >>> print kmv1.cardinality_union(kmv2) 75346.2874158
1 50 000 elementów umieszczanych jest w zbiorze kmv1. Zbiór kmv2 również uzyskuje 50 000 elementów, z których 25 000 jest takich samych jak w zbiorze kmv1.
294
Rozdział 11. Mniejsze wykorzystanie pamięci RAM
W przypadku tego rodzaju algorytmów wybór funkcji mieszania może mieć znaczny wpływ na jakość przybliżeń. W obu przedstawionych implementacjach użyto modułu mmh3, czyli implementacji języka Python modułu mumurhash3, który oferuje ciekawe właściwości na potrzeby mieszania łańcuchów. Możliwe jest jednak zastosowanie innych funkcji mieszania, jeśli okażą się bardziej wygodne dla konkretnego zbioru danych.
Filtry Blooma Czasem niezbędna jest możliwość wykonania innych typów operacji na zbiorach, które wymagają wprowadzenia nowych typów probabilistycznych struktur danych. Filtry Blooma5 zostały stworzone jako odpowiedź na pytanie dotyczące tego, czy dany element wcześniej wystąpił. Działanie filtrów Blooma polega na użyciu wielu wartości mieszania w celu reprezentowania wartości jako wielu liczb całkowitych. Jeśli później zostanie coś zauważone z tym samym zbiorem liczb całkowitych, słusznie można być pewnym tego, że jest to ta sama wartość. Aby to zrealizować w sposób pozwalający na efektywne wykorzystanie dostępnych zasobów, liczby całkowite są niejawnie kodowane jako indeksy listy. Można to potraktować jako listę wartości typu bool, które początkowo są ustawiane na wartość False. Jeśli zostanie zażądane dodanie obiektu z wartościami mieszania [10, 4, 7], wartość True zostanie ustawiona dla dziesiątego, czwartego i siódmego indeksu listy. Jeśli w przyszłości pojawi się pytanie dotyczące tego, czy wcześniej napotkano konkretny element, po prostu zostaną znalezione jego wartości mieszania, a ponadto zostanie sprawdzone, czy dla wszystkich odpowiednich miejsc na liście wartości typu bool ustawiono wartość True. Metoda ta nie powoduje żadnych fałszywie negatywnych wyników, a oprócz tego pozwala uzyskać możliwą do kontrolowania liczbę wyników fałszywie pozytywnych. Oznacza to tyle, że jeśli filtr Blooma informuje, że wcześniej nie napotkał danego elementu, można być całkowicie pewnym tego, że ten element wcześniej nie wystąpił. Z kolei jeśli filtr Blooma wskazuje, że dany element wystąpił wcześniej, istnieje prawdopodobieństwo tego, że w rzeczywistości jest inaczej i po prostu mamy do czynienia z błędnym rezultatem. Wynika to z faktu, że będą mieć miejsce kolizje wartości mieszania, a czasem wartości dla dwóch obiektów będą takie same, nawet jeśli same obiekty nie są identyczne. W praktyce jednak filtry Blooma są tak ustawiane, aby ich współczynniki błędu nie przekraczały 0,5%, a taki błąd może być do przyjęcia. Możliwe jest symulowanie użycia dowolnej żądanej liczby funkcji mieszania. W tym celu wystarczy zastosować dwie funkcje mieszania, które są niezależne od siebie. Metoda ta jest określana mianem „podwójnego mieszania”. Jeśli istnieje funkcja mieszania, która zapewnia dwie niezależne wartości mieszania, możliwe jest zastosowanie następującego kodu: def multi_hash(key, num_hashes): hash1, hash2 = hashfunction(key) for i in xrange(num_hashes): yield (hash1 + i * hash2) % (2^32 - 1)
Operacja modulo zapewnia, że wynikowe wartości mieszania są 32-bitowe (w przypadku 64-bitowych funkcji mieszania operacja modulo zostałaby wykonana z wykorzystaniem 2^64 - 1). 5
B. H. Bloom, dokument Space/time trade-offs in hash coding with allowable errors, „Communications of the ACM”, 13:7, 1970 r.: 422–426, doi:10.1145/362686.362692.
Probabilistyczne struktury danych
295
Dokładna wymagana długość listy wartości typu bool oraz liczba wartości mieszania przypadających na element będą ustalane na podstawie wielkości i żądanego współczynnika błędu. W przypadku dość prostych argumentów statystycznych6 stwierdzamy, że idealne wartości są następujące: liczba _ bitów wielkość
log(błąd ) log(2)2
liczba _ funkcji _ mieszania liczba _ bitów
log(2) wielkość
Oznacza to, że jeśli miałoby być przechowywanych 50 000 obiektów (niezależnie od tego, jak są duże) przy współczynniku fałszywie pozytywnych wyników wynoszącym 0,05% (co wskazuje, że stwierdzono, że przez 0,05% czasu wcześniej napotkano obiekt, choć w rzeczywistości tak nie było), wymagałoby to 791 015 bitów do przechowywania oraz 11 funkcji mieszania. Aby dodatkowo poprawić efektywność pod względem wykorzystania pamięci, możliwe jest użycie pojedynczych bitów do reprezentowania wartości typu bool (ten wbudowany typ zajmuje w rzeczywistości 4 bity). W łatwy sposób można to osiągnąć za pomocą modułu bitarray. Przykład 11.24 prezentuje prostą implementację filtru Blooma. Przykład 11.24. Prosta implementacja filtru Blooma import bitarray import math import mmh3 class BloomFilter(object): def __init__(self, capacity, error=0.005): """ Inicjowanie filtru Blooma dla danej wielkości i współczynnika fałszywie pozytywnych wyników """ self.capacity = capacity self.error = error self.num_bits = int(-capacity * math.log(error) / math.log(2)**2) + 1 self.num_hashes = int(self.num_bits * math.log(2) / float(capacity)) + 1 self.data = bitarray.bitarray(self.num_bits) def _indexes(self, key): h1, h2 = mmh3.hash64(key) for i in xrange(self.num_hashes): yield (h1 + i * h2) % self.num_bits def add(self, key): for index in self._indexes(key): self.data[index] = True def __contains__(self, key): return all(self.data[index] for index in self._indexes(key)) def __len__(self): num_bits_on = self.data.count(True) return -1.0 * self.num_bits * math.log(1.0 - num_bits_on / float(self.num_bits)) / float(self.num_hashes) @staticmethod def union(bloom_a, bloom_b): assert bloom_a.capacity == bloom_b.capacity, "Wielkości muszą być równe" assert bloom_a.error == bloom_b.error, "Współczynniki błędu muszą być równe" bloom_union = BloomFilter(bloom_a.capacity, bloom_a.error) bloom_union.data = bloom_a.data | bloom_b.data return bloom_union 6
Na stronie serwisu Wikipedia poświęconej filtrom Blooma (http://en.wikipedia.org/wiki/Bloom_filter) znajduje się bardzo prosty dowód dla ich właściwości.
296
Rozdział 11. Mniejsze wykorzystanie pamięci RAM
Co się stanie, gdy zostanie wstawionych więcej elementów, niż określono dla wielkości filtru Blooma? W przeciwnym wariancie wszystkie elementy listy typu bool zostaną ustawione na wartość True. W tym przypadku zaś stwierdzane jest, że napotkano każdy element. Oznacza to, że filtry Blooma są bardzo wrażliwe na to, jaką ustawiono dla nich wielkość początkową. Może to być dość irytujące, jeśli ma się do czynienia ze zbiorem danych o nieznanej wielkości (na przykład ze strumieniem danych). Jeden ze sposobów poradzenia sobie z tym problemem polega na użyciu wariantu filtrów Blooma nazywanego skalowalnymi filtrami Blooma7. Działanie takich filtrów polega na połączeniu ze sobą wielu filtrów Blooma, których współczynniki błędu różnią się w specyficzny sposób8. W ten sposób można zagwarantować ogólny współczynnik błędu i po prostu dodać nowy filtr Blooma, gdy wymagana będzie większa wielkość. Aby sprawdzić, czy wcześniej napotkano element, po prostu iterowane są wszystkie podfiltry Blooma do momentu znalezienia obiektu lub osiągnięcia końca listy. Przykład 11.25 prezentuje implementację takiej struktury, gdzie na potrzeby bazowej funkcjonalności używana jest poprzednia implementacja filtrów Blooma. Ponadto stosowany jest licznik w celu ułatwienia rozpoznania momentu, w którym ma zostać dodany nowy filtr Blooma. Przykład 11.25. Prosta implementacja skalowalnego filtru Blooma from bloomfilter import BloomFilter class ScalingBloomFilter(object): def __init__(self, capacity, error=0.005, max_fill=0.8, error_tightening_ratio=0.5): self.capacity = capacity self.base_error = error self.max_fill = max_fill self.items_until_scale = int(capacity * max_fill) self.error_tightening_ratio = error_tightening_ratio self.bloom_filters = [] self.current_bloom = None self._add_bloom() def _add_bloom(self): new_error = self.base_error * self.error_tightening_ratio ** len(self.bloom_filters) new_bloom = BloomFilter(self.capacity, new_error) self.bloom_filters.append(new_bloom) self.current_bloom = new_bloom return new_bloom def add(self, key): if key in self: return True self.current_bloom.add(key) self.items_until_scale -= 1 if self.items_until_scale == 0: bloom_size = len(self.current_bloom) bloom_max_capacity = int(self.current_bloom.capacity * self.max_fill) # Ponieważ do filtru Blooma mogło zostać dodanych wiele zduplikowanych wartości, # konieczne jest sprawdzenie, czy rzeczywiście niezbędne jest skalowanie lub czy nadal # dostępne jest miejsce if bloom_size >= bloom_max_capacity: self._add_bloom() 7
P. S. Almeida, C. Baquero, N. Preguiça i D. Hutchison, dokument Scalable Bloom Filters, „Information Processing Letters”, 101: 255–261, doi:10.1016/j.ipl.2006.10.007.
8
Wartości błędu zmniejszają się w rzeczywistości podobnie do szeregów geometrycznych. Dzięki temu po zastosowaniu iloczynu wszystkich współczynników błędu iloczyn zmierza do żądanego współczynnika błędu.
Probabilistyczne struktury danych
297
self.items_until_scale = bloom_max_capacity else: self.items_until_scale = int(bloom_max_capacity - bloom_size) return False def __contains__(self, key): return any(key in bloom for bloom in self.bloom_filters) def __len__(self): return sum(len(bloom) for bloom in self.bloom_filters)
Inny sposób poradzenia sobie z tym problemem sprowadza się do użycia metody określanej mianem czasowych filtrów Blooma. Ten wariant umożliwia unieważnianie elementów w strukturze danych, a tym samym zwalnianie dodatkowego miejsca dla większej liczby elementów. Jest to szczególnie przydatne przy przetwarzaniu strumieni, ponieważ elementy mogą zostać unieważnione na przykład po godzinie, a wielkość można ustawić na tyle dużą, aby została obsłużona ilość danych, jaka pojawiła się w ciągu godziny. Użycie filtru Blooma w ten sposób pozwoli zapewnić wygodny wgląd w to, co wydarzyło się przez ostatnią godzinę. Zastosowanie tej struktury danych będzie bardziej przypominać użycie obiektu set. W przedstawionej poniżej interakcji użyto skalowalnego filtru Blooma do dodania kilku obiektów, sprawdzenia, czy zostały wcześniej napotkane, a następnie podjęcia próby eksperymentalnego znalezienia współczynnika fałszywie pozytywnych wyników: >>> bloom = BloomFilter(100) >>> for i in xrange(50): ....: bloom.add(str(i)) ....: >>> "20" in bloom True >>> "25" in bloom True >>> "51" in bloom False >>> num_false_positives = 0 >>> num_true_negatives = 0 >>> # Żadna z poniższych liczb nie powinna być w filtrze Blooma >>> # Jeśli taka liczba zostanie znaleziona w filtrze Blooma, będzie to fałszywie pozytywny wynik >>> for i in xrange(51,10000): ....: if str(i) in bloom: ....: num_false_positives += 1 ....: else: ....: num_true_negatives += 1 ....: >>> num_false_positives 54 >>> num_true_negatives 9895 >>> false_positive_rate = num_false_positives / float(10000 - 51) >>> false_positive_rate 0.005427681173987335 >>> bloom.error 0.005
W przypadku filtrów Blooma możliwe jest też stosowanie sum zbiorów do łączenia wielu zbiorów elementów: >>> bloom_a = BloomFilter(200) >>> bloom_b = BloomFilter(200) >>> for i in xrange(50): ...: bloom_a.add(str(i)) ...: >>> for i in xrange(25,75):
298
Rozdział 11. Mniejsze wykorzystanie pamięci RAM
...: bloom_b.add(str(i)) ...: >>> bloom = BloomFilter.union(bloom_a, bloom_b) >>> "51" in bloom_a # Out[9]: False >>> "24" in bloom_b # Out[10]: False >>> "55" in bloom # Out[11]: True >>> "25" in bloom Out[12]: True
Wartość '51' nie znajduje się w filtrze bloom_a. Podobnie wartość '24' nie znajduje się w filtrze bloom_b. Jednakże obiekt bloom zawiera wszystkie obiekty w filtrach bloom_a i bloom_b! Związane jest z tym takie zastrzeżenie, że suma dwóch filtrów Blooma możliwa jest tylko dla filtrów z tą samą wielkością i współczynnikiem błędu. Co więcej, wielkość końcowego filtru Blooma może być nawet równa sumie wielkości dwóch filtrów Blooma połączonych w celu utworzenia tego filtru. Oznacza to tyle, że możliwe jest rozpoczęcie od dwóch filtrów Blooma, która są wypełnione trochę więcej niż w połowie, a po ich zsumowaniu zostanie uzyskany nowy filtr Blooma, który ma nadmierną wielkość i nie jest wiarygodny!
Licznik LogLog Liczniki typu LogLog (http://algo.inria.fr/flajolet/Publications/DuFl03-LNCS.pdf) bazują na tym, że poszczególne bity funkcji mieszania mogą być też rozpatrywane jako losowe. Oznacza to, że prawdopodobieństwo, iż pierwszy bit wartości mieszania to 1, pierwsze dwa bity to 01, a pierwsze trzy bity to 001, wynosi odpowiednio 50%, 25% i 12,5%. Znając te wartości, a ponadto zachowując wartość mieszania z największą liczbą zer na początku (czyli najmniej prawdopodobną wartość mieszania), można określić przybliżenie dotyczące liczby napotkanych do tej pory elementów. Dobrą analogią do tej metody jest podrzucanie monet. Wyobraźmy sobie, że chcielibyśmy podrzucić monetę 32 razy i za każdym razem uzyskać orzełka. Liczba 32 wynika z tego, że używamy 32-bitowych funkcji mieszania. Jeśli podrzucimy monetę raz i wypadnie reszka, zostanie zapisana liczba 0, ponieważ w najlepszej próbie nie uzyskano żadnego orzełka. Jako że znane są nam wartości prawdopodobieństwa związane z podrzucaniem monety, a ponadto możliwe jest stwierdzenie, że najdłuższa seria nie zakończyła się wyrzuceniem żadnego orzełka (0), możesz oszacować, że próba przeprowadzenia tego eksperymentu została podjęta jeden raz (2^0 = 1). Jeśli w dalszym ciągu moneta będzie podrzucana i uda się uzyskać 10 orzełków przed wyrzuceniem reszki, zostanie zapisana liczba 10. Stosując taką samą logikę, możesz oszacować, że próba wykonania tego eksperymentu została podjęta 1024 razy (2^10 = 1024). W przypadku takiego systemu największą możliwą do obliczenia liczbą byłaby maksymalna liczba podrzuceń monety (dla 32 podrzuceń jest to liczba 2^32 = 4 294 967 296). Aby zakodować tę logikę przy użyciu liczników typu LogLog, dla binarnej reprezentacji wartości mieszania danych wejściowych ustalane jest, ile zer występuje przed pierwszą jedynką. Wartość mieszania może być traktowana jako seria 32 podrzuceń monety, gdzie 0 oznacza wyrzucenie orzełka, a 1 wyrzucenie reszki (np. wartość 000010101101 oznacza wyrzucenie czterech orzełków przed uzyskaniem pierwszej reszki, a wartość 010101101 wskazuje na wyrzucenie
Probabilistyczne struktury danych
299
jednego orzełka przed uzyskaniem pierwszej reszki). Pozwala to zorientować się, ile prób miało miejsce przed otrzymaniem takiej wartości mieszania. Obliczenia matematyczne związane z tym systemem są prawie takie same jak w przypadku licznika Morrisa z jednym zasadniczym wyjątkiem: zamiast używania generatora liczb losowych wartości „losowe” są uzyskiwane przez sprawdzenie rzeczywistych danych wejściowych. Oznacza to, że jeśli nadal będzie dodawana identyczna wartość do licznika LogLog, jego stan wewnętrzny nie zmieni się. Przykład 11.26 prezentuje prostą implementację licznika LogLog. Przykład 11.26. Prosta implementacja licznika LogLog import mmh3 def trailing_zeros(number): """ Zwraca indeks pierwszego bitu ustawionego na 1, począwszy od prawej strony 32-bitowej liczby całkowitej >>> trailing_zeros(0) 32 >>> trailing_zeros(0b1000) 3 >>> trailing_zeros(0b10000000) 7 """ if not number: return 32 index = 0 while (number >> index) & 1 == 0: index += 1 return index class LogLogRegister(object): counter = 0 def add(self, item): item_hash = mmh3.hash(str(item)) return self._add(item_hash) def _add(self, item_hash): bit_index = trailing_zeros(item_hash) if bit_index > self.counter: self.counter = bit_index def __len__(self): return 2**self.counter
Największą wadą tej metody jest to, że możliwe jest uzyskanie wartości mieszania, która zwiększa licznik od samego początku, powodując wypaczanie oszacowań. Przypominałoby to wyrzucenie 32 reszek w pierwszej próbie. Aby temu zaradzić, w tym samym czasie wiele osób musiałoby wyrzucić monety i połączyć uzyskane dla nich wyniki. Prawo dotyczące dużych liczb głosi, że wraz z dodawaniem kolejnych osób rzucających monetą łączne statystyki w mniejszym stopniu stają się zależne od nietypowych prób przeprowadzonych przez poszczególne osoby. Konkretny sposób łączenia wyników stanowi podstawę różnic między metodami typu LogLog (klasyczny LogLog, SuperLogLog, HyperLogLog, HyperLogLog++ itd.). W celu uzyskania takiej metody z „wieloma osobami rzucającymi monetą” należy użyć kilku pierwszych bitów wartości mieszania do określenia, które z tych osób uzyskały konkretny wynik. Jeśli wykorzystano pierwsze cztery bity wartości mieszania, oznacza to, że zaangażowano 16 osób rzucających monetą (2^4 = 16). Ponieważ w tym przypadku użyto pierwszych czterech bitów, pozostało jedynie 28 bitów (odpowiadających 28 rzutom monetą przez jedną osobę). Oznacza to, że każdy licznik może prowadzić obliczenia jedynie do liczby 2^28 = 268 435 456. Ponadto istnieje stała (alfa), która zależy od liczby zaangażowanych osób.
300
Rozdział 11. Mniejsze wykorzystanie pamięci RAM
Powoduje to normalizowanie oszacowania9. Wszystko to zapewnia algorytm o dokładności
1,05 m
, gdzie m oznacza liczbę zaangażowanych rejestrów (lub osób rzucających monetą).
Przykład 11.27 prezentuje prostą implementację algorytmu LogLog. Przykład 11.27. Prosta implementacja algorytmu LogLog from llregister import LLRegister import mmh3 class LL(object): def __init__(self, p): self.p = p self.num_registers = 2**p self.registers = [LLRegister() for i in xrange(int(2**p))] self.alpha = 0.7213 / (1.0 + 1.079 / self.num_registers) def add(self, item): item_hash = mmh3.hash(str(item)) register_index = item_hash & (self.num_registers - 1) register_hash = item_hash >> self.p self.registers[register_index]._add(register_hash) def __len__(self): register_sum = sum(h.counter for h in self.registers) return self.num_registers * self.alpha * 2 ** (float(register_sum) / self.num_registers)
Oprócz tego, że algorytm ten deduplikuje podobne elementy, używając wartości mieszania jako indykatora, oferuje on możliwy do modyfikacji parametr, który może posłużyć do określania kompromisu między dokładnością i miejscem do przechowywania. W metodzie __len__ uśredniane są oszacowania uzyskane dla wszystkich poszczególnych rejestrów w przypadku algorytmu LogLog. Nie jest to jednak najbardziej efektywny sposób łączenia danych! Wynika to stąd, że można uzyskać niefortunne wartości mieszania, które sprawią, że określony rejestr osiągnie maksymalne wartości, inne natomiast nadal będą mieć niewielkie wartości. Z tego powodu możliwe jest jedynie osiągnięcie współczynnika błędu O(
1,30 m
), gdzie m to liczba używanych rejestrów.
Jako rozwiązanie tego problemu opracowano algorytm SuperLogLog10. W jego przypadku do szacowania wielkości używanych jest tylko 70% najmniejszych rejestrów. Ich wartość jest ograniczona przez maksymalną wartość określoną przez regułę ograniczania. Dzięki temu współczynnik błędu został zmniejszony do wartości O(
1,05 m
). Jest to sprzeczne z intuicją, po-
nieważ lepsze oszacowanie zostało uzyskane przez zignorowanie informacji!
9
Pełny opis podstawowych algorytmów LogLog i SuperLogLog dostępny jest pod adresem http://algo.inria.fr/ flajolet/Publications/DuFl03.pdf.
10
M. Durand i P. Flajolet, dokument LogLog Counting of Large Cardinalities, „Proceedings of ESA 2003”, 2832 (2003 r.): 605–617, doi:10.1007/978-3-540-39658-1_55.
Probabilistyczne struktury danych
301
W 2007 r. pojawił się algorytm HyperLogLog11, zapewniający dalszą poprawę dokładności. Zostało to osiągnięte przez zmianę metody uśredniania poszczególnych rejestrów: zamiast zwykłego uśredniania użyto schematu uśredniania sferycznego, który w specjalny sposób uwzględnia także różne przypadki brzegowe, jakie mogą dotyczyć struktury. Dzięki temu uzyskano najlepszy obecnie współczynnik błędu wynoszący O(
1,04 m
). Ponadto takie sfor-
mułowanie wyeliminowało operację sortowania niezbędną w przypadku algorytmu SuperLogLog. Może to znacznie zwiększyć wydajność struktury danych, gdy podejmowana jest próba wstawiania elementów przy ich dużej liczbie. Przykład 11.28 prezentuje prostą implementację algorytmu HyperLogLog. Przykład 11.28. Prosta implementacja algorytmu HyperLogLog from ll import LL import math class HyperLogLog(LL): def __len__(self): indicator = sum(2**-m.counter for m in self.registers) E = self.alpha * (self.num_registers**2) / float(indicator) if E , 61 synchroniczny interpreter, 259 synchronizacja, 198, 219 zapisów, 246 danych, 222 dostępu, 242 system Gearman, 269 komputerowy, 15 NSQ, 262–267 przetwarzający zadania, 269 przetwarzania danych, 322 Redis, 224, 229 SaltStack, 309 SoMA, 309 SQS, 269 wdrażania Chef, 256 Fabric, 256 Puppet, 256 Salt, 256 szereg nieskończony, 96 szybkości połączeń interfejsów, 23 szybkość zegara, 17
Ś średnia online, 98 środowisko Canopy, 28 EPD, 28 Sage, 28 systemu NSQ, 267
T tabela mieszająca, 82, 84 usuwanie wartości, 85 zmiana wielkości, 85 tablica, 69 tablica locals(), 89 tablice dynamiczne, 73, 74 statyczne, 73, 77 współużytkowane, 238 technika głębokiego uczenia, 310 testowanie szybkości, 62 testy jednostkowe, 64–67 tłumaczenie, 324 tokeny, 280, 281 topologia połączeń, 265 systemu NSQ, 264 TTL, Time to Live, 321 twierdzenie Pitagorasa, 201 tworzenie adnotacji, 145 dwóch kolejek, 218 funkcji obrotu, 125 hipotezy, 42 lokalnego profilu, 261 modułu rozszerzenia, 148 norm wektora, 117 operacji wektorowych, 169 procesu szeregowego, 196 systemu klastrowego, 254 tabeli mieszającej, 82 tablicy, 70 typ array, 116 double complex, 144 Future, 178 int, 144 struct, 166, 168 unsigned int, 144 typy podstawowe, 274
U uczenie maszynowe, 315 układy graficzne GPU, 162 uruchamianie interpretera PyPy, 159 modułu timeit, 39 narzędzia dowser, 59 serwera WWW, 59 skryptu, 140, 283
Skorowidz
337
urządzenie typu SS, 19 usługa AWS, 249 EC2, 254 Skype, 253 usuwanie elementu, 85 utrata danych, 256 użycie adnotacji, 170 adnotacji kompilatora, 141 dekoratora, 38 dekoratora @profile, 64 drzew trie, 287 dwóch kolejek, 217, 219 filtru Blooma, 298 filtru laplace, 130 flagi przenośności, 40 funkcji %timeit, 62 funkcji set, 282 funkcji wbudowanych, 62 generatorów, 97 grafu DAWG, 284 iloczynu skalarnego, 118 interaktywnego debugera, 60 kodu Fortran, 169 kompilatora kodu C, 137 menedżera kontekstów, 55 menedżera kontekstu, 245 modułu cProfile, 41 modułu ctypes, 164 modułu dis, 60, 63 modułu IPython Parallel, 259 modułu lockfile, 244, 245 modułu mmap, 232, 233 modułu numexpr, 127 modułu Parallel Python, 257 narzędzia cffi, 167 narzędzia dowser, 58 narzędzia heapy, 57 narzędzia line_profiler, 46 narzędzia memory_profiler, 51 narzędzia numpy, 119, 209 narzędzia runsnake, 46 obiektu Lock, 246 obiektu Manager.Value, 228 obiektu RawValue, 232 procesów, 210 profilowania, 29 serwera Redis, 230 skryptu kernprof.py, 48 słownika, 80 struktury datrie, 286 systemu NSQ, 262
338
Skorowidz
systemu Redis, 229 wątku, 220 wykonywania szeregowego, 202
W warstwy komunikacji, 21 wartości k-minimum, 291 wartościowanie leniwe generatora, 97 warunki brzegowe, 106 wąskie gardło, 29 wątki, 197, 202, 206–208 wdrażanie, 320 wdrażanie aktualizacji, 256 wektory, 103 wektoryzacja, 17, 119 weryfikowanie liczb pierwszych, 221 optymalizacji, 129 wyniku, 238 wielowątkowość współbieżna, 18 wiersz poleceń, 39 wirtualny procesor, 18 wizualizacja danych wyjściowych, 46 pliku profilowania, 47 w czasie rzeczywistym, 67 właściwość length, 95 next(), 98 wskaźnik, 112 wskaźnik Major page faults, 41 współbieżne procesy, 243 współbieżność, 175 współczynnik błędu, 301 współprogramy, coroutine, 178 współprogramy greenlet, 181 współrzędne zespolone, 35 współużytkowanie danych, 236, 260 tablicy, 239, 240 zadań, 197 wstawianie, 82 wstawianie z kolizjami, 84 wybór struktury danych, 73 wydajność algorytmu, 72 instrukcji while, 145 kolejki zadań, 326 wyszukiwań, 87 wyjątek NameError, 64 wykonywanie szeregowe, 209 wykorzystanie pamięci, 237, 272, 276, 288
wykres zbioru Julii, 32, 37 wykrywanie nieprawidłowości, 99 wyłączanie wektoryzacji, 121 wyodrębnianie danych sieciowych, 179, 183–185, 188 wyszukiwanie, 80 binarne, 72 efektywne, 71 liniowe, 80 liniowe listy, 71 powolne, 91 w przestrzeniach nazw, 90 w słowniku, 83, 87 wyświetlanie liczby instrukcji, 63 wywołania zwrotne, 177, 186 wywoływanie narzędzia dowser, 59 wzrosty szybkości, 134
Z zastosowanie, Patrz użycie zbiory, 79 zbiór Julii, 31, 34, 138, 152 zespolony warunek początkowy, 35 zestawienie przyspieszeń, 132 zewnętrzny system kolejek, 221 zintegrowane środowisko programistyczne, IDE, 28 złożoność, 62, 72, 80
zmiana funkcji, 56 szybkości zegara, 17 wielkości listy, 76 zmienne globalne, 108 zmniejszanie liczby alokacji pamięci, 110–113 liczby przydziałów pamięci, 123 znajdowanie elementu, 85 liczb pierwszych, 211, 212 nieprawidłowości, 101 unikalnych imion, 81 wartości, 72 zrównoważone obciążenie, 259 zwiększanie wydajności, 145
Ż żądania współbieżne, 182 żądany współczynnik błędu, 296
Skorowidz
339
340
Skorowidz
O autorach Micha Gorelick był pierwszym człowiekiem na Marsie w roku 2023, a ponadto zdobył Nagrodę Nobla w roku 2046 za wkład w podróże w czasie. W chwili gniewu po ujrzeniu godnych ubolewania zastosowań jego nowej technologii Micha cofnął się w czasie do roku 2012 i przekonał samego siebie do wycofania się z programu mającego na celu uzyskanie tytułu doktora fizyki oraz do poświęcenia się swoim ukochanym danym. Najpierw wykorzystał swoją wiedzę z dziedziny obliczeń w czasie rzeczywistym i badania danych w przypadku zbioru danych z serwisu bitly. Później, po uświadomieniu sobie, że chciał ułatwić ludziom zrozumienie technologii z przyszłości, pomógł w rozpoczęciu działalności firmy Fast Forward Labs jako mieszkaniec i naukowiec. W firmie tej zajmował się wieloma problemami, począwszy od uczenia maszynowego, a skończywszy na wydajnych algorytmach strumieni. W tym okresie jego życia można go było spotkać w ramach różnych projektów podczas konsultowania kwestii związanych z analizą danych o dużej wydajności. Pomnik z roku 1857 upamiętniający jego dokonania można zobaczyć w nowojorskim Central Parku. Ian Ozsvald jest naukowcem zajmującym się danymi i nauczycielem języka Python w firmie ModelInsight.io (http://modelinsight.io/) z ponaddziesięcioletnim doświadczeniem związanym z tym językiem. Prowadził wykłady na konferencjach PyCon i PyData, od ponad dekady oferuje także w Wielkiej Brytanii usługi konsultingowe związane ze sztuczną inteligencją i obliczeniami o dużej wydajności. Ian prowadzi blog IanOzsvald.com (http://ianozsvald.com/) i zawsze ucieszy się z dużego, dobrego piwa. Doświadczenie Iana obejmuje języki Python i C++, projektowanie aplikacji dla systemów Linux i Windows, systemy przechowywania danych, wiele procesów przetwarzania tekstu i przetwarzania z wykorzystaniem języka naturalnego, uczenie maszynowe i wizualizację danych. Ponadto wiele lat temu Ian był współtwórcą witryny internetowej ShowMeDo.com (http://showmedo.com/), która oferuje materiały edukacyjne w postaci filmów wideo dotyczące głównie języka Python.
Kolofon Zwierzę widoczne na okładce książki to żararaka (fr. fer-de-lance). Tą francuską nazwą, która w dosłownym tłumaczeniu oznacza „żelazo włóczni”, niektórzy określają gatunek węży (Bothrops lanceolatus) występujących głównie na Martynice. Nazwa ta może też być używana w odniesieniu do konkretnych gatunków żararaki, takich jak żararaka z wyspy Saint Lucia (Bothrops caribbaeus), żararaka lancetowata (Bothrops atrox) i terciopelo (Bothrops asper). Wszystkie te gatunki są jadowitymi grzechotnikami. Wyróżniają się tym, że między oczami i nozdrzami mają jamki policzkowe wyposażone w receptory ciepła. Węże terciopelo i żararaka lancetowata są odpowiedzialne za dużą liczbę śmiertelnych ukąszeń przez węże z rodziny Bothrops. Węże z tej rodziny spowodowały więcej przypadków zgonu w obu Amerykach niż jakakolwiek inna rodzina węży. Robotnicy na plantacjach kawy i bananów w Ameryce Południowej boją się ukąszeń żararak lancetowatych, które liczą na upolowanie gryzoni. Rzekomo bardziej agresywny wąż terciopelo jest równie niebezpieczny, gdy nie spędza czasu w odosobnieniu, wygrzewając się w słońcu na brzegu rzek i strumieni Ameryki Środkowej. Wiele zwierząt przedstawionych na okładkach książek wydawnictwa O’Reilly jest zagrożonych wyginięciem. Wszystkie z nich są ważne dla świata. Aby dowiedzieć się więcej o tym, jak można im pomóc, wejdź na stronę internetową pod adresem http://animals.oreilly.com/. Ilustracja na okładce pochodzi z dzieła Animate Creation autorstwa Wooda.