Published on

Fixing uFeature app binary sizes

Authors

Introduction

So right now I'm putting the finishing touches on the TCA rewrite of NanoFlick, and it's looking pretty slick! Unfortunately, it's literally double the size! The release executable of NanoFlick is ~80mb, while the release executable of NanoFlickTCA is ~175mb!

This is way, way too much of an increase based on what's actually being expressed, so lets try and get it down.

Diagnosis

The first port of call is a tool my friend Mofei showed me called bloaty. It's a tool that can analyze the size of a binary and break it down into sections. Here's the output of NanoFlickTCA:

bloaty /Users/jack/Library/Developer/Xcode/DerivedData/NanoFlickTCA-eubkyvwpfwodfccpjvlfzmaqklts/Build/Products/Release-iphoneos/NanoFlickTCA.app/NanoFlickTCA --debug-file /Users/jack/Library/Developer/Xcode/DerivedData/NanoFlickTCA-eubkyvwpfwodfccpjvlfzmaqklts/Build/Products/Release-iphoneos/NanoFlickTCA.app.dSYM/Contents/Resources/DWARF/NanoFlickTCA  -d compileunits
    FILE SIZE        VM SIZE
 --------------  --------------
  61.2%   101Mi  60.7%   101Mi    [__LINKEDIT]
  24.8%  41.3Mi  24.9%  41.7Mi    [3116 Others]
   3.6%  5.96Mi   3.6%  5.96Mi    [__TEXT,__text]
   1.4%  2.37Mi   1.4%  2.37Mi    [__LLVM_COV,__llvm_covfun]
   1.3%  2.14Mi   1.3%  2.14Mi    [__DATA,__llvm_prf_data]
   1.1%  1.74Mi   1.0%  1.75Mi    /Users/jack/Library/Developer/Xcode/DerivedData/NanoFlickTCA-eubkyvwpfwodfccpjvlfzmaqklts/SourcePackages/checkouts/firebase-ios-sdk/FirebaseCore/Sources/FIROptions.m
   1.0%  1.65Mi   1.0%  1.65Mi    [__TEXT,__const]
   0.8%  1.37Mi   0.8%  1.37Mi    [__DATA,__llvm_prf_names]
   0.8%  1.26Mi   0.7%  1.26Mi    [__TEXT,__cstring]
   0.7%  1.14Mi   0.7%  1.14Mi    [__DATA_CONST,__const]
   0.5%   890Ki   0.5%   890Ki    [__TEXT,__swift5_typeref]
   0.2%   282Ki   0.4%   693Ki    /Users/jack/Library/Developer/Xcode/DerivedData/NanoFlickTCA-eubkyvwpfwodfccpjvlfzmaqklts/SourcePackages/checkouts/MetalPetal/Sources/MetalPetalObjectiveC/MTIVertex.m
   0.4%   626Ki   0.4%   626Ki    [__DATA,__llvm_prf_cnts]
   0.4%   626Ki   0.4%   626Ki    [__DATA,__objc_const]
   0.4%   613Ki   0.4%   613Ki    [__DATA,__data]
   0.3%   565Ki   0.3%   565Ki    [__TEXT,__unwind_info]
   0.2%   281Ki   0.3%   564Ki    /Users/jack/Library/Developer/Xcode/DerivedData/NanoFlickTCA-eubkyvwpfwodfccpjvlfzmaqklts/SourcePackages/checkouts/Aspects/Sources/Aspects/Aspects.m
   0.2%   256Ki   0.3%   544Ki    /Users/jack/Library/Developer/Xcode/DerivedData/NanoFlickTCA-eubkyvwpfwodfccpjvlfzmaqklts/SourcePackages/checkouts/YoutubePlayer-in-WKWebView/WKYTPlayerView/WKYTPlayerView.m
   0.3%   525Ki   0.3%   525Ki    /Users/jack/Library/Developer/Xcode/DerivedData/NanoFlickTCA-eubkyvwpfwodfccpjvlfzmaqklts/SourcePackages/checkouts/swift-overture/Sources/Overture/Curry.swift
   0.3%   508Ki   0.3%   508Ki    /Users/jack/Documents/NanoFlickPrototypes/NanoFlickTCA/Sources/CaptureFeature/Capture.swift
   0.3%   507Ki   0.3%   507Ki    [__LLVM_COV,__llvm_covmap]
 100.0%   166Mi 100.0%   167Mi    TOTAL

And here's the output of NanoFlick:

bloaty /Users/jack/Library/Developer/Xcode/DerivedData/NanoFlick-ccweukbolvpvhhbzbqpybnoefatw/Build/Products/Release-iphoneos/NanoFlick.app/NanoFlick --debug-file /Users/jack/Library/Developer/Xcode/DerivedData/NanoFlick-ccweukbolvpvhhbzbqpybnoefatw/Build/Products/Release-iphoneos/NanoFlick.app.dSYM/Contents/Resources/DWARF/NanoFlick -d compileunits
    FILE SIZE        VM SIZE
 --------------  --------------
  37.2%  30.4Mi  37.5%  31.1Mi    [3002 Others]
  33.8%  27.6Mi  33.4%  27.6Mi    [__LINKEDIT]
  11.6%  9.45Mi  11.4%  9.45Mi    [__TEXT,__text]
   5.2%  4.22Mi   5.1%  4.22Mi    /Users/jack/Library/Developer/Xcode/DerivedData/NanoFlick-ccweukbolvpvhhbzbqpybnoefatw/SourcePackages/checkouts/FirebaseUI-iOS/FirebasePhoneAuthUI/Sources/FUIPhoneAuthStrings.m
   2.4%  1.97Mi   2.4%  1.97Mi    [__TEXT,__const]
   1.8%  1.46Mi   1.8%  1.46Mi    [__TEXT,__cstring]
   1.1%   900Ki   1.1%   900Ki    /Users/jack/Documents/NanoFlick/NanoFlick/Logging/AnalyticsManager.swift
   0.9%   782Ki   0.9%   782Ki    [__DATA,__objc_const]
   0.9%   762Ki   0.9%   762Ki    [__TEXT,__swift5_typeref]
   0.7%   555Ki   0.7%   555Ki    /Users/jack/Library/Developer/Xcode/DerivedData/NanoFlick-ccweukbolvpvhhbzbqpybnoefatw/SourcePackages/checkouts/GoogleSignIn-iOS/GoogleSignIn/Sources/GIDAuthentication.m
   0.6%   534Ki   0.6%   534Ki    [__TEXT,__unwind_info]
   0.6%   500Ki   0.6%   500Ki    [__TEXT,__objc_methname]
   0.5%   411Ki   0.5%   411Ki    /Users/jack/Library/Developer/Xcode/DerivedData/NanoFlick-ccweukbolvpvhhbzbqpybnoefatw/SourcePackages/checkouts/GoogleUtilities/GoogleUtilities/Logger/GULLogger.m
   0.5%   410Ki   0.5%   410Ki    /Users/jack/Documents/NanoFlick/NanoFlick/CaptureView.swift
   0.5%   392Ki   0.5%   392Ki    [__TEXT,__gcc_except_tab]
   0.2%   145Ki   0.4%   353Ki    /Users/jack/Library/Developer/Xcode/DerivedData/NanoFlick-ccweukbolvpvhhbzbqpybnoefatw/SourcePackages/checkouts/MetalPetal/Sources/MetalPetalObjectiveC/MTIVertex.m
   0.4%   345Ki   0.4%   345Ki    /Users/jack/Library/Developer/Xcode/DerivedData/NanoFlick-ccweukbolvpvhhbzbqpybnoefatw/SourcePackages/checkouts/MetalPetal/Sources/MetalPetalObjectiveC/MTIContext.m
   0.2%   152Ki   0.4%   340Ki    /Users/jack/Library/Developer/Xcode/DerivedData/NanoFlick-ccweukbolvpvhhbzbqpybnoefatw/SourcePackages/checkouts/CombineCocoa/Sources/Runtime/ObjcDelegateProxy.m
   0.4%   325Ki   0.4%   325Ki    [__DATA,__objc_data]
   0.4%   295Ki   0.3%   295Ki    [__TEXT,__eh_frame]
   0.3%   286Ki   0.3%   286Ki    [__TEXT,__constg_swiftt]
 100.0%  81.8Mi 100.0%  82.8Mi    TOTAL

So, the first thing to notice is that the __LINKEDIT section is much larger in NanoFlickTCA. One of the big things we did in TCA was rewrite to a uFeatures architecture to save on compile times. A consequence is we have a lot more modules, and therefore a lot more linking to do. The __LINKEDIT section is where the linker puts all of the symbols that it needs to link to, so it makes sense that it's larger. I tried my normal trick of adding -cross-module-optimization and -Xllvm -sil-cross-module-serialize-all, but it didn't seem to make much of a difference.

A quick ask to Bing Chat led me to consider that dead code stripping might be the culprit. Some people on the Swift forums have noticed a similar issue, with the recommended solution being to add -Xfrontend -internalize-at-link to the linker flags.1

And that helped nothing! Three megabytes off of the total size shaved off! What gives? Cutter was unhelpful at looking at the __LINKEDIT, and checking nm showed that there were 50% more symbols in NanoFlickTCA than NanoFlick. Perhaps that's a culprit? Looking at a diff of the outputs on nm, it seems that NanoFlickTCA has a lot more symbols with third-party libraries. Apparently those never get stripped! How can I fix that?

Looking at a blog post from emergetools, it seems like a change introduced in xcode 14 affected apps with uFeatures architectures. I'm a little jealous of their cool size graph, expect to see that a different date on this blog. That sounds like our app! It seems like we can address this problem by stripping binary symbols. And that fixed it! We're now sitting pretty at 54.5 mb. That's a 120mb reduction, or 68% of the original size!

bloaty /Users/jack/Library/Developer/Xcode/DerivedData/NanoFlickTCA-eubkyvwpfwodfccpjvlfzmaqklts/Build/Products/Release-iphoneos/NanoFlickTCA.app/NanoFlickTCA --debug-file /Users/jack/Library/Developer/Xcode/DerivedData/NanoFlickTCA-eubkyvwpfwodfccpjvlfzmaqklts/Build/Products/Release-iphoneos/NanoFlickTCA.app.dSYM/Contents/Resources/DWARF/NanoFlickTCA  -d symbols
    FILE SIZE        VM SIZE
 --------------  --------------
  26.3%  13.7Mi  25.7%  13.7Mi    [10583 Others]
  11.4%  5.92Mi  11.1%  5.92Mi    _MTIVertexEqualToVertex
   8.9%  4.63Mi   8.7%  4.63Mi    _main
   8.3%  4.31Mi   8.1%  4.33Mi    [__LINKEDIT]
   5.8%  3.03Mi   5.7%  3.03Mi    SoundpipeDSPBase::handleMIDIEvent()
   5.6%  2.91Mi   5.5%  2.91Mi    _sd_executeCleanupBlock
   4.9%  2.53Mi   4.7%  2.53Mi    _FIRRemoteConfigHasDeviceContextChanged
   4.6%  2.37Mi   4.4%  2.37Mi    [__LLVM_COV,__llvm_covfun]
   4.5%  2.36Mi   4.4%  2.36Mi    _GTLR_EnsureNSNumber
   4.2%  2.16Mi   4.0%  2.16Mi    [__DATA,__llvm_prf_data]
   2.6%  1.38Mi   2.6%  1.38Mi    [__DATA,__llvm_prf_names]
   0.0%       0   2.5%  1.35Mi    [__DATA,__bss]
   2.4%  1.26Mi   2.3%  1.26Mi    [__TEXT,__cstring]
   2.1%  1.12Mi   2.1%  1.12Mi    _sp_zitarev_compute
   1.7%   893Ki   1.6%   893Ki    [__TEXT,__swift5_typeref]
   1.6%   864Ki   1.6%   864Ki    _getVariableFromInstance
   1.2%   630Ki   1.2%   630Ki    [__DATA,__llvm_prf_cnts]
   1.2%   625Ki   1.1%   625Ki    [__DATA,__objc_const]
   1.1%   567Ki   1.0%   567Ki    [__TEXT,__unwind_info]
   1.0%   507Ki   0.9%   507Ki    [__LLVM_COV,__llvm_covmap]
   0.7%   394Ki   0.7%   394Ki    [__TEXT,__objc_methname]
 100.0%  52.0Mi 100.0%  53.4Mi    TOTAL

Amazing! We're going to want to modify the strip command (we'd really like debug symbols if possible), but this is a great start!

Anyway, this still doesn't work because we get an error now when launching! "Bad Executable", it says. Just checking that it still runs without the strip command, and it does. So what's going on?

Well, it turns out that the strip command is stripping out the __LLVM_COV section, which is used for code coverage. I suspect that's causing the issue. Changing the strip command from strip -rSTx to strip -rx seemed to only leave 5MB on the table while preserving the __LLVM_COV section, and leading the app to launch. Unfortunately, our debug symbols are gone! warning: (arm64) /Users/jack/Library/Developer/Xcode/DerivedData/NanoFlickTCA-eubkyvwpfwodfccpjvlfzmaqklts/Build/Products/Release-iphoneos/NanoFlickTCA.app/NanoFlickTCA empty dSYM file detected, dSYM was created with an executable with no debug info.

I really really want debug symbols and breakpoint support, so I wonder if there's a way to remove the local symbols without destroying everything else.

A better way

I got pointed to using... an xcode build flag! Deployment Postprocessing, so I decided to try that out. I'm a little nervous because it's off by default, but I can't see anyone saying anything bad about it, so I'm going to try it out.

Op, that was literally exactly what we wanted. 54mb binary, debug symbols, slim __LINKEDIT and __LLVM_COV sections. Here's the output of bloaty:2

bloaty /Users/jack/Library/Developer/Xcode/DerivedData/NanoFlickTCA-eubkyvwpfwodfccpjvlfzmaqklts/Build/Products/Release-iphoneos/NanoFlickTCA.app/NanoFlickTCA --debug-file /Users/jack/Library/Developer/Xcode/DerivedData/NanoFlickTCA-eubkyvwpfwodfccpjvlfzmaqklts/Build/Products/Release-iphoneos/NanoFlickTCA.app.dSYM/Contents/Resources/DWARF/NanoFlickTCA  -d symbols
    FILE SIZE        VM SIZE
 --------------  --------------
  81.7%  41.8Mi  82.2%  43.2Mi    [419247 Others]
   7.1%  3.63Mi   6.9%  3.64Mi    [__LINKEDIT]
   2.7%  1.37Mi   2.6%  1.37Mi    [__DATA,__llvm_prf_names]
   1.6%   817Ki   1.5%   817Ki    [__TEXT,__cstring]
   1.1%   564Ki   1.0%   564Ki    [__TEXT,__unwind_info]
   1.0%   507Ki   0.9%   507Ki    [__LLVM_COV,__llvm_covmap]
   0.8%   394Ki   0.7%   394Ki    [__TEXT,__objc_methname]
   0.7%   388Ki   0.7%   388Ki    [__TEXT,__eh_frame]
   0.5%   277Ki   0.5%   277Ki    firebase::firestore::remote::grpc_root_certificates_generated_data
   0.5%   236Ki   0.4%   236Ki    [__TEXT,__swift5_reflstr]
   0.4%   197Ki   0.4%   197Ki    [__TEXT,__swift5_capture]
   0.3%   171Ki   0.3%   171Ki    _descriptor
   0.3%   131Ki   0.2%   131Ki    [__DATA_CONST,__cfstring]
   0.2%   124Ki   0.2%   124Ki    firebase::firestore::util::(anonymous namespace)::kAutoIdAlphabet
   0.2%   121Ki   0.2%   121Ki    _$s8SwiftUIX12SFSymbolNameO8rawValueACSgSS_tcfCTf4nd_n
   0.2%   106Ki   0.2%   106Ki    [__TEXT,__objc_methtype]
   0.2%   102Ki   0.2%   102Ki    _$s4Hero28DefaultAnimationPreprocessorC7process9fromViews02toG0ySaySo6UIViewCG_AItF
   0.2%  99.6Ki   0.2%  99.6Ki    grpc_core::PercentEncodeSlice()::hex
   0.2%  90.9Ki   0.2%  90.9Ki    std::__1::__function::__func<>::operator()()
   0.2%  80.1Ki   0.1%  80.1Ki    [__DATA,__objc_selrefs]
   0.1%  72.6Ki   0.1%  72.6Ki    std::__1::__function::__func<>
 100.0%  51.2Mi 100.0%  52.6Mi    TOTAL

Actual xcbuild setting from god right there. I'm really curious what it changed. Looking at the build logs before and after, the only difference appears to be a Strip NanoFlickTCA step. We can either look through the xcspec files or just look in the inspector to find out what it does (I want to do both).

  • In our log, it does just
/Applications/Xcode-15.0.1.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -D /Users/jack/Library/Developer/Xcode/DerivedData/NanoFlickTCA-eubkyvwpfwodfccpjvlfzmaqklts/Build/Products/Release-iphoneos/NanoFlickTCA.app/NanoFlickTCA
  • It seems to refer to the StripSymbols XCSpec! We could've done the strip command inside xcode the whole time. I'm a little worried about our debug symbols, but firebase should be able to symbolicate with our full dSYM file.

Anyway, this is a great result! I'm really happy with this. I'd have liked this to be the default, but hopefully this blog post will help people who are in a similar situation.

Footnotes

  1. They also mentioned using -Xfrontend -disable-reflection-metadata, but that has implications for SwiftUI (at least since I last checked), so I'm not going to try that. I do wonder, though, if you could opt-in to reflection metadata for TCA only and then, by only observe stores, avoid SwiftUI's reliance on reflection metadata. Perhaps a post for a different day?

  2. I'm not soooo sure about these precise numbers and attributions, but the broad point is that it's a lot smaller.