Measuring the performance of mergeable libraries in iOS apps
I was curious about what the use of mergeable libraries meant for a typical app that may have a lot of library dependencies, so I devised a test which would measure the performance of merged frameworks versus old-school dynamic frameworks.
To read more about how mergeable libraries work, read this post.
The results
Let’s start with the results. These were taken on an iPhone 14 Pro using Instruments.
And here is a close-up of the region around the origin:
As you can see, the difference is dramatic. Virtually all of the O(n) cost of dynamic library loading is gone, even when an intermediate merged framework is introduced. It’s almost like the dynamic libraries have been turned into static ones, and that is not far from the truth.
The test
For the test, I programmatically created 100 small frameworks containing similar code but with unique symbols. In each framework, I exported
- 100 trivial functions in C
- 50 Objective-C classes, implemented in Swift1, each with unique selectors, for a total of 500 selectors in the library. Each library reused the same selectors.2 and contained trivial implementations.
You can download the test project with this configuration here.
For the “Individual frameworks” test, I linked and embedded anywhere from zero to 100 frameworks in a barebones app, and wrote code to invoke all of those functions and class methods.
I used the App Launch Instrument to profile the app, and measured the time between process creation and the UIKit Initialization signpost.
For the “Merged frameworks” test, I repeated the above process but with merging enabled.
For the “Merged frameworks with intermediate non-merged framework” test, I created an intermediate, non-mergeable framework which merged the remaining framework. The app then linked that intermediate framework only.
Key observations
Plain old dynamic frameworks have linear loading cost. Loading each framework happens in a single-threaded, linear fashion. Because my frameworks are trivial, the predominant cost of loading a framework appears to be just opening the file, which in my test cases accounted for about 2.9 ms per framework. No doubt loading cost will rise as framework complexity rises.
Mergeable libraries are fast. Really fast. Mergeable libraries work by turning dynamic loading into static linking, which eliminates file-opening, symbol lookups, and fixups. Mergeable libraries take constant time to run regardless of how many frameworks are merged, because no load-time activity takes place.
Intermediate merged libraries are really fast too. Much to my surprise, using an intermediate, non-merged framework to merge the rest of the libraries resulted in a similar flat curve as merged libraries, just a bit slower. My guess is that optimization and consolidation within the merged library (such as Objective-C selector uniquing) can be done across libraries to make load-time faster, but I’m not sure.
There is an inflection point at around 85 frameworks. Curiously, there is a slight uptick in load time for merged libraries at around 85 frameworks. I’m not sure why, but I suspect there is some number of frameworks that remains too high to be bundled into an app even with mergeable libraries, although that number is much higher than it used to be.
Your results will no doubt differ from mine, depending on library complexity. But my key takeaway is that it is now possible to freely use dynamic libraries in your app without necessarily having to pay the penalty for doing so at load time. If you’re a big fan of dynamic library semantics (you should be) this should be exciting news to you.