Skip to content

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

Open
simonrozsival wants to merge 14 commits into
dev/simonrozsival/clr-replace-std-mutexfrom
dev/simonrozsival/clr-timing-free-list
Open

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

Conversation

@simonrozsival

@simonrozsival simonrozsival commented Aug 27, 2026

Copy link
Copy Markdown
Member

Part of #12533 (drop libc++ from the CoreCLR host). Stacked on top of #12541.

Note: this PR is the combination of what were previously four stacked PRs, all of which modified timing-internal.hh (and three of them timing-internal.cc). Reviewed separately, the same two files had to be read in four different intermediate states; reviewed together, each libc++ dependency in the timing code is removed once. The sections below are in commit order, and the reference counts quoted in each are the counts at that point, so they step down as you read.

Why

The CoreCLR host's timing support is the last part of the host that reaches into libc++ for memory management, in two places:

  • Timing::sequence_pool is a std::vector<managed_timing_sequence>, which pulls in operator new and __libcpp_verbose_abort (the vector's own length check).
  • Host::_timing holds that Timing in a std::shared_ptr, which pulls in the whole __shared_weak_count family — atomic refcounting, the type-erased deleter and the control block destructor — none of which we need for an object that is created once and lives until the process exits.

Neither buys us anything here. The pool is a fixed-purpose allocator for a diagnostic feature, and the shared_ptr never shares: there is exactly one owner and no lifetime question to answer.

The constraint

Sequences are handed to managed code as an IntPtr and held across the TimingLogger.Start/Stop window, so every address handed out has to stay valid until the caller releases it. That is the requirement the container has to satisfy, and it rules out any contiguous growable array — reallocating to grow moves the elements, invalidating pointers already given out. Growability was never really the requirement; stable addresses are.

The fix

Allocate in chunks of 16 and chain them together:

struct sequence_chunk
{
    sequence_chunk           *next;
    managed_timing_sequence   sequences[SEQUENCE_CHUNK_SIZE];
};

get_available_sequence () scans the chunks for an entry that isn't in use and chains on a new one when it finds none. Chunks are never freed, so an address stays valid for the lifetime of the process, and entries are recycled through in_use exactly as the vector version did. Memory is bounded by peak concurrent usage rather than by total calls.

An intrusive free list would also work, and would be O(1) rather than a scan. It is deliberately not used: threading the recycling through the entries themselves makes a double release actively dangerous, because the entry lands on the list twice and is then handed to two callers at once, so release_sequence () would need a guard against it. Recycling through in_use keeps a double release a harmless redundant store, which is the behaviour today. This is diagnostic code and the scan is over at most a handful of entries, so there is nothing to gain by trading that away.

A chunk that cannot be allocated aborts, as elsewhere in the runtime, so get_available_sequence () never fails and no caller needs a null check.

Dropping the shared_ptr

With the vector gone, Timing is left with two constant-initialized members and needs no constructor, so it can be a plain static inline instance in BSS. That removes the allocation entirely, and with it the shared_ptr that owned it.

Host::_timing was then a pointer whose only remaining job was to encode "fast timing is disabled" as nullptr — a nullable pointer standing in for a boolean that FastTiming::enabled () already owns. So the pointer is gone too and get_timing () returns a Timing&; it cannot be const&, as get_available_sequence () and release_sequence () both mutate. Both P/Invokes in internal-pinvokes-clr.cc now gate on FastTiming::enabled () instead, which also removes an ordering assumption: previously enabled () could in principle be true before _timing had been assigned.

Note this only pays off on top of #12541. While sequence_lock was a std::mutex, its non-trivial destructor forced __cxa_atexit registration behind a guard variable, and making Timing a static was a net regression — measured at the time as +2 refs, trading one operator new for two __cxa_guard_*. Now that the lock is a pthread_mutex_t, the whole object is constant-initialized and the guard pair disappears as well.

Results

refs __cxa_guard_*
#12541 (base) 47 10
this PR 38 8

The nine removed, from diffing the demangled undefined-symbol sets of the two archives:

symbol before after
std::__ndk1::__shared_weak_count::__release_weak() 3 0
std::__ndk1::__shared_weak_count::~__shared_weak_count() 1 0
std::__ndk1::__shared_weak_count::__get_deleter(std::type_info const&) const 1 0
operator new(unsigned long) 4 3
std::__ndk1::__libcpp_verbose_abort(char const*, ...) 4 3
__cxa_guard_acquire 5 4
__cxa_guard_release 5 4

NativeAOT stays at 0.

Verification

  • CoreCLR, MonoVM and NativeAOT all build clean
  • The allocator was exercised standalone: 40 acquires → 40 distinct addresses in exactly 3 chunks; values written through the first 16 pointers are still readable after two further chunks are chained on; releasing all 40 and re-acquiring 40 allocates nothing new and draws entirely from the existing 48 slots; a triple release followed by two acquires still yields two distinct entries and no extra chunk
  • MonoVM shares Timing and picks up the same change, with no edits under src/native/mono/

Also: FastTiming's event chunks

FastTiming::TimingEventChunk is a structurally identical chunked pool to the one introduced above — a fixed array of entries plus a next pointer, chained together and handed out as references that must stay valid for the lifetime of the process:

struct TimingEventChunk
{
    TimingEvent events [EVENT_CHUNK_SIZE];
    TimingEventChunk *next = nullptr;
};

It was still allocated with new/delete, so it gets the same calloc/free treatment for consistency. Zero-filling matches TimingEvent's default member initializers.

This does not move the reference count on its own, because the same translation units still reference operator new/operator delete for the std::string behind TimingEvent::more_info. Those are removed further down in this PR, and only then does the count actually drop — the metric is a per-file cliff, so only removing the last use of a symbol in an object counts.


Also: FastTiming::open_sequences

FastTiming::open_sequences tracks the timing events which have been started but not yet ended on each thread:

static inline thread_local std::stack<TimingEvent*> open_sequences;

std::stack defaults to std::deque as its underlying container, and std::deque has both a non-trivial constructor and a non-trivial destructor. For a thread_local, that means every translation unit which includes timing-internal.hh emits a guarded dynamic initializer for it, plus a __cxa_thread_atexit registration so the deque is destroyed when the thread exits. timing-internal.hh is included by both the CoreCLR and the MonoVM hosts.

The stack is only ever used through push, top, pop and empty, so a naive singly linked list is enough:

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

static inline thread_local OpenSequence *open_sequences = nullptr;

The head pointer is a plain pointer, so it is trivially destructible and constant initialized: no guard variable, and nothing to register with __cxa_thread_atexit. The list itself is unbounded — any number of events may be open at once, exactly as before.

Locking: open_sequences is thread_local, so the list is private to its thread and needs no lock. This change keeps it that way — no state moves into shared storage — so there is still nothing to synchronize.

Lifetime: nodes are freed as they are popped rather than being recycled. That matters here because, unlike the process-wide timing sequence pool above, this list is per thread and threads come and go — recycling nodes would mean every thread that ever recorded a timing event left its nodes behind. A thread that balances its start_event and end_event calls now leaves nothing allocated when it exits. Allocation failure aborts, matching how the timing sequence chunks behave.

This removes the __cxa_thread_atexit category entirely (4 → 0).

The push/top/pop/empty semantics were checked against a standalone harness with instrumented malloc/free, covering the empty, balanced-LIFO, interleaved, over-pop and 100,000-deep cases, asserting strict LIFO order and that every node is freed. 20/20 pass.

Also: the two std::strings the earlier timing pass missed

#12513 removed the local strings from the timing code, but two heap-allocated ones survived in FastTiming and were only spotted later while attributing the remaining libc++ references.

TimingEvent::more_info

std::string *more_info = nullptr;

It is always built from one or two std::string_views whose combined length is known up front, so it becomes a plain NUL-terminated char* produced by a single malloc plus one or two memcpys. If the allocation fails we drop the extra information for that one event rather than aborting — timing is a diagnostic facility and must not take the application down with it.

FastTiming::output_file_name

std::unique_ptr<std::string> output_file_name{};

The name is parsed out of the debug.mono.timing system property, whose entire value is capped at PROP_VALUE_MAX (92) bytes, so a fixed 128 byte buffer inside FastTiming is always large enough. Keeping it inline also keeps the global internal_timing instance constant-initialized, so it needs no guard variable. A name that does not fit is rejected with a warning and the default is used.

<memory> and <string> are no longer needed by timing-internal.hh at all.

Together with the calloc change above, this clears the last operator new/operator delete references from timing-internal.cc.o (2 → 0) and, as a side effect, from typemap.cc.o (2 → 0)typemap.cc had been inheriting them purely from the inlined new TimingEventChunk in FastTiming::get_event.

Also: replacing std::function with function pointers

std::function is a type-erasing wrapper: it has to be able to store, copy and destroy an arbitrary callable, and it pulls <functional> into every translation unit that sees the declaration. Neither of the two remaining uses in the CoreCLR host needs any of that.

FastTiming::dump

The line writer was taken by value:

void dump (size_t entries, bool indent, std::function<void(std::string_view const&)> line_writer) noexcept;

Of its two callers, dump_to_logcat passes a captureless lambda and dump_to_file captures a single FILE*. A plain function pointer plus an opaque context covers both:

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

void dump (size_t entries, bool indent, LineWriter line_writer, FILE *output) noexcept;

The context is typed as FILE* rather than void*, since the only caller that needs one writes to a file — that avoids a cast in the writer. dump_to_file passes its FILE* through instead of capturing it, and dump stays out of line — no template, so no code duplication per callback type.

The two writers are plain functions in an anonymous namespace rather than captureless lambdas. A lambda converted to a function pointer goes through a compiler generated static invoker, so using functions directly removes a level of indirection.

AssemblyStore::configure_from_payload

static void configure_from_payload (const void *payload_start, const std::function<std::string()>& get_full_store_path) noexcept;

The callback existed only to produce a store path for diagnostics, but the single caller was:

AssemblyStore::configure_from_payload (payload, [store_path]() -> std::string { return std::string { store_path }; });

That wraps a const char * in a std::string purely so that all three use sites can call .c_str () on it again. One of those three is the success-path log_debugf at the end of the function, so the callback runs on every startup and this allocated a string every time.

It now takes the const char * directly and uses the existing optional_string () helper, which also makes it null-safe. The stale comment describing the callback was updated — it claimed the path was only used for invalid payloads, which was not true.

Why the reference count does not move here

Both uses were fully inlined by the optimizer at -O2, so std::function's machinery was emitted into the objects rather than left as undefined references. What goes away is that generated machinery — 6,992 bytes come off libnet-android.release.so (546,904 → 539,912 B), plus one std::string allocation per startup.

Worth recording, since it is easy to misread as "no progress": llvm-nm --undefined-only lists each undefined symbol once per object file, so this metric counts (object, symbol) pairs rather than call sites. Measured per object across this change:

object undefined symbol set relocations .text
host.cc.o identical 60 → 56 30,550 → 30,026
assembly-store.cc.o identical 84 → 83 24,424 → 24,106

The count only moves when the last use of a symbol in a given file goes away, so it behaves as a per-file cliff rather than a gradual measure.

The lambdas remaining inside dump are called directly instead of being converted to function pointers, so the optimizer already inlines them; replacing them with named functions measured 2 bytes larger, so they were left as they are.

Also: dropping <chrono>

FastTiming::get_time() has always read the clock with clock_gettime() directly — the comment above it even says we do that to avoid calling into libc++:

// We cheat a bit here, by avoiding a call to libc++ code that performs the same action.
// We can do it because we know our target platform.

std::chrono::steady_clock was then used only as the type tag of the chrono::time_point we wrapped the result in. So we were paying for <chrono> without using the clock it provides.

Timestamps are now a plain uint64_t nanosecond count, and <chrono> is removed from the four files that included it. In mainthread-dso-loader.hh the include was entirely unused.

Sharing the interval formatting

Four places repeated the same seconds / milliseconds / nanoseconds-within-the-millisecond split, so they now share one helper:

struct time_interval
{
	unsigned long long seconds;
	unsigned long long milliseconds;
	unsigned long long nanoseconds;

	explicit constexpr time_interval (time_point interval) noexcept
		: seconds { interval / NANOSECONDS_PER_SECOND },
		  milliseconds { interval / NANOSECONDS_PER_MILLISECOND },
		  nanoseconds { interval % NANOSECONDS_PER_MILLISECOND }
	{}
};

The split reproduces chrono::duration_cast exactly, including the fact that the middle field is the total milliseconds rather than a remainder. The timing output is byte-for-byte unchanged, which matters because the format after the first colon is parsed by our performance measuring utilities.

I verified this rather than assuming it: a standalone harness compared the old chrono computation against time_interval over the nine interesting edge cases (0, 999999, 1000000, 1000001, 999999999, 1000000000, …, INT64_MAX) plus 2,000,000 random values — 2,000,009 checked, 0 mismatches.

CLOCK_MONOTONIC_RAWCLOCK_MONOTONIC

We now read CLOCK_MONOTONIC, the clock steady_clock is specified to use, instead of CLOCK_MONOTONIC_RAW. The two differ only in that CLOCK_MONOTONIC is slewed by NTP, which is irrelevant at the granularity we measure.

<chrono> is header-only, so this removes no undefined symbols; it takes a further 80 B off libnet-android.release.so and removes one more libc++ header from the build, which is a prerequisite for eventually building without libc++ headers at all.

Overall result

Across the whole PR, the CoreCLR undefined-libc++ reference count goes from 47 (at #12541) to 38 after the chunked pool, and the __cxa_thread_atexit and timing-related operator new/operator delete categories are eliminated entirely. NativeAOT stays at 0. src/native/mono/ is untouched throughout, though MonoVM shares Timing and picks up the same changes.

CoreCLR, MonoVM and NativeAOT all build clean.

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 and others added 8 commits August 28, 2026 14:02
`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
simonrozsival and others added 6 commits August 28, 2026 14:02
`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
@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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

drop-libcpp Work to remove the libc++ dependency from Android NativeAOT

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants