Closed carson-katri closed 2 years ago
Here are the results of the TokamakCoreBenchmark with layout enabled:
Layout
protocolname time std iterations
----------------------------------------------------------------
update wide (StackReconciler) 46.404 ms ± 1.92 % 30
update wide (FiberReconciler) 42.673 ms ± 3.23 % 33
update narrow (StackReconciler) 45.874 ms ± 2.04 % 31
update narrow (FiberReconciler) 42.407 ms ± 1.84 % 33
update deep (StackReconciler) 17.447 ms ± 2.48 % 80
update deep (FiberReconciler) 7.656 ms ± 8.38 % 177
update shallow (StackReconciler) 8.899 ms ± 1.93 % 157
update shallow (FiberReconciler) 4.995 ms ± 4.32 % 277
LayoutComputer
implementationname time std iterations
----------------------------------------------------------------
update wide (StackReconciler) 46.891 ms ± 1.67 % 30
update wide (FiberReconciler) 33.054 ms ± 1.82 % 42
update narrow (StackReconciler) 46.437 ms ± 2.98 % 30
update narrow (FiberReconciler) 33.150 ms ± 3.61 % 42
update deep (StackReconciler) 17.587 ms ± 1.54 % 80
update deep (FiberReconciler) 6.135 ms ± 3.47 % 227
update shallow (StackReconciler) 8.952 ms ± 1.56 % 156
update shallow (FiberReconciler) 3.854 ms ± 3.17 % 363
Graphs! I wanted to visualize the performance of these different methods to see how they compare more visually (easier on the eyes than a bunch of nanosecond numbers 😅).
x-axis is # of views, y-axis is time in ms to update (not the first render).
I added a few things that have optimized the Layout protocol implementation for the "ForEach
Test" from above. Specifically, I attempted to optimize array capacity by having View
s that know their child counts implement a _viewChildrenCount
method, which gives us the information needed to reserveCapacity
on the LayoutSubviews
array.
The cache is now persisted as well, although currently I don't think any built-in Layout
implementations would benefit much from this.
Actually, seems like 2f018a2 made the biggest difference. I didn't realize I hadn't run the benchmarks against that commit. I reverted the reserveCapacity
code, because I do think it has potential to hurt performance more than help. The cache updates should still help in some scenarios (although I need to test cache invalidation against SwiftUI further to ensure the implementation matches).
Here is the new graph:
I've added a new TokamakLayoutTests
target, which compares native SwiftUI to custom layout implementations via image.
I only use grayscale colors in those tests because the RGB colors don't quite match between the images, and we really just want to test the actual view geometry.
I've adjusted the reconciler to perform updates from the root node when layout is enabled, because otherwise the View
s and children are not collected and the parents at the top of the hierarchy don't correctly place their children.
If anyone has a better solution to this, let me know.
Not sure how accurate this is, but I ran the same ForEach
benchmark on native SwiftUI in the iOS 16 simulator, and it was actually slower than this implementation and crashed before it got to 10,000 views.
This tests the first render for a SwiftUI view though, not an update like the other tests. I think an update will be significantly faster than this, but I'm not sure how to benchmark that. I used the below snippet to run the test in SwiftUI:
for i in stride(from: 1, to: 10000, by: 100) {
let start = DispatchTime.now()
let controller = UIHostingController(rootView: TestView(items: i))
_ = controller.view.intrinsicContentSize // Access the size to trigger SwiftUI layout.
let end = DispatchTime.now()
graph.append(end.uptimeNanoseconds - start.uptimeNanoseconds)
}
Sorry for the delay with the review. I have it on my list and will get to it during this weekend.
Here's my attempt at measuring an update (not a first render) in SwiftUI. This is a more accurate comparison, but still not perfect:
Also, no rush on the review!
ReconcilePass
was copying LayoutSubviews
each time a subview was added. Fixing that got the update down to <400ms!
I made a few changes to (1) move the LayoutSubview logic into the type instead of in the ReconcilePass
, and (2) remove layout
values from Views that don't participate in layout.
I was hoping this could help with stack overflows when using dynamic layout. But now I think the only real way to solve this is to make the calls into LayoutSubview
iterative. I have not been able to figure out how to do this yet. Currently the FiberReconciler
without dynamic layout enabled can handle ~400 nested views without overflowing, while dynamic layout can only handle ~15 unless you increase the stack size. For now, we can just use recursion though.
This adds support for the new
Layout
protocol introduced in iOS 16 and aligned releases.The
FiberReconciler
's layout code has been refactored to use this protocol in place of the previousLayoutComputer
implementation. In order to match the behavior of native SwiftUI, I split the reconciling and layout passes into two separate loops. The reason for this change is that all of theLayoutSubview
s must be collected prior to performing any layout computations. However, the previousLayoutComputer
approach built the size incrementally as theView
s appeared.VStack
/HStack
, and otherLayoutComputer
s have been rewritten to use theLayout
protocol. I specifically worked on getting the stacks to match SwiftUI as closely as possible, other layouts may need tweaking.TODOs:
LayoutComputer
Layout
protocol (possibly test against the computed frames)ViewSpacing
(for instance,Text
seems to prefer no spacing on the top/bottom, but default spacing on the leading/trailing edges)Layout.Cache
does not actually cache between runsLayoutValueKey