Repository navigation
[CoreCLR] Investigate static runtime linking to reduce Android app size #13071
Description
Activity
- addedneeds-triageIssues that need to be assigned.Issues that need to be assigned.
on Oct 11, 2026 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_OnLoadandJava_*, 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. .textincreased 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=safeproduced no saving.--icf=allreduced host bytes by 137,056 and.textby 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=thinor-flto=full, not just requested CMake settings. An initial full-LTO attempt usingEXTRA_*FLAGSsilently 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/Ainstead of local42.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=fullreduced 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.solives under the APK'slib/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/OnPlatformand 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=0and 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
WaitEventconstructor 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-buildrun 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.UpdateReturnTypeThis 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
InputMethodManagereditor exception and trim-warning risks above.Reproducibility lessons / follow-up work
- 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.
- 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.
- Use fully isolated runtime worktrees/output roots. Native
-subdir/-bindirdid not isolate external libunwind intermediates. - Verify compiler flags, archive bitcode, exact linker execution, and the installed payload rather than accepting build success or a passing run as a proxy.
- Pair CoreLib through the actual
RuntimeList.xmlpath. An early tracing-off pack modified a second copy underlib/net11.0, while the manifest selected the unchanged copy undernative/; 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. - Account for incremental identity changes, not just timestamps. Switching to a different archive with an older copied timestamp let
_LinkNativeRuntimeskip. Private experiment inputs were explicitly invalidated and actual linker execution verified; input-path/membership tracking deserves a product follow-up. - 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.
- No additional executable code was removed; the extra GC removal was an empty generated
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 version12.0.100-alpha.1.26480.102; matched local dotnet/runtime revisioncd42bb5514e1770b2af0ef127f0b3adb7475accd.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.aand repaired the unfinished_AndroidEnableNativeRuntimeLinkingpath. It produces a unifiedlibmonodroid.socontaining 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.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 -WTotalTime 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:
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.
make prepare CONFIGURATION=Releaseandmake all CONFIGURATION=Release.net11.0-android37.0/android-arm64, withAndroidRuntime=CoreCLR,PublishTrimmed=true,TrimMode=partial, andPublishReadyToRun=true. Point_CLRLocalRuntimePathto the runtime repository root and set_DotNetRuntimeConfiguration=Release._AndroidEnableNativeRuntimeLinking=falseandtrue, 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.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.libmonodroid.sohas SONAMElibmonodroid.soand 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.