RevenueCat / purchases-ios

In-app purchases and subscriptions made easy. Support for iOS, watchOS, tvOS, macOS, and visionOS.
https://www.revenuecat.com/
MIT License
2.19k stars 293 forks source link

Performance of `PurchasesReceiptParser.default.parse(from:)` could be drastically improved. #4006

Open Pikacz opened 4 days ago

Pikacz commented 4 days ago

Hey guys! I played a bit with profiler and was able to make PurchasesReceiptParser.default.parse(from:) way faster.

Here are results that I've observed on iPhone 8.

Fastest before changes Slowest after changes Improvement
Time measured via Date 0.0146 0.0035 4 times faster
Cycle measured via mach_absolute_time 352095 85862 4 times faster
Profiler counters Cycles 36 953 516 6 863 165 5 times faster
Profiler counters INST_ALL 76 994 013 7 666 052 10 times less
Profiler counters L1D_TLB_MISS 89 366 17 531 5 times less

Unfortunately I don't have active developer account and I only tested on small receipts that I've found over internet. If you have any big receipt please send me a base64String.

Surprisingly reason of slowness is Date parsing. In particular ISO8601DateFormatter.default called from ArraySlice<UInt8>.toDate. Since all receipts I see have date format yyyy-MM-dd'T'HH:mm:ssZ it's easy to parse components manually. Here is the whole change:

    func toDate() -> Date? {
        if let fastParsed = toDateTryFastParsing() {
            // This approach is around ~60% faster than `ISO8601DateFormatter.default`
            return fastParsed
        }
        guard let dateString = String(bytes: Array(self), encoding: .ascii) else { return nil }

        return ISO8601DateFormatter.default.date(from: dateString)
    }

    func toData() -> Data {
        return Data(self)
    }

}

private extension ArraySlice where Element == UInt8 {
    static let toDateCalendar: Calendar = {
        var calendar = Calendar(identifier: .gregorian)
        calendar.timeZone = TimeZone(secondsFromGMT: 0)!
        return calendar
    }()

    private func toDateTryFastParsing() -> Date? {
        // expected format 2015-08-10T07:19:32Z
        guard count == 20 else { return nil }
        let asciiZero: UInt8 = 48
        let asciiNine: UInt8 = 57
        let asciiDash: UInt8 = 45
        let asciiColon: UInt8 = 58
        let asciiT: UInt8 = 84
        let asciiZ: UInt8 = 90
        let limits: [(min: UInt8, max: UInt8)] = [
            (asciiZero, asciiNine), (asciiZero, asciiNine), (asciiZero, asciiNine), (asciiZero, asciiNine), // year
            (asciiDash, asciiDash),
            (asciiZero, asciiNine), (asciiZero, asciiNine), // month
            (asciiDash, asciiDash),
            (asciiZero, asciiNine), (asciiZero, asciiNine), // day
            (asciiT, asciiT),
            (asciiZero, asciiNine), (asciiZero, asciiNine), // hour
            (asciiColon, asciiColon),
            (asciiZero, asciiNine), (asciiZero, asciiNine), // minute
            (asciiColon, asciiColon),
            (asciiZero, asciiNine), (asciiZero, asciiNine), // second
            (asciiZ, asciiZ)
        ]
        for (character, limit) in zip(self, limits) {
            guard limit.min <= character,
                  character <= limit.max else { return nil }
        }

        let year = toDateParseAsciiNumber(from: 0, to: 4)
        let month = toDateParseAsciiNumber(from: 5, to: 7)
        guard 1 <= month,
              month <= 12 else { return nil }
        let day = toDateParseAsciiNumber(from: 8, to: 10)
        guard 1 <= day,
              day <= 31 else { return nil }
        let hour = toDateParseAsciiNumber(from: 11, to: 13)
        guard 0 <= hour,
              hour <= 23 else { return nil }
        let minute = toDateParseAsciiNumber(from: 14, to: 16)
        guard 0 <= minute,
              minute <= 59 else { return nil }
        let second = toDateParseAsciiNumber(from: 17, to: 19)
        guard 0 <= second,
              second <= 59 else { return nil }

        let components = DateComponents(
            year: year, month: month, day: day, hour: hour, minute: minute, second: second
        )
        return Self.toDateCalendar.date(from: components)
    }

    private func toDateParseAsciiNumber(from: Int, to: Int) -> Int { // swiftlint:disable:this identifier_name
        let asciiZero: UInt8 = 48
        var index = from + startIndex
        let end = to + startIndex
        var result = 0
        while index < end {
            let digit = self[index] - asciiZero
            result = result * 10 + Int(digit)
            index += 1
        }
        return result
    }
}

I have prepared branch with tests ensuring that this change has no impact on SDK behavior. Unfortunately I don't have permission to push. Could you grant me permissions so I could prepare pull-request and you could decide if you want this improvement or not?

Btw it's crazy that this change has so huge impact. I would imagine that Self.toDateCalendar.date(from: components) does most of the work. It also shows that industry standards are really bad. Apple's server had some kind integer counting seconds, they wasted compute to create string, on device we receive string and waste compute to calculate some kind integer counting seconds 🙃

RCGitBot commented 4 days ago

👀 We've just linked this issue to our internal tracker and notified the team. Thank you for reporting, we're checking this out!