Direct Calls with Objective-C

Illustration: Direct Calls with Objective-C

In November 2019, an interesting pull request by Pierre Habouzit was merged into LLVM. It promised a new calling convention for Objective-C methods that skip objc_msgSend via the objc_direct attribute. Read on to learn what this new feature means for your work, how you can use it to improve Objective-C codebases, and what the common gotchas of adoption are.

What’s the Big Deal?

Objective-C is a highly dynamic language. Its feature-rich runtime provides the foundation for functionality like the responder chain and KVC (runtime introspection), Core Data (at-runtime addition of methods), KVO (at-runtime subclassing and changing concrete object types), and of course swizzling.

In contrast, Swift is a much more strongly typed language. Methods can still be overridden and called dynamically, however, this is something people have to opt in to on a per-function basis.

Unannotated monomorphic dispatch is hard in Objective-C without a JIT compiler. It was always possible to rewrite Objective-C methods as C functions, but the new objc_direct attribute enables developers to tune dynamism vs. performance without busywork syntax changes, and the method behaves like a real Objective-C method, including supporting messaging nil.

Methods marked with objc_direct are invisible across modules, and they can’t be called dynamically or subclassed. For example, forming any @selector() expression will not work with direct selectors.

There are three ways this new feature can be used:

  • objc_direct for individual methods
  • objc_direct_members to mark all methods in an interface or implementation
  • direct as a property modifier, to make a property direct

Here’s how a regular method call looks in (x86_64) assembly.

When objc_direct is applied, we instead jump directly to the implementation.

The Hidden Costs of Objective-C Dynamic Dispatch

Besides the obvious costs of going through objc_msgSend, there are four other types of costs that are often overlooked. I’ll cover them next.

Codegen Size

In addition to self, _cmd is passed to every objc_msgSend, which adds eight bytes to every call site. Since objc_msgSend is used for every method call, these bytes add up quickly. Pierre explains that these two instructions alone represent 10.7 percent of the __text segment in CloudKit.

Optimization Barrier

The dynamic nature of Objective-C requires huge guarantees from the compiler. Inlining is the most obvious one, but it goes further. A simple integer property read could have side effects, so ARC has to insert superfluous objc_retain()/objc_release() calls around the property access.

Static Metadata

Each method in Objective-C takes up 150–250 bytes of metadata. The entry in the class method list generates 24 bytes of static metadata, while the type string is 60+ bytes, and the selector is 20+ bytes on average. On top of that, the pointers need to be relocated on startup, which marks the memory as dirty and reduces startup performance.

Most of this metadata is not needed unless NSInvocation is used, and that isn’t even available in Swift (unless you use tricks).

Runtime Metadata

To make message dispatch fast, the runtime builds up IMP caches. Many of these entries are only used once and add dead weight to the process.

Availability and Defining Macros

While the initial commit landed in November 2019, there have been several follow-up commits, and the feature finally landed in Xcode 12. It’s a compiler feature, so you can use it and still deploy down to earlier versions of the OS.

Apple didn’t add predefined macros this time around, but it’s fairly trivial to define them on our own. The only interesting part here is the availability check: Usually one would use a combination of #if defined(__has_attribute) && __has_attribute(objc_direct_members). The attribute is already available in Xcode 11, but when used, it throws an error the runtime doesn’t yet support, so instead we look for defines that only exist in Xcode 12 and upward (thanks to the new Base SDKs):

Copy
1
2
3
4
5
6
7
8
9
10
// Direct method and property calls with Xcode 12 and above.
#if defined(__IPHONE_14_0) || defined(__MAC_10_16) || defined(__TVOS_14_0) || defined(__WATCHOS_7_0)
#define PSPDF_OBJC_DIRECT_MEMBERS __attribute__((objc_direct_members))
#define PSPDF_OBJC_DIRECT __attribute__((objc_direct))
#define PSPDF_DIRECT ,direct
#else
#define PSPDF_OBJC_DIRECT_MEMBERS
#define PSPDF_OBJC_DIRECT
#define PSPDF_DIRECT
#endif

Please replace the prefix with the prefix that matches your codebase. We already claim PSPDF. 😉

Converting an Existing Codebase

Introducing direct method calls to an existing codebase is no small undertaking. We were able to introduce direct Objective-C calls in just a few days with a codebase that contains about a million lines of code, but you have to have a very tidy codebase and know which part uses runtime tricks to make it work. Here are a few things we learned on the way.

The most practical solution to applying the optimization is to add it to the class @implementation block:

1
2
PSPDF_OBJC_DIRECT_MEMBERS
@implementation PSPDFAvoidingScrollView

This will convert all selectors that are not declared in the interface as direct, which is a relatively safe bet. This works extremely well for model code.

UI code using UIKit or AppKit uses more dynamic features — anything that uses the target/selector approach requires dynamic dispatch. If you use Interface Builder, these methods will already be part of the header, so no change is needed. For UIs built in code, add a private interface where you declare the dynamic methods:

Copy
1
2
3
4
5
@interface PSPDFImageEditViewController ()
// dynamic
- (void)doneTapped:(nullable id)sender;
- (void)cancelTapped:(nullable id)sender;
@end

The same principle works for selector-based notifications. Consider converting the notification handlers to the block-based versions, which both solves the dynamic selector issue and is easier to read in terms of code locality.

The downside of Apple’s API is that the token needs to be stored and later used for deregistration. In PSPDFKit, we’re using a helper category that stores this token into a class via associated objects, and this code automatically deregisters the notification handler when the wrapper class is deallocated.

In short, we replace the following with the block-based variant that doesn’t require dynamic dispatch:

Copy
1
2
3
4
5
6
7
// old
[dnc addObserver:self selector:@selector(textDidChange:) name:UITextViewTextDidChangeNotification object:nil];

// new
[self pspdf_addObserverForName:UITextViewTextDidChangeNotification usingBlock:^(PSPDFAvoidingScrollView *_self, NSNotification *_) {
// run custom code using _self instead of self to avoid retain cycle.
}]

In cases where the notification handler is complex, the method-based approach might still be preferable, specifically to avoid bugs by replacing self with a weak or caller-supplied version.

Common Gotchas

While converting the PSPDFKit codebase, various tests started failing as we added the direct attribute; by far, the largest set of problems had to do with the target/action pattern. In most cases, the compiler will warn about the direct selector, but there are some cases where it won’t:

FB7827311: objc_direct does not recognize super calls from subclasses when they are declared in one file

Xcode does warn if a selector is marked as direct, but it misses this when the selector is available on some other class. If you have a notification handler for didReceiveMemoryWarning, there will not be any warning, since this method is declared on UIViewController:

FB7827387 - objc_direct fails to warn for direct selectors that are public in another class

Lastly, there will be cases where you rely on an override in Objective-C without declaring the method in the interface. This code was prone to break before, but it will definitely fail after applying direct. This category of bugs was the hardest to resolve, but it made the codebase better overall.

Update: It seems Apple took notice and is working on a new warning, -Wstrict-direct-dispatch, which will help with FB7827387.

Benchmarks

To measure impact, I used a small script that counts all class methods and properties before and after I added the `objc_direct attributes.

This script uses a simple prefix check, but it can easily be adjusted to check for classes in a specific bundle/framework instead, or to support multiple prefixes. If you don’t check for prefixes, measure all classes of all frameworks loaded into your application (a number that was north of 600,000 for PDF Viewer):

Copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
void PSPDFCountAllClasses(void);
void PSPDFCountAllClasses(void) {
    NSUInteger propertiesCount = 0, methodsCount = 0;

    unsigned int numClasses;
    Class *classes = objc_copyClassList(&numClasses);
    if (numClasses > 0) {
        classes = (__unsafe_unretained Class *)malloc(sizeof(Class) * numClasses);
        numClasses = objc_getClassList(classes, numClasses);
        for (unsigned int i = 0; i < numClasses; i++) {
            Class clazz = classes[i];
            if (![@(class_getName(clazz)) hasPrefix:@"PSPDF"]) {
                continue;
            }
            u_int count;
            objc_property_t *properties = class_copyPropertyList(clazz, &count);
            NSMutableArray *propertyArray = [NSMutableArray arrayWithCapacity:count];
            for (unsigned int propIndex = 0; propIndex < count; propIndex++) {
                const char *propertyName = property_getName(properties[propIndex]);
                [propertyArray addObject:(id)(@(propertyName))];
            }
            propertiesCount += count;
            free(properties);

            Method *methods = class_copyMethodList(clazz, &count);
            NSMutableArray *methodArray = [NSMutableArray arrayWithCapacity:count];
            for (unsigned int methodIndex = 0; methodIndex < count; methodIndex++) {
                SEL selector = method_getName(methods[methodIndex]);
                const char *methodName = sel_getName(selector);
                [methodArray addObject:(id)(@(methodName))];
            }
            methodsCount += count;
            free(methods);
        }
        free(classes);
    }
    NSLog(@"properties: %@, methods: %@, total: %@", @(propertiesCount), @(methodsCount), @(propertiesCount + methodsCount));
}

Do not ship the above benchmark code — while it is not using private APIs, using it would cause an unnecessary slowdown, and it should never be used in production apps.

Below is the result.

  • Without objc_direct

    • Properties: 6,982
    • Methods: 20,511
    • Total: 27,493
    • Size: 23.2 MB
  • With objc_direct

    • Properties: 6,629
    • Methods: 17,147
    • Total: 23,776
    • Size: 22.5 MB

Applying the attribute resulted in 3,717 methods saved, which is more than 13 percent of the total amount. The savings of 700 KB is roughly equal to 3,717 methods * ~200 bytes per methods saved.

This is a significant win. Not measured here is improved performance for app startup, both through the smaller binary and the reduced method relocation work.

What about Swizzling?

Methods marked as direct are much harder to swizzle. It’s not impossible, as dyld supports dyld_dynamic_interpose to even replace C functions, but doing so is quite a bit more difficult, and it doesn’t work if a function has been inlined.

While this hasn’t been the main motivation for this feature, we already see the impact in Apple’s code. EarlGrey (a UI testing framework from Google) stopped working in iOS 14 because a private API method was marked as direct. Luckily, we were able to work around it, but it’s been quite an ugly fix.

Of course, with private APIs, there are absolutely no guarantees, and we’re not in the position to complain; it’s just a side effect of a new feature that makes it extremely easy (and a performance win!) to mark methods as not dynamically callable. Ironically, I heard that this method (_setIsFirstTouchForView:YES) has been marked direct to protect it from being called by other teams inside Apple.

Conclusion

The new objc_direct attribute is a powerful addition to the Objective-C compiler that allows trading flexibility for performance and potentially even security.

If you want to learn more about this topic, I recommend both the NSHipster article and the Clang documentation on objc_direct.

If you want to reduce your binary even further, check out the Preventing Surprisingly Large Objective-C Type Encodings blog post.

PSPDFKit Newsletter

Subscribe to our newsletter for more articles like this.