for, forEach, and map (oh my)
We interrupt the recent political ramblings and assorted existential crises to bring you a post about loops in Swift.
I was tempted to use forEach
today but then I started wondering what sort of overhead closures add. So I cobbled together something resembling a test. Staring with an array of ints:
var loopIndices = [Int](0..<20000)>
I used these to create characters which are appended to a string. The “classic” for loop looks like:
for ch in self.loopIndices { self.loopStr.append(UnicodeScalar(ch)?.escaped(asASCII: false) ?? "X") }
The forEach
version looks like:
self.loopIndices.forEach { self.loopStr.append(UnicodeScalar($0)?.escaped(asASCII: false) ?? "X") }
I ran this test 100 times on my AppleTV and the total time for a regular for loop was 1.8076 seconds. The time in the forEach loop? 3.6851 seconds. So basically twice as long. For good measure I made a map version as well that looks like:
self.loopStr = self.loopIndices.map({ UnicodeScalar($0)?.escaped(asASCII: false) ?? "X" }).joined()
The map has to join as well so it’s not surprising that it’s basically the sum of the other two tests coming in at 5.3892 seconds. Let me summarize in this handy table:
Type | Duration | Comments |
---|---|---|
Basic for loop | 1.8076 seconds | |
forEach loop | 3.6851 seconds | Ew |
map | 5.3892 seconds | Super ew |
I thought, since the forEach
closures are not @escaping
, that Swift might be able to do some trickery since it shouldn’t need to capture values, worry about memory management, etc. So on a hunch I tried the same test with release settings (basically -O -whole-module-optimization
instead of -Onone
) and when we do that we get:
Type | Duration | Comments |
---|---|---|
Basic for loop | 1.5675 seconds | |
forEach loop | 1.6468 seconds | Better! Only 5% slower, but then… |
map | 1.5958 seconds | What sorcery is this?! |
This got me wondering what map looks like without the added join
so instead of appending to a String we have:
var loopStrings : [String]
And running this with optimization on yields:
Type | Duration | Comments |
---|---|---|
Basic for loop | 0.4478 seconds | |
forEach loop | 0.5814 seconds | Overhead more visible now. 30% slower |
map | 0.3168 seconds | Maybe it make better guesses about memory allocation? |
In general appending to an Array is faster than appending Strings. No surprise there, but look how much better map is. Okay, so maybe we eliminate the need to muck about with memory in our tests and see what happens. We'll take the same array of Ints but add them up or something. Hmm, but map isn't great for that sort of thing, we'll reduce
instead. We'll also double the number of items in our array of ints, but this is going to be really fast and our profiling numbers may be down in the noise floor. Whatever, the original question was how much overhead do closures add? So our map is now:
self.loopTotal = self.loopIndices.reduce(0) { $0 + $1 }
And the other loops have been modified to produce the same results. In this case our times are:
Type | Duration | Comments |
---|---|---|
Basic for loop | 0.0070 seconds | |
forEach loop | 0.0103 seconds | Not quite 50% slower |
reduce | 0.0093 seconds | Still better than forEach |
With most of the actual work stripped away we can see the closure overhead more clearly. Although it's not a constant value or it would have been at least the difference in time from the previous example (0.1336 seconds). In this case, the difference works out to an extra 3300µS over 4 million iterations. Woo. So if forEach
results in code that you believe is clearer you're probably not going to notice the performance hit. But it will be there so it's probably something you want to avoid if you're looking to conserve every cycle.