Performance with large @Published struct

I'm looking at performance around large codable nested structures that come in from HTTP/JSON.

We are seeing stalls on the main thread, and after reviewing all the code, the webrequests and parsing are async and background. The post to set the new struct value (80K) is handled on mainthread.

When I looked at the nested structures, they are about 80K. Reading several articles and posts suggested that observing structs will cause a refresh on any change. And that large structures will take longer as they have to be copied for passing to each observer. And that more observers will slow things down.

So a made a test app to verify these premises. The app has an timer animating a slider. A VM with a structure containing a byte array. Sliders to scale the size of the byte array from 10K to 200K and to scale the number of observers from 1 to 100.

It also measures the actual duration between the timer ticks. My intention is to be able to visual see mainthread stalls and be able to measure them and see the average and max frame delays.

Using this to test I found little difference in performance given different structure sizes or number of observers. I'm not certain if this is expected or if I missing something in creating my test app.

I have also created a variation where the top struct is a an observable class. I see no difference between struct or class.

I'm wondering if this is due to copy-on-mutate causing the struct to actually be passed as reference under the good?

I wonder if other optimizations are minimizing the affect of scaling from 1 to 100 observers.

I appreciate any insights & critiques.

#if CLASS_BASED
class LargeStruct: ObservableObject {
    @Published var data: [UInt8]
    
    init(size: Int = 80_000) {
        self.data = [UInt8](repeating: 0, count: size)
    }
    
    func regenerate(size: Int) {
        self.data = [UInt8](repeating: UInt8.random(in: 0...255), count: size)
    }
    
    var hashValue: String {
        let hash = SHA256.hash(data: Data(data))
        return hash.compactMap { String(format: "%02x", $0) }.joined()
    }
}
#else
struct LargeStruct {
    var data: [UInt8]
    
    init(size: Int = 80_000) {
        self.data = [UInt8](repeating: 0, count: size)
    }
    
    mutating func regenerate(size: Int) {
        self.data = [UInt8](repeating: UInt8.random(in: 0...255), count: size)
    }
    
    var hashValue: String {
        let hash = SHA256.hash(data: Data(data))
        return hash.compactMap { String(format: "%02x", $0) }.joined()
    }
}
#endif

class ViewModel: ObservableObject {
    @Published var largeStruct = LargeStruct()
   
}

struct ContentView: View {
   @StateObject var vm = ViewModel()
   @State private var isRotating = false
   @State private var counter = 0.0
   @State private var size: Double = 80_000
   @State private var observerCount: Double = 10

   // Variables to track time intervals
   @State private var lastTickTime: Date?
   @State private var minInterval: Double = .infinity
   @State private var maxInterval: Double = 0
   @State private var totalInterval: Double = 0
   @State private var tickCount: Int = 0
   
    var body: some View {
        VStack {
            Model3D(named: "Scene", bundle: realityKitContentBundle)
                .padding(.bottom, 50)

           // A rotating square to visualize stalling
           Rectangle()
               .fill(Color.blue)
               .frame(width: 50, height: 50)
               .rotationEffect(isRotating ? .degrees(360) : .degrees(0))
               .animation(.linear(duration: 2).repeatForever(autoreverses: false), value: isRotating)
               .onAppear {
                   isRotating = true
               }
           
           Slider(value: $counter, in: 0...100)
               .padding()
               .onAppear {
                  Timer.scheduledTimer(withTimeInterval: 0.005, repeats: true) { timer in
                     let now = Date()
                     
                     if let lastTime = lastTickTime {
                        let interval = now.timeIntervalSince(lastTime)
                        
                        minInterval = min(minInterval, interval)
                        maxInterval = max(maxInterval, interval)
                        totalInterval += interval
                        tickCount += 1
                     }
                     
                     lastTickTime = now
                     
                     counter += 0.2
                     if counter > 100 {
                        counter = 0
                     }
                  }
               }
           
           HStack {
               Text(String(format: "Min: %.3f ms", minInterval * 1000))
               Text(String(format: "Max: %.3f ms", maxInterval * 1000))
               Text(String(format: "Avg: %.3f ms", (totalInterval / Double(tickCount)) * 1000))
           }
           .padding()
           
           Text("Hash: \(vm.largeStruct.hashValue)")
               .padding()
           
            Text("Hello, world!")
           
           Button("Regenerate") {
              vm.largeStruct.regenerate(size: Int(size))  // Trigger the regeneration with the selected size
           }
           
           Button("Clear Stats") {
               minInterval = .infinity
               maxInterval = 0
               totalInterval = 0
               tickCount = 0
               lastTickTime = nil
           }
           .padding(.bottom)
           
           Text("Size: \(Int(size)) bytes")
           Slider(value: $size, in: 10_000...200_000, step: 10_000)
               .padding()
           
           Text("Number of Observers: \(observerCount)")
           Slider(value: $observerCount, in: 1...100, step: 5)
               .padding()
           
           HStack {
              ForEach(0..<Int(observerCount), id: \.self) { index in
                   Text("Observer \(index + 1): \(vm.largeStruct.data[index])")
                       .padding(5)
               }
           }
        }
        .padding()
    }
}

As I dive deeper into Swift performance and in memory representation, I realize that more context can be helpful.

My background is c/c++. so I'm looking to thoroughly understand the underlying behavior.

I see not my test might be overly simplified compared to the production code. It may be that swift can do much more optimization of a struct with an 80K array than it might with nesting of struct that have multiple variable length arrays in them.

I'll change my test to explore these options.

As an example, here is roughly what the source data looks like in swift. (Yes, it is a fairly naive generated codable interpretation of the JSON.)

All feedback and insights appreciated.

import Foundation

struct EventData: Codable {
    var events: [Event]
    var featuredItems: [FeaturedItem]
}

struct Event: Codable {
    var isAvailable: Bool
    var name: String
    var competitionName: String
    var competitionId: String
    var teams: [Team]
    var markets: [Market]
}

struct Team: Codable {
    var name: String
    var teamId: String
    var players: [Player]
}

struct Player: Codable {
    var playerId: String
    var parentId: String?
    var isTeam: Bool
    var lastName: String?
    var firstName: String
    var details: [PlayerDetail]
}

struct PlayerDetail: Codable {
    var position: String?
    var jerseyNumber: String?
}

struct Market: Codable {
    var marketId: String
    var marketTypeId: String
    var name: String
    var isSuspended: Bool
    var selections: [Selection]
}

struct Selection: Codable {
    var selectionId: String
    var handicap: Double
    var competitorId: String
    var name: String
    var odds: Odds
    var competitor: Competitor
}

struct Odds: Codable {
    var decimal: Double
    var numerator: Int
    var denominator: Int
}

struct Competitor: Codable {
    var competitorId: String
    var parentId: String?
    var isTeam: Bool
    var name: String
    var details: [CompetitorDetail]
}

struct CompetitorDetail: Codable {
    var position: String?
    var jerseyNumber: String?
}

struct FeaturedItem: Codable {
    var lastUpdatedAt: String
    var marketId: String
    var marketTypeId: String
    var name: String
    var isSuspended: Bool
    var selections: [FeaturedSelection]
}

struct FeaturedSelection: Codable {
    var selectionId: String
    var handicap: Double
    var competitorId: String
    var name: String
    var odds: Odds
    var competitor: Competitor
}

Shared a picture of the perf test app.

The 'regenerate' button rebuilds the published test structure.
And 'reset stats' clears the values.

You can use Instruments Time Profiler to find out which methods are taking long(er) than expected to perform a task a potentially break your very large @Published struct down into smaller independent objects. You can post your Time Profile results here if you need a second set of eyes for analysis.

Rico

WWDR - DTS - Software Engineer

I am not seeing symbols for SwiftUI calls which makes the perf stack trace difficult to interpret.
Are there symbols available for the related modules that would make this easier to track down in time profiler?

An advantage of using the built in Codable capabilities is not writing the encoders and decoders.
Happy to do this if we know we can get perf gains. Would prefer not to d this just to get symbols so we can to iterative block experimental testing of different data arrangements.

Symbols would be a very powerful option for investigating.

I am not seeing symbols for SwiftUI calls which makes the perf stack trace difficult to interpret.

Indeed. Try again with the latest beta Xcode and the latest beta iOS. I’m hoping that you’ll be pleasantly surprised (-:

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Performance with large @Published struct
 
 
Q