Skip to content

[CoreCLR] Investigate static runtime linking to reduce Android app size #13071

Description

@simonrozsival

Android framework version

net11.0-android (Preview)

Affected platform version

Local dotnet/android SDK build targeting net11.0-android37.0, Release, android-arm64. Measurements on Samsung A16 SM-A165F, Android 16/API 36. SDK version 12.0.100-alpha.1.26480.102; matched local dotnet/runtime revision cd42bb5514e1770b2af0ef127f0b3adb7475accd.

Description

Investigate completing and supporting the experimental static CoreCLR linking path for Android applications. Initial results show a nontrivial app-size reduction with effectively unchanged startup performance.

A local proof of concept built libcoreclr_static.a and repaired the unfinished _AndroidEnableNativeRuntimeLinking path. It produces a unified libmonodroid.so containing CoreCLR, its built-in JIT, the Android host, and selected BCL native PALs. This is not a CoreCLR-only comparison: SQLite and System.IO.Ports remain separately packaged shared libraries.

The official MAUI sample-content app (dotnet new maui --sample-content) was built using MAUI 10.0.110, Release, partial trimming, ReadyToRun, and matched local shared/static runtime and crossgen2 builds. Both APKs have byte-identical managed/linked/R2R assemblies and complete assembly stores, identical dependency versions, signing certificates, ABI, and packaging settings.

Metric Shared Unified static Change
Signed APK bytes 28,347,318 27,617,633 −729,685 (−2.57%)
Uncompressed native-library bytes 25,238,352 23,618,776 −1,619,576 (−6.42%)
ZIP-compressed native-library bytes 19,241,512 18,513,730 −727,782 (−3.78%)
Median initial-display startup 1290.5 ms 1287 ms −3.5 ms (−0.27%)
p90 initial-display startup 1335 ms 1301 ms −34 ms (−2.55%)

The median startup difference is too small to establish a meaningful performance gain. The demonstrated benefit is reduced APK/native-library size. Installed app bytes were unavailable because of device permissions.

Startup measurements used five warmups and 30 measured cold-process launches per variant, alternating AB/BA with exclusive device ownership. am start -W TotalTime matched logcat Displayed. These measure initial activity display, not full-content-render latency, cold-page-cache startup, or post-reboot startup. A prior interrupted series was excluded entirely. Both final APKs rendered populated charts, projects, and tasks; all final launches remained alive with no logged crash or competing foreground launch.

The proof of concept required fixes in these areas:

  • Consume the generated static P/Invoke resolver, correct its C ABI/LLVM memory effects, and invoke the crypto PAL JNI initializer.
  • Supply the full native dependency closure, including C++/ABI/unwind, compression dependencies, and Android system libraries.
  • Include the local CoreCLR static archive even though it is absent from the runtime pack's RuntimeList.xml; preserve the correct SONAME and remove only shared libraries replaced by static linking, including the built-in JIT replacement.
  • Track actual linker inputs and native-generation mode/missing outputs so shared↔static transitions and unchanged builds work without clearing managed intermediates.

Investigation should determine the supported archive distribution/runtime-pack contract, harden the experimental path across supported ABIs and build configurations, verify compatibility with trimming/R2R and native crash diagnostics, and repeat size/startup measurements on additional apps/devices. Keep the existing shared-runtime behavior unchanged while evaluating whether and how to expose a supported opt-in.

Related: #12640 (CoreCLR host size reductions), #12940 (removing app-build LLVM IR/binutils).

Steps to Reproduce

The working proof of concept uses local, uncommitted Android changes; the following is the comparison procedure, not a claim that current main supports this end-to-end.

  1. Build shared and static Android arm64 CoreCLR/runtime-pack artifacts from the same dotnet/runtime revision, including the matching crossgen2.
  2. Build the locally modified Android SDK with make prepare CONFIGURATION=Release and make all CONFIGURATION=Release.
  3. Generate the official MAUI sample-content app using MAUI 10.0.110. Build Release for net11.0-android37.0/android-arm64, with AndroidRuntime=CoreCLR, PublishTrimmed=true, TrimMode=partial, and PublishReadyToRun=true. Point _CLRLocalRuntimePath to the runtime repository root and set _DotNetRuntimeConfiguration=Release.
  4. Build sequentially with _AndroidEnableNativeRuntimeLinking=false and true, preserving each final signed APK. Reuse the same intermediate/output paths to avoid resource-designer MVID/debug metadata nondeterminism, and verify byte-identical managed/R2R/store payloads.
  5. Compare APK/native-library bytes and ELF dependencies. Install each APK on the same exclusively reserved device and measure alternating force-stop/start launches after warmups.

Did you find any workaround?

The local experimental changes enable the comparison, but there is no supported product workaround yet. Default shared runtime linking remains available. MAUI 10.0.110 was used because MAUI 11 RC referenced a Java.Interop assembly version newer than the local Android build; compiler compatibility checks were not bypassed.

Relevant log output

Local validation completed successfully:

  • make all CONFIGURATION=Release: SDK build succeeded.
  • Eight focused native-linking regression tests passed, including shared/static mode transitions, unchanged-build skips, and recovery after deleting a generated static LLVM source.
  • Static CoreCLR runtime tests on the Samsung A16: 934 passed, seven skipped, zero failures.
  • MAUI app-level transition checks passed; static libmonodroid.so has SONAME libmonodroid.so and only Android system DT_NEEDED dependencies, with no shared CoreCLR/JIT/replaced BCL dependencies.

Raw startup measurements, publish commands/binlogs, provenance hashes, payload comparisons, both signed APKs, and screenshots are preserved locally; they are not attached to this issue.

Activity

  1. simonrozsival commented on Oct 12, 2026

    @simonrozsival
    MemberAuthor

    Follow-up: dead-code removal, ICF, LTO, managed trimming, and runtime feature experiments

    We extended the original shared/static comparison to investigate where further size reductions are possible. The original result remains: unified static linking saves 729,685 signed APK bytes (2.57%), with no meaningful demonstrated startup improvement. Follow-up measurements below concern size and correctness, not a new startup benchmark.

    These are local experiments, not supported product configurations or a proposal to enable every option by default. Original SDK/runtime artifacts and APKs are preserved; 795 baseline runtime files remained byte-identical throughout the follow-up.

    Controlled app results

    The baseline is the official MAUI sample-content app, MAUI 10.0.110, Release, android-arm64, partial managed trimming and ReadyToRun, using the same source-matched CoreCLR build described above. Its signed static APK is 27,617,633 bytes.

    Experiment Signed APK bytes Change vs applicable control Status / interpretation
    Narrow exports to required JNI entry points 27,617,633 0 Symbol-metadata saving only
    Safe ICF 27,617,633 0 No folding/size benefit for this input
    Aggressive ICF 27,580,769 -36,864 Functional smoke passed with the baseline editor caveat below
    Android host ThinLTO only 27,613,537 -4,096 Small improvement
    Android host full LTO only 27,613,537 -4,096 Small improvement
    Runtime ThinLTO 27,543,905 -73,728 Functional smoke passed with baseline caveat
    Host + runtime ThinLTO 27,543,905 -73,728 Smaller native host, but same aligned APK size as runtime-only ThinLTO
    Host + runtime ThinLTO + aggressive ICF 27,515,233 -102,400 Verified broader runtime suite and qualified app smoke
    Runtime full LTO 27,957,601 +339,968 Size regression; not device-validated
    Host + runtime full LTO 27,953,505 +335,872 Size regression; not device-validated
    In-process crash report disabled 27,597,153 -20,480 Intentional diagnostic capability reduction; app-specific smoke pending
    Full managed trimming 25,692,513 -1,925,120 (-6.97%) Largest measured saving; unsuppressed warnings and incomplete editor coverage
    EventPipe/tracing off, with matching CoreLib 27,457,889 -159,744 Paired payload verified; qualified app smoke passed
    Tracing/crash diagnostics reduced, with matching CoreLib 27,437,409 -180,224 Capability-reduced configuration; qualified app smoke passed
    Reduced diagnostics + ThinLTO, with matching CoreLib 27,375,969 -241,664 Capability-reduced configuration; qualified app smoke passed
    Full managed trimming + host/runtime ThinLTO + aggressive ICF 25,590,113 -2,027,520 (-7.34%) Build/payload verified; qualified combined app smoke passed
    Full managed trimming + reduced diagnostics + host/runtime ThinLTO + aggressive ICF 25,426,273 -2,191,360 (-7.93%) Paired runtime/CoreLib verified; qualified combined app smoke passed; diagnostics tradeoff

    Native-only relinks preserve managed/R2R assemblies, the complete assembly store, resources, dex, and the signing certificate. LTO comparisons use LLVM 19 controls because the final optimizer backend matters. Full managed trimming uses its own rebuilt same-intermediate-tree partial-trim control, rather than falsely claiming its managed payload is identical to the original APK. Tracing-off rows intentionally change CoreLib and are paired-runtime experiments, not native-only relinks.

    The combined full-managed-trim + ThinLTO + aggressive-ICF rows use each full-trim build's actual native response and P/Invoke root objects. Managed/R2R assembly stores match their respective full-trim anchors, and the final APK's decoded CoreLib matches its own final R2R/shrunk input. Both builds retain 65 unsuppressed trim-warning lines. Both completed qualified on-device smoke: populated chart/projects render, and SQLite checkbox updates persist after force-stop/relaunch. Both retain the same baseline editor exception described below. Installed whole-APK hashes matched the candidate before and after each test. Diagnostics-enabled and diagnostics-reduced combinations remain separate; neither has a new startup measurement.

    Dead-code stripping was already enabled

    The original native linker already passes --gc-sections. CoreCLR, most BCL/compression libraries, and C++ support archives also use --exclude-libs, allowing unused sections to be removed without preserving all their symbols as dynamic exports.

    Most relevant archives already have function-level sections. The final baseline native host had only 23 defined dynamic exports: four JNI entry points, 18 generated configuration/data symbols, and weak __emutls_get_address.

    We tested a version script retaining JNI_OnLoad and Java_*, with explicit roots for all four required JNI functions, including the crypto certificate callback. Exports fell from 23 to four, and the host shrank by 1,200 bytes. However:

    • No additional executable code was removed; the extra GC removal was an empty generated .bss.
    • .text increased by 24 bytes because of layout/alignment.
    • Unwind data was unchanged.
    • APK size did not change after matched compression, zip alignment, and signing.

    This is not a useful additional dead-code-stripping win.

    ICF and LTO are different optimizations

    ICF merges equivalent native functions rather than removing only unreachable ones. --icf=safe produced no saving. --icf=all reduced host bytes by 137,056 and .text by 93,144, but can violate function-address identity assumptions. A passing test suite is useful evidence, not a general proof that aggressive ICF is safe for every runtime/application.

    ThinLTO retains LLVM intermediate code, builds a program-wide summary index, and selectively imports functions for cross-file optimization. Full LTO combines much more intermediate code into a single optimization unit. Both require real bitcode archives; merely adding an LTO flag while relinking machine-code-only archives is not equivalent.

    We rebuilt both the Android host archives and CoreCLR/static JIT in isolated roots:

    • Android host: NDK r28, LLVM 19.0.1; verified ThinLTO/full-LTO bitcode.
    • Runtime: same runtime commit as baseline, NDK r27c, LLVM 18.0.3.
    • Final combined linker: NDK r28 ld.lld, LLVM 19.0.1.
    • Runtime Thin/full archives each contained 711 bitcode members and 17 actual assembly-code ELF members. Reused BCL PAL/compression/NDK support archives remain native code, so this is not whole-app LTO.

    Actual compilation commands were checked for -flto=thin or -flto=full, not just requested CMake settings. An initial full-LTO attempt using EXTRA_*FLAGS silently lost those settings during Android configuration and was discarded.

    The backend version materially changes ThinLTO output. The same runtime ThinLTO probe was 7,942,008 bytes with LLVM 18, but 7,050,328 bytes with LLVM 19. The corresponding original baseline probe was 7,358,200 bytes with either linker. Always compare using the same linker and record its version.

    Full runtime LTO increased this app's size. Its runtime archive and no-undefined app link succeeded, but the broad native build also failed an optional single-file host link with ARM64 relocation-alignment errors. We do not claim complete tool-pack support for that configuration.

    Important attribution control: native build path and diagnostic metadata

    The direct native build path differs from the original full MSBuild runtime build even with default features:

    • Native build branding is N/A instead of local 42.42.42.42424, although both embed the exact same runtime commit.
    • Two post-build cDAC contract-descriptor/pointer objects are absent; the direct build uses a descriptor stub.

    We therefore built a same-direct-native-script default control to distinguish these differences from actual LTO/feature effects. It is 2,760 bytes smaller than the original native probe. In the actual MAUI app, this direct default control reduces host bytes by 2,680 but leaves the signed APK at 27,617,633 bytes under both LLVM 18 and 19. Runtime ThinLTO saves 305,168 host bytes relative to that app control; host/runtime ThinLTO plus aggressive ICF saves 413,520 host bytes, rather than attributing the full 416,200-byte SDK-baseline difference solely to optimization. The following are matched-control native-probe deltas, not APK savings:

    Runtime option Stripped probe change vs direct-native control
    ThinLTO -305,112 B
    Full LTO +369,056 B
    In-process crash report off -40,232 B
    EventPipe/tracing off -342,296 B
    Supported diagnostics reductions combined -382,624 B
    Supported diagnostics reductions + ThinLTO -666,152 B
    Single-file diagnostics target disabled alone +128 B; no demonstrated saving

    The direct-build metadata/tooling differences remain a limitation of these experiments, not something to silently attribute to optimization.

    Full managed trimming: larger saving, different risk

    TrimMode=full reduced the final assembly count from 182 to 169. Almost all its APK benefit comes from the managed/R2R assembly store:

    • Uncompressed store: 14,253,312 -> 12,327,456 bytes.
    • Compressed store saving: 1,922,029 bytes.
    • Actual native host: 7,624,336 -> 7,625,008 bytes, a small increase.

    libassembly-store.so lives under the APK's lib/ directory but contains managed data. Summing that directory and calling all of it native machine code would misclassify this result.

    The full-trim build emitted 65 unsuppressed IL-warning lines, including IL2026 warnings for MAUI OnIdiom/OnPlatform and dependency IL2104 warnings. We did not suppress warnings, copy baseline payloads into the candidate, or add speculative reflection roots to make the experiment appear successful.

    Reduced runtime features: explicit capability tradeoffs

    Tracing-off uses both native FEATURE_EVENT_TRACE=0 / FEATURE_PERFTRACING=0 and a same-source CoreLib build with /p:FeaturePerfTracing=false. Its ReadyToRun CoreLib was generated using the matching baseline crossgen2. The supported combined experiment additionally disables in-process crash reporting and the single-file diagnostics target, while keeping the JIT, tiering/PGO, GC, exceptions, JNI, profiler/ReJIT, and debugger IPC.

    These reductions sacrifice diagnostics; they should not be treated as transparent defaults. EventPipe-off also has an optional single-file host/tool build failure referencing eventprovider_objects, despite successful runtime-only targets and app-link closure.

    Two attempted reductions are not usable on this revision without broader source changes:

    • Profiler off: the unmodified JIT-preserving configuration fails ReJIT/profiler references. An isolated, narrowly scoped ReJIT/profiler dependency gate was tested, but additional runtime/DAC compilation failures remained. We stopped rather than broadly patching the runtime.
    • Debugger IPC off: both debugger-side and runtime-side compilation fail deleted WaitEvent constructor references. This is not merely an absent optional tool.

    Disabling the single-file diagnostics target alone demonstrated no size benefit; it is a build-target selection flag, not a separate VM macro.

    Correctness evidence and remaining limitation

    Host + runtime ThinLTO + aggressive ICF passed 934 on-device runtime tests, with seven skips and zero failures. The installed APK was pulled and its native-library hash verified before and after direct instrumentation. Managed/BCL payloads were unchanged.

    An initial dotnet test --no-build run silently redeployed the original split bundle after the candidate was preinstalled. That result was excluded as candidate evidence; the subsequent direct-instrumentation run verified the actual optimized library.

    Expanded MAUI testing validates populated dashboard rendering, project overview/navigation, and real SQLite checkbox changes persisting across process restarts and variant upgrades for the tested configurations. Final qualified smoke also passed for ThinLTO + aggressive ICF, EventPipe-off, reduced diagnostics, and reduced diagnostics + ThinLTO. Installed APK hashes were verified before and after every final scenario.

    However, both untouched original shared and static APKs already crash when opening an editor, with:

    System.InvalidCastException:
    Java.Lang.Object -> Android.Views.InputMethods.InputMethodManager
    Microsoft.Maui.Platform.EditTextExtensions.UpdateReturnType
    

    This also reproduces in optimized variants. It is an inherited managed/interop compatibility issue, not an ICF/LTO-specific regression; the generated mapping already contains InputMethodManager. We do not claim editor/category CRUD, complete app compatibility, or application-specific crypto coverage. The original dashboard-only benchmark did not exercise this path.

    Separate Shell investigation correction: a suspected missing-hero/rendering problem in a concurrent three-way MAUI Shell benchmark was based on an incorrect visual-preview inference. Objective inspection of the saved decoded PNG pixels found the hero content present in all three variants. There is no proven graphics/output bug from that observation, and it must not be listed as a blocker for this static-linking experiment. Independent verification of that benchmark's saved pixels/timings remains separate work; this correction does not establish a new performance result or remove the directly captured InputMethodManager editor exception and trim-warning risks above.

    Reproducibility lessons / follow-up work

    1. Preserve identical managed/R2R payloads when evaluating native options. Separate intermediate directories can introduce MVID/debug nondeterminism; sequential same-intermediate builds worked for the original comparison.
    2. Preserve raw compressed ZIP entries and use SDK-matched compression/alignment/signing. An exploratory Python recompression changed unrelated APK bytes and was discarded. The corrected packaging control reproduces the original APK length and unchanged compressed-entry hashes.
    3. Use fully isolated runtime worktrees/output roots. Native -subdir / -bindir did not isolate external libunwind intermediates.
    4. Verify compiler flags, archive bitcode, exact linker execution, and the installed payload rather than accepting build success or a passing run as a proxy.
    5. Pair CoreLib through the actual RuntimeList.xml path. An early tracing-off pack modified a second copy under lib/net11.0, while the manifest selected the unchanged copy under native/; those builds were rejected. Corrected packs have the exact declared file set, and the final APK's extracted CoreLib matches the candidate's final R2R/shrunk input.
    6. Account for incremental identity changes, not just timestamps. Switching to a different archive with an older copied timestamp let _LinkNativeRuntime skip. Private experiment inputs were explicitly invalidated and actual linker execution verified; input-path/membership tracking deserves a product follow-up.
    7. Keep optional-tool failures, removed diagnostic capabilities, unsuppressed trim warnings, inherited app failures, and untested rows visible in the final recommendation.

    The most promising results are full managed trimming for total APK size and ThinLTO/aggressive ICF for additional native savings. Safe ICF/export narrowing were ineffective for APK size, and full runtime LTO regressed it. All planned combined-row smoke checks are complete, with the explicit baseline-editor and trim-warning limitations above; no general compatibility or new startup claim is made. Exact commands, maps, GC/ICF logs, bitcode counts, package/provenance hashes, APKs, screenshots, and raw test results are retained locally. The owned experiment/test packages were removed and the device explicitly released; unrelated app data was untouched.

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

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions