私の備忘録がないわね...私の...

画像処理とかプログラミングのお話。

PythonのGarbageCollectionに関する考察

概要

Pythonでのgarbage collection(GC)について調べてみた。通常動作に加えて、メモリ制限下やGPU上での特殊な挙動についても見てみた。

環境

  • Ubuntu 16.04.6 LTS (Xenial Xerus)
  • JupyterLab
  • Python 3.7.6
  • pytorch 1.5.0

importしたライブラリは以下の通りである。

import os
import torch
import GPUtil
import psutil
import gc
import sys
import resource
import time

基本的な挙動(CPU)

Pythonでは基本的にGCは参照カウントGCとして実装されていることが組み込みライブラリであるgc(ガベージコレクタインターフェース)から読み取れる[1]。まずはこれらの基本的な挙動を考える。

Pythonではオブジェクトがメモリ上で解放されたとき、__del__メソッドが呼ばれる[2]。今後、gcの挙動を見るにあたって、簡単のため以下のような__del__メソッドを実装したクラスを作成する。

class MyObject:
    
    def __del__(self):
        print("free")

スコープに関するGC

まずはスコープが外されることによって発生するメモリの解放を考える。

def call(class_):
    print("f start")
    print("a define start")
    a = class_()
    print("a define finish")
    print("現在の参照数: {}".format(sys.getrefcount(a)))
    print("f finish")
    
call(MyObject)

これの結果は以下のようになる。

f start
a define start
a define finish
現在の参照数: 2
f finish
free

このようにユーザーが明示的にメモリを解放するコードを書かなくても、関数が終了すると共に自動的にメモリを解放していることがわかる。sys.getrefcountで参照数が2となっているのは変数の定義とこの関数自身によって参照されているからである。

delによるメモリの解放

delはオブジェクトを解放することができる関数である。注意すべきなのはdel xは直接x.__del__()を呼び出さず、xの参照カウントを1つ減らすだけだということである。__del__メソッドは参照カウントが0になったときの呼び出される[2]。

def call_del(class_):
    print("f start")
    print("a define start")
    a = class_()
    print("a define finish")
    print("現在の参照数: {}".format(sys.getrefcount(a)))
    del a
    print("f finish")
    
call_del(MyObject)

これの結果は以下のようになる。

f start
a define start
a define finish
現在の参照数: 2
free
f finish

delによってスコープが外れる前にメモリが解放されていることがわかる。

メモリ使用量の変化

CPUのメモリ使用量の変化を実際に見てる。

print(psutil.virtual_memory().percent)
x = torch.rand(50000, 3, 224, 224)
print(psutil.virtual_memory().percent)
del x
print(psutil.virtual_memory().percent)

これの結果は以下のようになる。

18.8
21.9
18.2

GCによってメモリ使用量が元に戻っていることがわかる。なお18.8, 18.2のように完全に同じ値にならないのは他のプログラムやソフトウェアがバックグラウンドで動作しているからだと思われる。

ゴミに対する挙動

ここでは参照型GCで問題となる循環参照されたゴミに対する挙動を見ていく。

まず以下のような組み込み型のlistを継承したクラスを作成する。

class MyList(list):
    
    def __del__(self):
        print("free")

a = MyList()
print("現在の参照数: {}".format(sys.getrefcount(a)))
del a

これの結果は以下のようになる。

現在の参照数: 2
free

次に循環参照したオブジェクトに対してdel関数を適用してみる。

a = MyList()
a.append(a)
print("現在の参照数: {}".format(sys.getrefcount(a)))
del a

これの結果は以下のようになる。

現在の参照数: 3

freeがprintされていないことから循環参照されたオブジェクトは自動的に解放が行われていないことがわかる。

Pythonには明示的にGCを行うようなgc.collectメソッドも用意されており、これを使うと循環参照されたオブジェクトも解放することができる。

a = MyList()
a.append(a)
print("現在の参照数: {}".format(sys.getrefcount(a)))
del a
gc.collect()

これの結果は以下のようになる。

現在の参照数: 3
free

メモリ制限下での挙動

では循環参照されたような特殊なゴミはプログラムが終了するまで自動的に解放されることは無いのだろうか。今回はメモリ制限下でこのような動作を考えてみる。

まずは通常の動作をみる。

class MyBigList(list):
    
    def __init__(self):
        self.x = [i for i in range(200)]
        
    def __del__(self):
        print("free")
        
for i in range(10):
    print(i)
    a = MyBigList()
    a.append(a)

これの結果は以下のようになる。

0
1
2
3
4
5
6
7
8
9

前述の結果と同じようにメモリは解放されない。試しに普通のゴミ(循環参照されない)でこれを試すと以下のようになることから循環参照が問題となっていることがわかる。

for i in range(10):
    print(i)
    a = MyBigList()
0
1
free
2
free
3
free
4
free
5
free
6
free
7
free
8
free
9
free

ここでメモリの使用量を制限する[3]。

resource.setrlimit(resource.RLIMIT_AS, (1000, 1000))
for i in range(10):
    print(i)
    a = MyBigList()
    a.append(a)

これの結果は以下のようになる。

0
1
2
3
4
5
6
7
8
free
free
free
free
free
free
free
9

これらの結果からメモリ制限下ではある一定のメモリ使用量に到達すると自動的にGCが行われることがわかった。ただCPUのメモリ量を計測する関数とその要素(psutil.virtual_memory().percent)ではプログラム内のメモリのみを計測できないこと、またプログラム起動時に確保されるメモリ量などの影響から正確にどのような閾値でこれらが動作しているかは分からなかった。

自動的にgarbage collectionを行わない(gc.disable())ような以下のプログラムは落ちる(停止する)ことからも自動的にGCが行われていることがわかる。

resource.setrlimit(resource.RLIMIT_AS, (1000, 1000))
gc.disable()
for i in range(10):
    print(i)
    a = MyBigList()
    a.append(a)

GPU上での挙動

次にGPU上でのGCを見ていく。

x = torch.rand(50000, 3, 224, 224, device="cuda")
GPUtil.showUtilization()
| ID | GPU | MEM |
------------------
|  7 | 15% | 61% |

GPU上のメモリの61%程度を使用しているオブジェクトをdel関数で削除してみる。

print("現在の参照数: {}".format(sys.getrefcount(x)))
del x
GPUtil.showUtilization()
現在の参照数: 2
| ID | GPU | MEM |
------------------
|  7 |  0% | 61% |

メモリの解放を行なったはずなのにメモリの使用量が全く変わっていない。以下のように明示的に解放を行なっても変化は見られない。

gc.collect()
GPUtil.showUtilization()
| ID | GPU | MEM |
------------------
|  7 |  0% | 61% |

このようなメモリ使用量が変わっていないように見える現象は以下のメソッド(torch.cuda.empty_cache)で対処できる。

torch.cuda.empty_cache()
GPUtil.showUtilization()
| ID | GPU | MEM |
------------------
|  7 | 41% |  2% |

これによって2%程度まで削減することができた。この関数が何を行なっているか見ていく。pytorchのドキュメントには以下のように書かれている。

Releases all unoccupied cached memory currently held by the caching allocator so that those can be used in other GPU application and visible in nvidia-smi.

これによるとcaching allocatorによってメモリが確保されていることがわかる。またドキュメントによると

empty_cache() doesn’t increase the amount of GPU memory available for PyTorch.

すなわちempty_cache()は、PyTorchで利用可能なGPUメモリの量を増やすことはないと言われている。

実際にpytorchにはallocated memoryとreserved memory(caching allocatorによって確保されているメモリ)を返す関数が分けられている。

torch.cuda.memory_allocated(device=None)

Returns the current GPU memory occupied by tensors in bytes for a given device.

torch.cuda.memory_reserved(device=None)

Returns the current GPU memory managed by the caching allocator in bytes for a given device.

これらを用いてみると

x = torch.rand(50000, 3, 224, 224, device="cuda")
print(torch.cuda.memory_allocated(device="cuda"))
print(torch.cuda.memory_reserved(device="cuda"))
del x
print(torch.cuda.memory_allocated(device="cuda"))
print(torch.cuda.memory_reserved(device="cuda"))
torch.cuda.empty_cache()
print(torch.cuda.memory_allocated(device="cuda"))
print(torch.cuda.memory_reserved(device="cuda"))
30105600000
30106714112
0
30106714112
0
0

このようにallocated memoryはdelによって完全に解放されているのに対して、reserved memoryは全く減っていないのがわかる。

pytorchではGPUメモリの管理に対して以下のように述べている。

PyTorch uses a caching memory allocator to speed up memory allocations. This allows fast memory deallocation without device synchronizations. However, the unused memory managed by the allocator will still show as if used in nvidia-smi. You can use memory_allocated() and max_memory_allocated() to monitor memory occupied by tensors, and use memory_reserved() and max_memory_reserved() to monitor the total amount of memory managed by the caching allocator. Calling empty_cache() releases all unused cached memory from PyTorch so that those can be used by other GPU applications. However, the occupied GPU memory by tensors will not be freed so it can not increase the amount of GPU memory available for PyTorch.

「caching memoryはメモリ確保を高速に動作させる」と述べられているので、これを実験してみる。

start = time.time()
x = torch.rand(50000, 3, 224, 224, device="cuda")
end = time.time()
print("最初のメモリ確保: {}".format(end-start))
del x
start = time.time()
x = torch.rand(50000, 3, 224, 224, device="cuda")
end = time.time()
print("cacheが残った状態でのメモリ確保: {}".format(end-start))
del x
torch.cuda.empty_cache()
start = time.time()
x = torch.rand(50000, 3, 224, 224, device="cuda")
end = time.time()
print("cacheを消去した状態でのメモリ確保: {}".format(end-start))
最初のメモリ確保: 3.524169683456421
cacheが残った状態でのメモリ確保: 0.00029540061950683594
cacheを消去した状態でのメモリ確保: 0.6183676719665527

cacheが残った状態でメモリを確保した方が圧倒的に早いことがわかった。

またtensorのcached memoryを開放した上で未だに2%程度のcached memoryが残っている。これはcuda memoryに最初にアクセスされるときに確保されるcached memoryである。torch.cuda.empty_cacheは使われていないメモリを解放されるものなのでこれは解放され得ない。実際に少量のメモリでこれを確認してみる。

# プログラムリスタート後
GPUtil.showUtilization()
x = torch.rand(10, device="cuda")
GPUtil.showUtilization()
del x
torch.cuda.empty_cache()
GPUtil.showUtilization()
| ID | GPU | MEM |
------------------
|  7 |  0% |  0% |

| ID | GPU | MEM |
------------------
|  7 |  2% |  2% |

| ID | GPU | MEM |
------------------
|  7 |  0% |  2% |

結論

今回はPythonにおけるGCの実装、またメモリ制限下やGPU上での動作について調べた。Pythonは参照型のGCを用いており、普通、循環参照されたようなゴミはスコープから外れても解放されない。しかしこのようなゴミがメモリを圧迫するようになると自動的にGCを行い、プログラムが落ちることを防いでる。またGPUに対してはメモリをただ確保するだけでなく、今後のアクセスが高速になるようにキャッシュを別途確保していることがわかった。

参考文献