Sledování využití paměti Pythonovských aplikací nástrojem Memory profiler

16. 5. 2023
Doba čtení: 27 minut

Sdílet

 Autor: Python
Ukážeme si, jak lze využít nástroj nazvaný Memory profiler pro sledování využití paměti aplikacemi, které jsou naprogramovány v jazyku Python. Tento nástroj dokáže sledovat i synovské procesy vytvořené v měřené aplikaci.

Obsah

1. Sledování využití paměti Pythonovských aplikací nástrojem Memory profiler

2. Instalace nástroje Memory profiler

3. Sledované demonstrační aplikace

4. Spuštění aplikace se sledováním práce s pamětí

5. Vyhodnocení a vizualizace obsazení paměti sledovanou aplikací

6. Analýza grafu

7. Sledování a analýza druhé i třetí aplikace

8. Analýza grafu

9. Získání informací o alokované paměti na úrovni programových řádků

10. Výsledky analýzy

11. Úprava aplikace přidávající a ubírající prvky ze seznamu

12. Výsledky analýzy

13. Měření spotřeby paměti u složitější aplikace

14. Výsledky měření

15. Spotřeba paměti u aplikací běžících ve větším množství procesů

16. Ukázka jednoduché aplikace, která spouští více souběžně běžících procesů

17. Výsledky analýzy: celková spotřeba operační paměti

18. Výsledky analýzy: spotřeba rozdělená podle jednotlivých procesů

19. Repositář s demonstračními příklady

20. Odkazy na Internetu

1. Sledování využití paměti Pythonovských aplikací nástrojem Memory profiler

V dnešním článku si ukážeme, jakým způsobem lze využít nástroj nazvaný Memory profiler pro sledování využití paměti aplikacemi, které jsou naprogramovány v jazyku Python. Navážeme tak na předchozí článek nazvaný Detekce velikosti hodnot uložených v operační paměti a spravovaných interpretrem Pythonu, který byl zaměřen na nástroje (resp. přesněji řečeno balíčky) Pympler a taktéž Guppy.

Připomeňme si, že jak Pympler, tak i Guppy dokážou poměrně přesně zjistit, jaké hodnoty (objekty) a s jakou velikostí jsou v daný okamžik používány interpretrem Pythonu. Dnes popisovaný nástroj Memory profiler je poněkud odlišný, protože navíc umožňuje, aby se informace o obsazené paměti získávala kontinuálně po celou dobu běhu aplikace, s následnou vizualizací obsazení paměti ve formě jednoduchých grafů. Navíc je možné do jednoho grafu vykreslit několik průběhů získaných během několika běhů sledované aplikace (například s nastavením různých parametrů, s přepnutím algoritmů atd.). Navíc je možné získat informace o spotřebě paměti s velmi dobrou přesností (resp. granularitou) – až na úrovni jednotlivých programových řádků (můžeme tedy velmi snadno detekovat ty programové řádky, v nichž se alokují velké paměťové bloky – což budou typicky operace s objemnými objekty).

2. Instalace nástroje Memory profiler

Díky tomu, že je balíček s nástrojem Memory profiler zaregistrovaný na PyPi, je instalace tohoto balíčku triviální a můžeme ji provést (pro právě přihlášeného uživatele) následujícím příkazem:

$ pip3 install --user memory_profiler

Samotná instalace proběhne prakticky okamžitě:

Collecting memory_profiler
  Downloading memory_profiler-0.61.0-py3-none-any.whl (31 kB)
Requirement already satisfied: psutil in /usr/lib/python3/dist-packages (from memory_profiler) (5.5.1)
Installing collected packages: memory-profiler
Successfully installed memory-profiler-0.61.0
Poznámka: samozřejmě je možné instalaci provést i do virtuálního prostředí Pythonu, pokud je to zapotřebí.

Součástí instalace nástroje Memory Profiler by měl být i spustitelný soubor nazvaný mprof, takže si otestujme, zda existuje a je skutečně spustitelný:

$ whereis mprof
 
mprof: /home/ptisnovs/.local/bin/mprof

Pokud byl soubor nalezen, můžeme ho spustit a vypsat si nápovědu:

$ mprof
 
Usage: mprof <command> <options> <arguments>
 
Available commands:
 
    run      run a given command or python file
    attach   alias for 'run --attach': attach to an existing process by pid or name
    rm       remove a given file generated by mprof
    clean    clean the current directory from files created by mprof
    list     display existing profiles, with indices
    plot     plot memory consumption generated by mprof run
    peak     print the maximum memory used by an mprof run
 
Type mprof <command> --help for usage help on a specific command.
For example, mprof plot --help will list all plotting options.
Poznámka: aby bylo možné mprof spustit i bez nutnosti uvedení cesty k němu, musí být ./local/bin přidáno do proměnné prostředí PATH. I tuto skutečnost si můžeme velmi snadno ověřit:
$ echo $PATH
 
/home/ptisnovs/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin:/opt/go/bin/:/home/ptisnovs/go/bin/:/home/ptisnovs/.local/bin

3. Sledované demonstrační aplikace

V navazujících kapitolách si základní možnosti nabízené nástrojem Memory profiler ukážeme na několika demonstračních „aplikacích“, které v průběhu své činnosti provádí alokaci paměti, popř. (i když v Pythonu nepřímo) její dealokaci. Mezi jednotlivé operace je vždy vložen příkaz sleep(2), což nám umožní lépe sledovat vliv jednotlivých příkazů na spotřebu operační paměti na grafech, které Memory profiler dokáže vykreslit.

První aplikace nejdříve v programové smyčce vytvoří delší řetězec, na který (po uplynutí dvou sekund) ztratí referenci, protože se do původní proměnné vloží odlišná hodnota (připomeňme si, že v proměnných jsou uloženy pouze reference na hodnoty, nikoli samotné hodnoty). Následně se provede alokace pole bajtů (bytearray) a po uplynutí dvou sekund ztratíme referenci i na toto pole. Úplný zdrojový kód této aplikace lze nalézt na adrese https://github.com/tisnik/most-popular-python-libs/blob/master/memory_pro­filer/app1.py:

from time import sleep
 
sleep(2)
 
x = "*foo*"
 
sleep(2)
 
y = ""
for i in range(200000):
    y += x
 
print(len(y))
 
sleep(2)
 
x = 0
y = 0
 
sleep(2)
 
x = bytearray(1000000)
 
sleep(2)
 
x = 0
 
sleep(2)

Ve druhé demonstrační aplikaci se taktéž provádí konstrukce řetězce (z kratších částí) i konstrukce pole bajtů, ovšem tentokrát lokálně, v rámci funkcí. To znamená, že reference na řetězec, resp. na pole bajtů existuje pouze pro lokální proměnnou viditelnou uvnitř funkce. V praxi to znamená, že po opuštění funkce je reference ztracena a daná hodnota (objekt) může být z paměti odstraněna. Úplný zdrojový kód této aplikace lze nalézt na adrese https://github.com/tisnik/most-popular-python-libs/blob/master/memory_pro­filer/app2.py:

from time import sleep
 
 
def foo_construct():
    x = "*foo*"
 
    sleep(2)
 
    y = ""
    for i in range(20000000):
        y += x
 
    print(len(y))
 
 
def bar_construct():
    x = bytearray(100000000)
    print(len(x))
    sleep(2)
 
 
sleep(2)
foo_construct()
sleep(2)
bar_construct()
sleep(2)

Ve třetí demonstrační aplikaci se v rámci funkce (tedy opět lokálně) vytvoří seznam, do kterého se postupně přidávají nové prvky. Posléze začnou být prvky ze seznamu odstraňovány příkazem del. Úplný zdrojový kód této aplikace lze nalézt na adrese https://github.com/tisnik/most-popular-python-libs/blob/master/memory_pro­filer/app3.py:

from time import sleep
 
 
def foo_construct():
    l = []
    for i in range(10000000):
        l.append(i)
        if i % 100000 == 0:
            sleep(0.05)
 
    for i in range(10000000, 0, -1):
        del l[i-1]
        if i % 100000 == 0:
            sleep(0.05)
 
    sleep(2)
 
 
sleep(2)
foo_construct()
sleep(2)

4. Spuštění aplikace se sledováním práce s pamětí

Nástroj Memory profiler je možné využít několika různými způsoby. Ten nejjednodušší způsob spočívá v tom, že se sledovaná aplikace nespustí přímo (tedy přesněji řečeno se nespustí její „vstupní“ Pythonovský skript), ale je spuštěna přes mprof. Memory profiler v takovém případě začne sledovat paměť, kterou virtuální stroj Pythonu alokuje a tuto informaci bude postupně ukládat do automaticky vytvořeného textového souboru:

$ mprof run app1.py
 
mprof: Sampling memory every 0.1s
running new process
running as a Python program...
1000000
Poznámka: povšimněte si, že časová granularita sledování je (pro většinu po delší dobu běžících aplikací) velmi dobrá – velikost obsazené paměti se získá a zapamatuje každých 100 milisekund.

Po doběhnutí aplikace by se měl v adresáři, z něhož bylo sledování spuštěno, objevit soubor, jehož název odpovídá globu „mprofile_*.dat“, kde se za hvězdičku doplňuje časové razítko spuštění aplikace ve formátu YYYYMMDDhhmmss. To v mém konkrétním případě znamená, že soubor s názvem mprofile_20230512085526 byl získán při sledování aplikace, která byla spuštěna 12.5.2023 v 8:55:26. Jedná se o textový soubor, který obsahuje informace o alokované paměti, přičemž každý řádek byl do souboru přidán přibližně každých 100 milisekund.

Tento soubor si pochopitelně můžeme prohlédnout, ovšem nebudeme ho muset zpracovávat přímo, protože pro zpracování nám nástroj Memory profiler poskytuje několik technik:

$ head mprofile_20230512085526.dat
 
CMDLINE /usr/bin/python3 app1.py
MEM 3.687500 1683874526.4900
MEM 18.460938 1683874526.5908
MEM 18.460938 1683874526.6918
MEM 18.460938 1683874526.7928
MEM 18.460938 1683874526.8935
MEM 18.460938 1683874526.9946
MEM 18.460938 1683874527.0954
MEM 18.460938 1683874527.1963
MEM 18.460938 1683874527.2973
...
...
...
$ tail mprofile_20230512085526.dat
MEM 19.316406 1683874537.5908
MEM 19.316406 1683874537.6915
MEM 19.316406 1683874537.7925
MEM 19.316406 1683874537.8932
MEM 19.316406 1683874537.9942
MEM 19.316406 1683874538.0951
MEM 19.316406 1683874538.1961
MEM 19.316406 1683874538.2971
MEM 19.316406 1683874538.3981
MEM 19.316406 1683874538.4991

5. Vyhodnocení a vizualizace obsazení paměti sledovanou aplikací

První informací, kterou můžeme velmi snadno získat, je nejvyšší obsazená kapacita operační paměti. Tuto informaci nám poskytne příkaz mprof peak, který nalezne všechny soubory vygenerované Memory profilerem a zobrazí informace z posledního souboru:

$ mprof peak
 
Using last profile data.
mprofile_20230512085526.dat     19.801 MiB
Poznámka: ve skutečnosti si můžete vynutit získání stejné informace ze všech souborů vygenerovaných Memory profilerem, a to následujícím příkazem:
$ mprof peak *.dat
mprofile_20230512085526.dat     19.801 MiB
mprofile_20230512090927.dat     114.148 MiB
mprofile_20230512092839.dat     405.219 MiB

Vraťme se ovšem zpět k našemu jedinému souboru vytvořenému Memory profilerem. Víme již, že tento soubor obsahuje informace o objemu obsazené paměti s granularitou 0,1 sekundy a přesně tyto informace lze vizualizovat do podoby jednoduchého grafu:

$ mprof plot
 
Using last profile data.

Výsledek by měl vypadat následovně:

Obrázek 1: Výsledek vizualizace obsazení paměti sledovanou aplikací.

6. Analýza grafu

Povšimněte si, jak průběh grafu uvedeného v předchozí kapitole koresponduje se zdrojovým kódem testované aplikace (uvádím zjednodušený výpis bez zbytečných importů atd.):

sleep(2)
 
x = "*foo*"
 
sleep(2)
 
y = ""
for i in range(200000):
    y += x
 
sleep(2)
 
x = 0
y = 0
 
sleep(2)
 
x = bytearray(1000000)
 
sleep(2)
 
x = 0
 
sleep(2)

Příkazy sleep, které aplikaci pozastaví na dvě sekundy, nám umožňují zjistit, kdy a v jakém objemu došlo k alokaci paměti, ovšem s tím, že prvotní nárůst na cca 18,46 MiB jde na vrub samotnému interpretru Pythonu a jeho infrastruktuře. Pokusme se tedy jednotlivé části kódu spojit s grafem:

Obrázek 2: Části kódu spuštěné na začátku každé „periody“ viditelné v grafu.

7. Sledování a analýza druhé i třetí aplikace

Podívejme se nyní na analýzu druhé aplikace, v níž se provádí alokace paměti lokálně, v rámci funkcí. Konkrétně je paměť alokována pro objekty, které jsou lokální a můžeme tedy počítat s tím, že po opuštění dané funkce dojde i k uvolnění příslušného paměťového bloku (ovšem tento předpoklad nemusí být splněn, což uvidíme dále):

from time import sleep
 
 
def foo_construct():
    x = "*foo*"
 
    sleep(2)
 
    y = ""
    for i in range(20000000):
        y += x
 
    print(len(y))
 
 
def bar_construct():
    x = bytearray(100000000)
    print(len(x))
    sleep(2)
 
 
sleep(2)
foo_construct()
sleep(2)
bar_construct()
sleep(2)

Aplikaci spustíme společně s jejím sledováním následovně:

$ mprof run app2.py
 
mprof: Sampling memory every 0.1s
running new process
running as a Python program...
100000000
100000000

Nyní bude nejvyšší obsazená kapacita operační paměti již mnohem vyšší, než tomu bylo u aplikace první:

$ mprof peak
 
Using last profile data.
mprofile_20230512090927.dat     114.148 MiB

Mnohem lépe bude tento nárůst viditelný na grafu:

$ mprof plot
 
Using last profile data.

Výsledek bude výsledek vypadat zcela odlišně:

Obrázek 3: Obsazení operační paměti druhou testovanou aplikací.

Podobně lze analyzovat aplikaci, v níž se postupně do seznamu přidávají prvky a poté se opět ubírají (od konce seznamu – jinak se výsledku nedočkáte :-):

from time import sleep
 
 
def foo_construct():
    l = []
    for i in range(10000000):
        l.append(i)
        if i % 100000 == 0:
            sleep(0.05)
 
    for i in range(10000000, 0, -1):
        del l[i-1]
        if i % 100000 == 0:
            sleep(0.05)
 
    sleep(2)
 
 
sleep(2)
foo_construct()
sleep(2)

Naměřený výsledek na mém počítači vypadá takto:

Obrázek 4: Obsazení operační paměti třetí testovanou aplikací.

8. Analýza grafu

Analýza grafu zobrazeného v sedmé kapitole na třetím obrázku je nepatrně složitější, a to z toho důvodu, že programová smyčka:

y = ""
for i in range(20000000):
    y += x

nebyla provedena ihned, ale trvala přibližně jednu sekundu. Vliv jednotlivých příkazů na potřebnou kapacitu operační paměti bude následující:

Obrázek 5: Vliv jednotlivých příkazů ve druhé testované aplikaci na kapacitu obsazené operační paměti.

Poznámka: povšimněte si, jak přesně odpovídají zobrazené výsledky našemu očekávání. Znamená to, že správce paměti virtuálního stroje Pythonu měl dostatek času na uvolnění alokované paměti a nemusel (na pozadí) čekat na „vhodnou příležitost“.

Analýza grafu zobrazeného na čtvrtém obrázku je naproti tomu triviální:

Obrázek 6: Vliv jednotlivých příkazů ve třetí testované aplikaci na kapacitu obsazené operační paměti.

9. Získání informací o alokované paměti na úrovni programových řádků

Nástroj Memory profiler dokáže získat informace o tom, jakým způsobem je paměť obsazována či naopak uvolňována, až na úrovni jednotlivých programových řádků. Výsledky sice nebývají zcela dobře pochopitelné (jak ostatně uvidíme dále), ovšem alespoň upozorní na ty části programového kódu, na něž se může vývojář při optimalizacích zaměřit. Vzhledem k tomu, že se jedná o již velmi detailní informace (jejichž získání je relativně zdlouhavé), je nutné označit celé funkce nebo metody, jejichž analýza nás zajímá, dekorátorem @profile. Můžeme si například upravit první testovací příklad následujícím způsobem, kterým se označí obě funkce ve skriptu:

from time import sleep
 
 
@profile
def foo_construct():
    x = "*foo*"
 
    y = ""
    for i in range(200000):
        y += x
 
    print(len(y))
 
    del x
    del y
 
 
@profile
def bar_construct():
    x = bytearray(10000000)
    print(len(x))
    del x
 
 
foo_construct()
bar_construct()

10. Výsledky analýzy

Nyní je nutné aplikaci spustit takovým způsobem, že se jí předá jméno modulu Memory profileru (to proto, aby byla načtena definice dekorátoru @profile). Konkrétně vypadá spuštění naší testovací aplikace následovně:

$ python3 -m memory_profiler app4.py
 
1000000
10000000

Výsledkem bude po dokončení běhu aplikace následující tabulka, v níž je patrné, jak se měnila obsazená kapacita operační paměti po provedení jednotlivých řádků. Navíc se zobrazí i celkový počet opakování daného řádku:

Filename: app4.py
 
Line #    Mem usage    Increment  Occurrences   Line Contents
=============================================================
     4   18.332 MiB   18.332 MiB           1   @profile
     5                                         def foo_construct():
     6   18.332 MiB    0.000 MiB           1       x = "*foo*"
     7
     8   18.332 MiB    0.000 MiB           1       y = ""
     9   22.137 MiB -85881.301 MiB      200001       for i in range(200000):
    10   22.137 MiB -85877.496 MiB      200000           y += x
    11
    12   22.004 MiB   -0.133 MiB           1       print(len(y))
    13
    14   22.004 MiB    0.000 MiB           1       del x
    15   19.570 MiB   -2.434 MiB           1       del y
 
 
Filename: app4.py
 
Line #    Mem usage    Increment  Occurrences   Line Contents
=============================================================
    18   19.570 MiB   19.570 MiB           1   @profile
    19                                         def bar_construct():
    20   29.105 MiB    9.535 MiB           1       x = bytearray(10000000)
    21   29.105 MiB    0.000 MiB           1       print(len(x))
    22   19.570 MiB   -9.535 MiB           1       del x
Poznámka: povšimněte si „divných“ hodnot ve sloupci Increment. Hodnotu v prvním řádku každé funkce je možné ignorovat – dekorátor je zpracováván již při načítání skriptu a inicializaci virtuálního stroje Pythonu, který v tomto případě vyžaduje přibližně 19 MiB pro svůj běh. Záporné hodnoty na řádcích 9–10 jsou poněkud zavádějící, ale souvisí s tím, že se zde skutečně na pozadí uvolňují řetězce, konkrétně předchozí hodnoty referencované z proměnné y. Současně je však ze sloupce Mem usage patrné, že se celková spotřeba paměti zvyšuje, nikoli snižuje (tento způsob reportingu Memory profileru není zcela přehledný, nicméně obrovský objem hlášený ve sloupci Increment by již měl dostačovat k tomu, aby těmto řádkům vývojář věnoval pozornost).

11. Úprava aplikace přidávající a ubírající prvky ze seznamu

Naprosto stejnou úpravu provedeme i u naší třetí testovací aplikace, která postupně přidává prvky do seznamu a posléze je zase odebírá. Úprava je ve skutečnosti triviální – postačuje totiž pouze přidat dekorátor @profile před testovanou funkcí:

from time import sleep
 
 
@profile
def foo_construct():
    l = []
    for i in range(100000):
        l.append(i)
 
    for i in range(100000, 0, -1):
        del l[i-1]
 
 
foo_construct()
Poznámka: zdrojový kód této miniaplikace naleznete na adrese https://github.com/tisnik/most-popular-python-libs/blob/master/memory_pro­filer/app5.py.

12. Výsledky analýzy

Výsledky analýzy provedené Memory profilerem opět mohou být poněkud matoucí (zejména sloupec Increment), ovšem i tak dobře naznačují, na kterých řádcích zdrojového kódu se provádí alokace paměti (přidávání prvků do seznamu) a její dealokace (odstraňování prvků z konce seznamu):

$ python3 -m memory_profiler app5.py 
 
Filename: app5.py

Line #    Mem usage    Increment  Occurrences   Line Contents
=============================================================
     4   18.312 MiB   18.312 MiB           1   @profile
     5                                         def foo_construct():
     6   18.312 MiB    0.000 MiB           1       l = []
     7   22.664 MiB    0.008 MiB      100001       for i in range(100000):
     8   22.664 MiB    4.344 MiB      100000           l.append(i)
     9
    10   22.664 MiB -147352.746 MiB      100001       for i in range(100000, 0, -1):
    11   22.664 MiB -147352.746 MiB      100000           del l[i-1]
Poznámka: oproti původní aplikaci byl počet přidávaných a odebíraných prvků snížen, ovšem pro zajímavost si můžeme otestovat chování i pro větší množství prvků (a tedy větší seznam), například pro milion přidávaných a odebíraných prvků:
$ python3 -m memory_profiler app5.py 
 
Filename: app5.py
 
Line #    Mem usage    Increment  Occurrences   Line Contents
=============================================================
     4   18.211 MiB   18.211 MiB           1   @profile
     5                                         def foo_construct():
     6   18.211 MiB    0.000 MiB           1       l = []
     7   57.199 MiB    0.117 MiB     1000001       for i in range(1000000):
     8   57.199 MiB   38.871 MiB     1000000           l.append(i)
     9
    10   57.199 MiB -17654430.246 MiB     1000001       for i in range(1000000, 0, -1):
    11   57.199 MiB -17654430.246 MiB     1000000           del l[i-1]

13. Měření spotřeby paměti u složitější aplikace

Měření spotřeby operační paměti samozřejmě můžeme provádět i u složitějších aplikací. Jako příklad jsem vybral (nepatrně) rozsáhlejší aplikaci použitou v článcích o populární knihovně Pygame. V této aplikaci se vytváří několik ploch (surface) a teoreticky by mělo být možné detekovat alokaci paměti pro tyto potenciálně velké bloky dat. Vyzkoušejme si tedy, zda a jak je výsledek získaný Memory Profilerem čitelný a užitečný:

#!/usr/bin/python
# vim: set fileencoding=utf-8
 
import pygame
import sys
 
# Nutno importovat kvůli konstantám QUIT atd.
from pygame.locals import *
 
# Velikost okna aplikace
WIDTH = 320
HEIGHT = 240
 
 
# Třída představující sprite zobrazený jako jednobarevný čtverec.
class BlockySprite(pygame.sprite.Sprite):
    # Konstruktor
    def __init__(self, color, size, x, y):
        # Nejprve je nutné zavolat konstruktor předka,
        # tj. konstruktor třídy pygame.sprite.Sprite:
        pygame.sprite.Sprite.__init__(self)
 
        # Vytvoření obrázku představujícího vizuální obraz spritu:
        self.image = pygame.Surface([size, size])
        self.image.fill(color)
 
        # Vytvoření obalového obdélníku
        # (velikost se získá z rozměru obrázku)
        self.rect = self.image.get_rect()
        self.rect.x = x
        self.rect.y = y
 
        # Počáteční rychlost spritu
        self.speed_x = 0
        self.speed_y = 0
 
    # Nastavení barvy spritu, který kolidoval s hráčem
    def yellowColor(self):
        self.image.fill(YELLOW)
 
    # Nastavení barvy spritu, který nekolidoval s hráčem
    def grayColor(self):
        self.image.fill(GRAY)
 
 
# Inicializace knihovny Pygame
pygame.init()
 
clock = pygame.time.Clock()
 
# Vytvoření okna pro vykreslování
display = pygame.display.set_mode([WIDTH, HEIGHT])
 
# Nastavení titulku okna
pygame.display.set_caption("Pygame test #22")
 
# Konstanty s n-ticemi představujícími základní barvy
BLACK = (0, 0, 0)
RED = (255, 0, 0)
GRAY = (128, 128, 128)
YELLOW = (255, 255, 0)
 
# Objekt sdružující všechny sprity
all_sprites = pygame.sprite.Group()
# Objekt sdružující všechny sprity kromě hráče
all_sprites_but_player = pygame.sprite.Group()
 
# Vytvoření několika typů spritů
#                    barva  x   y velikost
wall1 = BlockySprite(GRAY, 50, 10, 10)
wall2 = BlockySprite(GRAY, 15, 100, 100)
wall3 = BlockySprite(GRAY, 15, 100, 150)
wall4 = BlockySprite(GRAY, 15, 200, 100)
wall5 = BlockySprite(GRAY, 15, 200, 150)
wall6 = BlockySprite(GRAY, 15, 150, 100)
wall7 = BlockySprite(GRAY, 15, 150, 150)
player = BlockySprite(RED, 40, WIDTH / 2 - 20, HEIGHT / 2 - 20)
 
# Přidání několika dalších spritů do seznamu
# (jen jeden sprite - ten poslední - bude ve skutečnosti pohyblivý)
all_sprites.add(wall1)
all_sprites.add(wall2)
all_sprites.add(wall3)
all_sprites.add(wall4)
all_sprites.add(wall5)
all_sprites.add(wall6)
all_sprites.add(wall7)
all_sprites.add(player)
 
# Seznam všech nepohyblivých spritů
all_sprites_but_player.add(wall1)
all_sprites_but_player.add(wall2)
all_sprites_but_player.add(wall3)
all_sprites_but_player.add(wall4)
all_sprites_but_player.add(wall5)
all_sprites_but_player.add(wall6)
all_sprites_but_player.add(wall7)
 
 
# Posun všech spritů ve skupině na základě jejich rychlosti
@profile
def move_sprites(sprite_group, playground_width, playground_height):
    for sprite in sprite_group:
        # Posun spritu
        sprite.rect.x = sprite.rect.x + sprite.speed_x
        sprite.rect.y = sprite.rect.y + sprite.speed_y
        # Kontrola, zda sprite nenarazil do okrajů okna
        if sprite.rect.x < 0:
            sprite.rect.x = 0
            sprite.speed_x = 0
        if sprite.rect.x + sprite.rect.width > playground_width:
            sprite.rect.x = playground_width - sprite.rect.width
            sprite.speed_x = 0
        if sprite.rect.y < 0:
            sprite.rect.y = 0
            sprite.speed_y = 0
        if sprite.rect.y + sprite.rect.height > playground_height:
            sprite.rect.y = playground_height - sprite.rect.height
            sprite.speed_y = 0
 
 
# Vykreslení celé scény na obrazovku
@profile
def draw_scene(display, background_color, sprite_group):
    # Vyplnění plochy okna černou barvou
    display.fill(background_color)
    # Vykreslení celé skupiny spritů do bufferu
    sprite_group.draw(display)
    # Obnovení obsahu obrazovky (překlopení zadního a předního bufferu)
    pygame.display.update()
 
 
# Změna barvy spritu na základě kolize s hráčem
@profile
def change_colors(sprite_group, hit_list):
    # Projít všemi sprity ze skupiny, kterou detekovala kolizní funkce
    for sprite in sprite_group:
        if sprite in hit_list:
            sprite.yellowColor()
        else:
            sprite.grayColor()
 
 
# Zjistí kolize spritu se "stěnami" (nepohyblivými sprity)
@profile
def check_collisions(player, sprite_group):
    # Vytvoření seznamu spritů, které kolidují s hráčem
    hit_list = pygame.sprite.spritecollide(player, sprite_group, False)
    # Změna barev kolidujících spritů
    change_colors(sprite_group, hit_list)
    collisions = len(hit_list)
    # Přenastavení titulku okna
    caption = "Pygame test #22: collisions " + str(collisions)
    pygame.display.set_caption(caption)
 
 
@profile
def mainLoop():
    while True:
        # Načtení a zpracování všech událostí z fronty
        for event in pygame.event.get():
            if event.type == QUIT:
                pygame.quit()
                sys.exit()
            if event.type == KEYDOWN:
                if event.key == K_ESCAPE:
                    pygame.quit()
                    sys.exit()
                # Stiskem kurzorových kláves je možné měnit směr pohybu spritu
                elif event.key == pygame.K_LEFT:
                    player.speed_x = -3
                elif event.key == pygame.K_RIGHT:
                    player.speed_x = +3
                elif event.key == pygame.K_UP:
                    player.speed_y = -3
                elif event.key == pygame.K_DOWN:
                    player.speed_y = +3
            if event.type == KEYUP:
                # Puštění kurzorových kláves vede k zastavení pohybu spritu
                if event.key == pygame.K_LEFT:
                    player.speed_x = 0
                elif event.key == pygame.K_RIGHT:
                    player.speed_x = 0
                elif event.key == pygame.K_UP:
                    player.speed_y = 0
                elif event.key == pygame.K_DOWN:
                    player.speed_y = 0
 
        move_sprites(all_sprites, display.get_width(), display.get_height())
        check_collisions(player, all_sprites_but_player)
        draw_scene(display, BLACK, all_sprites)
        clock.tick(20)
 
 
mainLoop()
 
# finito

14. Výsledky měření

Podívejme se nyní na výsledky měření získané po spuštění aplikace z předchozí kapitoly a několika posunech spritu po obrazovce s využitím kurzorových kláves.

Hned v první sledované funkci vidíme alokaci paměti u řádku s dekorátorem @profile, což souvisí s inicializací virtuálního stroje Pythonu i knihovny Pygame (a zobrazenému incrementu vůbec nevěřte :-). Na dalších řádcích funkce move_sprites nedochází k měřitelným změnám v obsazení operační paměti:

Filename: sprites.py
 
Line #    Mem usage    Increment  Occurrences   Line Contents
=============================================================
   105   87.551 MiB 10934.941 MiB         125   @profile
   106                                         def move_sprites(sprite_group, playground_width, playground_height):
   107   87.551 MiB    0.000 MiB        1125       for sprite in sprite_group:
   108                                                 # Posun spritu
   109   87.551 MiB    0.000 MiB        1000           sprite.rect.x = sprite.rect.x + sprite.speed_x
   110   87.551 MiB    0.000 MiB        1000           sprite.rect.y = sprite.rect.y + sprite.speed_y
   111                                                 # Kontrola, zda sprite nenarazil do okrajů okna
   112   87.551 MiB    0.000 MiB        1000           if sprite.rect.x < 0:
   113                                                     sprite.rect.x = 0
   114                                                     sprite.speed_x = 0
   115   87.551 MiB    0.000 MiB        1000           if sprite.rect.x + sprite.rect.width > playground_width:
   116                                                     sprite.rect.x = playground_width - sprite.rect.width
   117                                                     sprite.speed_x = 0
   118   87.551 MiB    0.000 MiB        1000           if sprite.rect.y < 0:
   119                                                     sprite.rect.y = 0
   120                                                     sprite.speed_y = 0
   121   87.551 MiB    0.000 MiB        1000           if sprite.rect.y + sprite.rect.height > playground_height:
   122                                                     sprite.rect.y = playground_height - sprite.rect.height
   123                                                     sprite.speed_y = 0

Prakticky totéž platí i u další sledované funkce draw_scene, ovšem s tím rozdílem, že můžeme vidět alokaci paměti u volání metody Surface.update() (což bychom neočekávali):

Filename: sprites.py
 
Line #    Mem usage    Increment  Occurrences   Line Contents
=============================================================
   127   87.551 MiB 10934.941 MiB         125   @profile
   128                                         def draw_scene(display, background_color, sprite_group):
   129                                             # Vyplnění plochy okna černou barvou
   130   87.551 MiB    0.000 MiB         125       display.fill(background_color)
   131                                             # Vykreslení celé skupiny spritů do bufferu
   132   87.551 MiB    0.000 MiB         125       sprite_group.draw(display)
   133                                             # Obnovení obsahu obrazovky (překlopení zadního a předního bufferu)
   134   87.551 MiB    0.355 MiB         125       pygame.display.update()

I třetí měřená funkce change_colors neprovádí žádnou měřitelnou, resp. detekovatelnou alokaci paměti, což je jen dobře:

Filename: sprites.py
 
Line #    Mem usage    Increment  Occurrences   Line Contents
=============================================================
   138   87.551 MiB 10934.941 MiB         125   @profile
   139                                         def change_colors(sprite_group, hit_list):
   140                                             # Projít všemi sprity ze skupiny, kterou detekovala kolizní funkce
   141   87.551 MiB    0.000 MiB        1000       for sprite in sprite_group:
   142   87.551 MiB    0.000 MiB         875           if sprite in hit_list:
   143   87.551 MiB    0.000 MiB         111               sprite.yellowColor()
   144                                                 else:
   145   87.551 MiB    0.000 MiB         764               sprite.grayColor()

I další výsledek je matoucí, protože na řádku 154 se volá již změřená funkce change_colors, ve které se zcela jistě žádná alokace paměti neprovádí:

Filename: sprites.py
 
Line #    Mem usage    Increment  Occurrences   Line Contents
=============================================================
   149   87.551 MiB 10934.941 MiB         125   @profile
   150                                         def check_collisions(player, sprite_group):
   151                                             # Vytvoření seznamu spritů, které kolidují s hráčem
   152   87.551 MiB    0.000 MiB         125       hit_list = pygame.sprite.spritecollide(player, sprite_group, False)
   153                                             # Změna barev kolidujících spritů
   154   87.551 MiB 10934.941 MiB         125       change_colors(sprite_group, hit_list)
   155   87.551 MiB    0.000 MiB         125       collisions = len(hit_list)
   156                                             # Přenastavení titulku okna
   157   87.551 MiB    0.000 MiB         125       caption = "Pygame test #22: collisions " + str(collisions)
   158   87.551 MiB    0.000 MiB         125       pygame.display.set_caption(caption)

Povšimněte si, že ve funkci představující smyčku pro zpracování událostí, se dealokuje určitá část operační paměti při ukončování aplikace, konkrétně po zavolání funkce pygame.quit (ostatní hodnoty ve sloupci Increment jsou opět zcela matoucí):

Filename: sprites.py
 
Line #    Mem usage    Increment  Occurrences   Line Contents
=============================================================
   161   87.195 MiB   87.195 MiB           1   @profile
   162                                         def mainLoop():
   163                                             while True:
   164                                                 # Načtení a zpracování všech událostí z fronty
   165   87.551 MiB    0.000 MiB         149           for event in pygame.event.get():
   166   87.551 MiB    0.000 MiB          24               if event.type == QUIT:
   167                                                         pygame.quit()
   168                                                         sys.exit()
   169   87.551 MiB    0.000 MiB          24               if event.type == KEYDOWN:
   170   87.551 MiB    0.000 MiB           7                   if event.key == K_ESCAPE:
   171   86.102 MiB   -1.449 MiB           1                       pygame.quit()
   172   86.102 MiB    0.000 MiB           1                       sys.exit()
   173                                                         # Stiskem kurzorových kláves je možné měnit směr pohybu spritu
   174   87.551 MiB    0.000 MiB           6                   elif event.key == pygame.K_LEFT:
   175   87.551 MiB    0.000 MiB           1                       player.speed_x = -3
   176   87.551 MiB    0.000 MiB           5                   elif event.key == pygame.K_RIGHT:
   177   87.551 MiB    0.000 MiB           2                       player.speed_x = +3
   178   87.551 MiB    0.000 MiB           3                   elif event.key == pygame.K_UP:
   179   87.551 MiB    0.000 MiB           1                       player.speed_y = -3
   180   87.551 MiB    0.000 MiB           2                   elif event.key == pygame.K_DOWN:
   181   87.551 MiB    0.000 MiB           2                       player.speed_y = +3
   182   87.551 MiB    0.000 MiB          23               if event.type == KEYUP:
   183                                                         # Puštění kurzorových kláves vede k zastavení pohybu spritu
   184   87.551 MiB    0.000 MiB           6                   if event.key == pygame.K_LEFT:
   185   87.551 MiB    0.000 MiB           1                       player.speed_x = 0
   186   87.551 MiB    0.000 MiB           5                   elif event.key == pygame.K_RIGHT:
   187   87.551 MiB    0.000 MiB           2                       player.speed_x = 0
   188   87.551 MiB    0.000 MiB           3                   elif event.key == pygame.K_UP:
   189   87.551 MiB    0.000 MiB           1                       player.speed_y = 0
   190   87.551 MiB    0.000 MiB           2                   elif event.key == pygame.K_DOWN:
   191   87.551 MiB    0.000 MiB           2                       player.speed_y = 0
   192
   193   87.551 MiB 10934.941 MiB         125           move_sprites(all_sprites, display.get_width(), display.get_height())
   194   87.551 MiB 10934.941 MiB         125           check_collisions(player, all_sprites_but_player)
   195   87.551 MiB 10935.297 MiB         125           draw_scene(display, BLACK, all_sprites)
   196   87.551 MiB    0.000 MiB         125           clock.tick(20)

15. Spotřeba paměti u aplikací běžících ve větším množství procesů

V závěrečné části dnešního článku si ukážeme, jakým způsobem je možné nástrojem Memory Profiler sledovat spotřebu operační paměti v případě, že Pythonovská aplikace (skript) spouští nějaké další procesy. V takovém případě se sleduje buď celková spotřeba operační paměti v daném okamžiku nebo individuální spotřeba jednotlivých procesů. Memory Profiler podporuje, jak ostatně uvidíme v dalších třech kapitolách, obě zmíněné možnosti.

16. Ukázka jednoduché aplikace, která spouští více souběžně běžících procesů

V článku Souběžné a paralelně běžící úlohy naprogramované v Pythonu jsme si mj. popsali i standardní modul pojmenovaný multiprocesses. Tento modul vývojáře do značné míry odstiňuje od nízkoúrovňových operací, tedy od samotného rozvětvení procesu (fork), spuštění nového interpretru a specifikace, jaký kód má tento interpret použít. Z pohledu vývojáře je totiž použití modulu multiprocessing velmi přímočaré – pouze se zvolí, jaká funkce se má zavolat v novém procesu a jaké mají být této funkci předány argumenty. Navíc modul multiprocessing programátorům nabízí mechanismy umožňující komunikaci mezi procesy a v neposlední řadě taktéž čekání na dokončení spuštěného synovského procesu.

A právě tento modul využijeme v dalším demonstračním příkladu, který v samostatných souběžně běžících synovských procesech spustí úlohy, které již známe – postupnou konstrukci řetězce z kratších částí, což je z hlediska spotřeby paměti velmi náročná operace. Navíc se (metoda join) čeká na dokončení těchto procesů:

from multiprocessing import Process
from time import sleep
 
 
def worker(r):
    x = "*foo*"
 
    y = ""
    for i in range(r):
        y += x
 
    print(len(y))
    sleep(1)
    del y
    sleep(1)
 
 
def main():
    ps = []
 
    for r in (10000000, 20000000, 30000000, 10000000):
        p = Process(target=worker, args=(r,))
        p.start()
        ps.append(p)
        sleep(0.2)
 
    for p in ps:
        p.join()
 
    sleep(2)
 
 
if __name__ == "__main__":
    print("Running main")
    main()

17. Výsledky analýzy: celková spotřeba operační paměti

Výše uvedený program po svém spuštění využil poměrně velké množství operační paměti, ovšem tato paměť nebyla alokována původním procesem, ale procesy synovskými (původní program vyžaduje přibližně 18 MiB pro virtuální stroj Pythonu, ovšem mnohem více paměti vyžadují pro svůj běh synovské procesy). Celkovou spotřebu paměti, což je důležitý údaj zejména pro administrátory, zjistíme Memory Profilerem při použití přepínače –include-children, který zajistí, že se spotřeba paměti pro celý strom procesů v dané okamžiky měření sečte:

$ mprof run --include-children multiprocesses.py
 
mprof: Sampling memory every 0.1s
running new process
running as a Python program...
Running main
50000000
50000000
100000000
150000000

Výsledky si opět necháme vykreslit do grafu:

$ mprof plot mprofile_20230513190727.dat

Výsledkem bude tento graf, z něhož je patrné, že celková spotřeba operační paměti dosahuje 400 MiB. Daný bod největší spotřeby paměti – peak – je na grafu zvýrazněn:

Obrázek 7: Celková spotřeba operační paměti zobrazená na grafu.

18. Výsledky analýzy: spotřeba rozdělená podle jednotlivých procesů

Graf s celkovou spotřebou operační paměti lze použít ve chvíli, kdy je zapotřebí změřit paměťové požadavky nasazované aplikace. Ovšem pro vývojáře může být mnohem užitečnější zjištění, jakou spotřebu paměti mají jednotlivé synovské procesy (a navíc v jakém čase). I tuto informaci lze s využitím Memory Profileru změřit, pouze je zapotřebí použít přepínač –multiprocess:

$ mprof run --multiprocess multiprocesses.py
 
mprof: Sampling memory every 0.1s
running new process
Running main
50000000
50000000
100000000
150000000

Výsledkem nyní bude datový soubor obsahující odlišně zapsané informace (povšimněte si nových řádků začínajících na „CHLD“):

CMDLINE /usr/bin/python3 multiprocesses.py
MEM 1.683594 1683997647.0138
MEM 10.714844 1683997647.1216
CHLD 21871 12.796875 1683997647.1277
MEM 10.714844 1683997647.2279
CHLD 21871 19.632812 1683997647.2337
MEM 10.718750 1683997647.3340
CHLD 21871 26.343750 1683997647.3401
CHLD 21872 13.832031 1683997647.3401
MEM 10.718750 1683997647.4405
CHLD 21871 32.804688 1683997647.4464
CHLD 21872 20.675781 1683997647.4464
...
...
...

Z grafu, který Memory Profiler vytvoří, je nyní patrné, že každý synovský proces dosáhne nějaké maximální spotřeby paměti, v tomto stavu počká sekundu, dále je naalokovaný řetězec smazán (a paměť se uvolní), počká se další sekundu a až poté je proces následně je ukončen:

bitcoin_skoleni

Obrázek 8: Spotřeba paměti rodičovského procesu i všech synovských procesů.

19. Repositář s demonstračními příklady

Zdrojové kódy všech prozatím popsaných demonstračních příkladů určených pro programovací jazyk Python 3 byly uloženy do Git repositáře dostupného na adrese https://github.com/tisnik/most-popular-python-libs. V případě, že nebudete chtít klonovat celý repositář (ten je ovšem stále velmi malý, dnes má velikost zhruba několik desítek kilobajtů), můžete namísto toho použít odkazy na jednotlivé příklady, které naleznete v následující tabulce:

# Demonstrační příklad Stručný popis příkladu Cesta
1 getsizeof1.py získání nápovědy k funkci getsizeof z balíčku sys https://github.com/tisnik/most-popular-python-libs/blob/master/sys/getsizeof1.py
2 getsizeof2.py získání a tisk velikostí vybraných skalárních hodnot Pythonu https://github.com/tisnik/most-popular-python-libs/blob/master/sys/getsizeof2.py
3 getsizeof3.py získání a tisk velikosti kontejnerů Pythonu https://github.com/tisnik/most-popular-python-libs/blob/master/sys/getsizeof3.py
4 getsizeof4.py získání a tisk velikosti funkcí https://github.com/tisnik/most-popular-python-libs/blob/master/sys/getsizeof4.py
5 getsizeof5.py získání a tisk velikosti tříd a objektů https://github.com/tisnik/most-popular-python-libs/blob/master/sys/getsizeof5.py
6 getsizeof6.py získání a tisk velikosti tříd a objektů https://github.com/tisnik/most-popular-python-libs/blob/master/sys/getsizeof6.py
7 getsizeof7.py velikost kolekcí obsahujících velké prvky https://github.com/tisnik/most-popular-python-libs/blob/master/sys/getsizeof7.py
       
8 asizeof01.py získání nápovědy k balíčku pympler.asizeof https://github.com/tisnik/most-popular-python-libs/blob/master/asizeof/asizeof01.py
9 asizeof02.py všechny veřejné atributy balíčku pympler.asizeof https://github.com/tisnik/most-popular-python-libs/blob/master/asizeof/asizeof02.py
10 asizeof03.py získání nápovědy k funkci asizeof z balíčku pympler.asizeof https://github.com/tisnik/most-popular-python-libs/blob/master/asizeof/asizeof03.py
11 asizeof04.py získání a tisk velikostí vybraných skalárních hodnot Pythonu https://github.com/tisnik/most-popular-python-libs/blob/master/asizeof/asizeof04.py
12 asizeof05.py získání a tisk velikosti kontejnerů Pythonu https://github.com/tisnik/most-popular-python-libs/blob/master/asizeof/asizeof05.py
13 asizeof06.py získání a tisk velikostí vybraných skalárních hodnot Pythonu se statistikou https://github.com/tisnik/most-popular-python-libs/blob/master/asizeof/asizeof06.py
14 asizeof07.py získání a tisk velikosti kontejnerů Pythonu se statistikou https://github.com/tisnik/most-popular-python-libs/blob/master/asizeof/asizeof07.py
15 asizeof08.py získání a tisk velikosti funkcí bez parametru code https://github.com/tisnik/most-popular-python-libs/blob/master/asizeof/asizeof08.py
16 asizeof09.py získání a tisk velikosti funkcí s parametrem code https://github.com/tisnik/most-popular-python-libs/blob/master/asizeof/asizeof09.py
17 asizeof10.py získání a tisk velikosti tříd a objektů bez parametru code https://github.com/tisnik/most-popular-python-libs/blob/master/asizeof/asizeof10.py
18 asizeof11.py získání a tisk velikosti tříd a objektů s parametrem code https://github.com/tisnik/most-popular-python-libs/blob/master/asizeof/asizeof11.py
19 asizeof12.py velikost kolekcí obsahujících velké prvky https://github.com/tisnik/most-popular-python-libs/blob/master/asizeof/asizeof11.py
       
20 app1.py aplikace alokující velké řetězce a bytové pole https://github.com/tisnik/most-popular-python-libs/blob/master/memory_pro­filer/app1.py
21 app2.py lokální proměnné s velkými řetězci a bytovými poli https://github.com/tisnik/most-popular-python-libs/blob/master/memory_pro­filer/app2.py
22 app3.py přidávání a ubírání prvků do rozsáhlých seznamů https://github.com/tisnik/most-popular-python-libs/blob/master/memory_pro­filer/app3.py
23 app4.py příklad použití dekorátoru @profile https://github.com/tisnik/most-popular-python-libs/blob/master/memory_pro­filer/app4.py
24 app5.py příklad použití dekorátoru @profile https://github.com/tisnik/most-popular-python-libs/blob/master/memory_pro­filer/app5.py
       
25 sprites.py sprity a knihovna Pygame https://github.com/tisnik/most-popular-python-libs/blob/master/memory_pro­filer/sprites.py
26 multiprocesses.py skript, který spustí několik souběžně běžících synovských procesů https://github.com/tisnik/most-popular-python-libs/blob/master/memory_pro­filer/multiprocesses.py

20. Odkazy na Internetu

  1. Top 5 Python Memory Profilers
    https://stackify.com/top-5-python-memory-profilers/
  2. Pympler na GitHubu
    https://github.com/pympler/pympler
  3. Pympler na PyPI
    https://pypi.org/project/Pympler/
  4. Dokumentace k balíčku Pympler
    https://pympler.readthedoc­s.io/en/latest/
  5. Guppy 3 na GitHubu
    https://github.com/zhuyife­i1999/guppy3/
  6. Guppy 3 na PyPI
    https://pypi.org/project/guppy3/
  7. Memory Profiler na GitHubu
    https://github.com/python­profilers/memory_profiler
  8. Memory Profiler na PyPI
    https://pypi.org/project/memory-profiler/
  9. How to use guppy/heapy for tracking down memory usage
    https://smira.ru/wp-content/uploads/2011/08/heapy.html
  10. Identifying memory leaks
    https://pympler.readthedoc­s.io/en/latest/muppy.html#mup­py
  11. How do I determine the size of an object in Python?
    https://stackoverflow.com/qu­estions/449560/how-do-i-determine-the-size-of-an-object-in-python
  12. Why is bool a subclass of int?
    https://stackoverflow.com/qu­estions/8169001/why-is-bool-a-subclass-of-int
  13. Memory Management in Python
    https://realpython.com/python-memory-management/
  14. Why do ints require three times as much memory in Python?
    https://stackoverflow.com/qu­estions/23016610/why-do-ints-require-three-times-as-much-memory-in-python
  15. cpython/Include/cpython/longintrepr.h
    https://github.com/python/cpyt­hon/blob/main/Include/cpyt­hon/longintrepr.h#L64
  16. sys — System-specific parameters and functions
    https://docs.python.org/3/li­brary/sys.html
  17. Python 3.3 s flexibilní reprezentací řetězců
    https://www.root.cz/clanky/interni-reprezentace-retezcu-v-ruznych-jazycich-od-pocitacoveho-praveku-po-soucasnost/#k17

Autor článku

Vystudoval VUT FIT a v současné době pracuje na projektech vytvářených v jazycích Python a Go.