読者です 読者をやめる 読者になる 読者になる

静かなる名辞

pythonと読書

【python】pythonでメモリ不足になったときにすること

 pythonはLLですが、なぜかメモリを何十GBも消費するような(一般的なPCのリソースからすれば)大規模なデータ分析に広く使われています。このようなデータ分析では、往々にしてメモリ不足が生じ、それなりに配慮してプログラムを書かないとそもそもプログラムが走らないといった事態が発生しがちです。
 そういうときにやるべきことをつらつらと書いていきます。なお、下の方に行くほど邪悪度()が増していきます。

メモリを増設する・システムのswap領域を増やす

 一番安直な解決策です。無理矢理チューニングするより手っ取り早いことも多いのではないでしょうか。また、ガチガチにチューニングしても駄目だったとき、最後に選べる選択肢もこれです。
 swapする場合、HDDだと相当厳しいものがあるので、SSDが必要です。どうしてもHDDしかない場合、リムーバブルディスクでやるという手もあります(起動中に抜けちゃったら一貫の終わりですが)。

multiprocessingを使っているなら使うのをやめる、あるいはプロセス数を減らす

 二番目の選択肢です。プロセス並列は基本的に必要なデータがすべてコピーされるので、メモリ消費の面でかなりしんどいものがあります。プログラムが走っている最中にタスクマネージャ等で子プロセスのメモリ消費を見て、500MB以上消費しているなら検討するべき選択肢です。当然処理速度は低下します。
 どうしてもmultiprocessingを使いたい場合、できるだけ小さい粒度で回して、子プロセスにあまり多くのデータが行かないように気をつけます。また、子には必要なデータだけを渡すようにします。
 なお、multiprocessingには共有メモリがありますが、pythonのデータ型を直接プロセス間で共有することはできません。また、なんとかmanagerなる強そうな機能もありますが、内部的にはpickle漬けにしてパイプで送る普通のmultiprocessingでしかないので、使ってもメモリ消費低減機能はありません。

要らないデータはGCに回収させる

 基本中の基本ですが、よく使うので例を挙げて説明します。

with open("data", "r") as file:
    data1 = pickle.load(file)
data2 = some_processing1(data1)
data3 = some_processing2(data2)

 こういう処理を書くと、data1,data2,data3がそれぞれメモリ領域を消費することになります。普通、これらすべてが最終的に必要ということはないはずなので、使い終わったデータは解放してやった方がメモリを節約できます。
 一つの考え方としては、pythonGCは基本的に参照カウントなので、ぜんぶ同じ名前に束縛しちゃえば良いという方法があります。

with open("data", "r") as file:
    data = pickle.load(file)
data = some_processing1(data)
data = some_processing2(data)

 この方法だとsome_processingが返ってdataに値が束縛される度、古いdataはメモリ上から消えていきます(逆に言えば、代入が終わってからGCが走るまでの期間では、瞬間的に古いdataと新しいdataが両方メモリ上にあることになります。これについては後述)。ただ、実際問題としては、別の名前がついてた方がプログラムの可読性は上がります。なので、del文を使います。

with open("data", "r") as file:
    data1 = pickle.load(file)
data2 = some_processing1(data1)
del data1
data3 = some_processing2(data2)
del data2

 del文は受け取った名前の参照を消します。注意すべきなのは、del文は名前の指す実体を消す訳ではなく、あくまでも参照を消すに過ぎないということです。del文で実体を消せるのは、del文によってオブジェクトへの参照が0になるときだけです。

リストは積極的にnumpy配列にする

 私は癖で「配列の形をlistで作ってからnumpy配列に変換する」という処理を良く書くのですが、その度にその配列のメモリ消費が半分近く減少するという経験をしています。
 これはある意味当たり前のことで、そもそもリスト構造はポインタの塊なので、データの中身を記録するのと同じくらいのメモリ領域をアドレスデータを持つことに費やしています。numpy配列はリストと比べればだいぶ効率的なデータ構造をしているので、これを使ってメモリ消費が小さくなるのは当然です。
 なお、あまり使われているのを見かけませんが、numpy配列は数値型の他にも多くのデータを格納できます。たとえば、str型を入れられる他、objectなるデータ型も持っています。str型のnumpy配列は大規模データならそれなりに有用ですが、object型は実質的にpythonのデータへのポインタでしかなく、numpyの便利な機能(shapeとかtransposeとか)が使える以外のメリットはないと心得るべきです。

32bitにする

 データ処理では良くfloatのnumpy配列を使うと思います。巨大なnumpy配列がメモリを食っている場合、これを32bitにするとメモリ消費は単純計算で半分になります。

import numpy as np
some_data = np.array(some_data, dtype=np.float32)

 また、これをやると心なしか(ほとんど気のせいレベルですが)実行速度が速くなるような気がします。64bitのFPUが使えるCPUでもそうなのは、キャッシュに入れられるデータ量が増えるからとか?
 当たり前ですが、32bitで必要な精度が得られるかはまったく別の問題です。とはいえ、pythonで良くやる機械学習では32bitで足りる場面も多いのではないでしょうか。

配列処理は破壊的代入で行う

 たとえば、こういう処理があったとします。

large_data = [some_processing(x) for x in large_data]

 一見すると良さそうですが、リスト内包表記が返ってlarge_dataに新しい値が代入されてから、GCが走るまでの間、メモリ上には旧いlarge_dataと新しいlarge_dataが併存することになります。データの大きさ次第では、メモリが溢れてしまいます。
 こういうときは破壊的代入で書くしかありません。

for i in range(len(large_data)):
    large_data[i] = some_processing(large_data[i])

 これだとメモリは溢れません。
 pythonではfor文を回して配列のインデックスを叩くような処理は、とても重いことで有名です。とはいえ、他に良い手がないことも多いので、実際にはこういう方法は割と良く使うかと思います。これで速度が厳しいときはcythonで書くとだいぶマシになります。

メモリ上でデータを圧縮する

 「必要なデータなんだけどすごく大きくて、ただしそんなに頻繁にアクセスする訳ではない」というときは、メモリ上でデータを圧縮する方法があります。これについては以前記事にしました。
 当然これには圧縮・解凍分のオーバーヘッドがありますが、中間ファイルを吐くよりはだいぶマシになるかと思います。どこまで実用性があるかは微妙ですが、他にどうしようもないときに検討すべき選択肢ではあります。

終わりに

 pythonは泥臭いリスト・配列処理やメモリ管理を隠蔽してくれる良い言語ですが、データフローまで面倒を見てくれる訳ではないので、人間がちゃんとデータフローを作ってやらないとハマります。そして、pythonは泥臭い部分を隠蔽してしまっているので、いざというときチューニングが効かないシチュエーションも相応にあります。極端な話、「C/C++ならこのデータフローで大丈夫だけど、pythonだと無駄が多くて無理」という状況もあり得ます。なので、pythonで実用できる程度に効率的なプログラムを書くのは意外と難しい側面があります。やむを得ずバッドノウハウを駆使してどうにか動かす、というシチュエーションも多いのではないでしょうか。
 そういう難しい面はありますが、pythonはやっぱり楽です。なので、どうにか使っていけば良いと思います。