Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Core Data sectionNameKeyPath with Relationship Attribute Performance Issue

I have a Core Data Model with three entities:
Person, Group, Photo with relationships between them as follows:

  • Person <<-----------> Group (one to many relationship)
  • Person <-------------> Photo (one to one)

When I perform a fetch using the NSFetchedResultsController in a UITableView, I want to group in sections the Person objects using the Group's entity name attribute.

For that, I use sectionNameKeyPath:@"group.name".

The problem is that when I'm using the attribute from the Group relationship, the NSFetchedResultsController fetches everything upfront in small batches of 20 (I have setFetchBatchSize: 20) instead of fetching batches while I'm scrolling the tableView.

If I use an attribute from the Person entity (like sectionNameKeyPath:@"name") to create sections everything works OK: the NSFetchResultsController loads small batches of 20 objects as I scroll.

The code I use to instantiate the NSFetchedResultsController:

- (NSFetchedResultsController *)fetchedResultsController {

    if (_fetchedResultsController) {
        return _fetchedResultsController;
    }

    NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] init];
    NSEntityDescription *entity = [NSEntityDescription entityForName:[Person description]
                                              inManagedObjectContext:self.managedObjectContext];

    [fetchRequest setEntity:entity];

    // Specify how the fetched objects should be sorted
    NSSortDescriptor *groupSortDescriptor = [[NSSortDescriptor alloc] initWithKey:@"group.name"
                                                                        ascending:YES];

    NSSortDescriptor *personSortDescriptor = [[NSSortDescriptor alloc] initWithKey:@"birthName"
                                                                         ascending:YES
                                                                          selector:@selector(localizedStandardCompare:)];


    [fetchRequest setSortDescriptors:[NSArray arrayWithObjects:groupSortDescriptor, personSortDescriptor, nil]];

    [fetchRequest setRelationshipKeyPathsForPrefetching:@[@"group", @"photo"]];
    [fetchRequest setFetchBatchSize:20];

    NSError *error = nil;
    NSArray *fetchedObjects = [self.managedObjectContext executeFetchRequest:fetchRequest error:&error];

    if (fetchedObjects == nil) {
        NSLog(@"Error Fetching: %@", error);
    }

    _fetchedResultsController = [[NSFetchedResultsController alloc] initWithFetchRequest:fetchRequest
                                                                    managedObjectContext:self.managedObjectContext sectionNameKeyPath:@"group.name" cacheName:@"masterCache"];

    _fetchedResultsController.delegate = self;

    return _fetchedResultsController;
}

This is what I get in Instruments if I create sections based on "group.name" without any interaction with the App's UI: Core Data Fetch with Sections by Relationship

And this is what I get (with a bit of scrolling on UITableView) if sectionNameKeyPath is nil: Core Data Fetch without any Sections

Please, can anyone help me out on this issue?

EDIT 1:

It seems that I get inconsistent results from the simulator and Instruments: when I've asked this question, the app was starting in the simulator in about 10 seconds (by Time Profiler) using the above code.

But today, using the same code as above, the app starts in the simulator in 900ms even if it makes a temporary upfront fetch for all the objects and it's not blocking the UI.

I've attached some fresh screenshots: Time Profiler with SimulatorUpfront Fetch in Simulator without scrollingUpfront Fetch in Simulator with scrolling and small batch fetches

EDIT 2: I reset the simulator and the results are intriguing: after performing an import operation and quitting the app the first run looked like this: First run after simulator reset and new import After a bit of scrolling: First run after simulator reset, new import and some scrolling Now this is what happens on a second run: Second run after simulator reset and new import After the fifth run: Fifth run

EDIT 3: Running the app the seventh time and eight time, I get this: Seventh runEighth run

like image 675
Razvan Avatar asked Aug 27 '14 15:08

Razvan


1 Answers

This is your stated objective: "I need the Person objects to be grouped in sections by the relationship entity Group, name attribute and the NSFetchResultsController to perform fetches in small batches as I scroll and not upfront as it is doing now."

The answer is a little complicated, primarily because of how an NSFetchedResultsController builds sections, and how that affects the fetching behavior.

TL;DR; To change this behavior, you would need to change how NSFetchedResultsController builds sections.

What is happening?

When an NSFetchedResultsController is given a fetch request with pagination (fetchLimit and/or fetchBatchSize), several things happen.

If no sectionNameKeyPath is specified, it does exactly what you expect. The fetch returns an proxy array of results, with "real" objects for the first fetchBathSize number of items. So for example if you have setFetchBatchSize to 2, and your predicate matches 10 items in the store, the results contain the first two objects. The other objects will be fetched separately as they are accessed. This provides a smooth paginated response experience.

However, when a sectionNameKeyPath is specified, the fetched results controller has to do a bit more. To compute the sections it needs to access that key path on all the objects in the results. It enumerates the 10 items in the results in our example. The first two have already been fetched. The other 8 will be fetched during enumeration to get the key path value needed to build the section information. If you have a lot of results for your fetch request, this can be very inefficient. There are a number of public bugs concerning this functionality:

NSFetchedResultsController initially takes too long to set up sections

NSFetchedResultsController ignores fetchLimit property

NSFetchedResultsController, Table Index, and Batched Fetch Performance Issue

... And several others. When you think about it, this makes sense. To build the NSFetchedResultsSectionInfo objects requires the fetched results controller to see every value in the results for the sectionNameKeyPath, aggregate them to the unique union of values, and use that information to create the correct number of NSFetchedResultsSectionInfo objects, set the name and index title, know how many objects in the results a section contains, etc. To handle the general use case there is no way around this. With that in mind, your Instruments traces may make a lot more sense.

How can you change this?

You can attempt to build your own NSFetchedResultsController that provides an alternative strategy for building NSFetchedResultsSectionInfo objects, but you may run into some of the same problems. For example, if you are using the existing fetchedObjects functionality to access members of the fetch results, you will encounter the same behavior when accessing objects that are faults. Your implementation would need a strategy for dealing with this (it's doable, but very dependant on your needs and requirements).

Oh god no. What about some kind of temporary hack that just makes it perform a little better but doesn't fix the problem?

Altering your data model will not change the above behavior, but can change the performance impact slightly. Batch updates will not have any significant effect on this behavior, and in fact will not play nicely with a fetched results controller. It may be much more useful to you, however, to instead set the relationshipKeyPathsForPrefetching to include your "group" relationship, which may improve the fetching and faulting behavior significantly. Another strategy may be to perform another fetch to batch fault these objects before you attempt to use the fetched results controller, which will populate the various levels of Core Data in-memory caches in a more efficient manner.

The NSFetchedResultsController cache is primarily for section information. This prevents the sections from having to be completely recalculated on each change (in the best case), but can actually make the initial fetch to build the sections take much longer. You will have to experiment to see if the cache is worthwhile for your use case.

If your primary concern is that these Core Data operations are blocking user interaction, you can offload them from the main thread. NSFetchedResultsController can be used on a private queue (background) context, which will prevent Core Data operations from blocking the UI.

like image 77
quellish Avatar answered Oct 16 '22 21:10

quellish