views:

142

answers:

2

Hi!

In the model, I have two entities: Record and Category. Category is one-to-many with Record through inverse relationship. The persistent store is of SQLITE type and the db is not so small, about 23MB (17k records).

I use a list-detail design to show the records table and the detailed record view.The list viewController uses NSFetchedResultsController.

Building on the device, if I don't use setFetchBatchSize:

CoreData: annotation: sql connection fetch time: 15.8800s CoreData: annotation: total fetch execution time: 16.9198s for 17028 rows.

OMG!

If I use setFetchBatchSize:25, everything works great again:

CoreData: annotation: sql connection fetch time: 1.1736s CoreData: annotation: total fetch execution time: 1.1900s for 17028 rows.

Yeah, that would be great! But it is not! In the list viewController, when user taps on a record I allocate a detailed viewController and I pass the record at the indexPath in the fetchedResultsController:

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
Record *record = (Record *)[fetchedResultsController objectAtIndexPath:indexPath];
RecordViewController *recordViewController= [[RecordViewController alloc] init];
    recordViewController.record = record;
    [self.navigationController pushViewController:recordViewController animated:YES];
    [recordViewController release];
}

NOW, in the detailed viewController, I have a button to set a record as favorite or not:

- (IBAction) setFavorite {
if (![record.ISFAV intValue]) 
[record setValue:[NSNumber numberWithInt:1] forKey:@"ISFAV"];

else 
[record setValue:[NSNumber numberWithInt:0] forKey:@"ISFAV"];

###SAVE ON THE CONTEXT HERE###

}

OK, are u ready? If I tap on the first record in the list, then I add or remove it from the favorites, it happens in 0.0046 seconds, instantly! Console with SQL Debug mode shows only the UPDATE statement:

CoreData: sql: BEGIN EXCLUSIVE CoreData: sql: UPDATE ZRECORD SET ZISFAV = ?, Z_OPT = ? WHERE Z_PK = ? AND Z_OPT = ? CoreData: sql: COMMIT CoreData: annotation: sql execution time: 0.0046s

If I scroll very fast the big list (and I obviously find the batch requests on the console), when I tap a record reached with many batch requests and I add\remove it from favorites, many many many many (too many! the more I scroll the more they are!) SELECT statements appears in the console before the UPDATE one. This means total execution time not acceptable (the uibutton freezes for a long time on the iphone).

What's happening? The problem is clearly related to the batched fetch requests. More fetch requests = more SELECT statements before the UPDATE statement. This is one of them:

CoreData: sql: SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZCONTENT, t0.ZCONTENT2, t0.ZISUSER, t0.ZISFAV, t0.ZTITLE, t0.ZTITLE2, t0.ZID, t0.ZAUTHOR, t0.ZCATEGORY FROM ZRECORD t0 WHERE t0.Z_PK IN (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ORDER BY t0.ZTITLE LIMIT 26

If I remove the setFetchBatchSize, there's no problem (but startup requires 16 seconds). It seems that when I update the property ISFAV, CoreData needs to execute again all the fetchRequests that were needed to reach that record, even if I pass that record to the detail viewController as object.

Sorry for the long post, I tried to be as clearer as possible. Thank you very much, I'm driving myself crazy...

A: 

What's happening is the fetched results controller sees the change notification when you update the managed object, and it has to figure out what index that object was so it can tell its delegate that the object was updated. To do this, it's going through all the batch selects again until it can find the right one. I'm not sure why it can't just have this information cached, but obviously it isn't. Have you tried adding a section cache to the fetched results controller? That may possibly speed things up (depending on whether or not the fetched results controller uses that cache in this instance). You do so simply by specifying the cache name when you call -initWithFetchRequest:managedObjectContext:sectionNameKeyPath:cacheName:.

Kevin Ballard
Hi Kevin! Thank you very much! Sure, I think you're right. But I'm already using caching: NSFetchedResultsController *aFetchedResultsController = [[NSFetchedResultsController alloc] initWithFetchRequest:fetchRequest managedObjectContext:managedObjectContext sectionNameKeyPath:nil cacheName:@"Search"];
Donn
In that case, I think you're just going to have to chalk this up as a limitation in NSFetchedResultsController. You should file a [bug report](http://radar.apple.com) about this.
Kevin Ballard
+1  A: 

First, I would suggest to avoid showing to the user a big table with 17K records; instead you should allow the user searching for records and then selecting one of the search results. Anyway, if you want to allow the user selecting a record directly from the big table, you need to think about the fetching process.

Start checking that you have properly indexed in your Core Data model the attributes you use to setup the NSPredicate associate to your NSFetchedResultsController. Think about the size of your "working set" of records. This should be as small as possible, and is usually in the order of hundreds of records.

In your case, setting setFetchBatchSize to 25 is probably not appropriate, given that you want to allow your user browsing 17K records. Since 17000:25 = 680, you will need that many fetches to reach the latest 25 records. But fetching involves actual I/O to the underlying database to make sure that everything is always in sync with other "possible" insert/delete/update operations done by other threads.

Even if your application does not use multiple threads with Core Data, the Core Data framework must check to verify if something changed. Now, since I/O is expensive, you need a tradeoff. Setting setFetchBatchSize to 1000 will require in the worst case 17 fetches to reach the latest 1000 records (improving by a factor of 40) even though each individual fetch may take "slightly" longer.

Using the cache as suggested may provide some benefit unless other threads modify the data. Indeed, cache hits are fast, very fast. However, cache misses are extremely expensive, requiring I/O to fetch the associated data from the database. The chance of cache misses increases of course when multiple threads work simultaneously on the same database (unless these threads only read records).

Another possible solution you may want to try, involves using multiple threads. Your main thread only fetches an initial number of records and presents them to the user while another thread using a different managed object context fetches another batch of records asynchronously, in background (using a proper offset). These records are then handed over to the main thread for visualization.

One more thing. You should not use KVC to update the value of your attributes; for performance reasons it is much better to do something like

record.ISFAV =  [NSNumber numberWithInt:1];

or

[record setISFAV:[NSNumber numberWithInt:1]];

Updating just a single attribute you may not notice a difference, but if you need to work with several attributes, you may start experiencing huge savings.

unforgiven
+1 The first sentence is especially spot on. Sometimes the correct answer to the question, "How do I do this" is "Don't do that."
TechZen
Can you explain why it's bad to use KVC to update the attributes? You lose the compiler support, but the overhead should be fairly minimal. CoreData already generates the accessors on the fly as needed, and my inclination is to believe that KVC leverages that. Do you have benchmarks that show that it actually is a seriously performance penalty?
Kevin Ballard
Using KVC is not wrong, but, besides loosing the compiler support, you also incur an overhead due to KVC internals, since KVC - besides internal bookkeeping - needs to dereference the actual accessor method. This has been explained during WWDC 2010 session 118 "Mastering Core Data". Additional information related to optimization can be found in Session 137 "Optimizing Core Data Performance on iPhone OS".
unforgiven