Nah, that is reasonably in the category of a perf bug.
It's FB13547947 :)
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).
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:
- whether the thing observed is a variable in a
class
or a local variable ("value"). - whether the variable is observed with
willSet
,didSet
,modify
or nothing of those ("no_set"). - 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 for O(1),
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.
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.
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: