I’ve worked at several large companies including Airbnb and Snap that discouraged using Swift structs unless absolutely necessary due to binary size concerns.
Given a struct and an equivalent class with entirely immutable stored properties, the struct will take up more space due to the COW (copy-on-write) boilerplate that has to be generated.
Often, though, we want to use structs as purely immutable models and benefit from things like stack allocation.
What if we could get the best of both worlds with an @immutable attribute? With this attribute, mutable stored properties and mutating functions would be compiler errors and the binary size could be even smaller than a class in some cases, or equal in others.
Avi
2
The compiler does not generate COW code for structs. Are you thinking of the code that does copies?
3 Likes
tstromberg
(Tyler Stromberg)
3
I have also worked places where Swift structs were verboten due to binary size concerns. Anything we can do to make this better would be welcomed.
2 Likes
Joe_Groff
(Joe Groff)
4
Right, the code size issue arises because struct fields are stored inline and not copy-on-write by default. COW is part of the solution, not the problem here. We've had many threads discussing an indirect attribute for structs, akin to the indirect attribute for enums. You can approximate this today using a property wrapper to place large properties inside a COW box.
11 Likes
Jumhyn
(Frederick Kellison-Linn)
5
There's also #56633 to help this issue but it looks like there hasn't been much motion since an initial implementation attempt was closed at the end of last year?
1 Like
tera
6
I sill don't get the problem, can you provide more details?
In this example:
class C {
struct S {
var x0001: Int
// ...
var x9999: Int
}
var s: S
}
var c = C()
understandably size of S is large, size of C instances is large, but those are allocated on heap. Why is the code size larger, compared to the case where "S" is a class?
Would that be different to a struct all of whose fields are let instead of 'var'?
1 Like
Either…
- that
indirect attribute (modulo naming) as a language feature or
- that property wrapper as a part of the stdlib
…would certainly be useful and welcome.
That's not the problem he's describing. The problem is when large structs are being passed around directly.
tera
9
I'd appreciate to see some small example. Naïve thinking suggest the code size to copy a struct from one place to another is O(1) regardless of the struct size (basically the size of corresponding memmove call with a prolog and epilog), and I also tried a quick example where I am passing a large structure between functions but obviously I am doing something differently as I found the opposite effect in godbold to what's being discussed (with the class version being bigger than the struct version).
jaredgrubb
(Jared Grubb)
10
Just to make sure I get it, the idea is that foo(someBigStruct) generates a lot of code in order to copy every single member of the struct into a new one for the function?
lukasa
(Cory Benfield)
11
Structs have memberwise copy behaviour. Each time you copy a struct you need to issue a copy of each element. In cases where these elements are trivial, then in principle this can be optimized down to memmove, though in many cases a minor increase in binary size may be preferred in order to avoid the call.
The bigger problem is when your structs aren't trivial. For example:
struct WeirdURLType {
var scheme: String
var username: String?
var password: String?
var host: String
var port: UInt16
var path: String
}
To copy this struct requires emitting at least 5 swift_retain calls, one for each String in the structure. The copy gets vastly cheaper if this is a class, where there is only one call to swift_retain. This is the real pain point, and it's why in some performance-sensitive code one might choose to back a struct with a storage class even though the type isn't variable-size.
15 Likes
ksluder
(Kyle Sluder)
12
Can the Swift runtime implement a generic swift_retain that walks the runtime type metadata? Or is the same kind of app that is concerned about these extra retains also going to be removing the metadata necessary to implement a generic swift_retain?
Maybe this is something that move semantics can help with.
tera
13
Performance being O(N) on copy is understandable - and that's the case even for memcpy'ed POD structs. The increased binary size is what I fail to understand - all those 5 or 500 retain calls are the single call in the binary. Unless there's some heavy inlining - is this what cause that bloat? In which case is it possible to switch it off? OK, I see it now: it's a multiple retain/release calls in the executable code. hmm, no I don't 
Pseudocode of a O(1) by size struct copy:
for field in fields {
if field POD - simple copy
else {
retain
copy complex
}
}
The above copy is O(1) binary size wise regardless of the structure size or a number of retainable fields in it.
Abstaining from structs in favour of classes seems a significant setback to me. Are the mentioned binary size / performance issues inherent to value semantics?
A bit of a tangent.
It always felt strange to me that we need 64 bit pointers / ints when 99.99% of apps would be totally happy under 1GB memory limit. With doubling pointers/int sizes all apps now have to pay tax of increased working set size, effectively smaller cache size, increased number of cash misses, slower memory to memory and memory to/from disk copies including VM I/O.
benpious
(Ben Pious)
14
I was under the impression that if you turn on -OSize SwiftC will often outline those calls, but in other optimization modes it probably won't. I think there are also some other options available; there is an optimization pass in LLVM that can find duplicate instructions and outline them; it probably would pick up on these as an example of duplications that could be outlined.
I work at one of the companies @tstromberg mentioned, and I believe that we relaxed our restrictions a little; but we still don't let folks add too many mutable properties to structs.
Are the mentioned binary size / performance issues inherent to value semantics?
I think that ownership in general/borrow would improve this; there is no need for the compiler to retain at all if you know a reference won't outlive a function call. That said, Rust generates even bigger binaries than Swift last I checked, but probably for different reasons (generics always being specialized for one).
1 Like
bob
15
It seems like this issue could be solved by adopting another Rustism: the ability to take a reference to a struct. The struct and the reference itself live on the stack but by passing around a reference you reduce the size if the data you're copying to eight bytes.
No idea how this would interact with the rest of Swift semantics, though.
Wouldn’t that be similar to the proposed ownership features? Otherwise, I have a hard time imagining how the struct could remain on the stack while safely exposing references.
1 Like
lukasa
(Cory Benfield)
17
That's great, but that's not how the copy is implemented. You can see this by using Godbolt to observe one of the copy constructors:
initializeWithCopy value witness for output.WeirdURLType:
push r15
push r14
push r13
push r12
push rbx
mov rbx, rdi
mov rax, qword ptr [rsi]
mov qword ptr [rdi], rax
mov rdi, qword ptr [rsi + 8]
mov qword ptr [rbx + 8], rdi
mov rax, qword ptr [rsi + 16]
mov qword ptr [rbx + 16], rax
mov r14, qword ptr [rsi + 24]
mov qword ptr [rbx + 24], r14
mov rax, qword ptr [rsi + 32]
mov qword ptr [rbx + 32], rax
mov r15, qword ptr [rsi + 40]
mov qword ptr [rbx + 40], r15
mov rax, qword ptr [rsi + 48]
mov qword ptr [rbx + 48], rax
mov r12, qword ptr [rsi + 56]
mov qword ptr [rbx + 56], r12
movzx eax, word ptr [rsi + 64]
mov word ptr [rbx + 64], ax
mov rax, qword ptr [rsi + 72]
mov qword ptr [rbx + 72], rax
mov r13, qword ptr [rsi + 80]
mov qword ptr [rbx + 80], r13
call swift_bridgeObjectRetain@PLT
mov rdi, r14
call swift_bridgeObjectRetain@PLT
mov rdi, r15
call swift_bridgeObjectRetain@PLT
mov rdi, r12
call swift_bridgeObjectRetain@PLT
mov rdi, r13
call swift_bridgeObjectRetain@PLT
mov rax, rbx
pop rbx
pop r12
pop r13
pop r14
pop r15
ret
If you change this type to a class, not only does the copy constructor effectively shrink, but in fact it is removed from the program altogether, as Swift knows that it can be trivially inlined at all sites as swift_retain.
2 Likes
tera
18
Thank you lukasa. If I just change struct to class it won't compile as is, with "init" added it becomes 616 LOC (in asm) and with default values for non optional fields added it becomes 585 LOC. While the struct version is 539 and 592 LOC correspondingly. I'll look deeper at those asm's to see what's going on (e.g. perhaps the class version adds some "one off" boilerplate and the struct version is not a winner after all as it seems here purely by the LOC counts).
BTW, how would you specify -Os in godbolt?
Edit - corrected the LOC counts above.
lukasa
(Cory Benfield)
19
Put -Os in the compiler flags at the top right.
stuchlej
(Mikoláš Stuchlík)
20
I have been using inout for structures with significant retain/release cost, after reading this response Stack-allocated collections - #9 by taylorswift I was under the impression, that the unsafe buffer is allocated on the stack.
Those were the two "workarounds" I am aware of.
In the past, I was also trying to dive into how Stack Promotion works ( discussed here for example Stack promotion of reference types ), but I gave up. I was wondering whether this optimization may reduce some of the COW retain/release costs if the compiler can prove that the values won't escape.
1 Like