GCP の Cloud Functions を利用する際に,Python のプログラムのメモリ使用量を計測する必要があったため,その基本的な部分をキャッチアップした.
本記事では,memory-profiler という Python のメモリ使用量のプロファイリング用パッケージを用いて,プログラムのメモリ使用量の計測,そしてその経時変化の可視化を行う方法をまとめた.
なお,「メモリ使用量」のプロファイリング(「空間計算量」のプロファイリング)であって,プログラムの実行時間のプロファイリング(「時間計算量」のプロファイリング)ではないことに注意.
開発環境
- OS
$ sw_vers ProductName: Mac OS X ProductVersion: 10.15.4 BuildVersion: 19E287
- Python 3.8.0
- memory-profiler 0.57.0
- matplotlib 3.2.1
インストール
メモリ使用量のプロファイリングに memory-profiler,メモリ使用量の経時変化の描画に matplotlib を利用する.
- pip でインストール
$ pip install memory-profiler $ pip install matplotlib
- Poetry でインストール
$ poetry add memory-profiler $ poetry add matplotlib
メモリ使用量のプロファイリング
memory-profiler を用いてメモリ使用量のプロファイリングを行うにはいくつか方法がある.
- シェルから実行
memory_profiler.profile
デコレータを用いて,特定の関数を行ごとにプロファイリング- mprof を用いて,メモリ使用量の経時変化を描画する
- IPython(REPL 環境)から実行
- memory_profiler の IPython 拡張を用いてプロファイリング
シェルから実行
memory_profiler.profile
デコレータを用いて,特定の関数を行ごとにプロファイリング
測定したい関数に対して memory_profiler.profile
というデコレータを付与するだけで,その関数の行毎のメモリ使用量を計測できる.
ここでは,2 つの関数を用意してみた.
- プロファイラの起動に使用されるメモリを計測するための関数(恒常的に消費されるメモリを計測するためだけの関数)
- メモリを多く消費するような関数
プログラムは以下.
- main.py
from memory_profiler import profile @profile def profiler_base(): # プロファイラの起動で消費されるメモリを計測するための関数(ベースとなる) pass @profile def profiler_target_func(): # 大きめのリストを用意 a = [i for i in range(10000000)] # 大きめのリストを消去 del a # 小さめのリストを用意 b = [i for i in range(1000)] # 小さめのリストを消去 del b def main(): profiler_base() profiler_target_func() if __name__ == '__main__': main()
実行してみる.
$ python main.py Filename: main.py Line # Mem usage Increment Line Contents ================================================ 4 36.8 MiB 36.8 MiB @profile 5 def profiler_base(): 6 # プロファイラの起動で消費されるメモリを計測するための関数(ベースとなる) 7 36.8 MiB 0.0 MiB pass Filename: main.py Line # Mem usage Increment Line Contents ================================================ 10 36.9 MiB 36.9 MiB @profile 11 def profiler_target_func2(): 12 # 大きめのリストを用意 13 369.3 MiB 0.0 MiB a = [i for i in range(10000000)] 14 # 大きめのリストを消去 15 44.5 MiB 0.0 MiB del a 16 17 # 小さめのリストを用意 18 44.5 MiB 0.0 MiB b = [i for i in range(1000)] 19 # 小さめのリストを書虚 20 44.5 MiB 0.0 MiB del b
profiler_base
の計測結果を見ると,何もしない,ただプロファイラを走らせるだけの関数を実行するのに 40 MB 程度のメモリが使われている.profiler_target_func
の計測結果を見ると,大きめのリストを定義したところがメモリ使用量のピークになっており,del 文でそのリストのメモリを解放した行でメモリ使用量が元に戻っていることが分かる.
このように,memory-profiler によって 各関数の各行でどの程度メモリが使用されているかを計測することができる.
mprof を用いて,メモリ使用量の経時変化を描画する
こちらはかなり簡単.Python のコードを書いて mprof run
というコマンドを実行するだけ.
先ほどの memory_profiler.profile
というデコレータは外して構わない.このデコレータはあくまで関数の中でのメモリ使用量を詳細にプロファイリングするもので,mprof run
コマンドで全体のメモリ使用量を見る際には必要ない.先ほどのプログラムから @profile
を除去しただけだが,以下のプログラムを実行してみる.
- main.py
from memory_profiler import profile def profiler_base(): # プロファイラの起動で消費されるメモリを計測するための関数(ベースとなる) pass def profiler_target_func(): # 大きめのリストを用意 a = [i for i in range(10000000)] # 大きめのリストを消去 del a # 小さめのリストを用意 b = [i for i in range(1000)] # 小さめのリストを消去 del b def main(): profiler_base() profiler_target_func() if __name__ == '__main__': main()
上記のプログラムのメモリ使用量の経時変化を可視化する.
$ mprof run main.py $ mprof plot
下記のような画像が生成された.メモリ使用量の増減が分かるだろう.10^7
要素を持ったリストを作るのに約 0.4 秒(0.3 ~ 0.7 秒)掛かり,それが解放されるのに約 0.1 秒掛かっているのが分かる.
このように,memory-profiler と matplotlib を用いて,プログラムのどのタイミングでどの程度メモリが使用されているかを可視化することができる.
IPython から実行
IPython の拡張機能を利用してメモリ使用量のプロファイリングを行うこともできるが,ごちゃごちゃするためここでは省略する.
- memory_profiler の IPython 拡張を用いてプロファイリング
- マジックコマンドを用いて 1 liner でメモリ使用量のプロファイリング
以下を参照.
プロファイラでガベージコレクションの様子を可視化してみる
Python では,言語処理系(CPython)の機能としてガベージコレクション(以下,GC)が実装されている.これにより,不要になったメモリ領域がプログラムの動作中に自動的に解放されている.前節で行った「メモリ使用量の経時変化の描画」を行い,色んなプログラムについて GC の様子を見てみる
GC が行われるタイミング
GC の様子を見る前に,ざっくり Python で GC が行われるタイミングを挙げてみる.主に以下の 3 つ.
GC については以下の記事に簡単にまとめてある.
色んなプログラムのメモリ使用量の経時変化を観察する
ここでは,2 つの例を用いて,メモリ使用量の経時変化を観察してみる. time モジュールを使い,GC のタイミングをはっきりと可視化させるということを試みた.
その 1:通常の GC
- profile1.py
import time def target_func(): # 大きめのリストを用意 a = [i for i in range(1000000)] time.sleep(1) # 大きめのリストを消去 del a time.sleep(1) def main(): time.sleep(2) target_func() if __name__ == "__main__": main()
経時変化を描画.
$ mprof run profile1.py $ mprof plot
以下のようになった.
やはり先ほど同様,プログラムの読み込み部分で 40 MB 程度,使用されている(これはもともとこれくらい利用されているのか?それともプロファイリング用のパッケージの読み込み分なのか?分かる方いましたらコメント頂けると幸いです.).
また,del 文よるメモリの開放には約 0.1 秒程度掛かっている.
その 2:import にどれくらいのメモリが使用されるのか
import でどれくらいメモリが消費されるのかを計測してみた.今回はメモリの使用量が小・中・大のパッケージを(勘で)選んだ.
os
requests
pandas
これらの import の際にどの程度メモリが使用されるを以下のコードで調べた.
- profile2.py
import time def sleeper(func): def wrapper(*args, **kwargs): time.sleep(1) func(*args, **kwargs) time.sleep(1) return wrapper @sleeper def import_os(): import os return os @sleeper def import_requests(): import requests return requests @sleeper def import_pandas(): import pandas as pd return pd def main(): # os os = import_os() del os # requests requests = import_requests() del requests # pandas pd = import_pandas() del pd time.sleep(1) if __name__ == "__main__": main()
上記のコードをざっくり説明すると,各関数内で先述した 3 つのパッケージを import し,import の前後で 1 秒間 sleep させている(sleeper
というデコレータを定義).これを memory-profiler で計測する.
経時変化を描画.
$ mprof run profile2.py $ mprof plot
以下のようになった.
各パッケージのメモリ使用量はグラフからざっくり読み取ると以下のようになった.
os
は変化なし(ほぼメモリ使用量がない,もしくはプログラム起動時に読み込まれてる?)requests
は 3 MB 程度pandas
は 30 MB 程度
1 つポイントとしては,筆者も試すまで知らなかったのだが,import したパッケージは del 文によってローカルスコープから消去することは出来ても(組み込み関数 dir()
で確認),パッケージに使用されているメモリをプログラマ主導で解放することは出来ない,ということである(gc モジュールを用いても解放できなかった).下記リンクにその旨が書いてある.
There's no way to unload something once you've imported it. Python keeps a copy of the module in a cache, so the next time you import it it won't have to reload and reinitialize it again.
import したパッケージを upload する方法はない.パッケージに対して del を行っても,そのパケージへのアクセスは切ることができるが,再度 import したときのためにパッケージのキャッシュがメモリに残るようだ.思わぬ収穫だった.
> There's no way to unload something once you've imported it. Python keeps a copy of the module in a cache, so the next time you import it it won't have to reload and reinitialize it again.
— mathnuko (@mathnuko) April 28, 2020
del してもキャッシュが残ってるからメモリが開放されないという理解でおけ? https://t.co/MXIGUuUxLo
終わりに
本記事ではメモリプロファイリングで色々遊んでみた.実際のプロダクトで使ってみたい.
参考
書籍
以下の本を参考にした.
Python:メモリ使用量のプロファイリング
以下 2 記事は非常に参考になった.
Python:プログラムの実行時間のプロファイリング
実行時間のプロファイリングに関する記事.いずれちゃんとまとめたい.