Add @immutable attribute for structs and enums

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.

The compiler does not generate COW code for structs. Are you thinking of the code that does copies?

3 Likes

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

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

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

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.

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).

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?

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

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.

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 :slight_smile:

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.

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

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

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

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.

Put -Os in the compiler flags at the top right.

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