Improving ScriptingBridge Performance Using NSProxy & NSCache
The Problem
The ScriptingBridge API is an excellent way to tap into the internals of OS X applications with little effort. However, it does have its drawbacks. Not the least of which being lazy evaluation of SBObject's attributes. Lazy evaluation makes the retrieval of SBObject's very efficient since all of the objects attributes are not retrieved at the same time. However, if you need to sort those objects by a particular key (say the name of a movie) the latency of ScriptingBridge becomes glaringly obvious to the point of becoming unusable.
The Solution
We can solve this problem using a combination of NSCache and NSProxy. NSProxy provides a relatively simple API for wrapping NSObject's and intercepting messages sent to them. By doing so, you can interecept the message and if it's been previously called, retrieve the value from an NSCache.
The Implementation
For our example, we'll use iTunes. To do so we first have to generate an iTunes objc header file using sdef and sdp.
% sdef /Applications/iTunes.app | sdp -fh --basename iTunes
This produces a header file called "iTunes.h" that we include in our project to instantiate the iTunes SBApplication object.
This is what our source looks like so far:
NSArray* getTracks()
{
iTunesApplication *itunes;
itunes = [SBApplication applicationWithBundleIdentifier:@"com.apple.iTunes"]; // Retrieve a list of movies in our iTunes library.
NSArray *sources = [[itunes sources] get];
NSPredicate *predicate = [NSPredicate
predicateWithFormat:@"name == 'Library' && kind == %i",
iTunesESrcLibrary]; NSArray *libs = [sources filteredArrayUsingPredicate:predicate]; NSMutableArray *theMovies = [NSMutableArray array];
NSArray *playlists;
NSArray *movieLists; for (iTunesSource *source in libs) {
playlists = [source playlists];
movieLists = [playlists filteredArrayUsingPredicate:
[NSPredicate predicateWithFormat:@"name == 'Movies'"]]; for (iTunesPlaylist *playlist in movieLists) {
for (iTunesTrack *track in [playlist tracks]) {
[theMovies addObject:track];
}
}
} return theMovies;
};
The iTunesTrackProxy Object
Not terribly interesting, but now to the fun part. We create a proxy object to wrap the tracks we retrieved in our getTracks method and act as an intermediary to any requests for track attributes.
For our purposes we need to override three of the NSProxy objects methods to handle NSInvocations sent to the track object.
- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel
- (void)forwardInvocation:(NSInvocation *)invocation
- (BOOL)respondsToSelector:(SEL)aSelector
We want to respond to all the same methods that the original iTunesTrack object does, so we simply pass the method to the proxies track object.
- (BOOL)respondsToSelector:(SEL)aSelector
{
return [self.track respondsToSelector:aSelector];
}
We also want our object to transparantly behave just like a iTunesTrack for method returns. So all method signatures will also be identical.
- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel
{
NSMethodSignature *sig;
sig = [self.track methodSignatureForSelector:sel];
return sig;
}
The real action
The real action happens in the forwardInvocation. This is where we catch message invocations intended for the iTunesTrack object and return the values we want from the sources we want (e.g. NSCache). Since I'm not covering the uninteresting boiler plate code in detail, here's our iTunesTrackProxy header and implementation source so far:
#import @class iTunesTrack; @interface iTunesTrackProxy : NSProxy {
@private
iTunesTrack *_track;
NSCache *_cache;
} - (id)initWithTrack:(iTunesTrack *)track; @property(readonly) iTunesTrack *track;
@end
#import "iTunesTrackProxy.h"
#import "iTunes.h" @implementation iTunesTrackProxy - (id)initWithTrack:(iTunesTrack *)track
{
_track = [track retain];
_cache = [[NSCache alloc] init];
return self;
} - (void)dealloc
{
[_track release];
[_cache release];
[super dealloc];
} - (NSMethodSignature *)methodSignatureForSelector:(SEL)sel
{
NSMethodSignature *sig;
sig = [self.track methodSignatureForSelector:sel];
return sig;
} - (BOOL)respondsToSelector:(SEL)aSelector
{
return [self.track respondsToSelector:aSelector];
}
- (void)forwardInvocation:(NSInvocation *)invocation
{ }
@synthesize track = _track;
@end
First attempt at the forwardInvocation method
A naive first attempt might look similar to this:
- (void)forwardInvocation:(NSInvocation *)invocation
{
// Using the string representation of the selector as the NSCache key.
NSString *key = NSStringFromSelector([invocation selector]); // First check and see if we've already cached the object
id result = [_cache objectForKey:key]; // If the object is cached, use it as the returnValue
if (result) {
[invocation setReturnValue:&result]; } else {
// if not cached, forward it to the track object, then cache the return
[invocation invokeWithTarget:self.track];
[invocation getReturnValue:&result];
[_cache setObject:result forKey:key];
}
}
Simple enough. However, it makes a critically flawed assumption.Specifically that all method invocations to the iTunesTrack object return NSObjects. A quick glance at the iTunes.h header proves otherwise and many return primitive types. Just to keep everything interesting, NSCache only stores NSObjects, thus we have to transform the primitive return types to an NSObject when storing and retrieving these values.
To resolve this oversight we create a few more methods.
- (void)setValueFromTrack:(NSInvocation *)invocation
- (void)setValueFromCacheObj:(id)obj invocation:(NSInvocation *)inv
- (id)mapBuffer:(voidPtr)buffer type:(NSString *)type
- (void *)mapObject:(id)obj key:(NSString *)key
Final implementation
Let's walk through each of our new methods and then the final implementation of the forwardInvocation method.
setValueFromTrack
If the result is not found in the cache, this method retrieves it from track itself. If it's an NSObject, it stores the value directly in the NSCache. If it's a primitive, it passes the value to the mapBuffer:type method to retrieve an appropriate object for storing in the NSCache.
- (void)setValueFromTrack:(NSInvocation *)invocation
{
NSString *key = NSStringFromSelector([invocation selector]); NSString *returnType = [NSString
stringWithUTF8String:[[invocation methodSignature] methodReturnType]];
id result; // retrieve value from track object
[invocation invokeWithTarget:self.track]; // if object is of type NSObject.
if ([returnType isEqualToString:@"@"]) {
[invocation getReturnValue:&result]; if (!result) {
// retrieved value does not exist for track
// set to default
result = @"None";
[invocation setReturnValue:&result];
} [_cache setObject:result forKey:key]; } else {
void* buffer;
NSUInteger length = [[invocation methodSignature]
methodReturnLength];
buffer = (void*)malloc(length);
[invocation getReturnValue:buffer]; id obj = [self mapBuffer:buffer type:returnType];
// done with buffer
free(buffer); // cache value
[_cache setObject:obj forKey:key];
} }
In this implementation we see a new object in play, specifically the NSMessageSignature and its methodReturnType. The methodReturnType is an objc type encoding. Here, we only check for the "@", or NSObject, type. In our mapBuffer:type: method we'll need to handle all the other types we care about.
mapBuffer:type:
For iTunesTrack objects, NSNumber is perfectly adequate at storing all the primitives we're interested in storing. Also, to make mapping of buffers to void pointers easier, we create a union.
typedef union {
char *c;
int *i;
short *s;
long *l;
long long *q;
unsigned char *C;
unsigned int *I;
unsigned short *S;
unsigned long *L;
unsigned long long *Q;
float *f;
double *d;
_Bool *B;
void *v;
} TRACKDATA;
The name of each union item is the same as its type encoding key. We use the union and the appropriate type encoding to store the primitive data type in an NSNumber in the mapBuffer:type: method.
- (id)mapBuffer:(voidPtr)buffer type:(NSString *)type
{
id obj; TRACKDATA data;
data.v = buffer; if ([type isEqualToString:@"c"]) {
obj = [NSNumber numberWithChar:*data.c]; } else if ([type isEqualToString:@"i"]) {
obj = [NSNumber numberWithInt:*data.i]; } else if ([type isEqualToString:@"s"]) {
obj = [NSNumber numberWithShort:*data.s]; } else if ([type isEqualToString:@"l"]) {
obj = [NSNumber numberWithLong:*data.l]; } else if ([type isEqualToString:@"q"]) {
obj = [NSNumber numberWithLongLong:*data.q]; } else if ([type isEqualToString:@"C"]) {
obj = [NSNumber numberWithUnsignedChar:*data.C]; } else if ([type isEqualToString:@"I"]) {
obj = [NSNumber numberWithUnsignedInt:*data.I]; } else if ([type isEqualToString:@"S"]) {
obj = [NSNumber numberWithUnsignedShort:*data.S]; } else if ([type isEqualToString:@"L"]) {
obj = [NSNumber numberWithUnsignedLong:*data.L]; } else if ([type isEqualToString:@"Q"]) {
obj = [NSNumber numberWithUnsignedLongLong:*data.Q]; } else if ([type isEqualToString:@"f"]) {
obj = [NSNumber numberWithFloat:*data.f]; } else if ([type isEqualToString:@"d"]) {
obj = [NSNumber numberWithDouble:*data.d]; } else if ([type isEqualToString:@"B"]) {
// The BOOL type is a special case, so we do a little dance here.
BOOL val;
if (*data.B) {
val = YES;
} else {
val = NO;
}
obj = [NSNumber numberWithBool:val];
} else {
// Raise an exception if we receive a data type we're not prepared
// for...
[NSException
raise:@"Unhandled NSMethodSignature:methodReturnType:"
format:@"NSMethodSignature:methodReturnType: %@", type];
} return obj;
}
setValueFromCacheObj:invocation:
The reverse case is retrieving an object from the cache. We accomplish that by first checking if the invocation's returnType is an object and if not transforming our cache object into a primitive type with mapObject:type.
- (void)setValueFromCacheObj:(id)obj invocation:(NSInvocation *)inv
{
NSString *returnType = [NSString
stringWithUTF8String:[[inv methodSignature] methodReturnType]]; if ([returnType isEqualToString:@"@"]) {
[inv setReturnValue:&obj]; } else {
void *buffer = [self mapObject:obj type:returnType];
[inv setReturnValue:buffer];
// done with buffer
free(buffer);
} }
mapObject:type:
The reverse mapping from object to buffer is done by the mapObject:type: method. It's pretty much just the inverse of the mapBuffer:type: method.
- (void *)mapObject:(id)obj type:(NSString *)type
{
void *buffer; if ([type isEqualToString:@"c"]) {
buffer = (char *)malloc(sizeof(char));
*(char *)buffer = [obj charValue]; } else if ([type isEqualToString:@"i"]) {
buffer = (int *)malloc(sizeof(int));
*(int *)buffer = [obj intValue]; } else if ([type isEqualToString:@"s"]) {
buffer = (short *)malloc(sizeof(short));
*(short *)buffer = [obj shortValue]; } else if ([type isEqualToString:@"l"]) {
buffer = (long *)malloc(sizeof(long));
*(long *)buffer = [obj longValue]; } else if ([type isEqualToString:@"q"]) {
buffer = (long long *)malloc(sizeof(long long));
*(long long *)buffer = [obj longLongValue]; } else if ([type isEqualToString:@"C"]) {
buffer = (unsigned char *)malloc(sizeof(unsigned char));
*(unsigned char *)buffer = [obj unsignedCharValue]; } else if ([type isEqualToString:@"I"]) {
buffer = (unsigned int *)malloc(sizeof(unsigned int));
*(unsigned int *)buffer = [obj unsignedIntValue]; } else if ([type isEqualToString:@"S"]) {
buffer = (unsigned short *)malloc(sizeof(unsigned short));
*(unsigned short *)buffer = [obj unsignedShortValue]; } else if ([type isEqualToString:@"L"]) {
buffer = (unsigned long *)malloc(sizeof(unsigned long));
*(unsigned long *)buffer = [obj unsignedLongValue]; } else if ([type isEqualToString:@"Q"]) {
buffer = (unsigned long long *)malloc(sizeof(unsigned long long));
*(unsigned long long *)buffer = [obj unsignedLongLongValue]; } else if ([type isEqualToString:@"f"]) {
buffer = (float *)malloc(sizeof(float));
*(float *)buffer = [obj floatValue]; } else if ([type isEqualToString:@"d"]) {
buffer = (double *)malloc(sizeof(double));
*(double *)buffer = [obj doubleValue]; } else if ([type isEqualToString:@"B"]) {
_Bool val;
if ([obj boolValue]) {
val = true;
} else {
val = false;
}
buffer = (_Bool *)malloc(sizeof(_Bool));
*(_Bool *)buffer = val; } else {
[NSException
raise:@"Unhandled NSMethodSignature:methodReturnType:"
format:@"NSMethodSignature:methodReturnType: %@", type];
}
return buffer;
}
Summary & Benchmarks
That pretty much wraps it up. We can use our iTunesProxy object as a drop in replacement for the iTunesTrack object and it will cache all values for that track. This vastly improves query intensive operations such as sorting.
In fact, we'll use sorting as a demonstration on what we've gained from our work. The following output is from the main function attached to the article.
We retrieved 254 unproxied movies We retrieved 254 proxied movies Time elapsed for track sort: -7.293006 Time elapsed for proxy sort: -0.567359
Not the fastest sort in the world, but we did get a 13x improvement bringing the performance from "application breaking" to perfectly workable. Of course, most of the overhead in that second run is from the initial caching of the track values. Let's see what we get on a second run of our proxy array:
Time elapsed for proxy sort second run: -0.017478
That's more like it! After the initial hit when caching our variables, we are rewarded with a 365x increase in sorting speed.
It's easy to see how this pattern is extendible outside the scope of iTunes tracks or even ScriptingBridge to any situation where the cost of retrieval far out weighs a minor hit to memory.
Comments !