Unexpected difference in compiler behavior when using inout parameter

I have a pretty standard(?) linked-list implementation that looks like this:

struct Node {
        
    struct Raw {
        
        let key: UInt
        
        var value: UInt
        
        var next: Node?
        
    }
    
    let pointer: UnsafeMutablePointer<Raw>
    
    var raw: Raw {
        get { pointer.pointee }
        nonmutating set { pointer.pointee = newValue }
    }
    
    init(key: UInt, value: UInt) {
        pointer = .allocate(capacity: 1)
        pointer.initialize(to: Raw(key: key, value: value))
    }
    
    func deinitialize() {
        pointer.deinitialize(count: 1)
        pointer.deallocate()
    }
    
}

where Node is basically just an abstraction over a pointer. I was profiling iteration of a linked list of Nodes, and noticed that looping over the list had much worse performance than I was expecting.

Investigating further, I narrowed it down to my usage of one property in particular: raw. Specifically, when I mutated Raw through an inout parameter...

func mutateValue(of node: Node, using mutate: (inout UInt) -> Void) {
    mutate(&node.raw.value)
}

performance was worse than when I skipped raw and used pointer directly:

func mutateValue(of node: Node, using mutate: (inout UInt) -> Void) {
    mutate(&node.pointer.pointee.value)
}

My naive expectation was that Swift would basically treat these two functions as identical, "see through" what raw is doing, and treat it identically to the second case.

Looking at the generated assembly in Godbolt it appears to me that Swift appears is be treating raw literally — copying the value, mutating it, then copying it back, instead of mutating it in-place, like using pointer directly does. (I partly assumed this because I got used to seeing Swift implicitly generate modify accessors when it notices a particular kind of get/set pattern.)

Though slightly tangential, a second odd thing is...

..that these two functions generate identical assembly but don't get merged:

func testInlineMutationThroughPointer() {
    node.pointer.pointee.value = 300
}

func testInlineMutationThroughComputedProperty() {
    node.raw.value = 300
}

Two questions:

  1. Is there a way I can indicate to Swift that it should treat &raw... the same as &pointer.pointee...?
  2. Would it break anything if Swift did treat these two cases identically, or is this a potential "free" optimization?
1 Like

I think unsafeMutableAddress is the trick, which is mentioned in the accessors vision document. I will refrain myself from using it though..

1 Like

Hmm… do we get modify for free now behind the scenes? Is this behavior documented?

The generated modify accessor for a stored property yields the underlying storage. For a computed property with a getter and a setter, the best we can do is call the getter, store the result in a temporary buffer, yield the buffer, and then store it back by calling the setter. This will incur some copying overhead. If you want to avoid that when performing an inout access of your computed property, you need to implement the modify accessor yourself.

1 Like

A generated modify accessor only ever gets called if it witnesses a protocol requirement, eg

protocol P {
  var x: Int { get set }
}

struct S1: P {
  var x: Int
}

struct S2: P {
  var x: Int { get { … } set { … } }
}

func f<T: P>(_ t: inout T) {
  t.x += 1
}

f() will dynamically dispatch to S1.x.modify or S2.x.modify for the inout access. The generated modify for S1 just yields the address of x, while for S2 it’s implemented in terms of the computed getter and setter.

A concrete inout access of S2.x won’t call the generated modify, because it’s more efficient to directly call the getter and setter.

If your property explicitly declares a modify accessor though, an inout access of the property will generate a call the modify accessor.

2 Likes

Thanks for the clarification! And just to be clear, modify isn't yet available publicly, right?