Repository navigation
[native] Remove the libc++ dependencies from the CoreCLR host's timing - #12545
Merged
Merged
Conversation
Contributor
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. |
simonrozsival
force-pushed
the
dev/simonrozsival/clr-timing-free-list
branch
from
August 27, 2026 19:29
a0a02ae to
e2be711
Compare
simonrozsival
force-pushed
the
dev/simonrozsival/clr-timing-free-list
branch
from
August 27, 2026 21:42
e2be711 to
8d1abce
Compare
simonrozsival
force-pushed
the
dev/simonrozsival/clr-timing-free-list
branch
from
August 28, 2026 07:54
f85389f to
149001c
Compare
simonrozsival
force-pushed
the
dev/simonrozsival/clr-timing-free-list
branch
from
August 28, 2026 08:47
149001c to
19ad6ac
Compare
simonrozsival
force-pushed
the
dev/simonrozsival/clr-timing-free-list
branch
from
August 28, 2026 08:56
19ad6ac to
2dc39cf
Compare
simonrozsival
force-pushed
the
dev/simonrozsival/clr-timing-free-list
branch
from
August 28, 2026 09:51
2dc39cf to
0789cfc
Compare
simonrozsival
force-pushed
the
dev/simonrozsival/clr-timing-free-list
branch
from
August 28, 2026 10:29
0789cfc to
27ee5c2
Compare
simonrozsival
force-pushed
the
dev/simonrozsival/clr-timing-free-list
branch
from
August 28, 2026 12:06
27ee5c2 to
2b26b61
Compare
This was referenced Aug 28, 2026
jonathanpeppers
force-pushed
the
dev/simonrozsival/clr-timing-free-list
branch
from
August 31, 2026 13:39
d350d84 to
3a8bfab
Compare
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
force-pushed
the
dev/simonrozsival/clr-timing-free-list
branch
from
September 4, 2026 22:21
416b19f to
189a3cf
Compare
rolfbjarne
approved these changes
Sep 7, 2026
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.
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 subscribe to this conversation on GitHub.
Already have an account?
Sign in.
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.

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.