Reducing iOS App Size The Hard Way

After a few years I’ve decided to leave app development behind for a while. I’ve been in a pensive mood, reflecting on the things I’ve learnt and the foolish ideas I’ve had along the way. This post is about one of the foolish ideas.

In 2015 I was working on an iOS app that had a size problem. Because of new features and resources it reached 100 MB. This is the threshold when apps can only be downloaded via WiFi, not mobile data. We wanted to stay below this limit if we could.

Sometimes the size blows up by accident - I went digging in the IPA to see if someone had included a giant TIFF file or something - but it was no good. Most of the space was taken up by the app binary, the compiled executable inside the IPA.

It wasn’t too surprising that the binary was large because it used some large libraries. What did surprise me was a decent fraction of the binary was filled with human-readable symbol names - effectively debugging information. It was a promising target to save some space.

Most of the time this is easy to solve - you just use the strip command to remove the symbols. Boom, you’ve saved a bunch of space and the program works exactly the same. In this case we had some peculiar constraints around how we wanted to use those symbols. It became a little more complicated.

To explain the situation I’ll briefly review where the symbols come from. Imagine I write a C library mylib.c containing two functions - divide(), which makes use of another function divide_internal().

/* Function private to the library to do the actual division */
static int divide_internal(int n1, int n2)
    return n1 / n2;

/* "Public" method that can be called from app after including mylib.h */
int divide(int dividend, int divisor)
    return divide_internal(dividend, divisor);

I compile this to mylib.a and write a header file mylib.h that defines the divide() function. I can give these to a programmer and now they can use divide() in their own program. But what if they found out about the internal method? Imagine they tried adding divide_internal() to the header file and calling it. This would fail with a linker error undefined reference to 'divide_internal'. Because the internal method was marked static it is not available for use outside the library.

Using nm I can list the symbols inside the static library. The letter to the left of the name indicates what type of symbol it is. The capital T means that it’s a global symbol, while lower-case t means it is local. Only global symbols are available for linking in other libraries and programs.

$ nm mylib.a
0000000000000013 T divide
0000000000000000 t divide_internal

Suppose I write a program that divides two numbers, called app, using the library. I can run nm on the compiled app and see that both library functions have been included.

$ nm app | grep divide
0000000000000795 T divide
0000000000000782 t divide_internal

Where am I going with all this? This is the important question: why does the compiled program contain the text “divide_internal”? In the compiled code, the call to divide_internal() has been replaced with this instruction:

0x00000000000007ad <+24>:    call   0x782

In other words, “Dear CPU, please jump to 0x782”, which happens to correspond to the address in the output of nm. The name from the C source code is not required for the correct functioning of the program.

But it’s not completely useless. This program takes two numbers from the command line and divides them. This is fine:

$ ./app 100 50

A divide-by-zero is not:

$ ./app 100 0
Floating point exception

What went wrong? I’ll run it inside the debugger.

$ gdb ./app
(gdb) run 100 0
Starting program: /home/tk/Code/appsize/app 100 0

Program received signal SIGFPE, Arithmetic exception.
0x0000555555554790 in divide_internal ()
(gdb) bt
#0  0x0000555555554790 in divide_internal ()
#1  0x00005555555547b2 in divide ()
#2  0x0000555555554762 in main ()

Awesome, I know exactly where the problem occurred - an arithmetic exception in divide_internal(). That’s really handy, and it’s only possible because those symbols were saved in the file.

What if I wasn’t the author of the library, though? I might not even have the source code for divide_internal() so detail beyond divide() is probably a waste of space.

Imagine, worse still, that I’m using a C++ library with a lot of templates. Even in mangled form the names of the internal functions can get extremely large, like ZNK3MapI10StringName3RefI8GDScriptE10ComparatorIS0_E16DefaultAllocatorE3hasERKS0.

This was my problem in the iOS app - I had literally megabytes of these crazy function names inside the app binary.

I’ll try the strip command. If I run it on this test program it reduces the size from 8792 to 6312 bytes. Then I’ll run the stripped version in the debugger.

$ nm appstrip
nm: appstrip: no symbols

$ gdb appstrip
(gdb) run 100 0
Starting program: /home/tk/Code/appsize/appstrip 100 0

Program received signal SIGFPE, Arithmetic exception.
0x0000555555554790 in ?? ()
(gdb) bt
#0  0x0000555555554790 in ?? ()
#1  0x00005555555547b2 in ?? ()
#2  0x0000555555554762 in ?? ()
#3  0x00007ffff7a5a2e1 in __libc_start_main () from /lib/x86_64-linux-gnu/
#4  0x00005555555545ea in ?? ()

Now I’ve lost all my debugging symbols. gdb can trace the stack frames but it doesn’t know the name of the function associated with each one. It’s much harder to know why the problem occurred.

This isn’t necessarily game over - if I have an unstripped version of exactly the same binary on hand it is possible to give that supplemental information to gdb. (e.g. symbol-file app).

So is that our solution? Strip our iOS binary, save a bunch of megabytes, and if we get any crash reports we can resymbolicate them using the debug information we saved in advance for each release. Ship it!

We discussed this option and… it wasn’t very popular. It’s actually super convenient if a crashed app can unwind its stack and generate a crash report that includes function names. Often you can locate a bug within minutes of seeing the backtrace. Identifying the version, digging up the corresponding dSYM file and doing the resymbolication takes more time and effort. In principle it could be streamlined with processes and tooling but dang it’s an overhead.

So I worked on a compromise: what if I only stripped some of the symbols?

Mostly I could determine which symbols to include by a simple heuristic - if the name of the function is over n characters then I don’t want it. The smaller the value of n, the more space gets saved, but the larger the risk that I will accidentally exclude important symbol names from my own code. However in practice even ridiculously large values like 100 were sufficient to save a lot of space.

Conveniently, strip lets me choose exactly what symbols I want to remove. If I want, I can notch out just divide_internal().

$ strip -N divide_internal appselective
$ ls -l app appselective
-rwxr-xr-x 1 tk tk 8792 Mar 17 12:45 app
-rwxr-xr-x 1 tk tk 8752 Mar 17 13:43 appselective

Boom, 40 bytes saved. And my backtrace still has the most relevant information in it:

(gdb) bt
#0  0x0000555555554790 in ?? ()
#1  0x00005555555547b2 in divide ()
#2  0x0000555555554762 in main ()

New problem: I worked this out on Linux, which has GNU strip. GNU strip does not know how to work with iOS binaries. I need to use Darwin strip which comes with the Xcode toolchain. I checked the man page for the Darwin version to find the corresponding command line option.

-R filename
    Remove the symbol table entries for the global symbols listed in filename. [...]

Aha perfe-… no wait a minute. “global symbols”? Yes indeed, the Darwin version of strip only supports selectively stripping global symbols, the capital T ones mentioned previously. I want to process the local symbols too - they’re the worst offenders and arguably the more useless ones.

What to do? I was so close… well then. It turns out the source code for strip is available online. One evening I got this to build on my mac and I made some horrendous hacks that caused the -R option to walk the table of local symbols as well as the global symbols. Those specific code changes are lost to time, which is probably for the best.

From there it was easy - a new post-build step in Xcode that runs nm on the compiled binary, filters function names by size, builds a list of the symbols to remove, then invokes my crazy custom strip before signing and packaging. I could easily go in and tune the rules and therefore the size of the IPA. It really worked!

Needless to say, this is a pretty ridiculous thing to have in a build pipeline. Ultimately it never got merged and none of these binaries were ever submitted to the app store. I sometimes wonder whether it would have tripped any consistency checks: “ERROR ITMS-90123/WTF: What have you done, you monster?” Our priorities changed and the app went past 100 MB. Oh well.

In the end it was a waste of time but it was way more fun than finding a TIFF file.