@Observable pessimizes arrays

Nah, that is reasonably in the category of a perf bug.

It's FB13547947 :)

3 Likes

Is this what's happening when array variable is implemented with get + set instead of get + modify?

class Model4 {
    private var _array: [Int] = []
    var array: [Int] {
        get { _array }
        set { _array = newValue }
    }
}

// m.array.append(i)

var a = m.array
a.append(i)
// we are not using the old array here
m.array = a

Is COW copy inevitable here? Note that the old array is not used after append and before it's destroyed.


Update:

This example is slow (O(n)): changing array field of a class.
final class Model4 {
    var array: [Int] = []
}

func test() {
    let n = 100_000
    let m = Model4()
    
    for _ in 0 ..< 10 {
        let start = Date()
        for i in 0 ..< n {
            var a = m.array
            a.append(i)
            m.array = a
        }
        let elapsed = Date().timeIntervalSince(start)
        print("\(array.count) elapsed time: \(elapsed)")
    }
}
And this one is fast (O(1)): changing a local array variable.
func test() {
    let n = 100_000
    var array: [Int] = []
    
    for _ in 0 ..< 10 {
        let start = Date()
        for i in 0 ..< n {
            var a = array
            a.append(i)
            array = a
        }
        let elapsed = Date().timeIntervalSince(start)
        print("\(array.count) elapsed time: \(elapsed)")
    }
}

As far as Observable is concerned the crucial question: could it do whatever it's doing without having two or more copies of the value being observed.

On top of whatever Observable or ObservableObject+Published is doing, SwiftUI diffing machinery will be at play and it is highly likely it will take O(n) time anyway to compare the new state to the old state.

Well one would hope that just notifying that something changed is O(1). And then if the items are shown in, say, a LazyVGrid, I would hope adding an element is O(1) as well, but yes I suppose it could be linear.

Another find:

final class Model4 {
    var array: [Int] = [] {
        willSet {} // try commenting this out
        didSet {}
    }
}

func test() {
    let n = 100_000
    let m = Model4()
    
    for _ in 0 ..< 10 {
        let start = Date()
        for i in 0 ..< n {
            m.array.append(i)
        }
        let elapsed = Date().timeIntervalSince(start)
        print("\(m.array.count) elapsed time: \(elapsed)")
    }
}

test()

This minimal example (compiled with -O and whole module optimisation) exposes a slow O(n) behaviour. But comment out the "do nothing" willSet – and it is changed to a fast O(1).

2 Likes

I made this app to test most combinations of the factors affecting observation discussed above.

The app.
func test() {
    #if CLASS
        final class Model {
            #if NO_SET
                var array: [Int] = []
            #elseif WILL_SET
                var array: [Int] = [] {
                    willSet {}
                }
            #elseif DID_SET
                var array: [Int] = [] {
                    didSet {}
                }
            #elseif MODIFY
                var _array: [Int] = []
                var array: [Int] {
                    get { _array }
                    _modify { yield(&_array) }
                }
            #else
                #error("define one of: NO_SET, WILL_SET, DID_SET, MODIFY")
            #endif
        }
        let m = Model()
    #elseif VALUE
        #if NO_SET
            var array: [Int] = []
        #elseif WILL_SET
            var array: [Int] = [] {
                willSet {}
            }
        #elseif DID_SET
            var array: [Int] = [] {
                didSet {}
            }
        #elseif MODIFY
            var _array: [Int] = []
            var array: [Int] {
                get { _array }
                _modify { yield(&_array) }
            }
        #else
            #error("define one of: NO_SET, WILL_SET, DID_SET, MODIFY")
        #endif
    #else
        #error("define one of: CLASS, VALUE")
    #endif
    
    let n = 100_000
    
    for _ in 0 ..< 10 {
        let start = Date()
        for i in 0 ..< n {
            #if CLASS
                #if NO_SPLIT
                    m.array.append(i)
                #elseif SPLIT
                    var a = m.array
                    a.append(i)
                    m.array = a
                #elseif SPLIT_AND_CLEAN
                    var a = m.array
                    m.array = []
                    a.append(i)
                    m.array = a
                #else
                    #error("define one of: NO_SPLIT, SPLIT, SPLIT_AND_CLEAN")
                #endif
            #elseif VALUE
                #if NO_SPLIT
                    array.append(i)
                #elseif SPLIT
                    var a = array
                    a.append(i)
                    array = a
                #elseif SPLIT_AND_CLEAN
                    var a = array
                    array = []
                    a.append(i)
                    array = a
                #else
                    #error("define one of: NO_SPLIT, SPLIT, SPLIT_AND_CLEAN")
                #endif
            #else
                #error("define one of: CLASS, VALUE")
            #endif
        }
        let elapsed = Date().timeIntervalSince(start)
        #if CLASS
            print("\(m.array.count) elapsed time: \(elapsed)")
        #elseif VALUE
            print("\(array.count) elapsed time: \(elapsed)")
        #else
            #error("define one of: CLASS, VALUE")
        #endif
    }
}

test()
The corresponding Config.xcconfig file (uncomment a single line in it to test a particular case).
OTHER_SWIFT_FLAGS = $(inherited) -DCLASS -DNO_SET -DNO_SPLIT
//OTHER_SWIFT_FLAGS = $(inherited) -DCLASS -DNO_SET -DSPLIT
//OTHER_SWIFT_FLAGS = $(inherited) -DCLASS -DNO_SET -DSPLIT_AND_CLEAN

//OTHER_SWIFT_FLAGS = $(inherited) -DCLASS -DWILL_SET -DNO_SPLIT
//OTHER_SWIFT_FLAGS = $(inherited) -DCLASS -DWILL_SET -DSPLIT
//OTHER_SWIFT_FLAGS = $(inherited) -DCLASS -DWILL_SET -DSPLIT_AND_CLEAN

//OTHER_SWIFT_FLAGS = $(inherited) -DCLASS -DDID_SET -DNO_SPLIT
//OTHER_SWIFT_FLAGS = $(inherited) -DCLASS -DDID_SET -DSPLIT
//OTHER_SWIFT_FLAGS = $(inherited) -DCLASS -DDID_SET -DSPLIT_AND_CLEAN

//OTHER_SWIFT_FLAGS = $(inherited) -DCLASS -DMODIFY -DNO_SPLIT
//OTHER_SWIFT_FLAGS = $(inherited) -DCLASS -DMODIFY -DSPLIT
//OTHER_SWIFT_FLAGS = $(inherited) -DCLASS -DMODIFY -DSPLIT_AND_CLEAN

//OTHER_SWIFT_FLAGS = $(inherited) -DVALUE -DNO_SET -DNO_SPLIT
//OTHER_SWIFT_FLAGS = $(inherited) -DVALUE -DNO_SET -DSPLIT
//OTHER_SWIFT_FLAGS = $(inherited) -DVALUE -DNO_SET -DSPLIT_AND_CLEAN

//OTHER_SWIFT_FLAGS = $(inherited) -DVALUE -DWILL_SET -DNO_SPLIT
//OTHER_SWIFT_FLAGS = $(inherited) -DVALUE -DWILL_SET -DSPLIT
//OTHER_SWIFT_FLAGS = $(inherited) -DVALUE -DWILL_SET -DSPLIT_AND_CLEAN

//OTHER_SWIFT_FLAGS = $(inherited) -DVALUE -DDID_SET -DNO_SPLIT
//OTHER_SWIFT_FLAGS = $(inherited) -DVALUE -DDID_SET -DSPLIT
//OTHER_SWIFT_FLAGS = $(inherited) -DVALUE -DDID_SET -DSPLIT_AND_CLEAN

//OTHER_SWIFT_FLAGS = $(inherited) -DVALUE -DMODIFY -DNO_SPLIT
//OTHER_SWIFT_FLAGS = $(inherited) -DVALUE -DMODIFY -DSPLIT
//OTHER_SWIFT_FLAGS = $(inherited) -DVALUE -DMODIFY -DSPLIT_AND_CLEAN

Within the app the following variables are tested independently:

  1. whether the thing observed is a variable in a class or a local variable ("value").
  2. whether the variable is observed with willSet, didSet, modify or nothing of those ("no_set").
  3. whether the value is being modified via "value.append()" (no_split) or whether the access is manually "split": "a = value; a.append(); value = a" or whether there's a cleanup step during the split access ("split_and_clean"): "a = value; value = []; a.append(); value = a"

Here's the result I got:


Using :rabbit2: for O(1), :snail: for O(n). The two tables for class variable and local variable mainly agree, with a few differences encircled.

Tested with -O, whole module optimisation, Xcode 15.1, Swift 5.9.2.

7 Likes

Just curious, since property wrapper uses the get/set mechanism too, does that mean @State and ObservableObject's @Published has the same performance issue as @Observable macro?

I tested ObservableObject + Published above and yes, it is affected by the same issue.

1 Like

This is fixed in Swift 6 :slight_smile:

6 Likes

That’s great to hear! Do we know which changes were made in order to fix it?

Looks like this PR references the feedback number mentioned above:

2 Likes