Search code examples
iosobjective-cswiftperformancegrand-central-dispatch

Why is the performance gap between GCD, ObjC and Swift so large


OC: Simulator iPhoneSE iOS 13; (60-80 seconds)

    NSTimeInterval t1 = NSDate.date.timeIntervalSince1970;
    NSInteger count = 100000;

    for (NSInteger i = 0; i < count; i++) {
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
            NSLog(@"op begin: %ld", i);
            self.idx = i;
            NSLog(@"op end: %ld", i);
            if (i == count - 1) {
                NSLog(@"耗时: %f", NSDate.date.timeIntervalSince1970-t1);
            }
        });
     }

Swift: Simulator iPhoneSE iOS 13; (10-14 seconds)

let t1 = Date().timeIntervalSince1970
let count = 100000
for i in 0..<count {
            DispatchQueue.global().async {
                print("subOp begin i:\(i), thred: \(Thread.current)")
                self.idx = i
                print("subOp end i:\(i), thred: \(Thread.current)")
                if i == count-1 {
                    print("耗时: \(Date().timeIntervalSince1970-t1)")
                }
            }
}

My previous work has been writing code using oc. Recently learned to use swift. I was surprised by the wide performance gap for a common use of GCD


Solution

  • tl;dr

    You most likely are doing a debug build. In Swift, debug builds do all sort of safety checks. If you do a release build, it turns off those safety checks, and the performance is largely indistinguishable from the Objective-C rendition.


    A few observations:

    1. Neither of these are thread-safe regarding their interaction with index, namely your interaction with this property is not being synchronized. If you’re going to update a property from multiple threads, you have to synchronize your access (with locks, serial queue, reader-writer concurrent queue, etc.).

    2. In your Swift code, are you doing a release build? In a debug build, there are safety checks in a debug build that aren’t performed with the Objective-C rendition. Do a “release” build for both to compare apples-to-apples.

    3. Your Objective-C rendition is using DISPATCH_QUEUE_PRIORITY_HIGH, but your Swift iteration is using a QoS of .default. I’d suggest using a QoS of .userInteractive or .userInitiated to be comparable.

    4. Global queues are concurrent queues. So, checking i == count - 1 is not sufficient to know whether all of the current tasks are done. Generally we’d use dispatch groups or concurrentPerform/dispatch_apply to know when they’re done.

    5. FWIW, dispatching 100,000 tasks to a global queue is not advised, because you’re going to quickly exhaust the worker threads. Don’t do this sort of thread explosion. You can have unexpected lockups in your app if you do that. In Swift we’d use concurrentPerform. In Objective-C we’d use dispatch_apply.

    6. You’re doing NSLog in Objective-C and print in Swift. Those are not the same thing. I’d suggest doing NSLog in both if you want to compare performance.

    7. When performance testing, I might suggest using unit tests’ measure routine, which repeats it a number of times.

    Anyway, when correcting for all of this, the time for the Swift code was largely indistinguishable from the Objective-C performance.

    Here are the routines I used. In Swift:

    class SwiftExperiment {
    
        // This is not advisable, because it suffers from thread explosion which will exhaust
        // the very limited number of worker threads.
    
        func experiment1(completion: @escaping (TimeInterval) -> Void) {
            let t1 = Date()
            let count = 100_000
            let group = DispatchGroup()
    
            for i in 0..<count {
                DispatchQueue.global(qos: .userInteractive).async(group: group) {
                    NSLog("op end: %ld", i);
                }
            }
    
            group.notify(queue: .main) {
                let elapsed = Date().timeIntervalSince(t1)
                completion(elapsed)
            }
        }
    
        // This is safer (though it's a poor use of `concurrentPerform` as there's not enough
        // work being done on each thread).
    
        func experiment2(completion: @escaping (TimeInterval) -> Void) {
            let t1 = Date()
            let count = 100_000
    
            DispatchQueue.global(qos: .userInteractive).async() {
                DispatchQueue.concurrentPerform(iterations: count) { i in
                    NSLog("op end: %ld", i);
                }
                let elapsed = Date().timeIntervalSince(t1)
                completion(elapsed)
            }
        }
    }
    

    And the equivalent Objective-C routines:

    // This is not advisable, because it suffers from thread explosion which will exhaust
    // the very limited number of worker threads.
    
    - (void)experiment1:(void (^ _Nonnull)(NSTimeInterval))block {
        NSDate *t1 = [NSDate date];
        NSInteger count = 100000;
        dispatch_group_t group = dispatch_group_create();
    
        for (NSInteger i = 0; i < count; i++) {
            dispatch_group_async(group, dispatch_get_global_queue(QOS_CLASS_USER_INTERACTIVE, 0), ^{
                NSLog(@"op end: %ld", i);
            });
        }
        dispatch_group_notify(group, dispatch_get_main_queue(), ^{
            NSTimeInterval elapsed = [NSDate.date timeIntervalSinceDate:t1];
            NSLog(@"耗时: %f", elapsed);
            block(elapsed);
        });
    }
    
    // This is safer (though it's a poor use of `dispatch_apply` as there's not enough
    // work being done on each thread).
    
    - (void)experiment2:(void (^ _Nonnull)(NSTimeInterval))block {
        NSDate *t1 = [NSDate date];
        NSInteger count = 100000;
    
        dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INTERACTIVE, 0), ^{
            dispatch_apply(count, dispatch_get_global_queue(QOS_CLASS_USER_INTERACTIVE, 0), ^(size_t index) {
                NSLog(@"op end: %ld", index);
            });
    
            NSTimeInterval elapsed = [NSDate.date timeIntervalSinceDate:t1];
            NSLog(@"耗时: %f", elapsed);
            block(elapsed);
        });
    }
    

    And my unit tests:

    class MyApp4Tests: XCTestCase {
    
        func testSwiftExperiment1() throws {
            let experiment = SwiftExperiment()
    
            measure {
                let e = expectation(description: "experiment1")
    
                experiment.experiment1 { elapsed in
                    e.fulfill()
                }
    
                wait(for: [e], timeout: 1000)
            }
        }
    
        func testSwiftExperiment2() throws {
            let experiment = SwiftExperiment()
    
            measure {
                let e = expectation(description: "experiment2")
    
                experiment.experiment2 { elapsed in
                    e.fulfill()
                }
    
                wait(for: [e], timeout: 1000)
            }
        }
    
        func testObjcExperiment1() throws {
            let experiment = ObjectiveCExperiment()
    
            measure {
                let e = expectation(description: "experiment1")
    
                experiment.experiment1 { elapsed in
                    e.fulfill()
                }
    
                wait(for: [e], timeout: 1000)
            }
        }
    
        func testObjcExperiment2() throws {
            let experiment = ObjectiveCExperiment()
    
            measure {
                let e = expectation(description: "experiment2")
    
                experiment.experiment2 { elapsed in
                    e.fulfill()
                }
    
                wait(for: [e], timeout: 1000)
            }
        }
    }
    

    That resulted in

    enter image description here