静かなる名辞

pythonとプログラミングのこと


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

最終更新:2018/11/26

はじめに

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

 そういうときにやるべきことをつらつらと書いていきます。なお、下の方に行くほど邪悪度()が増していきます。

 目次


スポンサーリンク


対策

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

 一番安直な解決策です。無理矢理チューニングするより手っ取り早いことも多いのではないでしょうか。また、ガチガチにチューニングしても駄目だったとき、最後に選べる選択肢もこれです。

 swapする場合、HDDだと相当厳しいものがあるので、少なくともSSDが必要です。予算がない場合は、USBメモリーを使う(リムーバブルディスクでやる)という手もあります(起動中に抜けちゃったら一貫の終わりですが)。

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

 二番目の選択肢です。プロセス並列は基本的に必要なデータがすべてコピーされるので、メモリ消費の面でかなりしんどいものがあります。プログラムが走っている最中にタスクマネージャ等で子プロセスのメモリ消費を見て、500MB以上消費しているなら検討するべき選択肢です。当然処理速度は低下します。

 どうしてもmultiprocessingを使いたい場合、できるだけ小さい粒度で回して、子プロセスにあまり多くのデータが行かないように気をつけます。また、子には必要なデータだけを渡すようにします。ただし、そうした場合は、相対的に並列化のオーバーヘッドが増え、並列化することによる恩恵が減ります。

 なお、multiprocessingには共有メモリがありますが、pythonのデータ型を直接プロセス間で共有することはできません。また、Managerなる強そうな機能もありますが、内部的にはpickle漬けにしてパイプで送る普通のmultiprocessingでしかないので、使ってもメモリ消費低減機能はありません。

 なお、multiprocessing.Poolを使う場合、Poolのインスタンスを作るタイミングを調整するなどしてある程度の対策が可能です。また、spawnという方法で子プロセスを生成すればかなり余計なメモリ消費は抑えられます。ただし、これには余計なオーバーヘッドもあります。

multiprocessing.Poolがやたらメモリを消費するときの対策 - 静かなる名辞

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

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

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

data2 = 何らかの処理1(data1)
data3 = 何らかの処理2(data2)

 こういう処理を書くと、data1, data2, data3がそれぞれメモリ領域を消費することになります。普通、これらすべてが最終的に必要ということはないはずなので、使い終わったデータは解放してやった方がメモリを節約できます。

 一つの考え方としては、pythonのGCは基本的に参照カウントなので、ぜんぶ同じ名前に束縛しちゃえば良いという方法があります。

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

data = 何らかの処理1(data)
data = 何らかの処理2(data)

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

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

data2 = 何らかの処理1(data1)
del data1
data3 = 何らかの処理2(data2)
del data2

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

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

 私は癖で「配列の形をlistで作ってからnumpy配列に変換する」という処理を良く書くのですが、その度にその配列のメモリ消費が半分近く減少するという経験をしています。

 これはある意味当たり前のことで、そもそもリスト構造はポインタの塊なので、データの中身を記録するのと同じくらいのメモリ領域をアドレスデータを持つことに費やしています。numpy配列はリストと比べればだいぶ効率的なデータ構造をしているので、これを使ってメモリ消費が小さくなるのは当然です。

 なお、あまり使われているのを見かけませんが、numpy配列は数値型の他にも多くのデータを格納できます。たとえば、str型を入れられる他、objectなるデータ型も持っています。str型のnumpy配列は大規模データならそれなりに有用ですが、object型は実質的にpythonのデータへのポインタでしかなく、numpyの便利な機能(shapeとかtransposeとか)が使える以外のメリットはないと心得るべきです。

疎行列型配列を使用する

 機械学習で、大規模でかつスパース(ほとんどの部分がゼロ)なデータを扱っている場合限定の方法ですが、疎行列型(scipy.sparse.csr_matrixなど)でデータを持たせておくという手があります。これを使うとデータ自体で消費するメモリ領域を減らすことができ、また場合によっては高速化も期待できるなど、多くのメリットがあります。ただし、スパースでない配列を疎行列型で表現してもメリットは得られません。

【python】scikit-learnで大規模疎行列を扱うときのTips - 静かなる名辞

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 = [何らかの処理(x) for x in large_data]

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

 こういうときは破壊的代入で書くしかありません。

for i, x in enumerate(large_data):
    large_data[i] = 何らかの処理(x)

 これだとメモリは溢れません。

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

numpyの機能に頼る

 numpy配列などを使う場合は、様々な方法でin-placeな処理や、省メモリな処理を実現できます。たとえば、+=などの累積代入文を使う、viewとcopyを適切に使い分ける、などです。

 数値計算などを使う場合は、このようなnumpyの機能を積極的に使いこなすことで、高速化と省メモリ化が図れます。

ファイルにダンプする

 中間ファイルなどを一旦ストレージに吐き出してしまい、あとで必要になったときにまた読むという方法があります。そうすることで一時的にメモリを解放できますが、オーバーヘッドは相当かさみます。

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

 「必要なデータなんだけどすごく大きくて、ただしそんなに頻繁にアクセスする訳ではない」というときは、メモリ上でデータを圧縮する方法があります。これについては以前記事にしました。

【python】メモリ上のオブジェクトを是が非でも圧縮したい - 静かなる名辞

 当然これには圧縮・解凍分のオーバーヘッドがありますが、中間ファイルを吐くよりはだいぶマシになるかと思います。どこまで実用性があるかは微妙ですが、他にどうしようもないときに検討すべき選択肢ではあります。

 また、同じことですが、RAMディスク領域を確保して中間ファイルを置くのに使うという手もあります。

終わりに

 pythonは泥臭いリスト・配列処理やメモリ管理を隠蔽してくれる良い言語ですが、データフローまで面倒を見てくれる訳ではないので、人間がちゃんとデータフローを作ってやらないとハマります。

 そして、pythonは泥臭い部分を隠蔽してしまっているので、いざというときチューニングが効かないシチュエーションも相応にあります。極端な話、「C/C++ならこのデータフローで大丈夫だけど、pythonだと無駄が多くて無理」という状況もあり得ます。

 なので、pythonで実用できる程度に効率的なプログラムを書くのは意外と難しい側面があります。やむを得ずバッドノウハウを駆使してどうにか動かす、というシチュエーションも多いのではないでしょうか。

 そういう難しい面はありますが、pythonはやっぱり楽です。また、それなりに考えて書けばそれなりにメモリ消費を減らすこともできます。なので、データフローやメモリ確保のされかたを意識しながら工夫してコーディングすれば、極端にひどい事態にはならないと思います。