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:
- Who doesn’t care about the performance of their apps?
- 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:
- With only one pattern in the array (so no switching between patterns) the speed increase is 14.5x, 17.2x, 16.2x.
- With two patterns (50% chance of having to update the pattern) the speed increase is 6.3x, 9x, 7.1x.
- 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)) ?? "??"
}