2023 年 6 月: メモリプロファイラ「Memray」の解説(terapyon)

寺田 学@terapyonです。2023 年 6 月の「Python Monthly Topics」は、メモリプロファイラ「Memray」の使い方を解説します。

アプリケーケーションが利用するメモリの使用状況を把握したいことがあると思います。今回の記事では Python コードが利用するメモリの使用状況の把握を行いたいと思います。

この記事は、2023 年 4 月にアメリカで行われた PyCon US 2023 のトーク「How memory profilers work」を参考にしています。 このトークは、Python のコアデベロッパーであり、リリースマネジャーでもある「Pablo Galindo Salgado 氏」(以下、Pablo 氏)によるものでした。彼はブルームバーグ社に所属しているエンジニアです。

メモリプロファイラ

状況把握の難しさ

Python のコードが実行されるとオブジェクトが生成されます。それらのオブジェクトがどれだけメモリを利用しているのかを把握するツールがメモリプロファイラです。 Python のコードが実行され、それらの命令が必要なメモリをOS に対して要求し、メモリを確保します。

Python においては必要な時にメモリを確保し、不要になったら(厳密には参照がなくなったら)自動的にメモリを開放します。 また NumPy のような C 拡張で作られているライブラリには独自にメモリ確保・開放を行っているものもあります。

アプリケーション実行時に大量のメモリを消費することがあります。たとえばデータ分析において大量データの加工を行う際に、メモリエラーで処理が途中でとまることがあります。このような場合、メモリの状況を把握し適切に対処を行う必要があります。しかし、Python ではメモリ確保を自動的に行なっているため状況が把握しにくいです。 そのためにメモリプロファイラを使って状況把握をします。

プロファイリングの手法

プロファイリングにはいくつかの手法があります。そのうちの 1 つに「トレーシング(tracing)」があります。 トレーシングは、内部メモリのすべての状況を知るために、メモリ確保・開放に関わる全ての実行に対して、追加処理を入れる方法です。 他の手法として、「サンプリング(sampling)」があります。 サンプリングは、実際に使われているメモリ量を OS に問い合わせすることで、メモリの状況を知る方法です。この方法は統計的手法とも呼ばれています。

トレーシングの手法は、実行負荷や余分な命令が増えてしまいますが、極力負荷を減らしたツールとして「Memray」が登場しました。

なお、サンプリングの手法を用いるライブラリとして有名なものは、「memory-profiler」があります。このライブラリは、「psutil」に依存したパッケージです。psutil は OS のシステム監視機能を用いて Python でシステム状態を取得するためのツールです。ただ残念ながら memory-profiler は積極的にメンテナンスを行わない状況になっているようです。

Memray とは

Memray は、もともとブルームバーグ社内で使うツールとして開発が始まり、2022 年 4 月にオープンソース化されたツールです。 1 年前の PyCon US 2022 のライトニングトーク(5 分のショートトーク)で、このツールが発表されました。筆者もこのツールが発表されたときに、なにかすごいツールが発表されたなと印象を受けたのですが、しばらく忘れていました。今年の PyCon US 2023 で改めてその凄さを感じ、この記事にまとめています。

Memray は、Linux と macOS 上で動作するツールです。残念ながら Windows 上では動作しません。 (Windows 上での動作については方法はあるようですが今のところ対応しないようです: RealPython Podcast 2022 年 10 月 7 日のエピソードEpisode 128: Using a Memory Profiler in Python & What It Can Teach You にて作者の Pablo 氏の言葉で語られています)

Memray の概要は以下のとおりです。

項目

内容

ライブラリ名

memray

プロファイリング手法

トレーシング

対応 OS

Linux / macOS

Python バージョン

3.7 以上(執筆時点で 3.11 まで対応)

公式サイト

https://bloomberg.github.io/memray/index.html

PyPI

https://pypi.org/project/memray

github

https://github.com/bloomberg/memray

執筆時点のバージョン

1.7.0

Memray 公式サイトキャプチャ

インストール方法は以下のとおりです。

$ pip install memray

Memray の基本的な使い方

Memray がインストールされていると、memray コマンドが使えるようになります。

プロファイリング対象のスクリプトを memray から実行すると、スクリプトを実行した同じフォルダに実行時のプロファイリング結果がバイナリ形式でファイルに保存されます。

プロファイルの実行

サンプルの Python スクリプトとして sample.py を準備します。 以下のコードでは、状態表示をわかりやすくするために 1 秒間の待ちを入れています。 実行内容は、リスト内包表記で要素数が約 1 億個のリストを生成しています。

sample.py - リスト内包表記でリストを作る
import time

time.sleep(1)  # 状態表示をわかりやすくするために1秒待つ
result = [1 for _ in range(1024 * 1024 * 1024)]  # リスト内包表記で要素数が約1億個のリストを生成
time.sleep(1)
del result  # 変数を削除
time.sleep(1)

この Python スクリプトを実行する例を見てみます。

$ memray run sample.py

実行すると、以下のようなメッセージが表示され、結果が保存されているファイル名(memray-...bin)が出力されます。 さらに、後述する flamegraph という形式での出力方法も示されます。

[memray] Successfully generated profile results.

You can now generate reports from the stored allocation records.
Some example commands to generate reports:

(省略)/venv/bin/python3 -m memray flamegraph memray-sample.py.172322.bin

結果を HTML で出力

結果が出力されたファイルを flamegraph 形式の HTML に変換して出力することができます。

$ memray flamegraph 結果ファイル名

プロファイル実行時に memray-sample.py.172322.bin というファイル名が出力されましたので、以下のコマンドで変換します。

$ memray flamegraph memray-sample.py.172322.bin
Wrote memray-flamegraph-sample.py.172322.html

同じフォルダに HTML ファイル(memray-flamegraph-sample.py.172322.html)が出力されます。Web ブラウザで表示を行うと以下のような結果が出力されます。

Memray sample.py実行結果

メモリの状態をもっと細かく見るには、上部のグラフをクリックしポップアップ画面で詳細が見れます。

Memray sample.py実行メモリ詳細

段階的にメモリ確保されていることがわかり、最終的に 8GB 程度のメモリが使われています。 このグラフのオレンジ色の線がHeapメモリサイズで、青色の線がResidentメモリサイズです。Heapメモリはアプリケーションが確保しているサイズで、Residentメモリは物理メモリ上に確保されたサイズです。

物理メモリに余裕がない場合は以下のようにResidentメモリのグラフが、最初のサンプルとは大きく違う形となります。

Memray sample.py少メモリ実行

スクリプト実行中の状態を見る

Memray には、 live モードがあり、スクリプト実行中にコンソールにメモリの状況を出力する方法が 2 種類用意されています。

  • 実行したコンソールに直接状況を出力する方法

  • 実行したコンソールとは別に出力結果だけをライブで出力する方法

1 つ目のコンソールに状況を出力するには以下のように実行します。

$ memray run --live sample.py

Memray ライブで状況確認

画面の左上部には PID や経過時間などの概要が表示され、右上部は現在のメモリ確保の状況がグラフの様に表示されます。 その下には、現在のヒープメモリサイズと処理中の最大ヒープメモリサイズが表示されます。 さらに、表形式でメモリ確保されている場所・関数名とそれぞれの状態が表示されます。

live モードを終了するには、 q を入力します。

2 つ目の別のコンソールに出力する場合は以下のように実行し、実行時に出力される port を使って、別のコンソールから状況を確認します。 スクリプトの途中経過をコンソールに出力している場合に便利な機能です。

$ memray run --live-remote sample.py

別のコンソールで以下のコマンドを実行

$ memray live <port>

1 つ目の --live を使ったときと同様に出力が出力されます。 なお、 --live-remote でスクリプトを起動した場合は、別のコンソールでのプロファイル表示が行われるまでスクリプトの実行待ち状態となり、スクリプトの開始時からの状況が確認できます。

Python のリストオブジェクトの状況

ここでは、Python のリスト生成方法の違いによるメモリ確保の状況の違いを見ていきます。

以下の Python スクリプトを準備し、メモリプロファイラを実行し、どの状況でどのようなメモリ確保が行われているかを確認します。 このコードでは、3 つの方法でリストを生成しています。どれも、リスト型で 1 を約 10 億個確保して、削除する実行例です。 最初にリスト内包表記を使い、次に append メソッドを使い、最後にリストの掛け算でリストを生成しています。

sample-list.py - リストをさまざまな方法で複数回生成
import time

SIZE = 1024 * 1024 * 1024  # 1G

time.sleep(1)
result = [1 for _ in range(SIZE)]  # リスト内包表記でリストを生成
time.sleep(1)
del result  # 変数を削除
time.sleep(1)


result2 = []
for i in range(SIZE):  # for文とappendメソッドでリストを生成
    result2.append(1)
time.sleep(1)
del result2  # 変数を削除
time.sleep(1)


result3 = [1] * SIZE  # 掛け算でリストを生成
time.sleep(1)
del result3  # 変数を削除
time.sleep(1)

Python スクリプトを実行しメモリ確保状況を確認します。なお、この実行には 5 分から 10 分程度の時間がかかります。またメモリを多く消費する処理が入っているので、環境によって実行できない場合があります。実行できない場合は SIZE を小さくして実行をしてください。

$ memray run sample-list.py

結果を見てみましょう。筆者が実行したときには memray-sample-list.py.173297.bin というファイル名で結果が出力されました。

$ memray flamegraph memray-sample-list.py.173297.bin

結果の HTML キャプチャを掲載します。

Memray sample-list.py実行メモリ詳細

3 回のメモリ確保が行われていることがわかります。

ここで実行した結果は、同じメモリ量を確保しています。 なお、物理メモリの空きに余裕がない場合は、異なった青色の線のResidentメモリサイズが表示されます。

ここで注目は実行時間です。リスト要素を作る方法によって生成に掛かる時間が大きく違うのが可視化されています。実行時間がこのように可視化されるのは Memray の副産物として便利な点でもあります。

NumPy で確認

NumPy は C 拡張を使った Python ライブラリです。配列の生成時にメモリを確保してデータを管理します。 また、リストとは違いどのようなデータ型が入るかを指定し、データ型に合わせたメモリを確保します。

以下の Python スクリプトを準備します。

このコードは、 NumPy のオブジェクトを 4 回生成しています。 毎回、NumPy の関数 .ones を使い 1 を約 10 億個確保しています。 1 回目のオブジェクトを生成後、2 回目で同じ変数名に別のオブジェクトを上書きしています。 その後、オブジェクトを削除した後、3 回目と 4 回目は別の変数名に同様のオブジェクトを生成しています。

sample-arr.py - NumPyのndarrayを複数回生成
import time
import numpy as np

SIZE = 1024 * 1024 * 1024  # 1G

time.sleep(1)
arr = np.ones(SIZE, dtype=np.uint8)  # 要素数が約1億個のNumPy配列を8bit整数型で生成
time.sleep(1)
arr = np.ones(SIZE, dtype=np.uint8)  # 同じ変数名に同じ配列を再代入
time.sleep(1)
del arr  # 変数を削除
time.sleep(1)
arr = np.ones(SIZE, dtype=np.uint8)
time.sleep(1)
arr2 = np.ones(SIZE, dtype=np.uint8)  # 別の変数に代入
time.sleep(1)

Python スクリプトを実行してプロファイリングをし、その後結果を HTML 形式で出力します。

$ memray run sample-arr.py
$ memray flamegraph memray-sample-arr.py.173888.bin

Memray sample-arr.py実行メモリ詳細

今回は、 uint8 を指定していますので、要素 1 個あたり 1 バイトとなり、1 つのオブジェクトで約 1GB のメモリが確保されています。 リストで同じように約 1 億個の要素を生成した時は約 8GB のメモリが確保されたことに比べて少なくなっています。

ここで注目すべきは、同じ変数名にデータを再代入している 2 回目のメモリ確保です。 以下のような処理が内部的に行われています。

  • 1 回目の arr = np.ones(SIZE, dtype=np.uint8) で 1GB 確保

  • 2 回目の arr = np.ones(SIZE, dtype=np.uint8)np.ones(SIZE, dtype=np.uint8) を実行したところでさらに 1GB 確保

  • arr に代入すると古い領域が削除されて 1GB に戻る

これは、オブジェクトを生成している間の変数にオブジェクトが上書きされる直前まで、メモリが倍必要になっています。 システムメモリが十分な余裕がない場合は事前に del で変数を一旦削除してから実行する方がメモリ的に余裕ができることがわかります。 なお、後半の処理は arrarr2 の 2 つの変数にオブジェクトを代入しているので約 2GB のメモリが必要となります。

native モードで C 拡張内の処理を確認する

次に、C 拡張が使われている NumPy が具体的にどのようなメモリ確保をしているかを確認します。 native モードでプロファイルをしてみます。

$ memray run --native sample-arr.py
$ memray flamegraph memray-sample-arr.py.173921.bin

Memray nativeモードの実行結果

オプション --native を指定していますので、実行スタック上の内容が増えています。C 拡張部分の内部の処理が明確になっています。 出力された HTML 上のオブジェクトをマウスオーバーするとその時のメモリ確保状況を確認することができます。

一歩進んだ使い方

ここでは、Memray の一歩進んだ使い方を紹介します。

Jupyter Integration を使う

Memray は JupyterLab との連携機能があり、Notebook のセル内メモリプロファイリングができ、結果が Notebook 上に表示されます。

Jupyter で利用するには、マジックコマンドを用います。

以下のコマンドで memray 拡張モジュールをロードします。

%load_ext memray

拡張モジュールをロードすると、 %%memray_flamegraph セルマジックが利用できます。 プロファイル対象のセルの 1 行目に以下のようにマジックコマンドを追記します。 すると、プロファイリング結果が Notebook 上に表示されます。

%%memray_flamegraph

具体的には、以下の画面キャプチャを参照してください。 flamegraph 形式の HTML 出力と同じ結果がNotebook上に表示されます。

Memray Jupyter Integration画面

with ステートメントで部分的に状態を見る

大規模なコードになった場合、どこでどれだけメモリが確保されているかわかりにくいことがあります。さらに、影響範囲が大きくなりメモリを使っている場所の特定がしにくい場合があります。

このような場合、memray の Tracker クラスを使用して、部分的にメモリプロファイルができます。 プロファイルする場所を with 文の中に入れて、出力するファイル名を指定し部分的に結果を出力します。

以下のように記述します。

sample.py - コードの一部をプロファイリング
import time
import numpy as np
from memray import Tracker

SIZE = 1024 * 1024 * 1024  # 1G

with Tracker("memray-numpyarray-profile.bin"):
    arr = np.empty(SIZE, dtype=np.uint8)
    time.sleep(1)
    del arr
    time.sleep(1)
    arr = np.empty(SIZE, dtype=np.uint8)
    time.sleep(1)

結果は、コード内で示した結果ファイル名 memray-numpyarray-profile.bin に保存されますので、flamegraph を使って結果を HTML に変換し閲覧することができます。

pytest limit でテストにメモリ制限を設ける

pytest を用いた自動テストの実行時にメモリサイズの上限を設定できます。 システム上のメモリ上限に達する実行がされないように事前にチェックすることが可能です。

以下のように pytest-memray を追加でインストールします。

$ pip install pytest-memray

メモリ制限を設けたテストを行うには、以下のようにテスト関数にデコレータを追記します。

@pytest.mark.limit_memory("24 MB")
def test_foobar():
    pass

詳しくは、 pytest-memray の公式ドキュメントを確認してください。

Usage - pytest-memray

Flame Graph 形式以外のアウトプット方法

今回は、 flamegraph を使って結果を HTML に変換する方法を紹介しました。 Memray には他の形式への変換方法も用意されています。 プロファイル実行時に出力される結果ファイル(bin ファイル)をさまざまな形式に変換することができます。

公式ドキュメントによると以下のような形式が準備されています。

まとめ

今回は、メモリプロファイル「Memray」を紹介しました。 今回、紹介しきれなかったさまざまな情報が公式ドキュメントに記載されています。 たとえば、メモリ状況がわかりにくい子プロセスを生成するパターンにもオプションで対応しており、Celery や Gunicorn などの状況も確認できるようです。 また、このツールは実行速度への影響を最小限に抑える努力をしているようです。

大量のデータを使った分析やシミュレーションを行う場合、プログラムがメモリーエラーを出して強制終了されることがあります。 このような場合、どこでメモリを大量に使用しているのか、改善できる場所はどこかを特定する際に Memray が活用できると思います。

Memray をうまく活用することでメモリ状況の確認を始め、関数呼び出しの関係性がわかるなど、実行するコードの状況が可視化されるのは面白いと思いました。筆者も継続して利用し最新情報を追いかけてみようと思います。