Optimising Autolayout

posted on

There ]ut takes several seconds to layout a few 100 views" and "Autolayout can layout a few 100 views very quickly" true, despite their seemingly contradictory nature.

Methods

First, I want to cover exactly how I measured the numbers in question. I believe this is slightly different to how Florian measured them, but shows the layout much more closely. I started with the original source project and made modifications to test some variations. You can find my version of the project on GitHub.

To measure timings, I ran the app in Instruments using the Time Profiler template. I did not feel the need to restart the app each time as there is little-to-no caching going on. I ran each test 3 times in succession, clearing the views between each test. Afterwards, in Instruments, I focused on the region of the sample in which each test was run. To get the time a layout took, I used the time Instruments say its layout method took. I calculated the average of the 3 runs and used that to provide the data for this post.

Recreating The Initial Results

As my methods were slightly different, and I am using a different device to Florian (a 3rd Gen iPad), I first set out to test the same things he did. His project tested 3 ways of laying out:

  • A flat hierarchy of views, absolutely positioned in the root view
  • A flat hierarchy of views, relatively positioned to each other
  • A nested hierarchy of views, relatively positioned to each other

He also did both the flat and nested hierarchies by simply setting the frame. Below is the graph showing what I got for the flat hierarchy.

Graph showing the time take to layout a flat view hierarchy

If you compare to Florian's post, you'll see that this looks rather different. In Florian's graph, the green line is worse than the orange line, but they are both fairly close. In my graph, the orange line is a lot worse (as an example, for 600 views, Florian got 5 seconds, whereas I got closer to 7.5 seconds, despite having a faster iPad), but the green line is a lot better (for 600 views I got around 2.5 seconds vs Florian's 6-7 seconds).

I'm putting this difference down to the difference in measurement. As mentioned earlier, I used the timing of method creating the constraints. In order to do this, I invoked the -layoutIfNeeded method on the root view at the end of each method. This forces Autolayout to run immediately, rather than deferring until the end of the run loop, meaning that Instruments counts the performance on the method creating the constraints, rather than a system method.

I suspect Florian was measuring the overall time the CPU was working, but this isn't necessarily all due to Autolayout. I believe my way is more indicative of exactly what Autolayout is doing, but Florian's is more indicative of how long the app may be unresponsive for. Regardless, the actual values don't matter as much as the curve, and any relative improvements we can find.

Graph showing the time taken to layout a nested hierarchy

The nested layout graph has fewer differences with the original tests. The curve is pretty much identical. The only difference is that my times are slightly faster, which is to be expected when running on a faster device.

The Power Of Locality

One thing I noticed about the original code was that all the constraints were being added on the root view. In some cases this is required, as the constraint references the the root view. All the views a constraint references must be in the subtree of the view it is being added to. As such, you could just throw every constraint in the UI into the app's root view.

You don't want do that though, for several reasons. The most obvious is that it's a lot simpler to understand code when it is adding constraints locally. The other is that it dramatically affects performance.

Let's look at our flat layout. While the position constraints need to be on the root view, the size constraints don't. I changed the code so that the size constraints were being added to the subview instead, and got the following results:

Graph showing the time taken to layout a flat view hierarchy, comparing adding constraints to the root view against adding to the closest local ancestor

The purple line is the relatively layout, with the size constraints being as local as possible, and the red is the equivalent line for the absolute layout. As you can see, we're getting some performance improvements. I'm not 100% sure, but my educated guess is that this is because we are reducing the size of the calculation on the root view. We are letting Autolayout perform part of the layout as a lot of small calculations, rather than calculating the whole thing in one big blob.

These gains are relatively small though. The more complex calculations are still all clustered together and are as local as possible. Let's look at the nested layout then, as all the constraints relating to a view can be put in the immediate superview, dramatically increasing the locality. The graph below shows just how significant an improvement this gives.

Graph showing the time taken to layout a nested view hiearchy, comparing adding constraints to the root view against adding to the closest local ancestor

To give actual numbers, the 200 view layout took 22.75 seconds when putting all constraints in the root view, but only 2.00 seconds when putting them on the immediate superview. Putting the same constraints on the root view leads to the code running over 11 times slower. The lesson of this should be obvious. When working with Autolayout, put all your constraints as locally as possible.

Modifying Existing View Hierarchies

Florian mentioned that constraint satisfaction problems have a polynomial complexity. We can see this in the curves of the graphs above. However, the tests above are largely unrepresentative of the real-world use of Autolayout. Knowing how fast Autolayout is at throwing 1000 views into a parent view is useful, much as knowing how fast NSArray is at adding millions of objects. However, the majority of NSArrays created rarely hold more than a few 100 items, with many holding less than 10. Similarly, it's rare for an individual view to hold more than 40-50 subviews, or to have a view hierarchy more than 20-30 views deep (I suspect those values are wildly overestimated).

The more realistic scenario is having a view hierarchy where we want to move some views around, or to add a few additional views. I conducted some more tests based on those above. Taking both the flat (absolute, not relative) and nested layouts, at the sizes used above, I then calculated how long it took to move all the views and to add 10 additional views.

As we can see from the graph below, even at up to 1000 views, adding an additional 10 views to the flat, absolutely positioned layout is largely linear. This is because we are only referencing the root view, and so all the other views don't need to be recalculated. If we inserted a view into the middle of the chain of relatively positioned views, it would likely not be quite so fast.

Similarly, moving is largely linear, though it does spike at 1000 views. Again, this is because the constraints for one view do not depend on any other sibling view.

Graph showing the time taken to layout a flat view hierarchy when adding and moving views

If we look at the nested layout, we find that moving is also seemingly linear. While it looks a lot shallower than the flat hierarchy, it is merely a trick of the graph, they are largely on the same line. When it comes to adding, we do see a curve, but we are adding an additional 10 layers of view hierarchy here, each dependent on the previous.

Graph showing the time taken to layout a nested view hierarchy when adding and moving views

The thing to note with all of these, is how fast they are compared to the previous tests. To add 1000 absolutely positioned views took 6.6 seconds, but to add an additional 10 took just 0.055 seconds. This comes down to how clever the Cassowary Constraint Solver is.

Rather than attempting to re-solve the entire problem from scratch each time, it has an incremental system. It can re-use all its previous calculations and merely modify the results when you add, edit or remove constraints. This is why it can take several seconds to add a few 100 views in one go, but you can then resize that window rapidly, and have all the constraints be re-calculated and frames set.

Autolayout is slower than manually setting frames. It is generalising the solving quite complex layout problems across a whole UI. Having specialised algorithms focused on a single view is always going to be faster to run. Autolayout's advantage isn't in making layout faster at runtime, but in making it faster and easier for us to define layouts when coding.

Like with many of the tools we use, Autolayout takes advantage of the fact that we have an abundance of processing power, in order to make it easier for us to write apps. For the vast majority of use cases, and if used correctly, Autolayout is more than fast enough. It may sound like an excuse, but it's the same excuse we use to justify writing in our higher level programming languages instead of Assembly.