So if I'm getting this right, this pitch is basically monomorphized generics that are limited to:
Functions and computed properties only. Not classes.
Specializations are defined with the function, and cannot be added post-hoc.
Specialization cannot specify an alternative implementation.
Specializations are not exposed outside the module, even when defined.
I mean, yeah, I get it. Because C doesn't have generics, and because linkers think the C ABI is the only ABI, neither Rust nor C++ have managed to create monomorphized generics as an ABI. But even if you accept that constraint, this pitch is pretty limiting.
There is no such thing as specializing a generic class in Swift. Only functions generic over classes (or other types). You can't really talk about specializing "a class", unless what you mean is "always specialize methods you add to this class", which would be sugar for doing that function by function.
The latter does not follow the former. C++ or Rust could have an ABI for monomorphised generics. They just don't. Swift has an ABI for unspecialized generics despite using the same linker, and a future direction in this proposal would allow an ABI for monomorphized generics too, with the same linker.
@specialize(where Element == Int)
struct Stack<Element> {
var items: [Element] = []
mutating func push(_ item: Element) {
items.append(item)
}
mutating func pop() -> Element {
return items.removeLast()
}
}
vs
struct Stack<Element> {
var items: [Element] = []
@specialize(where Element == Int)
mutating func push(_ item: Element) {
items.append(item)
}
@specialize(where Element == Int)
mutating func pop() -> Element {
return items.removeLast()
}
}
(Sorry for the toy example. Taken from Swift docs)
It would imply extension methods on it should be auto-specialized as well.
extension Stack {
// No need to specify @specialize here because all of Stack is specialized for Element == Int
var topItem: Element? {
return items.isEmpty ? nil : items[items.count - 1]
}
}
I'm not sure that's the case. To monomorphise the generic, you basically need to create a copy of all the code, with the places where the generic is used replaced with direct calls to the specified type. This would need to be done in the linker, if the generic itself is part of the ABI. Linkers do not have this functionality. They can only fill in addresses to functions and variables based on a symbol name. They cannot make a full copy of a function, replacing specific lines in the code. That feature simply does not exist in any currently existing linker.
Is it too early to think about how this would intersect with @implements? If I recall correctly, the standard library has a couple of instances of methods that use a similarly-named underscored attribute to provide explicit specializations, and maybe even alter the lookup order.
Yes, that's what I said. Specializing a type would just be sugar for specializing all methods on a type.
This is not true and I'm not sure what point you're trying to make. Yes, monomorphizing involves replacing the general implementation with the specific one. To do this you need a representation that lets you reconstruct that information. Some languages do this with bytecode or IR, which you can process in a compiler, or a linker, or by a JIT at runtime. Swift does this by putting the source in a swift interface file and then processing it when compiling the caller. The linker isn't involved in that. It can be done by the linker, but it doesn't have to be.
I think the mismatch here is that @SlugFiller is talking about monomorphization on demand (which, yeah, the linker will not do) and @ben-cohen is talking about exporting specific monomorphizations (still as a future direction, though). C++ does have the latter: it's extern template foo<string>(); or whatever. That monomorphization of foo will get a (stable!) symbol name that external clients can call even without having a body for foo. (And the library may or may not have customized the implementation of foo<string>; you can't tell from outside the library.)
Swift's stable ABI does not currently have a mangling for a similar feature. (Also, there's no way to ask for it in the language, though the compiler will sometimes generate such a monomorphization as an optimization! It just won't export it.) What Swift has that C++ doesn't is its polymorphic generic ABI: any generic function has a single implementation that takes a dispatch table (probably several of them, really) to actually do its work, and that has a stable symbol name. And because of Swift's support for separate compilation, there's no way to monomorphize on demand except when information is revealed to the caller (usually via @inlinable). Which is similar to providing the source of a template in a header file, and is a feature that already exists.
Ben is saying that we could add a new attribute (or a variant of the proposed @specialize), which would export a specific monomorphization, chosen by the library, under a stable symbol name, which clients would then be able to call directly if (and only if) they statically knew they had a matching type. But this is all still a future direction, just like the ideas for partial specialization or "manual specialization" with an explicitly different implementation.
Though as discussed "manual specialization" is not super important for functions, because once you're no longer sharing a textual implementation with the generic path, you can just write the check as code (if you want it dynamically chosen) or add an overload (if you only care about static choices) or both (if you want to short-circuit the check when possible but still support it in the opaque case). For types this is still potentially more interesting.
That's not an ABI anymore, though. Quoting your own post:
Processing a monomorphized generic when compiling the caller is basically what C++ does when it includes templates in header instead of code files. And recompiling the caller is the result, if any fix is done to the template code.
If and when Swift has fully monomorphized generics, they would likely have these same properties.
As I understand it, Swift currently uses erasure for generics, however. This works as an ABI - Only one version of the function is exposed. The calls to methods on the parameter type are done indirectly, at runtime. The caller does not need to be recompiled when the generic type or function changes, because the "linking" is done at runtime (dynamically, per call).
Yes. Exactly. And since it's "future direction", and not part of the pitch, this pitch is even more limiting than this.
Swift's polymorphic generics allow for true on-demand generics while keeping a stable ABI and avoiding recompilations. But it comes at a performance cost. And without some fundamental change to linkers, this tradeoff is inevitable.
But there are still a lot of options in between "polymorphic everything" and "recompile everything". It's not a purely binary choice. This pitch, as presented, if I'm reading it correctly, leans very heavily towards the prior.
Ah, here's the point of confusion. Yes, inlinable code in a swift interface file is ABI, as least from Swift's perspective. Swift includes various features, such as resilient and frozen types, and useable-from-inline symbols, that allow for it to publish ABI-stable inlinable code.
Inlinable code forms a very messy kind of ABI â one where you need to consider things like how changing an implementation will behave when old copies are still hanging around and running against your new framework. But it is ABI.
The fact that this ABI is potentially emitted into the client, which means it can't be upgraded underneath a compiled binary, doesn't make it not ABI (though, note that there are variants of inlining where this does work, this is what @backDeployed does).
"Erasure" isn't a rigorously defined enough term, really, but generally speaking no, Swift doesn't use erasure for generics. The type of a value remains fully known at all times, and the value doesn't need to be held at the end of a pointer. Instead, metadata is passed around identifying the type when needed and provides functions that can manipulate the type (make copies, destroy etc).
I am still not following why you think that doing work "in the linker" is the key to resolving these things.
This one I'm not following. How could different types be held by the same binary code as anything other than a pointer? For instance, you can't allocate it on the stack if the size might vary.
Metadata being passed around doesn't make it not erasure. That's just RTTI, and is functionally indistinct from vtables. I would say "erasure" is sufficiently rigorously defined. It's when the actual binary implementation is as if the generic was monomorphically applied to a common ancestor of all possible type parameter values, and usage of the parameter type's method is the same as that for any interface or inheritable class with virtual methods.
It is distinct from "monomorphized" in the obvious way, and from "reified" in that "reified" adds runtime checks for the type and performs JIT to come closer to monomorphized at run time.
Not saying it's "key", just an inevitability. Compiling produces object files for caller and callee. The linker combines those object files. If the linker cannot represent or process generics, it cannot perform the necessary changes to the caller to account for changes in the callee. The result is a need to recreate the object file. i.e. To recompile.
Which comes back to the original point: You can't have on-demand monomorphization without recompiling the caller for every change in the callee. If you don't have on-demand monomorphization, then the caller can use a type parameter which would force the callee to operate using RTTI, i.e. indirect calls (assuming no JIT), which carries a performance cost (indirect calls are necessarily slower than direct ones). That's the tradeoff.
Just because itâs not mentioned explicitly in the pitch: does applying @specialize multiple times generate multiple distinct specializations like @_specialize does now?
That's where you're wrong, because this is exactly what Swift does. The value witness tables include the size of the type in memory, as well as functions required to copy, move or destroy a value. Slava had an excellent presentation on this a while ago:
Couldn't Swift's classes generate a specialized dispatch table per generic specialization? The dispatch table could then be chosen during initialization of an instance of that class. This would eliminate the need to check for each specialization per method call. The nice thing about this is that non-final/overrideable methods/properties already do dynamic dispatch through the dispatch table anyway and could directly dispatch to the specialized version, reducing the overhead per method call/property access to essentially zero (minus additional memory required for the specialized dispatch tables but that should be shared with all instances).
I've wanted something that sounds similar to this but I'm not sure if it's actually covered.
Occasionally, I've wanted to implement my own Encoder/Decoder for a custom serialization format, especially methods like this, where I'm given a generic Encodable-conforming parameter.
In some of these cases, I've wanted to conditionalize the logic for special types that I know the format handles differently. For example "If I'm encoding an Array<URL>, behave slightly different than an Array<String>, before falling back to the general 'some sort of encodable thing'".
I've looked into @_specialize before to do this, so I could have a public func on my coders that handle these mostly-the-same-but-slightly-different logic.
However, I don't think@specialize will handle this, correct?
Specific Example
A specific example would be around working with UserDefaults, which has type-specific methods for reading and writing Int versus Double versus Array<String> versus Data, etc.
I've tried making UserDefaults conform to generic "key-value store" protocols that only deal with simple "codable" values, and in every time I end up having to do things like this:
@MainActor protocol KeyValueStore {
func read<Value: Codable>(_ key: String) throws -> Value
func write<Value: Codable>(_ key: String, _ value: Value?) throws
}
extension UserDefaults: KeyValueStore {
...
func write<Value>(_ key: String, _ value: Value?) throws where Value : Codable & Equatable {
guard let unwrapped = value else {
self.removeObject(forKey: key)
return
}
switch unwrapped {
case let i as Int: self.set(i, forKey: key)
case let f as Float: self.set(f, forKey: key)
case let d as Double: self.set(d, forKey: key)
case let b as Bool: self.set(b, forKey: key)
default:
do {
let encoded = try PlistEncoder().encode(unwrapped)
self.set(encoded, forKey: key)
} catch let error as EncodingError {
throw KeyValueStoreError.encodingError(error)
} catch {
throw KeyValueStoreError.unknown(error)
}
}
}
}
I've wanted to provide specializations of the write<T>(_:_:) specifically for Int, Double, Bool etc instead of having to switch on the type in the single implementation above.
Edited to add: Playground
protocol Writer {
func write<E: Encodable>(_ value: E)
}
struct ConcreteWriter: Writer {
// is there anything I can do to make this be used as part of satisfying the `Writer` protocol?
func write(_ value: String) {
print("Writing a string")
}
func write<E: Encodable>(_ value: E) {
print("Using fallback writer of type \(E.self)")
}
}
let writer: any Writer = ConcreteWriter()
writer.write(42) // Using fallback writer of type Int
writer.write("hello") // Using fallback writer of type String â đ˘
Sure you can, so long as your metadata table holds the size.
If youâre familiar with generics from other languages, youâve probably learned that there are two ways to do them: monomorphization and boxing into a uniform representation (usually some kind of pointer). You are now hitting the point where you realize that Swift has invented a third way.
I see it uses alloca for the stack allocation. But I also see it receives an opaque pointer for the parameter e.g. void getSecond(opaque *result, opaque *pair, type *T). So, the value is still technically "held at the end of a pointer", even if it is occasionally allocated on stack (using alloca) instead of the heap.
I will note that passing T as a separate parameter instead of encoding it into the value itself is an interesting difference from classic erasure.
Comparing to erasure, it doesn't "save" the use of indirect calls. It only saves the memory requirement of keeping a vtable pointer in each instance of the value type. Instead a pointer to the "clean" value can be used as is, while the vtable-equivalent is passed as a separate parameter (which doesn't require per-instance allocation, only per-type), using the fact that the caller knows the type of the parameter it is passing.
Perfect, thanks. As a follow-up, is it (or will it be) documented anywhere in what order the types are checked for pre-specialization when calling the unspecialized version of the function? Or is that not behavior thatâs being committed to in this proposal?
I am wary to specify this, because it's very interlinked with how the re-dispatch is implemented, and that shouldn't be part of the defined semantics of the proposal. Even if it's just "as a rule of thumb, it's better to put common specializations at the top" since in future it may be optimized to work completely differently like a hash table lookup and that advice gets left behind like an oxbow lake that people still follow forever.
(as it happens, today it is basically a sequence of if statements that appear in the order of the @specialize declarations)