Skip to main content

Binary size and build time

Where the shipped SDK's bytes and build minutes actually go, measured rather than assumed, plus the mechanisms that make some of it hard to remove. Render-side performance lives in rendering/10-performance.md; this page is about the artifact.

All numbers: Android arm64-v8a, Release, one build tree, NDK 27.3.

What the profiles cost

The profile machinery (scripts/build/sdk_profiles.json) is not cosmetic — it is by far the largest size lever in the project, and CI already publishes one AAR and one iOS zip per profile (.github/workflows/build.yml matrix ["full","standard","lite"]).

profile.sovs fullJNI wrapper files
full (valhalla + geocoding + routing + packagemanager + sqlite)11,418,904322
standard (sqlite, search, offline, editable)7,997,064−3.42 MB, −30.0%256
lite7,043,888−4.38 MB, −38.3%236

Nothing needs inventing to get those 3.4 MB back — an app that does not route offline should consume the standard artifact. Measure a profile by regenerating its wrappers first, because they are profile-specific:

cd scripts && python3 swigpp-java.py --profile standard --swig /path/to/mobile-swig/swig

generated/ is gitignored, not checked in — that command overwrites whatever profile's wrappers are currently in the tree, and there is no git checkout to undo it. Regenerate with the profile you actually develop against when you are done.

Where the bytes go (full profile)

Sections, and .text ownership by symbol attribution:

sectionsizenote
.text6.7 MB
unwind metadata1.69 MB.eh_frame 1.04 + .gcc_except_table 0.36 + .eh_frame_hdr 0.28 — 16% of the file
.rodata1.01 MB
.dynsym+.dynstr+hashes0.41 MB3138 exported Java_* symbols
.bss1.9 MBRAM, not file; demand-zero

.text ownership: valhalla ~22%, libc++/unwind ~16%, all/native ~15%, generated JNI wrappers ~5%, boost ~4%, freetype/sqlite/harfbuzz ~2% each. vt + mapnikvt + cartocss together are 3.2% — the renderer is not where the size is.

What the MLT decoder costs

libs-external/mlt builds the decoder half of maplibre-tile-spec's C++ implementation — 9 source files plus FastPFOR's bitpacking.cpp. The encoder is not built, so fsst, earcut and nlohmann/json never enter the tree and none of that repo's own submodules need checking out.

Compiled alone for arm64 at -Oz without LTO (so: an upper bound, before --gc-sections and --icf=all see it), .text+.rodata+.data sum to 254 KB:

objectsize
vendor/fastpfor/bitpacking.cpp131.5 KB
decoder.cpp67.9 KB
decode/int.cpp20.0 KB
everything else (7 files)34.5 KB

Half of it is FastPFOR's unrolled 32x32 pack/unpack table, and the pack half of that is encode-only, so the linked cost should land well under the 254 KB. It is reached from MBVectorTileDecoder once an app sets TILE_FORMAT_MLT, but the code is unconditionally linked — --gc-sections cannot drop it. Gating the subproject behind a profile flag is still open.

Two mechanisms worth knowing

--gc-sections cannot drop a translation unit that has a namespace-scope static. .init_array references its initializer, which roots the whole TU no matter what else the linker strips. This is why unreachable Valhalla service actions stayed linked despite -Oz, --gc-sections and --icf=all, and why the fix was to keep the sources out of the build list rather than to lean harder on the linker. The full-profile library ran 309 static initializers at every dlopen before that prune, 291 after.

.bss is not RAM until it is touched. baldr::BucketCosTable is 1.5 MB of .bss, which reads alarming, but the symbol is a function-local static (_ZZ…E8instance with a _ZGV… guard) reached only from graphtile.h behind has_predicted_speed(). Demand-zero pages cost nothing until the first predicted-speed decode. Check the symbol type and the guard before "fixing" a large .bss entry.

Build time

45.6 min of CPU per ABI, four ABIs, each recompiling the same headers. By module: mapnikvt 24%, valhalla 19%, generated JNI wrappers 14%, cartocss 8%. The individual hogs are all Boost.Spirit grammars — ParserUtils.cpp 24 s, CartoCSSParser.cpp 22 s, GeneratorUtils.cpp 21 s, QueryExpressionParser.cpp 12 s — roughly 15% of a full build between them, and they essentially never change, so they are pure ccache fodder. With ninja + ccache one ABI goes 70.9 s cold to 13.8 s warm; raise the cache first (ccache --max-size 30G), since one ABI writes about 1 GB and the 5 GB default has the four ABIs evicting each other.

Open, roughly by value

  • iOS gets no LTO for the static framework-flto=full sits inside if(SHARED_LIBRARY) in scripts/build/CMakeLists.txt, so the default build misses it while Android gets -flto=thin. Bitcode is still wired (ENABLE_BITCODE=YES for armv7/arm64, dead since Xcode 14) and i386 and armv7 are still in IOS_ARCHS.
  • Unwind tables on the C-only dependencies (sqlite, libpng, libjpeg, libwebp, freetype, brotli, zstd, miniz) against that 1.69 MB. Risky rather than free: a C++ exception thrown from a callback has to unwind back through those C frames, and without tables that is a std::terminate, so it needs the callback paths audited before it can be trusted.
  • Unset and cheap: -Wl,--exclude-libs,ALL, -fmerge-all-constants, -fno-math-errno. No PGO anywhere.

Measured NOT to work — RegisterNatives

The obvious-looking win is to stop exporting the 3496 Java_* wrappers and register them from JNI_OnLoad instead. It was built and measured: the generator derives every descriptor from the *ModuleJNI.java sources, and the emitted symbol set matched the 3496 exports exactly, in both directions. It makes the library 131 KB bigger.

sectionexportedregistereddelta
.dynstr307,7254,202−303,523
.dynsym92,1608,280−83,880
.hash + .gnu.hash + .gnu.version64,0963,490−60,606
.rela.dyn574,512836,784+262,272
.rodata1,112,9971,338,149+225,152
.data.rel.ro209,168298,352+89,184
file11,418,90411,547,912+131,055

The −448 KB of dynamic symbol machinery is real and lands as predicted. What kills it is that JNINativeMethod holds three pointers — name, signature, function — so 3496 methods put 10,488 relocated pointers in .data.rel.ro, each costing a 24-byte R_AARCH64_RELATIVE entry. A string table the dynamic linker already packs efficiently gets traded for a relocated pointer table that does not.

A version storing string offsets into one blob, and materialising the function pointers in code rather than data, would remove ~178 KB of relocations and ~84 KB more, landing near −150 to −190 KB (1.3–1.6%). That is a hand-rolled offset encoding inside generated code, for less than a twentieth of what moving an app to the standard profile gives. Not worth it on size alone. The one argument not tested is load time: 3496 fewer dynamic symbols should make dlopen cheaper, and if that turns out to matter the calculus changes.