It is a valid question (isn’t it?) that why should we put effort into reducing the size of an SDK, with mobile storage capacities increasing all the time. Surely, how much do a few MBs matter when the device has multiple hundred gigabytes of storage? To put it into perspective, 1 MB is around 0.001% of the storage available on a modern mobile device.
However, it turns out that the download size of an app matters. A blog post by Google published in 2017, analyzing data from the Google Play Store, says that every 6MB increase in app size reduces installs by 1%. Emerging markets such as India are even more conscious about the download sizes, where removing 10MB from an app’s APK size correlates with an increase in install conversion rate by about 2.5%.
An experiment conducted by Twilio, as detailed in their blog post, tried increasing bloat in an app which had steadily growing installs. The install rate, which is the ratio of app installs to page views, decreased at 0.45% per MB increase. They also explain that Apple doesn’t allow cellular network usage for downloading an app which is more than 100 MB, significantly decreasing installs.
Android provides Dynamic Feature Modules where features can be delivered on demand and downloaded post install, reducing the size downloaded from the Play Store. However, iOS provides no such mechanism, making size reduction even more important for iOS apps.
Clearly, in the Mobile App World, size matters and smaller is better for a change.
In this blog post, we’re going to look at techniques to reduce SDK size on Android and iOS. This is primarily based on learnings from optimizing NimbleEdge’s SDK that enables running AI models on-device and applies to everything written using C++.
CocoaPods is the most common package manager for integrating third-party libraries in iOS apps. A dependency is called a pod in this framework. A pod can have a podspec file which specifies how that pod is to be integrated.
Some techniques help us in minimizing the app download size after it integrates our pod:
s.static_framework = true
to Podspec files.pod_target_xcconfig = {
'OTHER_CFLAGS' => '-Oz -fdata-sections -ffunction-sections -flto',
'OTHER_LDFLAGS' => '-Wl,--gc-sections -flto',
'DEAD_CODE_STRIPPING' => 'YES',
'STRIP_INSTALLED_PRODUCT' => 'YES',
'SWIFT_OPTIMIZATION_LEVEL' => '-Osize',
}
Optimising C++ code for binary size runs orthogonal, if not opposite, to optimising for performance. Some surprisingly common modern C++ paradigms lead to binary size bloat. We’ll look at ways of reducing binary size, starting with compiler optimizations.
Adding a few compiler flags gives you the biggest ROI on size versus time spent:
-Oz
optimization: Yes you read that right, it’s -Oz
and not -O3
. This flag aggressively optimizes for size and you can expect a slight performance hit as optimizations like function inlining & loop unrolling would be restricted. This is an example of where performance optimizations go opposite to binary size optimizations.-ffunction-sections -fdata-sections
to compiler flags and -Wl,--gc-sections
to linker flags. This instructs the compiler to put each function & variable into a different section of the object file. The linker flags tell the linker to garbage collect (gc) the sections that are not referred to by anything, in effect removing unused functions.-flto
to both compiler and linker flags. The linker has an overview of the entire program, enabling optimizations that are simply not possible at the compilation stage which only works with 1 translation unit.-fvisibility=hidden
flag to compiler flags. Functions that need to be called from outside the shared library then need to be marked with default visibility. Doing this reduces the shared library size as the dynamic symbol table shrinks. It also improves load times and allows the optimizer to produce better code, leading to a win in performance as well.Shared or Dynamic Libraries have their uses: they can be shared between multiple programs and can optionally be loaded at runtime using dlopen
. However, they have a larger binary size footprint than static libraries.
The reasons for this are pretty straightforward. Dynamic libraries need to keep the symbols exposed to the outside world in their dynamic symbol table, which can be quite big if you don’t restrict visibility as mentioned in the previous section. With static libraries, the symbol names can be completely stripped out. Further, LTO kicks in when the static library is linked, further helping in size reduction.
You have applied all the compiler trickery but still your SDK size is a little too much for comfort. It’s now time to roll up your sleeves and get in the code trenches to hand optimize code size.
Bloaty is a size profiler for binaries. It breaks down the size of a binary, which can be an executable or a library, and attributes sizes to symbols or code files. This can be used to guide hand-optimization for size. Working on things contributing most to the size will have the most impact.
C++ templates are incredibly useful at writing generic functions and classes, allowing code reuse that wouldn’t be otherwise possible. However, they come with a big cost: Every time a templated function or class is instantiated with a new type, it generates separate code. What this means is that the functions of std::vector<int>
would live separately in the binary than the functions of std::vector<long>
. This bloats the final binary size.
Here are a few pointers from our experience, your mileage may vary. These trade off safety for code size, usage of these classes and functions has to be done carefully:
template<typename... Args> void log(const std::string& message, Args... args)
, we’ll define the function as void log(const std::string& message, ...)
. The implementation will then have to use va_start
, va_arg
and related functions for going through the variadic arguments.While exceptions are extremely useful to propagate errors out of a deeply nested function and help write cleaner looking code, they have a big cost. Exceptions add stack unwinding code and exception tables to your binary, which significantly increases the binary size.
Disabling exceptions is simple: add -fno-exceptions
compiler flag. However, your codebase has to be designed in a way that it propagates error using return values rather than exceptions. Depending on the extent of exceptions used, removing the use of exceptions can be a rather big change.
Linking to shared standard libraries like libc++.so
and libstdc++.so
can help keep your binary lean. These libraries are present on the device, and so don’t impact the download size of your application. That is a widely known advantage of using shared libraries, however C++ templates add complications.
When you compile a cpp file using std::vector<int>
, the template class is instantiated from the template present in the vector
header. These are generated as weak symbols, so only one copy of the instantiation lives when multiple translation units are linked. However, as you may have noticed in this explanation, this code ends up living in your binary!
As far as I know, there is nothing to help this case with the standard library. Of course, there are advantages to this approach as well, a big one being that you can create a container for your custom type too.
For your own shared library, you can explicitly instantiate some common templates (like a C++ library could instantiate std::vector<int>
) and then you can have an extern template
declaration in the header. This would lead to no instantiation inside the code that’s including your header, and its binary size won’t increase.
In your shared library, you write template vector<int>
to explicitly instantiate and then you have a declaration extern template vector<int>
in your exposed header to prevent implicit instantiations in the code that will use the library.
In our previous blog, we covered how NimbleEdge helps capture event streams
Today AI is everywhere - well almost; at least it’s everywhere in conversations but is it making a real-world impact? Impact on everyone’s daily lives? As
We are stoked to welcome Neeraj Poddar, LinkedIn to the NimbleEdge team as our new VP of Engineering! With a remarkable background in building infrastructure products for massive scale, Neeraj has previously co-founded Aspen Mesh and led the engineering team at Solo.io where he also spearheaded [Is