[CoreCLR] Experimental: dlopen()-based assembly store loading#12033
Draft
simonrozsival wants to merge 12 commits into
Draft
[CoreCLR] Experimental: dlopen()-based assembly store loading#12033simonrozsival wants to merge 12 commits into
simonrozsival wants to merge 12 commits into
Conversation
Prototype (additive, CoreCLR-only; Mono untouched) that lets the runtime locate the assembly store via dlopen()+dlsym() instead of parsing the APK ZIP central directory, mmapping the wrapper .so and walking its ELF section headers by hand. Build side (opt-in via -p:_AndroidDlopenAssemblyStore=true): * New DlopenAssemblyStoreGenerator wraps the store blob into a shared library whose payload lives in a *loadable* section (SHF_ALLOC, covered by PT_LOAD) pointed at by an exported dynamic symbol `_assembly_store_start`. It emits a tiny `.incbin` stub assembled with the shipped `llvm-mc` and linked with `ld` (no clang needed). This differs from DSOWrapperGenerator, which uses `llvm-objcopy --add-section` to inject a *non-loadable* section. * WrapAssembliesAsSharedLibraries gains a UseDlopenAssemblyStore switch, plumbed from the new _AndroidDlopenAssemblyStore MSBuild property. The existing DSOWrapperGenerator path is left intact. Runtime side (self-detecting, no runtime flag): * AssemblyStore::map is refactored to extract configure_from_payload(); a new map_from_pointer() configures the store from an in-memory payload pointer. * Host::gather_assemblies_and_libraries first tries dlopen(libassembly-store.so) + dlsym(_assembly_store_start). On success it skips the ZIP scan entirely; if the symbol is absent it falls back to the existing scan, so a prebuilt host handles both old and new APKs. Feasibility of the loadable-symbol wrapper was validated on an arm64 emulator (dlopen+dlsym resolves the payload; objcopy --add-section does not). Startup A/B benchmarking is still pending. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 527f04b6-c95a-4a66-b90f-4c52ba5496f3
Take a `() -> std::string` path provider (templated) instead of an eagerly constructed std::string, so the full store path is only built when it is actually needed - i.e. in the abort_application() branches. This restores the original map() behavior, where the path was computed by a lambda only on failure (or when the LOG_ASSEMBLY-guarded debug log is enabled), rather than on every successful load. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 527f04b6-c95a-4a66-b90f-4c52ba5496f3
…ayload directly Remove the thin map_from_pointer() wrapper and have Host call the now-public AssemblyStore::configure_from_payload() directly, passing the path provider lambda. configure_from_payload no longer needs to be a template: it takes a `const std::function<std::string()>&`, which is still only invoked to build diagnostics on the invalid-payload abort paths. Also fix DlopenAssemblyStoreGenerator to use `List<string>` with collection expressions (a bare `var x = [...]` has no target type -> CS9176). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 527f04b6-c95a-4a66-b90f-4c52ba5496f3
Commits to the dlopen()-based assembly store for CoreCLR and removes the code it
replaces. The store is wrapped as a real shared library whose payload is exposed
via the `_assembly_store_start` dynamic symbol; the host resolves it with
dlopen()+dlsym() and lets the dynamic linker locate + map it out of the APK.
Runtime (src/native/clr), CoreCLR only:
* Host::gather_assemblies_and_libraries now only does dlopen()+dlsym(); there is
no ZIP-scanning fallback. It aborts if the store lacks the symbol.
* Removed zip.cc entirely (the ZIP central-directory parser), the xamarin-startup
static library, zip_scan_callback, AssemblyStore::map(fd/offset) and the ELF
section-header walk it used to reach the payload.
* Removed the DSOApkEntry fd/offset native-library fast path from MonodroidDl
(it was populated by the same ZIP scan); bundled native libraries now load via
dlopen()-by-name, which works because they are uncompressed and page-aligned in
lib/{ABI}/ (extractNativeLibs=false).
* The scan_filesystem (fastdev) path also loads the store via dlopen().
Build tasks:
* `_AndroidDlopenAssemblyStore` is now hardcoded by runtime and MUST NOT be
overridden: true for CoreCLR (its host only supports the dlopen store), false
for MonoVM (its host only supports the classic scanned store).
* WrapAssembliesAsSharedLibraries selects DlopenAssemblyStoreGenerator (CoreCLR)
vs DSOWrapperGenerator (MonoVM) accordingly.
MonoVM is untouched and keeps the classic DSO-wrapper + ZIP-scan store.
Isolated cold-start impact (both local P7, MAUI sample-content, Samsung A16, 30
interleaved runs) is within noise: dlopen +16 ms (+0.8%) vs the classic path,
while dlopen()+dlsym() of the store measures <0.5 ms on-device. The arm64 CoreCLR
host shrinks ~34 KB (-2.7%).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 527f04b6-c95a-4a66-b90f-4c52ba5496f3
We only dlsym() our own handle, so there is no need to add the store's symbols to the global lookup scope. RTLD_LOCAL avoids the extra linker bookkeeping that RTLD_GLOBAL would incur. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 527f04b6-c95a-4a66-b90f-4c52ba5496f3
…ectness Three refinements to the dlopen-based assembly store: - Fix CI: name the wrapper's payload section `payload` (not `.payload`) so tools/assembly-store-reader-mk2 (Utils.FindELFPayloadSectionOffsetAndSize looks up "payload") and the CoreClrTrimmableTypeMap build tests that read the APK via it recognize the store again. Matches the section name the MonoVM DSOWrapperGenerator already uses. The runtime is unaffected: it resolves the exported dynamic symbol, not the section name. - Rename the exported payload symbol `_assembly_store_start` -> `_assembly_store`. - Make the whole read-only assembly-store pointer chain const so there are no const_casts on the store path: configure_from_payload() takes `const void*`, and data_start, image_data, debug_info_data, config_data, assemblies, descriptor and assembly_store_hashes are now const pointers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 527f04b6-c95a-4a66-b90f-4c52ba5496f3
With the dlopen-based store the CoreCLR host no longer consumes the
DSOApkEntry {name_hash,offset,fd} table (the ZIP-scan native-library fast
path was removed earlier in this PR). Delete the now-orphaned CoreCLR-side
dead code:
- the `dso_apk_entries` LLVM IR emission + `DSOApkEntry` struct/data-provider
and PopulateDsoApkEntries() in ApplicationConfigNativeAssemblyGeneratorCLR
- the `DSOApkEntry` struct, `dso_apk_entries[]` extern declaration, and stub
definition in the CoreCLR xamarin-app.hh / application_dso_stub.cc
MonoVM still uses DSOApkEntry (its own ApplicationConfigNativeAssemblyGenerator
+ mono/xamarin-app-stub/xamarin-app.hh) and is left untouched.
Verified: native builds + links clean (all ABIs), and a net11.0-android MAUI
sample-content app builds, links, and cold-launches (Status: ok) with no
dso_apk_entries present.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 527f04b6-c95a-4a66-b90f-4c52ba5496f3
Note in the comment that the assembly-store payload is mapped read-only and never modified, which is why it and every pointer derived from it are `const`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 527f04b6-c95a-4a66-b90f-4c52ba5496f3
Accept the size changes from dlopen-based assembly store loading. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7be7d34c-aff3-4338-b15b-6061bc7a3ba9
Propagate UseAssemblyStore into the native application config and skip dlopen-based discovery when no store is packaged. This restores individual assembly loading from FastDev overrides. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7be7d34c-aff3-4338-b15b-6061bc7a3ba9
Update the assembly store and shared-library payload design docs to
describe the CoreCLR loading mechanism introduced in this PR and to
disambiguate it from the existing MonoVM behavior.
- AssemblyStores.md: add a per-runtime note explaining that MonoVM
locates the store by scanning the APK ZIP and mmap-ing the wrapper,
while CoreCLR resolves it via dlopen()+dlsym("_assembly_store").
- ApkSharedLibraries.md: mark the existing stub/mmap narrative and
readelf sample as MonoVM-only, and add a detailed "Layout of the
CoreCLR (dlopen) payload library" section with real llvm-readelf
section-header, program-header and dynamic-symbol output showing the
allocatable `payload` section, its PT_LOAD segment and the exported
`_assembly_store` symbol.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b99a3be6-afab-4429-a40e-1a236ac0d98e
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The CoreCLR host used to locate the assembly store by parsing the APK ZIP central directory,
mmap-ing the wrapper.so, and walking its ELF section headers by hand to find the payload. Since the store is already a valid ELF shared library (#9154), this PR instead makes its payload a loadable section (SHF_ALLOC, in aPT_LOADsegment) exposed via an exported dynamic symbol, so the host just:…and lets the platform dynamic linker locate + map it out of the APK. The hash-index lookup (
AssemblyStore::open_assembly) is unchanged.This is CoreCLR-only. MonoVM is untouched and keeps the classic
DSOWrapperGenerator+ ZIP-scan store. The mechanism is hardcoded by runtime and cannot be overridden (_AndroidDlopenAssemblyStore= true for CoreCLR, false for MonoVM), because after this change the CoreCLR host has no ZIP fallback and MonoVM's host doesn't understand the loadable-symbol layout.What's removed (CoreCLR)
src/native/clr/startup/zip.ccin its entirety (the ZIP central-directory parser) + thexamarin-startupstatic library.Host::zip_scan_callback,AssemblyStore::map(fd/offset), andUtil::get_wrapper_dso_payload_pointer_and_sizeusage (the ELF section-header walk).DSOApkEntry {fd,offset}native-library fast path inMonodroidDl(it was populated by the same ZIP scan). Bundled native libraries now load viadlopen-by-name, which works because they're uncompressed + page-aligned inlib/{ABI}/(extractNativeLibs=false). The now-deadDSOApkEntrystruct, itsdso_apk_entries[]emission inApplicationConfigNativeAssemblyGeneratorCLR, and the stub definition are removed too (MonoVM keeps its own copy).Net: 889 lines deleted, 197 added (−692 net). The arm64 CoreCLR host (
libnet-android.release.so) shrinks 34,784 B (−2.73%) in a clean same-tree isolation (referenced code removed; arm −3.05%, x64 −3.34%); the actual shippedmain-vs-PR APK host delta is −28,640 B (see the Size table below).Why this hasn't been done before (history)
dlopen-based loading was never used, and this is not a regression from a nicer past design — ZIP-scan +mmapis the original and only design, three layers accreted over the years:mmap-ed in place from the APK (never extracted)mmapassemblies.blob, O(n)→O(1))mmaplib/{ABI}/, renamedlib*.so(not real ELF)mmapobjcopy --add-section, non-loadable, 16 KB-aligned) for the Android 16 KB page-size requirementmmap+ ELF section-header walkdlopenonly became possible in #9154 (the store became real ELF), and even then the payload was deliberately kept non-loadable so the existingmmaploader could be reused — on the unverified assumption (seeApkSharedLibraries.md: "…without having to load the ELF image into memory") thatmmap-the-section beats loading the ELF. This PR measures that assumption.Benchmark results
Measured on a physical Samsung A16 (SM-A165F),
net11.0-androidMAUI sample-content app (dotnet new maui --sample-content), Release, arm64, CoreCLR,extractNativeLibs=false, 30 interleaved cold-starts (am start -WTotalTime).Apples-to-apples (both built from the same local P7 SDK — isolates the store mechanism)
Three independent 30-interleaved rounds,
main(classic ZIP-scan) vs this PR (dlopen). Rounds 1–2 reused a fixed dlopen APK; round 3 rebuiltlatestfresh from the committed code (verified store exports_assembly_store, sectionpayloadis loadable, host has noDT_NEEDED):The three rounds span +16 → −24 ms (a ~40 ms between-session spread that exceeds any single round's effect) ⇒ the cross-session noise floor dominates. dlopen is startup-neutral — within noise, if anything trending faster, and never a regression.
Size (arm64,
extractNativeLibs=false, allStored/uncompressed in the APK; round-3 fresh build)libmonodroid.so(host)libassembly-store.so(The host shrinks because the ZIP parser + ELF section-header walk +
DSOApkEntrypath are gone. Clean referenced-code isolation on the same tree: arm64 −34,784 B, arm −3.05%, x64 −3.34%.)Is there a real difference? No — it's noise
The
dlopen+dlsymcall itself was measured on-device against the real 7.6 MB store:That's ~0.02% of a ~2050 ms cold start — far below the ±20–40 ms cross-session variance. Deleting the ZIP scan + ELF walk is provably not a startup regression.
Can we optimize the dlopen further? No — there's nothing to skip
The store
.sois inspected withreadelf: 0 relocations, noDT_NEEDED, noDT_INIT/init-array, twoPT_LOADsegments. The linker therefore does no relocation, dependency resolution, or constructor work — it justmmaps the (demand-paged) segments + minor bookkeeping. Concretely:android_dlopen_exthas no "skip relocations/init" flag — and there's nothing to skip anyway.RTLD_LAZYvsRTLD_NOWis moot (no code → no PLT relocations). We passRTLD_LOCAL(we onlydlsymour own handle, so there's no need forRTLD_GLOBAL's extra global-scope bookkeeping), though the difference is immaterial at <0.5 ms.apk!/lib/{abi}/lib*.sozip-path syntax (howextractNativeLibs=falseis implemented) works, but measured slower on-device (~0.52–0.98 ms — the linker still opens the zip + does a CD lookup for the entry) and would re-introduce runtime APK-path / split-config handling. The by-sonamedlopenis simpler and already at the floor.Follow-ups (out of scope)
.so, so warm starts skip both Zstd decompression and any ZIP work;android_dlopen_ext(USE_LIBRARY_FD[_OFFSET])is the right tool to load it by fd.Util::get_wrapper_dso_payload_pointer_and_sizedefinition can be deleted as pure dead-code cleanup.Validation
_assembly_store(hardcode works) and cold-launches cleanly (Status: ok).Stored(uncompressed) + 16 KB-aligned;dlsymreturns a pointer to a validAssemblyStoreHeader(magicXABA).payload(matching the MonoVMDSOWrapperGeneratorandtools/assembly-store-reader-mk2), so theCoreClrTrimmableTypeMap*build tests that inspect the APK through the reader keep working.configure_from_payload(const void*),data_start,image_data/debug_info_data/config_data,assemblies,descriptor,assembly_store_hashes) isconst— noconst_caston the store path.Reference PRs
/libdirectories #9154, Move all of assembly store building code to a separate class #9382, CLR hosting bits #9778, CLR hosting #9572AssemblyStores.md·ApkSharedLibraries.md· Android 16 KB pages