Core Dataに久々にはまった

ユビレジを使っているお客さんから商品のダウンロードがえらい遅いという報告を受けました。さあ、いったい何が起きている??

遅い原因は、二つありました。

  • インポートの際に手抜きをして、Find-or-Createをナイーブに実装していた
  • saveがめちゃくちゃ遅い

このうちの前者は、Core Data Programming Guideにあるとおり、ちょっと賢く実装しなおせば、かなり改善されます。まあ、リレーションがあると、そっちも事前に辞書にして渡すとかの工夫は必要ですが、大したことない。

これでダウンロードのスループットは改善されましたが、やっぱり偉いレスポンシブネスに問題があります。

これが後者の問題。なんかわからないけど、インポートしていくと、だんだんsaveにかかる時間が長くなります。最初は100件ずつsaveとかしていて、もっと少ない方が良いかなーと思って、10件ずつとか1件ずつとかにしても、saveに1秒とかかかるていたらく。遅すぎるだろこれ。

ちょっと簡単なモデルで試してみました。

NSManagedObjectContext* context = self.managedObjectContext;

NSUInteger total = 0;
NSUInteger batchSize = 10000;

Account* account = [self insertNewObject:[Account class] intoContext:context];
[context obtainPermanentIDsForObjects:@[account] error:nil];
[context save:nil];

NSManagedObjectID* accountID = [account objectID];

while (true) {
	[self benchmark:[NSString stringWithFormat:@"Inserting %d posts", batchSize] block:^{
		for (int i = 0; i < batchSize; i++) {
			Post* post = [self insertNewObject:[Post class] intoContext:context];
			post.account = account;
			post.title = [NSString stringWithFormat:@"Post %d", i + total];
		}
	}];
	
	NSTimeInterval timeToPush = [self benchmark:@"Save to root" block:^{
		NSArray* newObjects = [[context insertedObjects] sortedArrayUsingDescriptors:@[]];
		[context obtainPermanentIDsForObjects:newObjects error:nil];
		[context save:nil];
	}];
	
	NSUInteger nextBacthSize = batchSize / timeToPush;
	if (nextBacthSize == 0) {
		nextBacthSize = 1;
	}
	
	total += batchSize;
	NSLog(@"Total Posts = %d, nextBatchSize = %d", total, nextBacthSize);
	
	batchSize = nextBacthSize;
}

こんな感じで、1秒間に何個要素を追加してsaveできるのか、計算します。モデルは、AccountとPostというのが作ってあって、AccountはpostsというリレーションにたくさんPostを保持します。あと、benchmarkっていうのは、こんなメソッド。ブロックの実行にかかった時間を計測して、NSLogして、ついでにその時間を返します。

- (NSTimeInterval)benchmark:(NSString*)message block:(void(^)())block {
	NSDate* start = [NSDate date];
	block();
	NSDate* finish = [NSDate date];
	NSTimeInterval time = [finish timeIntervalSinceDate:start];
	
	if (message) {
		NSLog(@"%@: in %g secs", message, time);
	}
	
	return time;
}

しばらく動かすと、ばっちり遅くなっていく様子が観察できました。

2012-07-08 09:32:58.753 TransactionTest[2609:707] Inserting 10000 posts: in 1.48322 secs
2012-07-08 09:33:01.008 TransactionTest[2609:707] Save to root: in 2.25142 secs
2012-07-08 09:33:01.010 TransactionTest[2609:707] Total Posts = 10000, nextBatchSize = 4441
......
2012-07-08 09:34:22.501 TransactionTest[2609:707] Inserting 2353 posts: in 1.23398 secs
2012-07-08 09:34:23.509 TransactionTest[2609:707] Save to root: in 1.00631 secs
2012-07-08 09:34:23.510 TransactionTest[2609:707] Total Posts = 113627, nextBatchSize = 2338

しばらく動かすと、毎秒4000個あまりPostsをインサートしてsaveできていたのが、半分程度まで落ちます*1。ばっちり問題を再現できています(多分)。

ちなみに、

post.account = account;

としている行をコメントアウトすると、性能の劣化は発生しません。リレーションがあるとよくないみたい。

うーむ。こまった。「1万件のデータセットはfairly small sizeだ!」と豪語するCore Dataさんですが、10万件はそうでもないようです。


ちなみに、製品のコードではiOS 5で導入されたNested Managed Object Contextを使っています。子供をsaveしてから親をsaveして、とするわけですが、ここで「親をsaveするのをバックグラウンドで実行することができるのでUIはブロックしないよ!」とWWDC 2011のビデオで説明しているのが見られます。が、この問題は子供から親へsaveするときにも発生するのよね……

ちょっと本当に困っています。


Workaroundとしては、

Core Dataのリレーションを使うのを止めて、accountIDみたいな感じで、リレーションのためのプロパティを適当に書く。ORまっぱみたいのを自分で実装しなおす。

ことになるのかなぁ。これなら、insertのときには数字を入れるだけなので、この問題は発生しないはず……

*1:ちなみにこの後もどんどん性能が落ちていきます。