Skip to content

[native] Investigate further CoreCLR host size reductions #12640

Description

@simonrozsival

Follow-up to #12533.

Android framework version

net11.0-android (Preview)

Affected platform version

Current dotnet/android CoreCLR native runtime stack ending at PR #12572; ARM64 Release build targeting Android API 24.

Description

After removing the CoreCLR host's libc++ dependency, investigate reducing the remaining libnet-android.release.so/libmonodroid.so footprint. A local-only proof of concept reduced the ARM64 host from 215,064 bytes to 58,408 bytes (72.8%), while still cold-starting a CoreCLR application on an ARM64 Android emulator.

The experiments fall into three groups: mechanical linker/compiler improvements, optional feature removal, and aggressive diagnostic removal. They should be evaluated independently rather than landed as one change.

Baseline

ArtifactBytes
Normal linked ARM64 Release DSO215,064
llvm-strip --strip-unneeded168,392
llvm-strip --strip-all167,928

Stripping preserved the JNI entry points in .dynsym. Internal P/Invoke implementations do not rely on .symtab: CoreCLR calls the live P/Invoke override callback, which returns function pointers. A stripped host successfully reached managed startup.

Measured progression

The values are cumulative and some optimizations interact, especially LTO and ICF.

ConfigurationStrip modeBytes
Baseline--strip-unneeded168,392
Per-function/data sections + --gc-sections--strip-unneeded145,744
Experimental --icf=all before LTO--strip-unneeded139,856
--as-needed + --no-export-dynamic--strip-unneeded139,840
Android packed relocations--strip-unneeded130,656
Non-throwing string_view construction--strip-unneeded130,624
Full LTO across host and linked archives--strip-unneeded126,048
Disable fast timing--strip-unneeded101,120
Disable decompressed-assembly disk cache--strip-unneeded94,944
Compile out Debug/Info logging--strip-unneeded87,328
Inline ARM64 atomics--strip-unneeded85,168
Disable configurable logging--strip-all76,128
Remove abort locations, simplify validation diagnostics, and use timing stubs--strip-all58,408

Mechanical changes worth investigating

  1. Compile both the shared and static CoreCLR host targets with -ffunction-sections -fdata-sections, then link with --gc-sections. The application-time unified native linker already enables GC, but the archive inputs need function granularity for it to be effective.
  2. Add --pack-dyn-relocs=android. Android packed relocations are supported by the API 24 minimum and saved about 9 KB.
  3. Enable full LTO for the host and all static archives consumed by it. This saved about 4.6 KB, at the cost of native link time/memory and changed inlining.
  4. Add --as-needed and remove the redundant --export-dynamic; this removed the unused libm.so dependency but saved only 16 bytes.
  5. Consider -mno-outline-atomics on ARM64. It removed the compiler-rt CPU-feature dispatcher and saved 2,160 bytes, but may make contended atomics slower on LSE-capable CPUs.
  6. Replace provably in-range string_view::substr() calls with direct {data, length} views. This avoids libc++ out-of-range abort helpers under size-oriented optimization.
  7. Strip release runtime DSOs during pack production while retaining unstripped symbol artifacts for offline crash symbolication.
  8. Test --icf=safe. It initially found no folds; after full LTO it matched the tested --icf=all result in the minimized profile. --icf=all should not be used without proving that function-address identity is irrelevant.

Optional feature reductions

Fast timing: approximately 25 KB

The PoC made FastTiming::enabled() a compile-time false, made initialization and managed timing P/Invokes no-ops, and removed Java_mono_android_Runtime_dumpTimingData from the export map. This removes startup timing collection, timing files/logcat output, and debug.mono.timing. A production implementation should probably retain dumpTimingData as a small no-op export for ABI compatibility.

Decompressed-assembly disk cache: approximately 6.2 KB

The PoC made cache initialization/writes no-ops and cache lookup return nullptr; LTO then removed the background writer, queue, mmap validation, hashing, and filesystem code. Normal in-memory decompression still worked. This may improve first launch slightly but makes every subsequent process launch decompress assemblies again, increasing warm-start CPU/battery use.

Aggressive diagnostic reductions

Compile out Debug/Info logging: approximately 7.6 KB

log_debugf() and log_infof() became compile-time no-ops. Warning, error, fatal logging, Android abort messages, and tombstones remained. Call arguments are no longer evaluated, so all call sites would need a side-effect audit.

Disable configurable logging: approximately 8.8 KB

The logging-category parser and reference-log initialization became no-ops. LTO removed assembly-loader diagnostics, GC spew, gref/lref file/logcat tracing, and timing-category configuration. Reference counters and GC behavior remained functional. This is likely suitable only for a separate minimal runtime flavor.

Remove abort source locations and simplify validation messages: approximately 17 KB

The PoC retained the primary fatal message, android_set_abort_message(), tombstones, and abort(), but removed file:line:column, pretty C++ function signatures, and the runtime function-name parser. This greatly reduces duplicated source paths/signatures but significantly harms field diagnostics unless unstripped symbols and stack addresses are available.

Rejected experiments

  • -Os/-Oz made the stripped host larger and caused libc++ out-of-range abort helpers to survive where -O2 optimized them away.
  • --pack-dyn-relocs=android+relr saved another 872 bytes, but RELR requires newer Android loaders and is unsuitable for the API 24 minimum.
  • llvm-strip --strip-sections saved about 2 KB, but Android rejected the DSO with unsupported e_shentsize: 0x0 (expected 0x40).
  • Unrestricted --icf=all saved 5,888 bytes before LTO but can collapse distinct function addresses and is too risky as a default.

The final 58,408-byte ELF retained these dynamic JNI exports:

  • JNI_OnLoad
  • Java_mono_android_Runtime_initInternal
  • Java_mono_android_Runtime_register
  • Java_mono_android_Runtime_propagateUncaughtException

Its largest remaining areas were core runtime behavior: startup, GC bridge processing, P/Invoke dispatch, DSO loading, typemaps, and assembly probing/decompression.

Steps to Reproduce

  1. Prepare a Release dotnet/android native build and build the ARM64 CoreCLR net-android.release target.
  2. Record the linked and stripped baseline sizes.
  3. Apply each compiler/linker option independently and cleanly relink the ARM64 host.
  4. For feature experiments, compile out timing, the decompressed-assembly cache, and logging independently rather than combining them initially.
  5. Run llvm-strip --strip-unneeded or --strip-all, verifying the required JNI exports remain in .dynsym.
  6. Replace lib/arm64-v8a/libmonodroid.so in an API-24 CoreCLR sample APK, zipalign and sign it.
  7. Cold-start it on an ARM64 emulator/device and verify managed UI startup, assembly loading/decompression, typemap setup, GC bridge initialization, and internal P/Invoke resolution.
  8. Benchmark cold/warm startup, memory, CPU, battery, native link time, and crash diagnosability for each candidate change across all supported ABIs.

Did you find any workaround?

A local uncommitted PoC demonstrates the possible size floor and isolates the candidate changes. The recommended first investigation is the behavior-neutral set: stripping with separate symbols, section GC, Android packed relocations, full LTO, --as-needed, and safe ICF. Timing, cache, and diagnostic removal should be separate opt-in decisions or a distinct minimal-runtime profile.

Relevant log output

# Baseline
linked-size=215064
strip-unneeded=168392
# Final aggressive PoC
strip-all=58408
pid=8696
errors=0
# Managed UI hierarchy
text="Hello, NativeAOT on Android!"
text="HelloNativeAOT"# Invalid sectionless ELF experiment
E/linker: libmonodroid.so has unsupported e_shentsize: 0x0 (expected 0x40)
java.lang.UnsatisfiedLinkError: dlopen failed: unsupported e_shentsize

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-triageIssues that need to be assigned.

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions