Ok, so I run into a somewhat unexpected performance issue, maybe someone can illuminate?
I have a class generating unique identifiers with approx. the following usage:
let frostflakeGenerator = Frostflake(generatorIdentifier: 1) // the generatorIdentifier is a keyspace
...
let frostflake1 = frostflakeGenerator.generate()
let frostflake2 = frostflakeGenerator.generate()
...
This one runs with approx. 120M/s on my laptop.
So, I thought I'd add a convenience static function for using it to make it more convenient in larger code bases (which would use the same generatorId):
Frostflake.setup(generatorIdentifier: 1)
...
let frostflake1 = Frostflake.generate()
let frostflake2 = Frostflake.generate()
The class implementation is simply:
public static var sharedGenerator: Frostflake?
/// Setup of the shared generators identifier
public static func setup(generatorIdentifier: UInt16) {
sharedGenerator = Self(generatorIdentifier: generatorIdentifier)
}
/// Convenience static variable when using the same generator in many places
/// The global generator identifier must be set using `setup(generatorIdentifier:)` before accessing
/// this shared generator or we'll fatalError().
///
@inlinable
@inline(__always)
public static func generate() -> FrostflakeIdentifier {
guard let generator = sharedGenerator else {
fatalError("sharedGeneratorIdentifier must be set if using the shared generator")
}
return generator.generate()
}
Performance tanks down to approx 30M/s and profiling shows that a lot of time is being spent in TLS access.
I'm assuming this is due to the static var for the sharedGenerator, but basically it's immutable (but I can't have it as a static let as the end user of the library must be able to specify the generatorId).
Is Swifts semantics for static variables that they are thread-local ?
Are there any better ways to have global immutable state that is initialised once and don't force me into SwiftTLSContext:: which seems a bit heavy?
You should be able to use a global let and have its lazy initializer read its global configuration out of a variable that you set. That should remove exclusivity enforcement from the hot path of your code. Of course, you'll need to make sure you set that configuration variable before you touch your global let, but your code is already assuming something similar with the call to setup. Just make your initializer assert that the configuration variable has been filled in, e.g.:
private static var generatorIdentifier: UInt16?
public static let sharedGenerator: Frostflake = {
guard let identifier = generatorIdentifier else {
preconditionFailure("accessed sharedGenerator before calling setup")
}
return Self(generatorIdentifier: identifier)
}()
public static func setup(generatorIdentifier identifier: UInt16) {
if generatorIdentifier != nil {
preconditionFailure("called setup multiple times")
}
generatorIdentifier = identifier
}
Many thanks all, I tried the approach @John_McCall suggested basically verbatim and it gave a significant improvement (and removed the TLS from the time profile), but still there's a 60% performance hit (normalising to time to generate 1M identifiers, it went from ~10ms for manually created instance to ~16ms for the shared one. Before the fix it would have been ~40ms). Will try to see if I can nail down where the difference goes...
Ok, some more careful measurements and profiling and there's still a hit and going through TLS - just adding a summary here for future searchers.
Normalised to time period required for generating 1M identifiers:
Original shared implementation: 28ms
Shared implementation as suggested by @John_McCall: 16.5ms
Per-instance implementation: 10.5ms
The current shared implementation is:
fileprivate var sharedGeneratorIdentifier: UInt16?
public let sharedGenerator: Frostflake = {
guard let identifier = sharedGeneratorIdentifier else {
preconditionFailure("accessed sharedGenerator before calling setup")
}
return Frostflake(generatorIdentifier: identifier)
}()
/// Frostflake generator
public final class Frostflake {
public var seconds: UInt32
public var sequenceNumber: UInt32
public let generatorIdentifier: UInt16
public let lock: Lock?
public static func setup(generatorIdentifier identifier: UInt16) {
if sharedGeneratorIdentifier != nil {
preconditionFailure("called setup multiple times")
}
sharedGeneratorIdentifier = identifier
}
@inlinable
@inline(__always)
public static func generate() -> FrostflakeIdentifier {
return sharedGenerator.generate()
}
...
}
So there was a significant improvement with the changes suggested by John, but still TLS..
I could have the variable and public let inside the class too, it gives the same result as breaking them out as global.
I still see TLS in the sample also with this version: