Skip to content

Conversation

@stackotter
Copy link
Owner

@stackotter stackotter commented Dec 11, 2025

This PR addresses SwiftCrossUI's biggest layout performance issues. It contains quite a few structural changes that pave the way for future optimisations as well.

Optimisations

Splitting View.update into View.computeLayout and View.commit

Before this PR, View had a single update-related method, View.update, which had two modes: dry run, and non dry run. Dry runs were used to probe the sizes of views without actually committing the layout to the underlying widgets. This let us cache layout results (because we didn't have to worry about keeping the widgets in sync during dry run updates). Then when the final layout was decided, a non dry run update would happen, during which the layout would be committed to the underlying widgets. This architecture was inefficient because the final non dry run update would effectively have to compute the entire layout again, leading to a lot of doubling up of work.

My solution was to split View.update into two separate requirements; View.computeLayout and View.commit. View.computeLayout computes layouts as usual without committing anything to the underlying widgets, and then View.commit can be called with the result of View.computeLayout to efficiently commit the last computed layout of the view and all of its children.

This lead to a 7.7x improvement for the grid benchmark, and a 2.8x improvement for the message list benchmark.

The main reason that this was so effective is that during the commit phase, each view gets handed its last computed result which it can blindly trust, as opposed to each view having to recompute its desired size.

Update layout algorithm to match SwiftUI

The layout system's biggest layout problem prior to this PR was that its stack layout algorithm required computing the layout of each child view multiple times. This led to exponentially worse layout performance as the amount of nesting increased. My caching system managed to curb the exponential growth in some situations, but it was easy to render the caching useless.

After much thinking, I discovered a few unavoidable approximations that we'd have to adopt if we were to avoid computing the layout of each child view multiple times per stack layout pass. I created some edge cases that would behave differently depending on whether or not SwiftUI used these approximations I had landed on. SwiftUI uses all of them.

In my opinion, these approximations lead to less desirable behaviour than SwiftCrossUI's previous layout system, but I don't think that we can handle all of the aforementioned edge cases well without keeping our serious performance issues. There are likely other sets of approximations that would lead to similar performance, but given that SwiftUI already uses these approximations, and that I arrived at these approximations independently, I decided that they're our best option.

Adopting said approximations went hand in hand with updating ViewSize to be a simple size type (rather than tracking minimum size, maximum size, ideal size, etc), and our proposed view sizes to be 2d vectors of Double?.

Together, adopting the approximations and updating simplifying our ViewSize type allowed us to reduce our effective child layout passes per stack layout pass to 1. I say effective layout passes, because we still query the child multiple times for its minimum, maximum, and final sizes, but together those roughly equate to the same amount of work as a single layout pass would have if we still had our old ViewSize type. The key to making it work out like so is that querying the minimum, maximum or ideal size of a stack layout now only requires computing the minimum, maximum or ideal sizes of its children respectively. This means that minimum, maximum and ideal size requests are linear in the number of views in the stack's view hierarchy. Additionally, our probing child layout requests enable environment.allowLayoutCaching, so any given view only ends up computing its minimum, maximum or ideal size once during a given update cycle. This means that even though our stack layout algorithm technically queries each child multiple times, the minimum and maximum size requests are free if any parent view has already computed them, meaning that we effectively only query each child once.

This lead to a 4x improvement for the grid benchmark, but somehow made our message list benchmark twice as bad. I haven't properly investigated why that happened, but that'd be a good place to start for future performance work, as it would probably help us figure out exactly what became slower when introducing this new layout system.

Layout system changes

Any VStack (or height-specific) behaviours described here apply to HStacks as well. I'm just being lazy.

  • The minimum height of a stack layout is just the minimum heights of its children added together. Due to the greedy nature of the stack layout algorithm this can lead to the stack overflowing its frame when proposed its minimum size. This is most noticeable when the stack is in the layout context of a window (rather than inside of a decoupling container such as a ScrollView).
  • When a VStack is given a concrete height and an unspecified width, it may end up overflowing its own reported bounds, because we delay the final layout pass until the commit step. This isn't ideal, but it's what SwiftUI does, and it lets us keep the effective branching factor of our stack layout algorithm at 1.
  • When a minHeight frame is proposed an unspecified height, it may end up with a different width to its child. The child gets proposed an unspecified height, then the frame clamps the resulting height and assumes that the child will keep its reported width. During commit, the frame will lay out the child again with the clamped height, and the child may end up growing or shrinking horizontally. This means that the unconstrained axis of a frame may end up not hugging its content even though it has no reason not to. This less than ideal behaviour is to keep our branching factor at 1.

Using a custom Mirror replacement

I noticed that we were spending about half of each layout update in Mirror-related code. I've had a Mirror optimisation idea in the back of my head for a while so I gave it a go, and it basically eliminated Mirror overhead for stateless views (1500x faster), and made updating dynamic properties (e.g. @State properties) 5-10x faster for views with a <16 dynamic properties. The new system still uses Mirror during ViewGraphNode creation, but it uses a custom technique to infer the offset of each dynamic property discovered by the mirror. We can then reuse the offsets for the rest of the ViewGraphNode's lifecycle, and we cache the offsets for each type in a global look up table to reduce Mirror usage even further.

I've documented this system quite thoroughly in Sources/SwiftCrossUI/State/DynamicPropertyUpdater.swift, so if you wanna know more about how it works, give that a read.

This made both benchmarks (grid and message list) twice as fast.

Benchmarks

All benchmarking has been done on my M2 MacBook Air with 8gb of RAM.

The grid benchmark is now 61x faster.

The message list benchmark is now 4x faster (it didn't benefit as much from our branching factor reductions because it has less nesting).

@bbrk24's private DiscordBotGUI app now has 29x faster window resize handling, and a synthetic benchmark based of the core of its performance issues is 11.34x faster.

Future directions

  • Investigate why ed24b0fa (splitting View.update into computeLayout and commit) -> e4daa213 (adopting SwiftUI's layout approximations) made the message list benchmark twice as slow. It may help us figure out inefficiencies in the new layout system.
  • Optimise ViewGraphNode and other generic parts of the layout system for some linear performance gains. E.g. TupleViewChildren does a bunch of work computing information related to the state snapshotting system, even when no snapshots are present.

Tasks

  • Test Gtk 3 window sizing issues mentioned in huge comment in previous WindowGroupNode implementation
  • Move all views and modifiers back after ensuring things work with the current limited set of views
  • Look into ways to improve minimum window sizing behaviour (to match the previous implementation as much as possible). The SwiftUI way is not satisfactory in my opinion.
  • Uncomment the hot reloading macro declarations
  • Update benchmark test case visualiser to correctly set frame of app to requested size proposal
  • Update roundSize implementation to print a warning when given an infinite size (once I've updated AppBackend's APIs such that we don't have any legitimate use for passing an infinite size to an integer-accepting backend method; e.g. the method to get the size of text makes sense to pass infinity to, but it should take a double vector not an integer one).
  • Uncomment NSViewRepresentable implementation and fix it
  • Update text to clip by default so that minimum window sizing at least seems sensible :) (that's what SwiftUI relies on)
  • Avoid sorting stack children by flexibility when there's only one child.
  • When asked for the maximum width of a vstack (or maximum height of a hstack) stop once a child has infinite width (or height for an hstack), to save computation.
  • Detect 'simple' ScrollView size proposals such as 0x0 that don't require querying the child view.
  • Figure out how to get Gtk to clip automatically-inserted ellipsis's to the bounds of a Label when the label is smaller than the size of a single ellipsis

Some of the bad behaviour from SwiftUI's layout system can probably be
mitigated, but other undesirable behaviour is probably the best we can
do, cause some things are impossible to compute nicely without laying
out child views more than once (a no go for performant layout).
Some backends (such as AppKitBackend) trigger window resize
handlers before the underlying window's size gets updated.
Our existing code assumed that it should set a window's size
if the proposed window size didn't match the underlying
window's size, but in this case that led to an infinite update
loop.
This led to situations where the last accessed cached layout would
replace the real current layout and then the layout system would commit
a cached layout instead of the current layout, leading to strange
results. I noticed this bug when testing StressTestExample. Open the
app and then click Generate without interacting with the window in any
way beforehand to reproduce the bug (resizing the window first seems to
stop the bug). I only tried reproducing the bug on macOS with
AppKitBackend.
The frame modifier had undesirable behaviour for ideal-size proposals.
You'd end up with a child taking on its ideal size and being much
smaller than the frame's clamped bounds, making it impossible to grow
views in scroll views.

ScrollView didn't grow to use up available space along scrolling axes.

TextEditor didn't grow to use up available width.

Used GeometryReader to grow the TextEditor to at least fill the
ScrollView which used to (somewhat erroneously) be the default
behaviour.
@stackotter stackotter marked this pull request as ready for review December 31, 2025 09:17
@stackotter stackotter merged commit de536e2 into main Dec 31, 2025
22 checks passed
@stackotter stackotter deleted the layout_performance branch December 31, 2025 09:17
stackotter added a commit that referenced this pull request Jan 2, 2026
#278 broke UIKitBackend's Text implementation when switching from
UILabel to a custom text rendering UIView implementation. It didn't
override its NSTextContainer's lineFragmentPadding, so the text
content got rendered with a horizontal offset and ended up clipping
even when there was available space.
stackotter added a commit that referenced this pull request Jan 2, 2026
#278 broke UIKitBackend's Text implementation when switching from
UILabel to a custom text rendering UIView implementation. It didn't
override its NSTextContainer's lineFragmentPadding, so the text
content got rendered with a horizontal offset and ended up clipping
even when there was available space.
stackotter added a commit that referenced this pull request Jan 9, 2026
We weren't updating the button before measuring its naturalSize,
and we were using the button's width instead of its height when
positioning its popover.
stackotter added a commit that referenced this pull request Jan 9, 2026
We weren't updating the button before measuring its naturalSize,
and we were using the button's width instead of its height when
positioning its popover.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants