How Expensive is DateFormatter?

Inspired by Sarun’s post about the price of initializing, configuring and using a DateFormatter I thought I’d do my own tests. Why? Well, two reasons:

  1. Who doesn’t care about the performance of their apps?
  2. I am using a Date –> String converting function all the time.

My current implementation is this:

func localizedDateTime(forDate date: Date, withFormat format: String) -> String {
    let tdFormatter = DateFormatter()
    tdFormatter.setLocalizedDateFormatFromTemplate(format)
    return tdFormatter.string(from: date)
}

So contrary to Sarun’s advice I’ve been creating and destroying a DateFormatter all the time and missing out on the benefits of recycling the instance. Question is: how big is the benefit, and is that worth rewriting „all“ my code?

Since I am not using Apple’s predefined styles but using more than a few different options I thought it could be interesting to see just how big the benefit will be with my use cases. (I’ll share my thoughts on using Unicode’s Date Format Patterns in a future post)

Setup

To do the measurements I used Swift Playgrounds on my MBP. The playgrounds file is at the bottom of this post. Later I used the same code on my iPhone and my Apple Watch for comparison.

I’m using two simple for-Loops to call the rival functions a million times in a row and measure the timeInterval between start and end of each loop.

As mentioned in Sarun’s post and congruent with my own results there is a difference between generating one million strings with the same settings and generating the same amount with all different settings. So I need a setup to quickly and fairly differentiate between those two extremes. For that I first define an array of different format patterns and comment-out some of those lines for fewer variations:

let formats = [
    "eeeedMMMMYYYYjjmmss",
    "eddMMYYjmm",
    "jmm", "dMY", "eeeedMMM", "eeee", "jmmss", "eedMM",
    "ddMMYY"
]

To pick one of those patterns I define a random format. In essence (omitting the safety for an empty array):

var randomFormat: String {
    let rndIndex = Int.random(in: formats.startIndex..<formats.endIndex)
    return formats[rndIndex]
}

And in the extension I introduce kind of a cache for the currently set-up format (pattern) so that I can check wether it’s really necessary to change it.

static var formatForLocalizedDateFormatter = "MMMM"

And finally my implementation of the localizedDateTime function in the DateFormatter extension:

static func localizedDateTime(for date: Date, withFormat format: String = randomFormat) -> String {
        // only set format if it's not set to the right format already
        if formatForLocalizedDateFormatter != format {
            localizedDateFormatter.setLocalizedDateFormatFromTemplate(format)
            formatForLocalizedDateFormatter = format
        }
        return localizedDateFormatter.string(from: date)
    }

Results

I got the following results comparing the execution time of the two loops with three setups. That’s the three respective numbers in this order:

  1. With only one pattern in the array (so no switching between patterns) the speed increase is 14.5x, 17.2x, 16.2x.
  2. With two patterns (50% chance of having to update the pattern) the speed increase is 6.3x, 9x, 7.1x.
  3. And with all nine patterns (89% chance of having to update the pattern) the speed increase is 4.9x, 6.2x, 5.2x.

Footnotes:

  • On Apple Watch I decreased the number of iterations to 500,000 to keep runtime reasonably low.
  • The execution times varied from try to try so take the numbers with a grain of salt. The overall should be obvious never the less.

Conclusion

So my take is:

  • When formatting strings for a list or an array, especially when handling lots of data, defining a dedicated DateFormatter with parameters just for this use case is the only reasonable way to go.
  • When – like me – you have a (larger) set of functions with predefined formats (like fullWeekdayDayMonthNumbers(fromDate) I recommend dedicated DateFormatters for each one and rewriting all function calls accordingly. This is what I’ll be doing the next few days.If that’s too much of a transition then at least create one DateFormatter extension with a cache and hook that one up in all those functions while keeping the function calls as they were.
  • When you are using one function with varying parameters (like localizedDateTime(for:withFormat) above) then I highly recommend using a cached DateFormatter extension like I used here.
  • When you have few places in your app where you’re showing a date that very infrequently changes I recommend using a DateFormatter on the fly without any bells and whistles.

So: thanks Sarun, for inspiring a new, recyclable implementation for my applications.

This is the „final“ extension I’m going to use from now on with Unicode patterns:

extension DateFormatter {
    // format cache
    static var formatForLocalizedDateFormatter = "MMMM"
    // create the formatter once and recycle it as long as proggy is alive
    static let localizedDateFormatter: DateFormatter = {
        let df = DateFormatter()
        df.setLocalizedDateFormatFromTemplate(formatForLocalizedDateFormatter)
        return df
    }()
    static func localizedDateTime(for date: Date, withFormat format: String) -> String {
        // only set format if it's not set to the right format already
        if formatForLocalizedDateFormatter != format {
            localizedDateFormatter.setLocalizedDateFormatFromTemplate(format)
            formatForLocalizedDateFormatter = format
        }
        return localizedDateFormatter.string(from: date)
    }
}

And here’s my Playground file:

import PlaygroundSupport
import Foundation

// array of formats
// comment out some of the lines to test out the effect of more or less 
// frequently changing the format between calls.
// spoiler: the less format is changed the faster it goes. Who would've thought.
let formats = [
    //"eeeedMMMMYYYYjjmmss",
    //"eddMMYYjmm",
    //"jmm", "dMY", "eeeedMMM", "eeee", "jmmss", "eedMM",
    "ddMMYY"
]

// pick out one format randomly.
var randomFormat: String {
    if !formats.isEmpty {
        let rndIndex = Int.random(in: formats.startIndex..<formats.endIndex)
        return formats[rndIndex]
    } else {
        return "eeeedMMMYYYYjjmmss"
    }
}

// the stupid function that always first initializes a brand new formatter, 
// then sets the format, returns the string and lastly discards the formatter.
func localizedDateTime(forDate date: Date, withFormat format: String = randomFormat) -> String {
    let tdFormatter = DateFormatter()
    tdFormatter.setLocalizedDateFormatFromTemplate(format)
    return  tdFormatter.string(from: date)
}

extension DateFormatter {
    // format cache
    static var formatForLocalizedDateFormatter = "MMMM"
    // create the formatter once and recycle it as long as proggy is alive
    static let localizedDateFormatter: DateFormatter = {
        let df = DateFormatter()
        df.setLocalizedDateFormatFromTemplate(formatForLocalizedDateFormatter)
        return df
    }()
    static func localizedDateTime(for date: Date, withFormat format: String = randomFormat) -> String {
        // only set format if it's not set to the right format already
        if formatForLocalizedDateFormatter != format {
            localizedDateFormatter.setLocalizedDateFormatFromTemplate(format)
            formatForLocalizedDateFormatter = format
        }
        return localizedDateFormatter.string(from: date)
    }
}

print("Comparison of \(formats.count) pattern strings")
print("Starting 1.000.000 extensions' function calls @", DateFormatter.localizedDateTime(for: Date(), withFormat: "jmmss.SSSSS"))
var start = Date() 
for i in 1...1_000_000 {
    let string = DateFormatter.localizedDateTime(for: Date())
    let moreString = "Extesions' alive @ " + string
    if i.isMultiple(of: 250_000) { print(moreString) }
}
var end = Date()
let extPerformance = end.timeIntervalSince(start) 
print("Done. @", DateFormatter.localizedDateTime(for: Date(), withFormat: "jmmss.SSSSS"))
print("1.000.000 extension calls took \(numberFormatted(extPerformance)) seconds, that's \(numberFormatted(1_000_000.0 / extPerformance)) calls / second")
print("Starting 1.000.000 stupid function calls @", localizedDateTime(forDate: Date(), withFormat: "jmmss.SSSSS"))
start = Date()
for i in 1...1_000_000 {
    let string = localizedDateTime(forDate: Date())
    let moreString = "Function's alive @ " + string
    if i.isMultiple(of: 250_000) { print(moreString) }
}
end = Date()
print("Done. @", localizedDateTime(forDate: Date(), withFormat: "jmmss.SSSSS"))
let funcPerformance = end.timeIntervalSince(start)
print("1.000.000 function calls took \(numberFormatted(funcPerformance)) seconds, that's \(numberFormatted(1_000_000.0 / funcPerformance)) calls / second")
print("Extension beats function by \(numberFormatted(funcPerformance - extPerformance)) seconds \(numberFormatted(funcPerformance / extPerformance))x")


//just to prettyfy output, not part of performance measurement
func numberFormatted(_ theValue: Double) -> String {
    let nFormatter = NumberFormatter()
    nFormatter.maximumFractionDigits = 1
    nFormatter.minimumFractionDigits = 0
    nFormatter.numberStyle = .decimal
    return nFormatter.string(from: NSNumber(value: theValue)) ?? "??"
}