dotnet / dotnet/android

[native] Investigate further CoreCLR host size reductions

Open
#12,640 1 comment 0 reactions 0 assignees View on GitHub
needs-triage
Dominant language
C#
Stars
2.1k
Forks
579
Avg merge
1d 20h
Merged PRs (30d)
257

Description

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

| Artifact | Bytes |
|---|---:|
| Normal linked ARM64 Release DSO | 215,064 |
| `llvm-strip --strip-unneeded` | 168,392 |
| `llvm-strip --strip-all` | 167,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.

| Configuration | Strip mode | Bytes |
|---|---|---:|
| Baseline | `--strip-unneeded` | 168,392 |
| Per-function/data sections + `--gc-sections` | `--strip-unneeded` | 145,744 |
| Experimental `--icf=all` before LTO | `--strip-unneeded` | 139,856 |
| `--as-needed` + `--no-export-dynamic` | `--strip-unneeded` | 139,840 |
| Android packed relocations | `--strip-unneeded` | 130,656 |
| Non-throwing `string_view` construction | `--strip-unneeded` | 130,624 |
| Full LTO across host and linked archives | `--strip-unneeded` | 126,048 |
| Disable fast timing | `--strip-unneeded` | 101,120 |
| Disable decompressed-assembly disk cache | `--strip-unneeded` | 94,944 |
| Compile out Debug/Info logging | `--strip-unneeded` | 87,328 |
| Inline ARM64 atomics | `--strip-unneeded` | 85,168 |
| Disable configurable logging | `--strip-all` | 76,128 |
| Remove abort locations, simplify validation diagnostics, and use timing stubs | `--strip-all` | **58,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

```shell
# 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
```

Contributor guide

No contributing guide indexed for this repository

Research direction

Prepare the ARM64 `net-android.release` target and record linked and stripped sizes for `libmonodroid.so`. Evaluate the behavior-neutral linker and compiler changes independently, then replace the DSO in an API-24 sample APK and cold-start it on an ARM64 emulator. Done means required JNI exports and managed startup remain working, with size, startup, resource, link-time, and diagnostic results recorded across supported ABIs.

Written by the indexing model from the issue text.

Assessment

Tech stack
android, cpp
Domain
build-system, mobile, performance
Issue type
Refactor
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.