pyてよn日記

一寸先は闇が人生

Python:memory-profiler によるメモリ使用量のプロファイリング,可視化

GCP の Cloud Functions を利用する際に,Python のプログラムのメモリ使用量を計測する必要があったため,その基本的な部分をキャッチアップした.

本記事では,memory-profiler という Python のメモリ使用量のプロファイリング用パッケージを用いて,プログラムのメモリ使用量の計測,そしてその経時変化の可視化を行う方法をまとめた.

pypi.org

なお,「メモリ使用量」のプロファイリング(「空間計算量」のプロファイリング)であって,プログラムの実行時間のプロファイリング(「時間計算量」のプロファイリング)ではないことに注意.

開発環境

  • 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 秒掛かっているのが分かる.

f:id:pytwbf201830:20200429030501p:plain
main.py

このように,memory-profiler と matplotlib を用いて,プログラムのどのタイミングでどの程度メモリが使用されているかを可視化することができる.

IPython から実行

IPython の拡張機能を利用してメモリ使用量のプロファイリングを行うこともできるが,ごちゃごちゃするためここでは省略する.

  • memory_profiler の IPython 拡張を用いてプロファイリング
  • マジックコマンドを用いて 1 liner でメモリ使用量のプロファイリング

以下を参照.

blog.amedama.jp

プロファイラでガベージコレクションの様子を可視化してみる

Python では,言語処理系(CPython)の機能としてガベージコレクション(以下,GC)が実装されている.これにより,不要になったメモリ領域がプログラムの動作中に自動的に解放されている.前節で行った「メモリ使用量の経時変化の描画」を行い,色んなプログラムについて GC の様子を見てみる

GC が行われるタイミング

GC の様子を見る前に,ざっくり PythonGC が行われるタイミングを挙げてみる.主に以下の 3 つ.

  • オブジェクトの参照を切る
  • 変数を del 文で消去し,「最後の」参照を切る
  • 標準モジュール gc を用いて手動で GC を行う

GC については以下の記事に簡単にまとめてある.

pyteyon.hatenablog.com

色んなプログラムのメモリ使用量の経時変化を観察する

ここでは,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

以下のようになった.

f:id:pytwbf201830:20200429031011p:plain
profile1.py

やはり先ほど同様,プログラムの読み込み部分で 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

以下のようになった.

f:id:pytwbf201830:20200429032020p:plain
profile2.py

各パッケージのメモリ使用量はグラフからざっくり読み取ると以下のようになった.

  • os は変化なし(ほぼメモリ使用量がない,もしくはプログラム起動時に読み込まれてる?)
  • requests は 3 MB 程度
  • pandas は 30 MB 程度

1 つポイントとしては,筆者も試すまで知らなかったのだが,import したパッケージは del 文によってローカルスコープから消去することは出来ても(組み込み関数 dir() で確認),パッケージに使用されているメモリをプログラマ主導で解放することは出来ない,ということである(gc モジュールを用いても解放できなかった).下記リンクにその旨が書いてある.

stackoverflow.com

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 したときのためにパッケージのキャッシュがメモリに残るようだ.思わぬ収穫だった.

終わりに

本記事ではメモリプロファイリングで色々遊んでみた.実際のプロダクトで使ってみたい.

参考

書籍

以下の本を参考にした.

  • Python データサイエンスハンドブック
    • Python でデータ分析をする際に必要な知識が良くまとまっている(結構分厚い),1 章に IPython とプロファイリングに関する記述がある.

Python:メモリ使用量のプロファイリング

以下 2 記事は非常に参考になった.

blog.amedama.jp

tech.curama.jp

Python:プログラムの実行時間のプロファイリング

実行時間のプロファイリングに関する記事.いずれちゃんとまとめたい.

blog.amedama.jp

blog.amedama.jp

その他