3x performance regression from Swift 4.2 to 5 — help needed

I am encountering an ~3-4x performance regression when switching from Swift 4.2 to Swift 5.

Here is an Instruments trace from Swift 4.2:

And one from Swift 5:

29

Notice that equality checks, hashing and object destruction (even for structs and enums) have become much slower with Swift 5, which I assume is related to extra overhead added by ABI stability.

I have tried to reproduce this slowdown with an isolated test case in https://github.com/MrMage/Swift5PerformanceTest (the Swift5PerformanceTestApp target), but Swift 5 is actually faster there.

The code's task is to take an array of elements combined with a list of associated groups (ElementWithGroups), and group that recursively by the list of associated groups. Essentially, it would convert {element: XYZ, groups: [A, B, C]} to {A: {B: {C: [XYZ]}}} (note that in reality there would be thousands of elements that all have the same set of groups [A, B, C]). The actual "group" type (DetailedGroupSpecifier) is an enum with lots of cases that have associated values (some of which are Optionals), and apparently comparing these enum instances with == as part of changing the dictionary's values in the grouping process is slow due to lots of type metadata lookups for the associated values.

A slightly edited version of this code can be found at https://gist.github.com/MrMage/f96627e354c07cb8ba9246760576c9d9?ts=4. Note that replacing the custom == and hash(into:) with synthesized ones does not affect performance. Changing TaskActivityProperties et al. to structs does not make a difference, either.

Any suggestions as to what I could try in order to improve performance (e.g. specializing the dictionary or avoiding the metadata lookups somehow) would be greatly appreciated.

Thanks,
Daniel

In the Swift 5.0 trace, it looks like Date is being accessed resiliently, because it has a non-fragile layout in Swift 5. Is it any faster if you store the date locally in a more concrete format, such as an Int?

If it's Date specifically, TimeInterval is a round-trippable concrete format.

1 Like

Wow, replacing Date immediately restored performance to almost the previous level. Thank you so much!

The problem is that my app is a time-tracking app and uses Dates pervasively throughout the app. Given that the ABI is now stable with Swift 5, I assume we can not expect that Date will ever return to be the blazing-fast type it used to be? Are there any "easy" workarounds for this?

Alternatively, I am thinking of creating a simple FastDate type that allows fast processing where needed and quick instantiation of the underlying date where needed:

public struct FastDate: Hashable {
	public let value: TimeInterval

	@inlinable public init(_ value: TimeInterval) {
		self.value = value
	}

	@inlinable public var dateValue: Date { return Date(timeIntervalSince1970: value) }
}

public extension Date {
	@inlinable var fastValue: FastDate { return FastDate(timeIntervalSince1970) }
}

I do have a few questions, about this approach:

  1. Would using timeIntervalSinceReferenceDate instead of timeIntervalSince1970 be slightly faster, given that Foundation Dates are stored relative to the reference date? Would that difference matter in practice?
  2. Will the performance penalty be relevant only for the == and hash methods on Date, or for other methods on Date as well?
  3. What would be the most idiomatic way to convert between Date and FastDate? dateValue and fastValue properties as above, or using constructors on Date and FastDate instead? Those would be slightly more annoying to use with optionals, though — date.map { FastDate($0) } instead of date?.fastValue.
  4. Speaking of optionals: Now that I have more control over the structure of the FastDate type, I am thinking of using one of the bits in that type to indicate not being set, letting me avoid an extra byte in Optional<FastDate>. Is that at all possible, or are these details hidden by the compiler? Alternatively, I could create a custom OptionalFastDate type which uses that bit and only requires 8 bytes for storage. That type could then be converted to Optional<FastDate> when the actual value needs to be accessed.
1 Like

timeIntervalSinceReferenceDate is more likely to round-trip cleanly, since the floating-point math to adjust from timeIntervalSince1970 and back can introduce error. Converting between Date and FastDate ought to be possible through the reference date.

It's worth asking the Foundation team how important it really is for Date to be resilient. @Philippe_Hausler or @millenomi, would we consider making Date frozen in a future release?

(There's also the general issue of compiler quality when it comes to resilient and unspecialized types. Because these currently use a less ideal representation in SIL, they miss out on a lot of optimization passes that would normally reduce copying overhead. That's something we still ought to improve over time; it shouldn't be necessary to make everything frozen just to avoid an order of magnitude performance hit.)

1 Like

@Joe_Groff yes. That overhead I think will go away (or be mitigated a lot) by opaque values.

I dont see a problem with making it frozen, however I this really seems like a compiler regression. @Tony_Parker what do you think? I doubt we can make any changes right now for it, but perhaps this is something that we can address later?

I don't have any issue making Date frozen. Most of its functions are also probably fine to inline.

One thing we still need is a way to declare a type as frozen as of a specific version, so that the compiler preserves the resilient ABI for existing binaries. I agree that there's compiler work we need to do to make resilient code more efficient as well.

2 Likes

If/Once such improvements ship, what would be their availability? I am targeting macOS 10.11 and above; would these improvements be available for all my users or only to those running a hypothetical 10.16 and later containing those fixes, given that the Swift stdlib is stuck on 5.0 for users pre-10.15?

Also, is there any kind of bit fiddling I could do to "reserve" one bit inside Date to have an 8-byte "Optional Date"? The problem isn't the extra byte itself, but byte alignment rules actually make that into 16 bytes when used in an array.

You could wrap it in a computed property that manually converts nil to NaN or some other known-invalid value, maybe.