2010年9月8日水曜日

CoreData - 大量データを扱う場合のメモリ利用量を減らす

概要


CoreData に格納されている 6,000件のデータを CSVファイルへエクスポートする処理を走らせていたところ、どうもメモリ不足が原因で落ちてしまった。最終的にはメモリ使用量を減らすことでこの問題を回避することができた。以下はその時のInstruments のグラフ。

当初こうだったのが

こうなった。フットプリントは 1/15まで激減した。


※実機: iPhone 3GS / iOS4.0


最初のコード


下図のようなエンティティ構造を持つ CoreData のデータを CSV形式でファイルへエクスポートする。


データ件数は、Customerが 500件、Karteが6,000件(Customer1件につきKarte 12件)となっていて、それぞれを1つの CSVファイルへ書き出す。

処理は次のような感じになる。
NSMangedObjectContext* moc = [取得];
NSArray* customers = [moc Customer全件取得];

for (NSManagedObject* customer in customers) {
    NSArray* kartes = [moc Karte取得・条件:customer];
    for (NSManagedObject* karte in kartes) {
       [CSV1行書き出し];
    }
}
メモリのフットプリント(利用状況)はこんな感じ。


Customer, Karte を1件づつ読み込む度にメモリが消費され、フットプリントが増大していくのがわかる。ピーク時には 150MB程度まで膨れ上がった。

コードに手を入れてこれを改善して行こう。


改良その1 - NSAutoreleasePoolの導入


CSVエクスポート中はメインスレッドの Run Loop に制御が戻らないので、autorelease で確保したメモリの解放が行われない。そこで CSVエクスポート内で NSAutoreleasePoolインスタンスを作成するようにしてみた。NSAutoreleasePool のインスタンスを作ると、その後に autoreleaseが送られたオブジェクトは NSAutoreleasePoolの解放のタイミングで releaseが送られ解放される。
(利用例)
NSAutoreleasePool* pool = [[NSAutoreleasePool alloc] init]

    NSMutableDictionary* dict = [[[NSMutableDictionary alloc] init] autorelease];
    NSArray* array = [NSArray arrayWithObjects:obj1, obj2, nil];
     :
[pool release];
// このタイミングで dict, array に releaseが送られる=解放される。
NSAutoreleasePoolの局所利用は一度に大量のオブジェクトを使う場合の Cocoaプログラミングにおける定石ともいえる。早速これを組み込んでみよう。

改良後のコードはこんな感じ。
NSAutoreleasePool* pool = nil;

NSMangedObjectContext* moc = [取得];
NSUInteger numberOfCustomers = [moc Customer件数];
NSUInteger index = 0;

while (index < numberOfCustomers) {
    NSAutoreleasePool* pool = [[NSAutoreleasePool alloc] init];
    NSArray* customers = [moc Customer取得 from:index count:25];
    for (NSManagedObject* customer in customers) {
        NSArray* kartes = [moc Karte取得・条件:customer];
        for (NSManagedObject* karte in kartes) {
           [CSV1行書き出し];
        }
    }
    [pool release];
    index += 25;
}
Customerの全件取得をやめて 25件毎のフェッチに切り替えてある。全件の場合 NSArray* customers 内に Customerへの参照が最後まで残ってしまうため NSAutoreleasePoolの導入が意味をなさなくなってしまう。その為、適当な件数(今回は 25件)で分割して、その度に NSAutoreleasePoolの作成と解放を行っている。

実行した結果がこれ。


メモリのフットプリントはかなり改善して1/3程度となった。増加と解放を繰り返すのでグラフはギザギザとなっている。25件毎の NSAutoreleasePool解放によってメモリ使用量が減っているのがわかる(ギザギザの谷の部分)。ただ、見ての通り全体としては右上がりでメモリの利用量は増加している。一部解放されないメモリが残っていてそれが積み上がっていく。何かが解放されていない。


改良その2 - NSManagedObjectContextのリセット


何度もコードを見直したが怪しいところが見つからない。強いて言えば NSManagedObjectContext の管理下にあるオブジェクト(NSManagedObject)が怪しい。フェッチ結果を格納している NSArrayはきちんと解放されていることはわかったので、その中に格納されているNSManagedObject にも releaseメッセージが届いているはず。解放されないメモリがあるとしたらそれはNSManagedObjectで、その原因としては NSManagedObjectContextが参照(retain)を持っているからでは? そう仮説を立てて NSManagedObjectContext内のオブジェクトを解放する方法を探したところ、以前調べたことのある -[NSManagedObjectContext reset] が見つかった。

[参考] NSManagedObjectContext Class Reference

(旧) Cocoaの日々: CoreData - トランザクション(4) reset
(旧) Cocoaの日々: CoreData - トランザクション(5) まとめ

リファレンスの説明よりの引用
All the receiver's managed objects are “forgotten.” If you use this method, you should ensure that you also discard references to any managed objects fetched using the receiver, since they will be invalid afterwards.
ずばり参照の破棄(discard references)と書いてある。これを使ってみよう。

NSAutoreleasePool* pool = nil;

NSMangedObjectContext* moc = [取得];
NSUInteger numberOfCustomers = [moc Customer件数];
NSUInteger index = 0;

while (index < numberOfCustomers) {
    NSAutoreleasePool* pool = [[NSAutoreleasePool alloc] init];
    NSArray* customers = [moc Customer取得 from:index count:25];
    for (NSManagedObject* customer in customers) {
        NSArray* kartes = [moc Karte取得・条件:customer];
        for (NSManagedObject* karte in kartes) {
           [CSV1行書き出し];
        }
    }
    [moc reset];    // NSManagedObjectContextをリセット
    [pool release];
    index += 25;
}

するとこうなった。

減った。

NSAutoreleasePool だけでは解放しきれなかったメモリのフットプリントが綺麗になくなっている。その結果 25件毎にメモリ利用量は元に戻り、最大でも 10MB程度しかメモリを使わなくなった。やはり NSManagedObjectContextが参照しているオブジェクト(NSManagedObjectが主)が原因だったようだ。これは強力だ。

実機で試したところ 6,000件の CSVファイル書き出しを行っても落ちなくなった。


補足


今回のアプリの場合、CSVエクスポートは通常使う機能とは独立した機能として実装されている。この為、NSManagedObjectContext をリセットしても他の機能への影響はなかった。しかし既に直前の画面でフェッチを行い多数の NSManagedObjectが存在する状態であったり、またはトランザクションの最中でこの resetを呼び出すと、それまでの NSManagedObjectがすべて解放され、トランザクションはすべて rollbackされてしまう。この為、-[NSManagedObjectContext reset]は今回のケースにおいては非常に有用だったが利用できる場面は限られる。

10/7追記
なんのことはない NSManagedObjectContext を重い処理専用に作れば他に気兼ねなく自由に resetできる。「利用出来る場面は限られる」なんて書いてしまったが嘘でした...
※NSManagedObjectContextは複数作れます。
[参考] (旧) Cocoaの日々: Core Data : 複数の NSManagedObjectContext を使う - Optimistic Locking


Instruments


SDK付属のパフォーマンスチューニング用の各種測定ツール。

Xcodeからは「実行」メニューの「パフォーマンスツールを使って実行」から利用することができる。今回は "Allocations"測定に使った。


他にメモリリーク(Leaks)などを調べることもできる。


参考情報


一時オブジェクトを大量に使う(メモリを消費する)処理をループする場合は、ループの中で自動解放プールを生成する - 24/7 twenty-four seven


- - - - - -
パフォーマンスが劇的に改善できた時ほど気分がいいものはないw

0 件のコメント:

コメントを投稿