2

I've encountered a performance issue bridging between swift and objective-c. I'm trying to fully understand whats going on behind the scenes so that I can avoid it in the future.

I have an objc type Car

@interface Car 
  @property (nonatomic,readonly) NSDictionary<NSString *, Part *> *parts;
@end

Then in swift I enumerate over a huge amount of Car objects and look up a Part from each cars parts dictionary.

for(var car in allCarsInTheWorld) {
  let part = car.parts["partidstring"] //This is super slow. 
}

Overall the loop above in my working app takes about 5-10 seconds. I can work around the problem by modifying the above code like below, which results in the same loop running in milliseconds:

Fixed obj-c file

@interface Car 
  @property (nonatomic,readonly) NSDictionary<NSString *, Part *> *parts;

  // Look up the part from the parts dictionary above
  // in obj-c implementation and return it
  -(Part *)partFor:(NSString *)partIdString; 
@end

Fixed Swift file

for(var car in allCarsInTheWorld) {
  let part = car.partFor("partidstring") //This is fast, performance issue gone. 
}

Whats the cause of the performance dip? The only thing I can think is that the obj-c dictionary is being copied when I access it from swift.

Edit: Added picture of profiling stack. These are the frames as I call into the dictionary. Seems like it has to do with the string rather than the dictionary itself.

Expensive call stack

This seems to be the closest match to the issue I can find: https://forums.swift.org/t/string-performance-how-to-ensure-fast-path-for-comparison-hashing/27436

Craigt
  • 3,418
  • 6
  • 40
  • 56
  • 1
    Have you tried to profile the code with Instruments (Time Profiler)? – A similar observation was made here: https://stackoverflow.com/q/46833459. – Martin R Aug 19 '19 at 11:07

3 Answers3

2

A part of the problem is that bridging NSDictionary<NSString *, Part *> to [String: Part] involves runtime checks for all keys and values of the dictionary. This is needed because the Objective-C generics arguments for NSDictionary don't guarantee that the dictionary won't held incompatible keys/values (for example Objective-C code could add non-string keys or non-Part values to the dictionary). And for large amounts of dictionaries this can become time consuming.

Another aspect is that Swift will also likely create a corresponding dictionary to make it immutable, since the Objective-C one might as well be a `NSMutableDictionary'. This involves extra allocations and deallocations.

Your approach of adding a partFor() function avoids the above two by keeping the dictionary hidden from the Swift world. And it's also better architecturally speaking, as you are hiding the implementation details for the storage of the car parts (assuming you also make the dictionary private).

Cristik
  • 30,989
  • 25
  • 91
  • 127
  • Thank you for this explanation. So simple and makes total sense. I learned something new :) – Craigt Aug 26 '19 at 08:58
0

the problem seems to be that in your first version the whole dictionary is converted to a swift dictionary, in each iteration of the loop.

The code behaves like this:

for(var car in allCarsInTheWorld) {
  let car_parts = car.parts as [String: Part] // This is super slow.
  let part = car_parts["partidstring"] 
}

This is a kind of misfeature of how Swift bridging works here. It would be much faster if the Swift compiler would just call the Objective C method -objectForKeyedSubscript:.

Until that happens, implementing a custom objc method like -partFor: is a good solution if you care for performance.

Michael
  • 6,451
  • 5
  • 31
  • 53
0

Say you have an ObjC class shown blow:

@interface MCADictionaryHolder : NSObject

@property (nonatomic) NSDictionary<NSString *, id> * _Nonnull objects;

- (void)randomise:(NSInteger)upperBound;
- (id _Nullable)itemForKey:(NSString * _Nonnull)key;

@end

@implementation MCADictionaryHolder

- (instancetype)init
{
   self = [super init];
   if (self) {
      self.objects = [[NSDictionary alloc] init];
   }
   return self;
}

-(void)randomise:(NSInteger)upperBound {
   NSMutableDictionary * d = [[NSMutableDictionary alloc] initWithCapacity:upperBound];
   for (NSInteger i = 0; i < upperBound; i++) {
      NSString *inStr = [@(i) stringValue];
      [d setObject:inStr forKey:inStr];
   }
   self.objects = [[NSDictionary alloc] initWithDictionary:d];
}

-(id)itemForKey:(NSString *)key {
   id value = [self.objects objectForKey:key];
   return  value;
}

@end

Say you launching a performance test similar to shown below:

class NSDictionaryToDictionaryBridgingTests: LogicTestCase {
   
   func test_default() {
      let bound = 2000
      let keys = (0 ..< bound).map { "\($0)" }
      var mutableDict: [String: Any] = [:]
      for key in keys {
         mutableDict[key] = key
      }
      let dict = mutableDict
      let holder = MCADictionaryHolder()
      holder.randomise(bound)
      benchmark("Access NSDictionary via Swift API") {
         for key in keys {
            let value = holder.objects[key]
            _ = value
         }
      }
      benchmark("Access NSDictionary via NSDictionary API") {
         let nsDict = holder.objects as NSDictionary
         for key in keys {
            let value = nsDict.object(forKey: key)
            _ = value
         }
      }
      benchmark("Access NSDictionary via dedicated method") {
         for key in keys {
            let value = holder.item(forKey: key)
            _ = value
         }
      }
      benchmark("Access to Swift Dictionary via Swift API") {
         for key in keys {
            let value = dict[key]
            _ = value
         }
      }
   }
}

Then performance test will show results similar to shown below:

Access NSDictionary via Swift API:
.......... 1103.655ms ± 9.358ms (mean ± SD)
Access NSDictionary via NSDictionary API:
.......... 0.263ms ± 0.001ms (mean ± SD)
Access NSDictionary via dedicated method:
.......... 0.335ms ± 0.002ms (mean ± SD)
Access to Swift Dictionary via Swift API:
.......... 0.174ms ± 0.001ms (mean ± SD)

From results you can see that:

  • Accessing Swift Dictionary via Swift API gives a fastest result.
  • Accessing NSDictionary via NSDictionary API on Swift side a bit slower, but acceptable.

Thus, no need to create a dedicated method to perform operations on NSDictionary, just cast [AnyHashable: Any] to NSDictionary and perform needed operations.

Update

In some cases it is worth to access ObjC via convenience method or property to minimise cost of crossing border between ObjC <-> Swift.

Say you have an ObjC extension shown below:

@interface NSAppearance (MCA)

-(BOOL)mca_isDark;

@end

-(BOOL)mca_isDark {
   if ([self.name isEqualToString:NSAppearanceNameDarkAqua]) {
      return true;
   }
   if ([self.name isEqualToString:NSAppearanceNameVibrantDark]) {
      return true;
   }
   if ([self.name isEqualToString:NSAppearanceNameAccessibilityHighContrastDarkAqua]) {
      return true;
   }
   if ([self.name isEqualToString:NSAppearanceNameAccessibilityHighContrastVibrantDark]) {
      return true;
   }
   return false;
}

@end

Say you launching a performance test similar to shown below:

class NSStringComparisonTests: LogicTestCase {

   func isDarkUsingSwiftAPI(_ a: NSAppearance) -> Bool {
      switch a.name {
      case .darkAqua, .vibrantDark, .accessibilityHighContrastDarkAqua, .accessibilityHighContrastVibrantDark:
         return true
      default:
         return false
      }
   }

   func isDarkUsingObjCAPI(_ a: NSAppearance) -> Bool {
      let nsName = a.name.rawValue as NSString
      if nsName.isEqual(to: NSAppearance.Name.darkAqua) {
         return true
      }
      if nsName.isEqual(to: NSAppearance.Name.vibrantDark) {
         return true
      }
      if nsName.isEqual(to: NSAppearance.Name.accessibilityHighContrastDarkAqua) {
         return true
      }
      if nsName.isEqual(to: NSAppearance.Name.accessibilityHighContrastVibrantDark) {
         return true
      }
      return false
   }

   func test_default() {
      let appearance = NSAppearance.current!
      let numIterations = 1000000
      benchmark("Compare using Swift API", numberOfIterations: numIterations) {
         let value = isDarkUsingSwiftAPI(appearance)
         _ = value
      }

      benchmark("Compare using ObjC API", numberOfIterations: numIterations) {
         let value = isDarkUsingObjCAPI(appearance)
         _ = value
      }

      benchmark("Compare using ObjC convenience property", numberOfIterations: numIterations) {
         let value = appearance.mca_isDark()
         _ = value
      }
   }
}

Then performance test will show results similar to shown below:

Compare using Swift API:
.......... 813.347ms ± 7.560ms (mean ± SD)
Compare using ObjC API:
.......... 534.337ms ± 1.065ms (mean ± SD)
Compare using ObjC convenience property:
.......... 142.729ms ± 0.197ms (mean ± SD)

From results, you can see that getting information from ObjC world via convenience method is the fastest solution.

Vlad
  • 6,402
  • 1
  • 60
  • 74