Repository navigation
Description
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.
- Build shared and static Android arm64 CoreCLR/runtime-pack artifacts from the same dotnet/runtime revision, including the matching crossgen2.
- Build the locally modified Android SDK with
make prepare CONFIGURATION=Releaseandmake all CONFIGURATION=Release. - Generate the official MAUI sample-content app using MAUI 10.0.110. Build Release for
net11.0-android37.0/android-arm64, withAndroidRuntime=CoreCLR,PublishTrimmed=true,TrimMode=partial, andPublishReadyToRun=true. Point_CLRLocalRuntimePathto the runtime repository root and set_DotNetRuntimeConfiguration=Release. - Build sequentially with
_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. - 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.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.
Activity
simonrozsival commentedon Oct 12, 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.
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:.bss..textincreased by 24 bytes because of layout/alignment.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:
ld.lld, LLVM 19.0.1.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:
N/Ainstead of local42.42.42.42424, although both embed the exact same runtime commit.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:
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: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:
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:
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
InputMethodManagereditor exception and trim-warning risks above.Reproducibility lessons / follow-up work
-subdir/-bindirdid not isolate external libunwind intermediates.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._LinkNativeRuntimeskip. Private experiment inputs were explicitly invalidated and actual linker execution verified; input-path/membership tracking deserves a product follow-up.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.