Skip to content

[native] Remove the libc++ dependencies from the CoreCLR host's timing - #12545

Merged
simonrozsival merged 25 commits into
mainfrom
dev/simonrozsival/clr-timing-free-list
Sep 7, 2026
Merged

simonrozsival merged 25 commits into
mainfrom
dev/simonrozsival/clr-timing-free-list

Conversation

@simonrozsival

@simonrozsival simonrozsival commented Aug 27, 2026 •

Copy link
Copy Markdown
Member

Part of #12533. Builds on the mutex changes from #12541, which is now merged into main.

Why

The CoreCLR host's timing support still reached into libc++ for memory management:

  • Timing::sequence_pool was a std::vector<managed_timing_sequence>. It returned pointers to managed code as IntPtr; growing the vector could move its elements and invalidate every outstanding pointer.
  • Host::_timing held the single process-lifetime Timing instance in a std::shared_ptr, despite there being no shared ownership or dynamic lifetime.
  • FastTiming still used new/delete, std::stack, std::string, std::function, and std::chrono in paths that do not need those abstractions.

Final implementation

Stable managed timing sequences

Timing allocates sequences in linked chunks of 16. Chunks never move or free, so every address handed to managed code remains valid for the process lifetime. Entries are recycled through in_use; a double release remains a harmless redundant store rather than corrupting a free list. Allocation failure aborts consistently with other required runtime allocations.

With the vector gone, Timing contains only constant-initialized state. CoreCLR stores it directly as a static inline process-lifetime instance, and callers use FastTiming::enabled() rather than a nullable ownership pointer.

Allocation-free open-event stack

FastTiming event records already live at stable addresses in process-lifetime chunks. Each TimingEvent now carries a previous_open_event link, and the per-thread stack is a single thread_local TimingEvent*. Starting and ending an event performs no allocation, requires no lock, and needs no TLS destructor or __cxa_thread_atexit registration. An unbalanced sequence cannot leak a separately allocated stack node.

Event chunks use calloc/free, preserving stable references while avoiding operator new/operator delete.

Plain timing strings and file names

TimingEvent::more_info is a NUL-terminated char* built with one malloc and direct copies. Because timing is diagnostic, allocation failure drops only that event's optional detail.

Timing output file names use a 128-byte inline buffer for normal values and malloc-owned storage for longer bundled debug.mono.timing / debug.dotnet.timing values, which are not constrained by Android's system-property limit. If that fallback allocation fails, timing warns and writes to the default timing.txt instead of aborting the application. The inline buffer, null heap pointer, and configured flag remain constant-initialized.

Removing unnecessary type erasure and chrono

  • FastTiming::dump uses a plain line-writer function pointer plus a typed FILE* context instead of std::function.
  • AssemblyStore::configure_from_payload accepts its diagnostic const char* directly instead of wrapping a lambda and allocating a temporary std::string.
  • Timestamps are plain uint64_t nanosecond counts from clock_gettime(CLOCK_MONOTONIC). A shared time_interval reproduces the former duration_cast output exactly, including total milliseconds and nanoseconds within the final millisecond.

MonoVM shares Timing and receives the allocator changes; monodroid-glue.cc also formats the new plain-nanosecond interval.

Results

libc++ refs __cxa_guard_*
#12541 baseline 47 10
this PR 38 8

The removed references include the std::shared_ptr control-block family, one operator new, one __libcpp_verbose_abort, and one pair of __cxa_guard_acquire / __cxa_guard_release. The __cxa_thread_atexit category is eliminated from the timing path. NativeAOT remains at 0.

The simple ARM64 CoreCLR APK decreases by 24 KiB (0.36%); libmonodroid.so itself decreases by 27,144 bytes (5.05%). The four affected APK-size references are updated from CI output.

Coverage

  • The timing duration contract is pinned at compile time: 1,500,000,123 ns remains 1:1500::123.
  • The device integration test grows the concurrent event store beyond one chunk and dumps it.
  • The same test covers both the short Android system-property filename path and a 144-byte bundled-property filename that requires heap fallback.
  • CoreCLR, MonoVM, and NativeAOT build paths are covered by the full CI matrix.

Copilot AI lite review requested due to automatic review settings August 27, 2026 18:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

Review tier: Lite
Findings: 1 Medium severity

New issues introduced by this change (1)
Severity Finding
Medium severity src/​native/​common/​include/​runtime-base/​timing.hh — ❌ error: get_available_sequence() returns nullptr on malloc failure, but the current callers…
What changed in this PR

This PR updates the native fast-timing implementation used by managed code to avoid handing out pointers into a growable std::vector buffer (which can reallocate and invalidate outstanding pointers). It replaces the sequence pool with a stable-address intrusive free list and updates the CoreCLR host to use a constant-lifetime Timing instance without heap allocation, supporting the broader effort to drop libc++ dependencies from the CoreCLR host.

Changes:

  • Replace Timing::sequence_pool (std::vector) with an intrusive free list of individually malloc’d managed_timing_sequence nodes.
  • Make the CoreCLR host timing singleton use a static inline instance (_timing_instance) and point _timing at it when enabled (no new Timing()).
File Description
src/​native/​common/​include/​runtime-base/​timing.hh Replaces vector-backed pool with free-list backed stable allocations for managed timing sequences.
src/​native/​clr/​include/​host/​host.hh Introduces a static inline Timing instance to avoid heap allocation when timing is enabled.
src/​native/​clr/​host/​host.cc Switches timing initialization from new Timing() to using the static instance.

Comment thread src/native/common/include/runtime-base/timing.hh Outdated
@simonrozsival
simonrozsival force-pushed the dev/simonrozsival/clr-timing-free-list branch from a0a02ae to e2be711 Compare August 27, 2026 19:29
@simonrozsival simonrozsival changed the title [native] Replace the Timing sequence pool with an intrusive free list [native] Drop std::vector and std::shared_ptr from the CoreCLR host's timing Aug 27, 2026
@simonrozsival simonrozsival added the drop-libcpp Work to remove the libc++ dependency from Android NativeAOT label Aug 27, 2026
@simonrozsival
simonrozsival force-pushed the dev/simonrozsival/clr-timing-free-list branch from e2be711 to 8d1abce Compare August 27, 2026 21:42
@simonrozsival
simonrozsival force-pushed the dev/simonrozsival/clr-timing-free-list branch from f85389f to 149001c Compare August 28, 2026 07:54
@simonrozsival
simonrozsival force-pushed the dev/simonrozsival/clr-timing-free-list branch from 149001c to 19ad6ac Compare August 28, 2026 08:47
@simonrozsival
simonrozsival force-pushed the dev/simonrozsival/clr-timing-free-list branch from 19ad6ac to 2dc39cf Compare August 28, 2026 08:56
@simonrozsival
simonrozsival force-pushed the dev/simonrozsival/clr-timing-free-list branch from 2dc39cf to 0789cfc Compare August 28, 2026 09:51
@simonrozsival
simonrozsival force-pushed the dev/simonrozsival/clr-timing-free-list branch from 0789cfc to 27ee5c2 Compare August 28, 2026 10:29
@simonrozsival
simonrozsival force-pushed the dev/simonrozsival/clr-timing-free-list branch from 27ee5c2 to 2b26b61 Compare August 28, 2026 12:06
@simonrozsival simonrozsival changed the title [native] Drop std::vector and std::shared_ptr from the CoreCLR host's timing [native] Remove the libc++ dependencies from the CoreCLR host's timing Aug 28, 2026
@jonathanpeppers
jonathanpeppers force-pushed the dev/simonrozsival/clr-timing-free-list branch from d350d84 to 3a8bfab Compare August 31, 2026 13:39
simonrozsival and others added 21 commits September 5, 2026 00:21
The previous commits replaced the `std::vector` backing `Timing`'s sequence
pool with chunks allocated by `calloc` and chained together. `FastTiming`'s
`TimingEventChunk` is a structurally identical pool that was left using
`new`/`delete`, so apply the same treatment to it.

This does not change the `libc++` reference count on its own, because the
same translation units still reference `operator new`/`operator delete` for
the `std::string` that `TimingEvent::more_info` points to. Removing those
strings is done in the next commit of the stack, and only then does the
count actually drop.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
`FastTiming::open_sequences` was a `thread_local std::stack<TimingEvent*>`,
which defaults to `std::deque` as its container.  `std::deque` has both a
non-trivial constructor and a non-trivial destructor, so every translation
unit including `timing-internal.hh` emitted a guarded dynamic initializer
plus a `__cxa_thread_atexit` registration for the thread-local instance.

The stack only ever needs `push`, `top`, `pop` and `empty`, and its depth is
bounded by how deeply the instrumented calls nest (currently 3) because every
`start_event` is matched by exactly one `end_event` or `store_more_info`.
Replace it with a fixed `TimingEvent*` array plus a depth counter, both of
which are trivially constructible and destructible and therefore constant
initialized.

`open_sequences` is `thread_local`, so it is private to each thread and needs
no locking - that remains true here, as no state is shared between threads.

The depth counter is incremented even when the array is full, so a push past
the bound only loses that one entry instead of misaligning the pairing of the
events below it.  Once the depth drops back within bounds the remaining
entries are still correct.

Removes all 4 `__cxa_thread_atexit` references and one
`__libcpp_verbose_abort`, taking the CoreCLR host's libc++ references from
64 to 59.  As a side effect, pushing a timing event no longer allocates.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
The fixed array capped the nesting depth of timing events, which is not a
limit the timing code should impose - any number of events may be open on a
thread at once.  Replace it with a naive singly linked list used as a stack,
with one malloc'd node per open sequence:

    struct OpenSequence
    {
        TimingEvent *event;
        OpenSequence *next;
    };

    static inline thread_local OpenSequence *open_sequences = nullptr;

The head pointer is still a trivially destructible thread-local, so this keeps
the property that motivated the change: no guarded dynamic initializer and no
`__cxa_thread_atexit` registration.

Nodes are freed as they are popped rather than being recycled, so a thread
that balances its `start_event` and `end_event` calls leaves nothing behind
when it exits.  That matters here because, unlike the process-wide timing
sequence pool, this list is per thread and threads come and go.

Allocation failure aborts, matching how the timing sequence chunks behave.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
`FastTiming` kept two heap-allocated `std::string`s that the earlier pass
over the timing code missed: the per-event `TimingEvent::more_info` and the
output file name parsed out of the `debug.mono.timing` property.

`more_info` becomes a plain NUL-terminated `char*`. It was always built from
one or two `std::string_view`s whose total length is known up front, so a
single `malloc` and one or two `memcpy`s replace the string entirely. When
the allocation fails we simply drop the extra information instead of
aborting - timing is a diagnostic facility and must not take the application
down with it.

The output file name comes from a system property, whose value is limited to
`PROP_VALUE_MAX` (92) bytes, so it now lives in a fixed 128 byte buffer
inside `FastTiming` rather than in a `std::unique_ptr<std::string>`. Keeping
it inline also means the global `internal_timing` instance stays
constant-initialized and needs no guard variable. Names that do not fit are
rejected with a warning and the default is used.

Together with the previous commit this removes the last `operator new` and
`operator delete` references from `timing-internal.cc.o` and, as a side
effect, all of them from `typemap.cc.o`, which had been inheriting them from
the inlined `new TimingEventChunk` in `FastTiming::get_event`.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
`std::function` is a type-erasing wrapper which needs to store, copy and
destroy an arbitrary callable, and it pulls `<functional>` into every
translation unit that sees the declaration.  Neither of the two uses in the
CoreCLR host needs any of that.

`FastTiming::dump` took its line writer as `std::function<void(std::string_view const&)>`
by value.  Of its two callers one passes a captureless lambda and the other
captures a single `FILE*`, so a plain function pointer plus an opaque
`void *context` covers both:

    using LineWriter = void (*) (void *context, std::string_view const& line);

`AssemblyStore::configure_from_payload` took a `const std::function<std::string()>&`
used only to produce a path for diagnostics.  Its only caller wrapped a
`const char *` in a `std::string` just so that the callee could call
`c_str ()` on it again, and the callback is invoked unconditionally in the
success path, so this allocated a string on every startup.  It now takes the
`const char *` directly.

This does not change the number of undefined libc++ references, since both
uses were fully inlined by the optimizer, but it removes the generated
machinery: `libnet-android.release.so` shrinks by 6,976 bytes.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
Both `dump` callers either write to a file or ignore the context entirely, so
there is no need for the context to be `void*`.  Typing it as `FILE*` removes
the `static_cast` in the file line writer.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
The two line writers were captureless lambdas converted to function pointers
at the call site.  That conversion goes through a compiler generated static
invoker, so making them plain functions in an anonymous namespace removes a
level of indirection: `libnet-android.release.so` shrinks by a further 56
bytes.

The remaining lambdas inside `dump` are called directly rather than converted
to function pointers, so the optimizer already inlines them completely -
replacing those measured 2 bytes *larger*, so they are left alone.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
Addresses review feedback: `configure_from_payload()` takes a raw `const char*`
and every use of it goes through `optional_string ()`, so the header comment now
says explicitly that passing `nullptr` is allowed.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
`FastTiming::get_time()` already read the clock with `clock_gettime()`;
`std::chrono::steady_clock` was only used as the type tag of the
`chrono::time_point` the result was wrapped in.  Store the timestamps as a
plain `uint64_t` nanosecond count instead and drop `<chrono>` from the four
files that included it (it was entirely unused in mainthread-dso-loader.hh).

All four places that formatted an interval repeated the same
seconds/milliseconds/nanoseconds split, so they now share a `time_interval`
helper.  The split is reproduced exactly as `chrono::duration_cast` computed
it, so the timing output is unchanged - this matters because the format after
the first colon is parsed by our performance measuring utilities.

Also read `CLOCK_MONOTONIC` rather than `CLOCK_MONOTONIC_RAW`, so that we keep
using the same clock `steady_clock` was documented to use.  The two differ only
in that `CLOCK_MONOTONIC` is slewed by NTP, which is irrelevant at the
granularity we measure.

This does not remove any undefined libc++ symbols - `<chrono>` is header only
- but it does shrink libnet-android.release.so by 80 bytes and removes one
more libc++ header from the build.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
…tals

Addresses review feedback. Both fields are totals for the whole interval and both
are printed, so `milliseconds` is not milliseconds-within-the-second. The output
format is consumed by performance measuring utilities, so spell this out to keep a
future change from "correcting" it.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
Store the per-thread open sequence link in the stable timing event itself so starting and ending an event no longer allocates or leaks a TLS list node. Add a compile-time boundary check for the externally consumed duration components.

Co-authored-by: Copilot App <[email protected]>
Preserve the base branch non-copyable contract while retaining the default construction required by the static CoreCLR timing instance after rebasing.

Co-authored-by: Copilot App <[email protected]>
Update the four ARM64 CoreCLR APK descriptions with the values emitted by Azure DevOps build 1580684 after removing the timing code libc++ dependencies.

Co-authored-by: Copilot App <[email protected]>
Keep system-property-sized timing file names inline and allocate longer bundled property values with malloc. Preserve explicit filename option semantics and cover the heap fallback with a bundled-property device test.

Co-authored-by: Copilot App <[email protected]>
Keep the long bundled-property filename test allocation-free at declaration time by using a constant literal.

Co-authored-by: Copilot App <[email protected]>
Use new string to keep the 128-character test boundary apparent instead of embedding an unreadable literal.

Co-authored-by: Copilot App <[email protected]>
Fall back to the default timing file on allocation failure, use a dedicated inline filename capacity, and exercise both short system-property and long bundled-property names.

Co-authored-by: Copilot App <[email protected]>
CI preconfigures debug.dotnet.log, and clearing that property did not activate the bundled fast-bare value. Keep the short logging option in the device system property while exercising only the long bundled timing filename.

Co-authored-by: Copilot App <[email protected]>
Fast Deployment places AndroidEnvironment values in a runtime override file that is loaded after FastTiming initializes. Embed the long-name test case so debug.dotnet.timing is generated into application config and is available during early timing initialization.

Co-authored-by: Copilot App <[email protected]>
Route the long bundled timing property into application config without setting EmbedAssembliesIntoApk. Assemblies continue to use Fast Deployment while the early timing option is available before override files are loaded.

Co-authored-by: Copilot App <[email protected]>
Fast Deployment uploads AndroidEnvironment overrides after FastTiming initializes, so the long bundled filename case could never configure startup timing. Restore the last green device test unchanged and leave Fast Deployment behavior untouched.

Co-authored-by: Copilot App <[email protected]>
@simonrozsival
simonrozsival force-pushed the dev/simonrozsival/clr-timing-free-list branch from 416b19f to 189a3cf Compare September 4, 2026 22:21
@simonrozsival simonrozsival added the ready-to-review This PR is ready to review/merge, I think any CI failures are just flaky (ignorable). label Sep 5, 2026
@simonrozsival
simonrozsival merged commit 4b0cc8e into main Sep 7, 2026
44 checks passed
@simonrozsival
simonrozsival deleted the dev/simonrozsival/clr-timing-free-list branch September 7, 2026 11:19
simonrozsival added a commit that referenced this pull request Sep 8, 2026
…12571)

Part of #12533: remove the remaining allocating C++ library types from the CoreCLR assembly store and its decompressed-assembly cache.

<!-- stack-prerequisites -->
All prerequisites (#12541, #12545, #12551, #12552, #12560, #12568, and #12570) have merged. This branch was rebuilt on `main` at `4b0cc8efa9`. The runtime changes are confined to `src/native/clr/host/assembly-store.cc`, with cache regression coverage and APK size baseline updates alongside them. The already-merged startup, timing, DSO-loader, and other prerequisite changes are no longer included in this PR's diff. Upstream's pthread-backed `lock_guard` is preserved.
<!-- /stack-prerequisites -->

### Changes

- Replace `std::deque<WriteRequest>` with an intrusive FIFO linked through `WriteRequest::next`.
- Replace the queued request's `std::unique_ptr<uint8_t[]>` with an explicit `uint8_t *payload`. Allocate the request and payload separately with two `malloc` calls and release both with two `free` calls on every completed or discarded write. If payload allocation fails, release the request and skip the write. No trailing-storage pointer arithmetic or explicit over-alignment is needed.
- Replace the cache directory and per-request `std::string` paths with a process-lifetime directory string and checked `snprintf` formatting. Requests store the descriptor index instead of a path; the writer reconstructs the destination from the immutable cache directory. Replace temporary-file name construction and matching with `snprintf` and `strstr`.
- Use a scoped `CachePath` buffer for directory creation, reads, writes, and stale-file cleanup. Short paths remain on the stack; longer paths retry in an exactly sized `malloc` allocation that is freed on every exit. Formatting and allocation failures are logged and remain non-fatal for the optional cache, unlike the abort-on-failure `Util::format_with_retry` helper.
- Allocate the decompression tracking array and assembly-name table with `calloc`, replacing their `unique_ptr`/`new[]` allocations.
- Extend the existing device cache regression test with a Java Application that supplies a code-cache directory longer than 1 KB. Cover persistence, mapping, corruption recovery, and stale temporary-file cleanup for both ordinary and long paths.

The asynchronous writer, queue byte limit, cache footer validation, and scope-based pthread locking are retained. `Util::LocalPathBufferSize` is the stack fast-path size, not a hard path limit; truncated paths are never used for I/O. The change does not alter linker flags or other runtime hosts.

### Original measurements

These results were recorded on the original pre-rebuild branch, **not on the current revision**:

| Release, arm64 | Before | After |
|---|---:|---:|
| Undefined libc++ references in `assembly-store.cc.o` | 12 | 0 |
| Undefined libc++ references across the CoreCLR host | 12 | 0 |
| `libnet-android.release.so` size | 520,112 bytes | 203,776 bytes |

Reaching zero references allowed the linker to stop pulling members from `libc++_static.a`. Relinking without libc++ also succeeded on that original branch; dropping the linker flag remains a follow-up.

### Current validation status

The malloc fallback passed sanitizer-backed host cases covering stack/heap boundaries, nested lifetimes, allocation failure, and formatting/retry failures. The cache namespace extracted unchanged from the current source, using the real CRC32 and mutex helpers with runtime configuration/property stubs, compiled with the Android NDK for arm64, arm32, and x86_64. A standalone arm64 emulator harness passed cache initialization, asynchronous persistence, mmap reads, corruption recovery, and stale-file cleanup with both short and greater-than-1-KB paths. The generated Java regression-test application also compiled against the Android API. `git diff --check` passes.

A local `dotnet build src/native/native-clr.csproj` restored packages but could not reach native compilation because this worktree lacks `xa-prep-tasks.dll` and `Xamarin.Android.Tools.BootstrapTasks.dll`. The complete native host build and repository device regression suite have **not** been run for this revision; the locally built SDK is unavailable. Binary measurements have not been repeated. The original branch's reported full native builds and binary measurements remain historical, not validation of this revision.
simonrozsival added a commit that referenced this pull request Sep 10, 2026
)

## Why

`System.NetTests.SslTest.VerifyTrustedCertificates` validates Android default/system trust against `google.com`, but CI failures occurred before TLS certificate validation began:

- Build 1574749: IPv6 `No route to host` in the `NoAab` flavor.
- Build 1581249 / PR #12545: DNS `hostname nor servname provided, or not known`.
- A later PR build exposed Android's raw native `EHOSTUNREACH` value (113) instead of the normalized `SocketError.HostUnreachable` value.

These stacks ended while constructing `TcpClient`, before `SslStream.AuthenticateAsClient`, so they do not indicate certificate expiry, rotation, device trust-store state, device time, or test ordering problems.

Fixes #12704

## What changed

The test still validates `google.com` through Android default/system trust. The pre-TLS connection is ignored only for managed `HostNotFound`, `NoData`, `NetworkUnreachable`, and `HostUnreachable`, plus Android native `ENETUNREACH` and `EHOSTUNREACH` values when they are surfaced without managed normalization. Endpoint and socket diagnostics are retained.

TLS authentication remains outside that catch boundary, so timeouts, connection resets/refusals, authentication, hostname, expiry, chain, TLS alert, and all other failures continue to fail.

Parameterized regression coverage pins the four managed values and verifies that `TimedOut` and `ConnectionReset` remain failures. Native `SocketException` coverage pins `ENETUNREACH` and `EHOSTUNREACH` as ignored and `ECONNRESET` as non-ignored.

## Testing

On `emulator-5554`:

- Debug SSL category: 33 passed, 0 failed, 0 skipped; classifier coverage and `VerifyTrustedCertificates` passed.
- Release `TestsFlavor=NoAab`, `AndroidPackageFormat=apk` SSL category: 33 passed, 0 failed, 0 skipped; classifier coverage and `VerifyTrustedCertificates` passed.
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 8, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

drop-libcpp Work to remove the libc++ dependency from Android NativeAOT ready-to-review This PR is ready to review/merge, I think any CI failures are just flaky (ignorable).

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants