Uh oh!
There was an error while loading. Please reload this page.
[native] Remove the libc++ dependencies from the CoreCLR host's timing - #12545
[native] Remove the libc++ dependencies from the CoreCLR host's timing#12545simonrozsival wants to merge 25 commits into
Conversation
There was a problem hiding this comment.
Copilot review overview
Review tier: Lite
Findings: 1
New issues introduced by this change (1)
| Severity | Finding |
|---|---|
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 individuallymalloc’dmanaged_timing_sequencenodes. - Make the CoreCLR host timing singleton use a static inline instance (
_timing_instance) and point_timingat it when enabled (nonew 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. |
Uh oh!
There was an error while loading. Please reload this page.
a0a02ae to
e2be711Comparee2be711 to
8d1abceComparef85389f to
149001cCompare149001c to
19ad6acCompare19ad6ac to
2dc39cfCompare2dc39cf to
0789cfcCompare0789cfc to
27ee5c2Compare27ee5c2 to
2b26b61Compared350d84 to
3a8bfabCompare✅ Android PR Reviewer completed successfully!
|
There was a problem hiding this comment.
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
Uh oh!
There was an error while loading. Please reload this page.
`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>
416b19f to
189a3cfCompare
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_poolwas astd::vector<managed_timing_sequence>. It returned pointers to managed code asIntPtr; growing the vector could move its elements and invalidate every outstanding pointer.Host::_timingheld the single process-lifetimeTiminginstance in astd::shared_ptr, despite there being no shared ownership or dynamic lifetime.FastTimingstill usednew/delete,std::stack,std::string,std::function, andstd::chronoin paths that do not need those abstractions.Final implementation
Stable managed timing sequences
Timingallocates 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 throughin_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,
Timingcontains only constant-initialized state. CoreCLR stores it directly as astatic inlineprocess-lifetime instance, and callers useFastTiming::enabled()rather than a nullable ownership pointer.Allocation-free open-event stack
FastTimingevent records already live at stable addresses in process-lifetime chunks. EachTimingEventnow carries aprevious_open_eventlink, and the per-thread stack is a singlethread_local TimingEvent*. Starting and ending an event performs no allocation, requires no lock, and needs no TLS destructor or__cxa_thread_atexitregistration. An unbalanced sequence cannot leak a separately allocated stack node.Event chunks use
calloc/free, preserving stable references while avoidingoperator new/operator delete.Plain timing strings and file names
TimingEvent::more_infois a NUL-terminatedchar*built with onemallocand 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 bundleddebug.mono.timing/debug.dotnet.timingvalues, which are not constrained by Android's system-property limit. If that fallback allocation fails, timing warns and writes to the defaulttiming.txtinstead of aborting the application. The inline buffer, null heap pointer, and configured flag remain constant-initialized.Removing unnecessary type erasure and
chronoFastTiming::dumpuses a plain line-writer function pointer plus a typedFILE*context instead ofstd::function.AssemblyStore::configure_from_payloadaccepts its diagnosticconst char*directly instead of wrapping a lambda and allocating a temporarystd::string.uint64_tnanosecond counts fromclock_gettime(CLOCK_MONOTONIC). A sharedtime_intervalreproduces the formerduration_castoutput exactly, including total milliseconds and nanoseconds within the final millisecond.MonoVM shares
Timingand receives the allocator changes;monodroid-glue.ccalso formats the new plain-nanosecond interval.Results
__cxa_guard_*The removed references include the
std::shared_ptrcontrol-block family, oneoperator new, one__libcpp_verbose_abort, and one pair of__cxa_guard_acquire/__cxa_guard_release. The__cxa_thread_atexitcategory is eliminated from the timing path. NativeAOT remains at 0.The simple ARM64 CoreCLR APK decreases by 24 KiB (0.36%);
libmonodroid.soitself decreases by 27,144 bytes (5.05%). The four affected APK-size references are updated from CI output.Coverage
1,500,000,123 nsremains1:1500::123.