Performance of Automatic Equality Checks

Sat, 16 Mar 2024 12:29:00 +0100

Testing the speed of automated equality in both Objective-C and Swift.

This is an update to my previous article where I showed how you could compare objects in an automated way using the Objective-C runtime. At the end of it I asked:

It makes me wonder though, how would this compare to the compiler-generated == and hash(into:) functions of a Swift class? 🤔

It was a throwaway comment to wrap up the article, but since then it’s been bugging me. So I just had to find out the answer!

The test candidates

For the performance showdown I created four versions of the same data model: a Swift struct, a Swift class and two Objective-C classes. Each is a reasonably large data structure with 24 properties, so we can really stress the different equality implementations.

1. Swift struct

First up is the Swift struct with automatic Hashable conformance, which means the compiler will generate the == and hash(into:) functions for us:

struct EqPerfStruct: Hashable {
    var byte1: UInt8
    var int1: Int
    var arr1: [String]
    var dbl1: Double
    // plus the same types repeated 5 more times
}

2. Swift class

For the Swift class we have to manually implement the equality functions because the compiler does not generate these for classes:

final class EqPerfSwiftClass: Hashable {
    var byte1: UInt8
    var int1: Int
    var arr1: [String]
    var dbl1: Double
    // plus the same types repeated 5 more times

    static func == (lhs: EqPerfSwiftClass, rhs: EqPerfSwiftClass) -> Bool {
        guard lhs.byte1 == rhs.byte1 else { return false }
        guard lhs.int1 == rhs.int1 else { return false }
        guard lhs.arr1 == rhs.arr1 else { return false }
        guard lhs.dbl1 == rhs.dbl1 else { return false }
        // test the remaining 20 properties...
        return true
    }
}

One interesting thing I learned: this is exactly how the Swift compiler generates the automatic Equatable conformance.

3. Objective-C class using manual equality

The first Objective-C class has a standard hand-coded isEqual method:

@interface EqPerfObjCClassManual: NSObject

@property (nonatomic) uint8 byte1;
@property (nonatomic) NSInteger int1;
@property (nonatomic) NSArray *arr1;
@property (nonatomic) double dbl1;
// plus the same types repeated 5 more times

@end


@implementation EqPerfObjCClassManual

- (BOOL)isEqual:(id)object {
    if (![object isKindOfClass:EqPerfObjCClassManual.class]) { return NO; }
    EqPerfObjCClassManual *other = (EqPerfObjCClassManual *)object;

    return self.byte1 == other.byte1
        && self.int1 == other.int1
        && [self.arr1 isEqualToArray:other.arr1]
        && self.dbl1 == other.dbl1
        // test the remaining 20 properties...
}

@end

4. Objective-C class using automatic equality

Finally, we have an Objective-C class with an identical interface but using the automatic ivar-based equality from the previous article. This one is called EqPerfObjCClassAuto.

The test setup

For each of the four candidates I measured the performance using XCTestCase.measure. Since each equality check takes only a few nanoseconds, I called them 100,000 times per test run:

func testStructs() {
    let s1 = EqPerfStruct(), s2 = EqPerfStruct()

    measure {
        for _ in 0..<100_000 {
            _ = s1 == s2
        }
    }
}

I made sure that the tests were run in the Release configuration with diagnostics disabled.

Initial results

The performance tests produced an obvious winner:

Candidate | Notes | Time
Swift struct | Generated equality | 7 ms
Swift class | Manual equality | 30 ms
ObjC class | Manual equality | 33 ms
ObjC class | Automatic equality | 24 ms

The clear takeaway here is that the equality functions generated by the Swift compiler for structs are really fast and you should definitely take advantage of those whenever possible. I was surprised that the Swift and Objective-C classes are very comparable performance-wise, considering that the Swift class is using static dispatch versus dynamic dispatch for the ObjC class. The automated equality method is around 30-40% faster than the hand-coded one which lines up with my experience in production code.

But I wasn’t done yet, there were a few other variations I wanted to test...

Non-final Swift class

I was curious how much difference declaring the Swift class as final made, so I made another version of it which was not marked as final:

Candidate | Notes | Time
Swift class | Manual equality, final | 30 ms
Swift class | Manual equality, non-final | 51 ms

This actually looks a bit suspicious, even a non-final Swift class shouldn’t be so much slower than the Objective-C equivalent 🤨

After checking the High-Performance Swift docs I noticed that “whole-module optimisation” is off by default even in Release builds. When WMO was enabled the Swift class tests got ridiculously fast (under 0.01ms) even with 1000x as many iterations! Clearly the call to the equality function was being optimised away, so I tweaked the tests to actually use the result of == instead of discarding it and finally got some reasonable-looking results with WMO enabled:

Candidate | Notes | Time
Swift class | Manual equality, final | 6 ms
Swift class | Manual equality, non-final | 11 ms

This looks much more impressive now, and the Swift class and struct are very comparable. The slight advantage to classes may be due to not having to copy around the large struct value.

Swift class with dynamic properties

What if we made the Swift class behave the same as the ObjC class? We can do this by adding the dynamic keyword to each of the Swift properties, which forces them to be called with objc_msgSend just like in Objective-C:

Candidate | Notes | Time
ObjC class | Manual equality | 33 ms
Swift class | Manual equality, dynamic | 33 ms

I found this quite interesting as it implies that all the performance benefits of Swift in this scenario are solely due to using static dispatch.

Direct ivar access in Objective-C

In the previous article we saw that while performing a lot of equality checks, the runtime itself can be a bottleneck because of all the message sending. So what if instead of accessing every property via the getter in our isEqual method we used the ivar directly? Reading _byte1 instead of self.byte1 would save a call to objc_msgSend each time. Our isEqual method from above would look more like this:

return _byte1 == other->_byte1
    && _int1 == other->_int1
    && [_arr1 isEqualToArray:other->_arr1]
    && _dbl1 == other->_dbl1
    // test the remaining 20 ivars...

Candidate | Notes | Time
ObjC class | Manual equality, using properties | 33 ms
ObjC class | Manual equality, using ivars | 12 ms

That is way faster! It’s now very similar to the performance of the non-final Swift class, which makes sense because there is far less message sending going on.

Direct-dispatched Objective-C properties

Did you know that now we can even mark Objective-C methods and properties as direct dispatch? It may be of limited usefulness since it makes your methods non-public and non-overridable, but I was curious to see what difference it makes to equality performance.

I created yet another test class and annotated it as having direct members:

__attribute__((objc_direct_members))
@interface EqPerfObjCClassDirect : NSObject

Despite the article saying “You can’t annotate the primary class interface”, I found this actually worked fine, so maybe it’s been fixed since Mattt wrote that.

Using direct properties certainly makes an improvement, but not quite as much as using ivars instead:

Candidate | Notes | Time
ObjC class | Manual equality, using properties | 33 ms
ObjC class | Manual equality, using ivars | 12 ms
ObjC class | Manual equality, direct properties | 21 ms

Final results

Here are the performance results of all of the different variations:

These pretty much line up with my expectations: Swift is fast due to static dispatch and adding any indirection to that has a performance cost. It’s nice to see that Objective-C is very competitive with the equivalent Swift classes!

But wait, weren’t the automated equality methods supposed to be the fastest option for Objective-C? Well there may have been a couple of details I didn’t mention in that article.

Two more things

Even though automated equality was mainly about reducing boilerplate in the app I worked on, I used two additional shortcuts that made the equality checks significantly faster.

Fast bitwise comparison

Since classes are backed by structs, it’s possible to compare the backing structs of two objects as raw blocks of memory using memcmp. This will only tell you if the objects are bitwise identical, so any object properties must contain the exact same pointers. It’s still a useful shortcut if you expect large objects to be equal a lot of the time.

There is one small wrinkle: we need to skip the isa field at the start of each class because that is no longer a pointer and now contains housekeeping information instead. Given that, we can add this inside our isEqual method:

// Skip the isa fields
Ptr selfAfterIsa = (__bridge void *)self + sizeof(Class);
Ptr otherAfterIsa = (__bridge void *)other + sizeof(Class);
size_t sizeWithoutIsa = class_getInstanceSize(self.class) - sizeof(Class);

// Compare as raw memory, returns 0 if identical
if (memcmp(selfAfterIsa, otherAfterIsa, sizeWithoutIsa) == 0) {
    return YES;
}

// Otherwise continue with memberwise equality...

Using this shortcut in the performance tests results in a time of 7ms, but only when comparing equal objects of course. This is as fast as Swift structs!

Side note: it turns out this memcmp trick does not work on Swift structs because unlike ObjC objects they are not initialised to all zeroes. This means the padding between fields is usually different even with two equal structs.

Cached hash comparison

This second shortcut helps when the objects are unequal, but it does come with a big caveat: the objects must be immutable. In the app I worked on, this was used with classes which only had readonly properties and could not be changed after initialisation (think: Redux state or wrapped JSON payloads).

If your objects are immutable then you can cache their hash values because they should never change. Since objects with different hash values must be unequal, we can quickly test those in isEqual before we compare every property:

// Pre-check of cached hash values
if (self.hash != other.hash) {
    return NO;
}

// Otherwise continue with memberwise equality...

Note that this doesn’t work the other way around; two unequal objects may have the same hash value.

This second shortcut resulted in a time of 6ms when comparing unequal immutable classes, which is also as fast as Swift structs.

Conclusion

Going back to the original question “how does my automated equality in ObjC hold up against Swift?” I have to say the answer is: amazingly well! I honestly didn’t expect it to be just as fast as the generated == function of a Swift struct though.

It’ll be a shame to remove this automated equality during the migration away from Objective-C but I certainly don’t have to worry about the performance of the Swift replacement.