Skip to content

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

Open
simonrozsival wants to merge 25 commits into
mainfrom
dev/simonrozsival/clr-timing-free-list
Open

[native] Remove the libc++ dependencies from the CoreCLR host's timing#12545
simonrozsival wants to merge 25 commits into
mainfrom
dev/simonrozsival/clr-timing-free-list

Conversation

@simonrozsival

@simonrozsivalsimonrozsival 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 baseline4710
this PR388

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.

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

CopilotAI 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)
SeverityFinding
Medium severitysrc/​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()).
FileDescription
src/​native/​common/​include/​runtime-base/​timing.hhReplaces vector-backed pool with free-list backed stable allocations for managed timing sequences.
src/​native/​clr/​include/​host/​host.hhIntroduces a static inline Timing instance to avoid heap allocation when timing is enabled.
src/​native/​clr/​host/​host.ccSwitches timing initialization from new Timing() to using the static instance.

Comment threadsrc/native/common/include/runtime-base/timing.hh Outdated
@simonrozsival
simonrozsivalforce-pushed the dev/simonrozsival/clr-timing-free-list branch from a0a02ae to e2be711CompareAugust 27, 2026 19:29
@simonrozsivalsimonrozsival 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 timingAug 27, 2026
@simonrozsivalsimonrozsival added the drop-libcpp Work to remove the libc++ dependency from Android NativeAOT label Aug 27, 2026
@simonrozsival
simonrozsivalforce-pushed the dev/simonrozsival/clr-timing-free-list branch from e2be711 to 8d1abceCompareAugust 27, 2026 21:42
@simonrozsival
simonrozsivalforce-pushed the dev/simonrozsival/clr-timing-free-list branch from f85389f to 149001cCompareAugust 28, 2026 07:54
@simonrozsival
simonrozsivalforce-pushed the dev/simonrozsival/clr-timing-free-list branch from 149001c to 19ad6acCompareAugust 28, 2026 08:47
@simonrozsival
simonrozsivalforce-pushed the dev/simonrozsival/clr-timing-free-list branch from 19ad6ac to 2dc39cfCompareAugust 28, 2026 08:56
@simonrozsival
simonrozsivalforce-pushed the dev/simonrozsival/clr-timing-free-list branch from 2dc39cf to 0789cfcCompareAugust 28, 2026 09:51
@simonrozsival
simonrozsivalforce-pushed the dev/simonrozsival/clr-timing-free-list branch from 0789cfc to 27ee5c2CompareAugust 28, 2026 10:29
@simonrozsival
simonrozsivalforce-pushed the dev/simonrozsival/clr-timing-free-list branch from 27ee5c2 to 2b26b61CompareAugust 28, 2026 12:06
@simonrozsivalsimonrozsival 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 timingAug 28, 2026
@jonathanpeppers
jonathanpeppersforce-pushed the dev/simonrozsival/clr-timing-free-list branch from d350d84 to 3a8bfabCompareAugust 31, 2026 13:39
@github-actions

github-actionsBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Android PR Reviewer completed successfully!

Generated by Android PR Reviewer for #12545

@github-actionsgithub-actionsBot 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.

Reviewed the native timing refactor across CoreCLR, MonoVM, event allocation, TLS nesting, output formatting, and managed/native callers. I found 1 correctness warning: the fixed output-file-name buffer assumes all timing options are bounded by PROP_VALUE_MAX, but bundled properties are explicitly unbounded, causing long configured names to fall back to the default file.

The intrusive open-event stack addresses the prior hot-path allocation concern, and the compile-time interval-format assertion covers the previously requested boundary. CI is green: all 44 reported checks, including the dotnet-android Azure build and CLA, completed successfully.

Generated by Android PR Reviewer for #12545 · gpt56 · 275.7 AIC · ⌖ 19.4 AIC · ⊞ 25.7K
Comment /review to run again

Comment threadsrc/native/common/runtime-base/timing-internal.cc Outdated
@simonrozsivalsimonrozsival removed the ready-to-review This PR is ready to review/merge, I think any CI failures are just flaky (ignorable). label Sep 4, 2026
simonrozsivaland others added 25 commits September 5, 2026 00:21
`Timing::sequence_pool` was a `std::vector<managed_timing_sequence>` that
`get_available_sequence` scanned for a free entry, growing it with
`emplace_back` when every entry was in use.
Returning pointers into a vector's buffer is unsound. The constructor
does `resize (16)`, which leaves capacity at exactly 16, so the
seventeenth concurrent sequence reallocates the buffer -- and every
pointer already handed out to managed code (held as an `IntPtr` across
the `TimingLogger.Start`/`Stop` window) is left dangling.
`monodroid_timing_stop` then writes `sequence->end` and `in_use = false`
into freed memory, and the measurement is silently lost. Because those
entries are never marked free again, the pool also grows on every
subsequent call.
Replace the vector with an intrusive free list. Entries are allocated
individually with `malloc`, so they never move, and `release_sequence`
pushes them back onto the list instead of freeing them. Nothing is ever
freed, so no pointer can dangle; the total allocation is bounded by the
peak number of concurrent sequences. Acquire and release are now O(1)
rather than an O(n) scan under the lock.
`in_use` is kept purely as a guard: a double release would otherwise
push an entry onto the list twice and hand it to two callers at once.
Today a double release is harmless, and it stays harmless.
`Timing` is left with two constant-initialized POD members, so it no
longer needs a constructor and can be a plain `static inline` instance
in BSS, removing the `new Timing ()` as well. This only pays off on top
of the `pthread_mutex_t` change: while `sequence_lock` was a `std::mutex`
its non-trivial destructor forced `__cxa_atexit` registration behind a
guard variable, which cost two more symbols than the `operator new` it
saved.
Real libc++ references in the CoreCLR archive drop from 40 to 38
(one `operator new`, one `__libcpp_verbose_abort` from the vector's
length check). `__cxa_guard_*` stays at 8 and NativeAOT stays at 0.
MonoVM also uses this class and gets the same fix without any change to
`src/native/mono/`.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
`Host::_timing` was a pointer whose only job was to encode "fast timing is
disabled" as `nullptr`. `FastTiming::enabled ()` already answers that
question, so the pointer was redundant indirection over a static instance
that always exists.
Keep just the object, have `get_timing ()` return a reference, and gate both
P/Invokes on `FastTiming::enabled ()`. This also closes a window where
`enabled ()` was true but the pointer had not been assigned yet.
Also null-check `get_available_sequence ()` in `monodroid_timing_start ()`:
it can now return `nullptr` when `malloc` fails, which the previous
vector-backed implementation never did.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
The free list gave every sequence its own `malloc`, and threaded the recycling
through a `next_free` pointer inside the sequence itself. That works, but a
double release would put an entry on the list twice and hand it to two callers
at once, so `release_sequence ()` had to guard against it.
Allocate in chunks of 16 instead and go back to recycling through `in_use`, the
way the original vector-backed code did. `get_available_sequence ()` scans the
chunks for an unused entry and chains on a new chunk when it finds none.
Chunks are never freed, so every address handed to managed code stays valid for
the lifetime of the process, and a double release is just a redundant store.
MonoVM shares `Timing` and dereferenced `get_available_sequence ()` without
checking it, which was safe while the pool was a vector but is not now that
allocation can fail. Add the missing check.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
Returning `nullptr` on allocation failure pushed the problem onto every
caller, and both `monodroid_timing_start ()` implementations had to grow a
check they never needed while the pool was a `std::vector`.
Abort instead, which is what the rest of the runtime does when it cannot
allocate. `get_available_sequence ()` can no longer fail, so both checks go
away again and `src/native/mono/` is untouched by this PR.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0a35a0db-502d-48c0-8468-e73b5dd0ab2e
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 <223556219+Copilot@users.noreply.github.com>
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 <223556219+Copilot@users.noreply.github.com>
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 <223556219+Copilot@users.noreply.github.com>
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 <223556219+Copilot@users.noreply.github.com>
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 <223556219+Copilot@users.noreply.github.com>
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 <223556219+Copilot@users.noreply.github.com>
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 <223556219+Copilot@users.noreply.github.com>
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 <223556219+Copilot@users.noreply.github.com>
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 <223556219+Copilot@users.noreply.github.com>
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 <223556219+Copilot@users.noreply.github.com>
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 <223556219+Copilot@users.noreply.github.com>
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 <223556219+Copilot@users.noreply.github.com>
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 <223556219+Copilot@users.noreply.github.com>
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 <223556219+Copilot@users.noreply.github.com>
Keep the long bundled-property filename test allocation-free at declaration time by using a constant literal.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Use new string to keep the 128-character test boundary apparent instead of embedding an unreadable literal.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
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 <223556219+Copilot@users.noreply.github.com>
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 <223556219+Copilot@users.noreply.github.com>
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 <223556219+Copilot@users.noreply.github.com>
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 <223556219+Copilot@users.noreply.github.com>
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 <223556219+Copilot@users.noreply.github.com>
@simonrozsival
simonrozsivalforce-pushed the dev/simonrozsival/clr-timing-free-list branch from 416b19f to 189a3cfCompareSeptember 4, 2026 22:21
@simonrozsivalsimonrozsival 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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

drop-libcppWork to remove the libc++ dependency from Android NativeAOTready-to-reviewThis 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.

2 participants

@simonrozsival