From 13e1a1c1ee630b9f77c9f1d7baf9fb83ec79d33f Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 27 Aug 2026 20:53:38 +0200 Subject: [PATCH 01/14] [native] Replace the Timing sequence pool with an intrusive free list `Timing::sequence_pool` was a `std::vector` 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 --- src/native/clr/host/host.cc | 2 +- src/native/clr/host/internal-pinvokes-clr.cc | 11 ++-- src/native/clr/include/host/host.hh | 8 ++- .../common/include/runtime-base/timing.hh | 55 +++++++++++-------- 4 files changed, 43 insertions(+), 33 deletions(-) diff --git a/src/native/clr/host/host.cc b/src/native/clr/host/host.cc index a7d12ed8ab8..a1d19ebfea7 100644 --- a/src/native/clr/host/host.cc +++ b/src/native/clr/host/host.cc @@ -316,7 +316,7 @@ void Host::Java_mono_android_Runtime_initInternal ( FastTiming::initialize ((Logger::log_timing_categories() & LogTimingCategories::FastBare) != LogTimingCategories::FastBare); if (FastTiming::enabled ()) [[unlikely]] { - _timing = std::make_shared (); + _timing = &_timing_instance; internal_timing.start_event (TimingEventKind::TotalRuntimeInit); } diff --git a/src/native/clr/host/internal-pinvokes-clr.cc b/src/native/clr/host/internal-pinvokes-clr.cc index 844f9b748f0..f547387c67a 100644 --- a/src/native/clr/host/internal-pinvokes-clr.cc +++ b/src/native/clr/host/internal-pinvokes-clr.cc @@ -41,11 +41,8 @@ _monodroid_lookup_replacement_method_info (const char *jniSourceType, const char managed_timing_sequence* monodroid_timing_start (const char *message) { - // Technically a reference here is against the idea of shared pointers, but - // in this instance it's fine since we know we won't be storing the pointer - // and this way things are slightly faster. - std::shared_ptr const &timing = Host::get_timing (); - if (!timing) { + Timing *timing = Host::get_timing (); + if (timing == nullptr) { return nullptr; } @@ -64,8 +61,8 @@ void monodroid_timing_stop (managed_timing_sequence *sequence, const char *messa return; } - std::shared_ptr const &timing = Host::get_timing (); - if (!timing) [[unlikely]] { + Timing *timing = Host::get_timing (); + if (timing == nullptr) [[unlikely]] { return; } diff --git a/src/native/clr/include/host/host.hh b/src/native/clr/include/host/host.hh index a492ecf21b6..61e96fab88a 100644 --- a/src/native/clr/include/host/host.hh +++ b/src/native/clr/include/host/host.hh @@ -23,7 +23,7 @@ namespace xamarin::android { static void Java_mono_android_Runtime_registerNatives (JNIEnv *env, jclass nativeClass) noexcept; static void propagate_uncaught_exception (JNIEnv *env, jobject javaThread, jthrowable javaException) noexcept; - static auto get_timing () -> std::shared_ptr + static auto get_timing () noexcept -> Timing* { return _timing; } @@ -54,7 +54,11 @@ namespace xamarin::android { private: static inline void *clr_host = nullptr; static inline unsigned int domain_id = 0; - static inline std::shared_ptr _timing{}; + // Points at `_timing_instance` when fast timing is enabled, `nullptr` otherwise. The + // instance is constant-initialized and lives for the whole lifetime of the process, + // so there is nothing to allocate or free. + static inline Timing _timing_instance {}; + static inline Timing *_timing = nullptr; static inline bool found_assembly_store = false; static inline jnienv_register_jni_natives_fn jnienv_register_jni_natives = nullptr; static inline jnienv_propagate_uncaught_exception_fn jnienv_propagate_uncaught_exception = nullptr; diff --git a/src/native/common/include/runtime-base/timing.hh b/src/native/common/include/runtime-base/timing.hh index 5decc37823f..cb68fa4b8cb 100644 --- a/src/native/common/include/runtime-base/timing.hh +++ b/src/native/common/include/runtime-base/timing.hh @@ -4,7 +4,7 @@ #include #include -#include +#include #include #include @@ -18,20 +18,16 @@ namespace xamarin::android time_point start; time_point end; bool in_use; + + // Valid only while the sequence sits on `Timing::free_sequences`. + managed_timing_sequence *next_free; }; // This class is intended to be used by the managed code. It can be used by the native code as // well, but the overhead it has (out of necessity) might not be desirable in native code. class Timing { - static constexpr size_t DEFAULT_POOL_SIZE = 16uz; - public: - explicit Timing (size_t initial_pool_size = DEFAULT_POOL_SIZE) noexcept - { - sequence_pool.resize (initial_pool_size); - } - static void info (managed_timing_sequence const *seq, const char *message) { do_log (LogLevel::Info, seq, message); @@ -46,20 +42,25 @@ namespace xamarin::android { pthread_mutex_lock (&sequence_lock); - managed_timing_sequence *ret; - for (size_t i = 0uz; i < sequence_pool.size (); i++) { - if (sequence_pool[i].in_use) { - continue; + managed_timing_sequence *ret = free_sequences; + if (ret != nullptr) { + free_sequences = ret->next_free; + } else { + // Sequences are handed out to managed code, which holds on to them until it + // stops the measurement, so they must never move. Each one is therefore its + // own allocation, recycled through `free_sequences` rather than freed, for + // the lifetime of the process. + ret = static_cast (std::malloc (sizeof (managed_timing_sequence))); + if (ret == nullptr) [[unlikely]] { + pthread_mutex_unlock (&sequence_lock); + return nullptr; } - - ret = &sequence_pool[i]; - ret->in_use = true; - - pthread_mutex_unlock (&sequence_lock); - return ret; } - ret = &sequence_pool.emplace_back (); + + ret->start = time_point::min (); + ret->end = time_point::min (); ret->in_use = true; + ret->next_free = nullptr; pthread_mutex_unlock (&sequence_lock); return ret; @@ -72,9 +73,17 @@ namespace xamarin::android } pthread_mutex_lock (&sequence_lock); - sequence->start = time_point::min (); - sequence->end = time_point::min (); - sequence->in_use = false; + + // Ignore a sequence that isn't checked out, otherwise a double release would put + // it on the free list twice and it would then be handed to two callers at once. + if (sequence->in_use) { + sequence->start = time_point::min (); + sequence->end = time_point::min (); + sequence->in_use = false; + sequence->next_free = free_sequences; + free_sequences = sequence; + } + pthread_mutex_unlock (&sequence_lock); } @@ -100,7 +109,7 @@ namespace xamarin::android } private: - std::vector sequence_pool; + managed_timing_sequence *free_sequences = nullptr; pthread_mutex_t sequence_lock = PTHREAD_MUTEX_INITIALIZER; }; } From da79e3e6d061a36d09443df4247cdf091a9e2f45 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 27 Aug 2026 21:09:45 +0200 Subject: [PATCH 02/14] [native] Drop the Timing pointer and null-check the sequence `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 --- src/native/clr/host/host.cc | 1 - src/native/clr/host/internal-pinvokes-clr.cc | 16 +++++++--------- src/native/clr/include/host/host.hh | 11 +++++------ 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/src/native/clr/host/host.cc b/src/native/clr/host/host.cc index a1d19ebfea7..3a241b06820 100644 --- a/src/native/clr/host/host.cc +++ b/src/native/clr/host/host.cc @@ -316,7 +316,6 @@ void Host::Java_mono_android_Runtime_initInternal ( FastTiming::initialize ((Logger::log_timing_categories() & LogTimingCategories::FastBare) != LogTimingCategories::FastBare); if (FastTiming::enabled ()) [[unlikely]] { - _timing = &_timing_instance; internal_timing.start_event (TimingEventKind::TotalRuntimeInit); } diff --git a/src/native/clr/host/internal-pinvokes-clr.cc b/src/native/clr/host/internal-pinvokes-clr.cc index f547387c67a..eaa31de8851 100644 --- a/src/native/clr/host/internal-pinvokes-clr.cc +++ b/src/native/clr/host/internal-pinvokes-clr.cc @@ -41,12 +41,15 @@ _monodroid_lookup_replacement_method_info (const char *jniSourceType, const char managed_timing_sequence* monodroid_timing_start (const char *message) { - Timing *timing = Host::get_timing (); - if (timing == nullptr) { + if (!FastTiming::enabled ()) [[likely]] { + return nullptr; + } + + managed_timing_sequence *ret = Host::get_timing ().get_available_sequence (); + if (ret == nullptr) [[unlikely]] { return nullptr; } - managed_timing_sequence *ret = timing->get_available_sequence (); if (message != nullptr) { log_write (LOG_TIMING, LogLevel::Info, message); } @@ -61,12 +64,7 @@ void monodroid_timing_stop (managed_timing_sequence *sequence, const char *messa return; } - Timing *timing = Host::get_timing (); - if (timing == nullptr) [[unlikely]] { - return; - } - sequence->end = FastTiming::get_time (); Timing::info (sequence, message == nullptr ? DEFAULT_MESSAGE.data () : message); - timing->release_sequence (sequence); + Host::get_timing ().release_sequence (sequence); } diff --git a/src/native/clr/include/host/host.hh b/src/native/clr/include/host/host.hh index 61e96fab88a..c9cd3f38523 100644 --- a/src/native/clr/include/host/host.hh +++ b/src/native/clr/include/host/host.hh @@ -23,7 +23,7 @@ namespace xamarin::android { static void Java_mono_android_Runtime_registerNatives (JNIEnv *env, jclass nativeClass) noexcept; static void propagate_uncaught_exception (JNIEnv *env, jobject javaThread, jthrowable javaException) noexcept; - static auto get_timing () noexcept -> Timing* + static auto get_timing () noexcept -> Timing& { return _timing; } @@ -54,11 +54,10 @@ namespace xamarin::android { private: static inline void *clr_host = nullptr; static inline unsigned int domain_id = 0; - // Points at `_timing_instance` when fast timing is enabled, `nullptr` otherwise. The - // instance is constant-initialized and lives for the whole lifetime of the process, - // so there is nothing to allocate or free. - static inline Timing _timing_instance {}; - static inline Timing *_timing = nullptr; + // Constant-initialized and live for the whole lifetime of the process, so there is + // nothing to allocate or free. Only used when fast timing is enabled, which callers + // check with `FastTiming::enabled ()`. + static inline Timing _timing {}; static inline bool found_assembly_store = false; static inline jnienv_register_jni_natives_fn jnienv_register_jni_natives = nullptr; static inline jnienv_propagate_uncaught_exception_fn jnienv_propagate_uncaught_exception = nullptr; From 0c2468d245fa222320c6f6362917a8f6d83d54ca Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 27 Aug 2026 21:15:14 +0200 Subject: [PATCH 03/14] [native] Allocate timing sequences in chunks of 16 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 --- .../common/include/runtime-base/timing.hh | 83 ++++++++++++------- .../mono/monodroid/internal-pinvokes.cc | 3 + 2 files changed, 54 insertions(+), 32 deletions(-) diff --git a/src/native/common/include/runtime-base/timing.hh b/src/native/common/include/runtime-base/timing.hh index cb68fa4b8cb..240501f9d20 100644 --- a/src/native/common/include/runtime-base/timing.hh +++ b/src/native/common/include/runtime-base/timing.hh @@ -18,9 +18,6 @@ namespace xamarin::android time_point start; time_point end; bool in_use; - - // Valid only while the sequence sits on `Timing::free_sequences`. - managed_timing_sequence *next_free; }; // This class is intended to be used by the managed code. It can be used by the native code as @@ -42,25 +39,16 @@ namespace xamarin::android { pthread_mutex_lock (&sequence_lock); - managed_timing_sequence *ret = free_sequences; - if (ret != nullptr) { - free_sequences = ret->next_free; - } else { - // Sequences are handed out to managed code, which holds on to them until it - // stops the measurement, so they must never move. Each one is therefore its - // own allocation, recycled through `free_sequences` rather than freed, for - // the lifetime of the process. - ret = static_cast (std::malloc (sizeof (managed_timing_sequence))); - if (ret == nullptr) [[unlikely]] { - pthread_mutex_unlock (&sequence_lock); - return nullptr; - } + managed_timing_sequence *ret = find_unused_sequence (); + if (ret == nullptr) { + ret = allocate_chunk (); } - ret->start = time_point::min (); - ret->end = time_point::min (); - ret->in_use = true; - ret->next_free = nullptr; + if (ret != nullptr) [[likely]] { + ret->start = time_point::min (); + ret->end = time_point::min (); + ret->in_use = true; + } pthread_mutex_unlock (&sequence_lock); return ret; @@ -73,21 +61,52 @@ namespace xamarin::android } pthread_mutex_lock (&sequence_lock); + sequence->in_use = false; + pthread_mutex_unlock (&sequence_lock); + } - // Ignore a sequence that isn't checked out, otherwise a double release would put - // it on the free list twice and it would then be handed to two callers at once. - if (sequence->in_use) { - sequence->start = time_point::min (); - sequence->end = time_point::min (); - sequence->in_use = false; - sequence->next_free = free_sequences; - free_sequences = sequence; + private: + // Sequences are handed out to managed code, which holds on to them until it stops the + // measurement, so they must never move. They are allocated in chunks that are chained + // together and never freed, and are recycled through `in_use`, so that every address + // handed out stays valid for the lifetime of the process. + static inline constexpr size_t SEQUENCE_CHUNK_SIZE = 16uz; + + struct sequence_chunk + { + sequence_chunk *next; + managed_timing_sequence sequences[SEQUENCE_CHUNK_SIZE]; + }; + + // Must be called with `sequence_lock` held. + auto find_unused_sequence () noexcept -> managed_timing_sequence* + { + for (sequence_chunk *chunk = sequence_chunks; chunk != nullptr; chunk = chunk->next) { + for (size_t i = 0uz; i < SEQUENCE_CHUNK_SIZE; i++) { + if (!chunk->sequences[i].in_use) { + return &chunk->sequences[i]; + } + } } - pthread_mutex_unlock (&sequence_lock); + return nullptr; + } + + // Must be called with `sequence_lock` held. `calloc` clears `in_use` for every entry in + // the new chunk, so all of them start out available. + auto allocate_chunk () noexcept -> managed_timing_sequence* + { + auto *chunk = static_cast (std::calloc (1uz, sizeof (sequence_chunk))); + if (chunk == nullptr) [[unlikely]] { + return nullptr; + } + + chunk->next = sequence_chunks; + sequence_chunks = chunk; + + return &chunk->sequences[0uz]; } - private: [[gnu::always_inline]] static void do_log (LogLevel level, managed_timing_sequence const *seq, const char *message) { @@ -109,7 +128,7 @@ namespace xamarin::android } private: - managed_timing_sequence *free_sequences = nullptr; - pthread_mutex_t sequence_lock = PTHREAD_MUTEX_INITIALIZER; + sequence_chunk *sequence_chunks = nullptr; + pthread_mutex_t sequence_lock = PTHREAD_MUTEX_INITIALIZER; }; } diff --git a/src/native/mono/monodroid/internal-pinvokes.cc b/src/native/mono/monodroid/internal-pinvokes.cc index e7f580e8e41..0b5ea6a90b0 100644 --- a/src/native/mono/monodroid/internal-pinvokes.cc +++ b/src/native/mono/monodroid/internal-pinvokes.cc @@ -160,6 +160,9 @@ monodroid_timing_start (const char *message) return nullptr; managed_timing_sequence *ret = timing->get_available_sequence (); + if (ret == nullptr) + return nullptr; + if (message != nullptr) { log_write (LOG_TIMING, LogLevel::Info, message); } From 7b28eab422ea5597121f87ad74c00929e4515db4 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 27 Aug 2026 21:20:59 +0200 Subject: [PATCH 04/14] [native] Abort when a timing sequence chunk cannot be allocated 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 --- src/native/clr/host/internal-pinvokes-clr.cc | 4 ---- src/native/common/include/runtime-base/timing.hh | 12 ++++++------ src/native/mono/monodroid/internal-pinvokes.cc | 3 --- 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/native/clr/host/internal-pinvokes-clr.cc b/src/native/clr/host/internal-pinvokes-clr.cc index eaa31de8851..7c978f9ee08 100644 --- a/src/native/clr/host/internal-pinvokes-clr.cc +++ b/src/native/clr/host/internal-pinvokes-clr.cc @@ -46,10 +46,6 @@ managed_timing_sequence* monodroid_timing_start (const char *message) } managed_timing_sequence *ret = Host::get_timing ().get_available_sequence (); - if (ret == nullptr) [[unlikely]] { - return nullptr; - } - if (message != nullptr) { log_write (LOG_TIMING, LogLevel::Info, message); } diff --git a/src/native/common/include/runtime-base/timing.hh b/src/native/common/include/runtime-base/timing.hh index 240501f9d20..ef83d230536 100644 --- a/src/native/common/include/runtime-base/timing.hh +++ b/src/native/common/include/runtime-base/timing.hh @@ -9,6 +9,8 @@ #include +#include + #include "timing-internal.hh" namespace xamarin::android @@ -44,11 +46,9 @@ namespace xamarin::android ret = allocate_chunk (); } - if (ret != nullptr) [[likely]] { - ret->start = time_point::min (); - ret->end = time_point::min (); - ret->in_use = true; - } + ret->start = time_point::min (); + ret->end = time_point::min (); + ret->in_use = true; pthread_mutex_unlock (&sequence_lock); return ret; @@ -98,7 +98,7 @@ namespace xamarin::android { auto *chunk = static_cast (std::calloc (1uz, sizeof (sequence_chunk))); if (chunk == nullptr) [[unlikely]] { - return nullptr; + Helpers::abort_application (LOG_TIMING, "Unable to allocate memory for timing sequences"); } chunk->next = sequence_chunks; diff --git a/src/native/mono/monodroid/internal-pinvokes.cc b/src/native/mono/monodroid/internal-pinvokes.cc index 0b5ea6a90b0..e7f580e8e41 100644 --- a/src/native/mono/monodroid/internal-pinvokes.cc +++ b/src/native/mono/monodroid/internal-pinvokes.cc @@ -160,9 +160,6 @@ monodroid_timing_start (const char *message) return nullptr; managed_timing_sequence *ret = timing->get_available_sequence (); - if (ret == nullptr) - return nullptr; - if (message != nullptr) { log_write (LOG_TIMING, LogLevel::Info, message); } From 2b26b61a8333f134c76b7ba237d09320eee8452e Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 28 Aug 2026 09:08:17 +0200 Subject: [PATCH 05/14] [native] Allocate the timing event chunks with calloc too 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 --- .../include/runtime-base/timing-internal.hh | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/native/common/include/runtime-base/timing-internal.hh b/src/native/common/include/runtime-base/timing-internal.hh index a02f23992df..da308f66b89 100644 --- a/src/native/common/include/runtime-base/timing-internal.hh +++ b/src/native/common/include/runtime-base/timing-internal.hh @@ -27,6 +27,7 @@ using namespace xamarin::android::internal; #include #include #include +#include #include namespace xamarin::android { @@ -98,7 +99,7 @@ namespace xamarin::android { protected: void configure_for_use () noexcept { - first_event_chunk = new TimingEventChunk; + first_event_chunk = allocate_event_chunk (); } public: @@ -113,7 +114,7 @@ namespace xamarin::android { for (TimingEvent &event : chunk->events) { delete event.more_info; } - delete chunk; + std::free (chunk); chunk = next; } } @@ -487,6 +488,20 @@ namespace xamarin::android { } private: + // Event chunks are chained together and the events in them are handed out as references that + // stay valid until the process exits, so a chunk must never move. Allocating them with + // `calloc` avoids `operator new` and, with it, a dependency on `libc++`; zero-filling matches + // the default member initializers of `TimingEvent`. + static auto allocate_event_chunk () noexcept -> TimingEventChunk* + { + auto *chunk = static_cast (std::calloc (1uz, sizeof (TimingEventChunk))); + if (chunk == nullptr) [[unlikely]] { + Helpers::abort_application (LOG_TIMING, "Unable to allocate memory for timing events"); + } + + return chunk; + } + void parse_options (const char *options) noexcept; static void really_initialize (bool log_immediately) noexcept; @@ -504,7 +519,7 @@ namespace xamarin::android { for (size_t i = current_chunk_index; i < chunk_index; ++i) { TimingEventChunk *next = __atomic_load_n (&chunk->next, __ATOMIC_ACQUIRE); if (next == nullptr) [[unlikely]] { - TimingEventChunk *new_chunk = new TimingEventChunk; + TimingEventChunk *new_chunk = allocate_event_chunk (); if (__atomic_compare_exchange_n ( &chunk->next, &next, @@ -521,7 +536,7 @@ namespace xamarin::android { (i + 2uz) * EVENT_CHUNK_SIZE ); } else { - delete new_chunk; + std::free (new_chunk); } } chunk = next; From de17f4165f9d4eedc33f14c44f494bb56847cc74 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 27 Aug 2026 21:55:00 +0200 Subject: [PATCH 06/14] [native] Replace std::stack with a fixed array in FastTiming `FastTiming::open_sequences` was a `thread_local std::stack`, 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 --- .../include/runtime-base/timing-internal.hh | 33 ++++++++++++++----- .../common/runtime-base/timing-internal.cc | 4 +-- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/native/common/include/runtime-base/timing-internal.hh b/src/native/common/include/runtime-base/timing-internal.hh index da308f66b89..c67a210b733 100644 --- a/src/native/common/include/runtime-base/timing-internal.hh +++ b/src/native/common/include/runtime-base/timing-internal.hh @@ -9,7 +9,6 @@ #include #include #include -#include #include #include #include @@ -79,6 +78,11 @@ namespace xamarin::android { // normal application startup. static constexpr size_t EVENT_CHUNK_SIZE = 4096uz; + // Upper bound on how deeply timing events may nest on a single thread. Every + // `start_event` is matched by exactly one `end_event` or `store_more_info`, so the + // depth is determined purely by how deeply the instrumented calls nest (currently 3). + static constexpr size_t MAX_OPEN_SEQUENCES = 16uz; + struct TimingEventChunk { TimingEvent events [EVENT_CHUNK_SIZE]; @@ -283,7 +287,7 @@ namespace xamarin::android { ev.start = get_time (); ev.kind = kind; ev.before_managed = MonodroidState::is_startup_in_progress (); - open_sequences.push (&ev); + push_sequence_event (&ev); } // If `uses_more_info` is `true`, the caller **MUST** call `add_more_info`, since the @@ -406,21 +410,31 @@ namespace xamarin::android { } [[gnu::always_inline]] - auto get_sequence_event () noexcept -> TimingEvent* + static void push_sequence_event (TimingEvent *event) noexcept + { + size_t depth = open_sequence_depth++; + if (depth < MAX_OPEN_SEQUENCES) [[likely]] { + open_sequences [depth] = event; + } + } + + [[gnu::always_inline]] + static auto get_sequence_event () noexcept -> TimingEvent* { - if (open_sequences.empty ()) [[unlikely]] { + size_t depth = open_sequence_depth; + if (depth == 0uz || depth > MAX_OPEN_SEQUENCES) [[unlikely]] { return nullptr; } - return open_sequences.top (); + return open_sequences [depth - 1uz]; } [[gnu::always_inline]] - auto pop_sequence_event () noexcept -> TimingEvent* + static auto pop_sequence_event () noexcept -> TimingEvent* { TimingEvent *event = get_sequence_event (); - if (event != nullptr) [[likely]] { - open_sequences.pop (); + if (open_sequence_depth > 0uz) [[likely]] { + open_sequence_depth--; } return event; @@ -552,7 +566,8 @@ namespace xamarin::android { TimingEventChunk *first_event_chunk = nullptr; std::unique_ptr output_file_name{}; - static inline thread_local std::stack open_sequences; + static inline thread_local TimingEvent *open_sequences [MAX_OPEN_SEQUENCES] {}; + static inline thread_local size_t open_sequence_depth = 0uz; static inline thread_local TimingEventChunk *cached_event_chunk = nullptr; static inline thread_local size_t cached_event_chunk_index = 0uz; static inline bool is_enabled = false; diff --git a/src/native/common/runtime-base/timing-internal.cc b/src/native/common/runtime-base/timing-internal.cc index ee64739753d..f3e9b4fd219 100644 --- a/src/native/common/runtime-base/timing-internal.cc +++ b/src/native/common/runtime-base/timing-internal.cc @@ -21,8 +21,8 @@ void FastTiming::really_initialize (bool log_immediately) noexcept // TLS variables are initialized on first use, do it here so that we can have // the overhead out of mind later, at least for the main thread. - open_sequences.push (0); - open_sequences.pop (); + push_sequence_event (nullptr); + pop_sequence_event (); // Options in `debug.mono.timing` are relevant only when immediate logging is disabled if (immediate_logging) { From d99bd6f48b20152c8969c84c7ed5292a03d0bbcb Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 27 Aug 2026 21:59:45 +0200 Subject: [PATCH 07/14] [native] Use a linked list for the open timing sequences 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 --- .../include/runtime-base/timing-internal.hh | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/src/native/common/include/runtime-base/timing-internal.hh b/src/native/common/include/runtime-base/timing-internal.hh index c67a210b733..5607fe8e870 100644 --- a/src/native/common/include/runtime-base/timing-internal.hh +++ b/src/native/common/include/runtime-base/timing-internal.hh @@ -78,17 +78,20 @@ namespace xamarin::android { // normal application startup. static constexpr size_t EVENT_CHUNK_SIZE = 4096uz; - // Upper bound on how deeply timing events may nest on a single thread. Every - // `start_event` is matched by exactly one `end_event` or `store_more_info`, so the - // depth is determined purely by how deeply the instrumented calls nest (currently 3). - static constexpr size_t MAX_OPEN_SEQUENCES = 16uz; - struct TimingEventChunk { TimingEvent events [EVENT_CHUNK_SIZE]; TimingEventChunk *next = nullptr; }; + // A single entry on the per-thread stack of timing events which have been started but + // not yet ended. + struct OpenSequence + { + TimingEvent *event; + OpenSequence *next; + }; + // defaults static constexpr bool default_fast_timing_enabled = false; static constexpr bool default_log_to_file = false; @@ -412,31 +415,39 @@ namespace xamarin::android { [[gnu::always_inline]] static void push_sequence_event (TimingEvent *event) noexcept { - size_t depth = open_sequence_depth++; - if (depth < MAX_OPEN_SEQUENCES) [[likely]] { - open_sequences [depth] = event; + auto *entry = static_cast (std::malloc (sizeof (OpenSequence))); + if (entry == nullptr) [[unlikely]] { + Helpers::abort_application (LOG_TIMING, "Unable to allocate memory for an open timing sequence"); } + + entry->event = event; + entry->next = open_sequences; + open_sequences = entry; } [[gnu::always_inline]] static auto get_sequence_event () noexcept -> TimingEvent* { - size_t depth = open_sequence_depth; - if (depth == 0uz || depth > MAX_OPEN_SEQUENCES) [[unlikely]] { + OpenSequence *entry = open_sequences; + if (entry == nullptr) [[unlikely]] { return nullptr; } - return open_sequences [depth - 1uz]; + return entry->event; } [[gnu::always_inline]] static auto pop_sequence_event () noexcept -> TimingEvent* { - TimingEvent *event = get_sequence_event (); - if (open_sequence_depth > 0uz) [[likely]] { - open_sequence_depth--; + OpenSequence *entry = open_sequences; + if (entry == nullptr) [[unlikely]] { + return nullptr; } + TimingEvent *event = entry->event; + open_sequences = entry->next; + std::free (entry); + return event; } @@ -566,8 +577,7 @@ namespace xamarin::android { TimingEventChunk *first_event_chunk = nullptr; std::unique_ptr output_file_name{}; - static inline thread_local TimingEvent *open_sequences [MAX_OPEN_SEQUENCES] {}; - static inline thread_local size_t open_sequence_depth = 0uz; + static inline thread_local OpenSequence *open_sequences = nullptr; static inline thread_local TimingEventChunk *cached_event_chunk = nullptr; static inline thread_local size_t cached_event_chunk_index = 0uz; static inline bool is_enabled = false; From c6c4047ba6a839e5e8ac63af00c9eba08906db15 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 28 Aug 2026 09:12:32 +0200 Subject: [PATCH 08/14] [native] Stop storing the timing event details in std::string `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`. 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 --- .../include/runtime-base/timing-internal.hh | 56 ++++++++++++++----- .../common/runtime-base/timing-internal.cc | 13 ++++- 2 files changed, 53 insertions(+), 16 deletions(-) diff --git a/src/native/common/include/runtime-base/timing-internal.hh b/src/native/common/include/runtime-base/timing-internal.hh index 5607fe8e870..803c09099ea 100644 --- a/src/native/common/include/runtime-base/timing-internal.hh +++ b/src/native/common/include/runtime-base/timing-internal.hh @@ -5,11 +5,10 @@ #include #include #include +#include #include #include #include -#include -#include #include #include @@ -64,7 +63,7 @@ namespace xamarin::android { time_point start; time_point end; TimingEventKind kind; - std::string *more_info = nullptr; + char *more_info = nullptr; bool complete = false; }; @@ -103,6 +102,9 @@ namespace xamarin::android { static constexpr std::string_view OPT_FILE_NAME { "filename=" }; static constexpr std::string_view OPT_TO_FILE { "to-file" }; + // Enough to hold any value the `debug.mono.timing` property can carry, `PROP_VALUE_MAX` is 92. + static constexpr size_t MAX_TIMING_FILE_NAME_SIZE = 128uz; + protected: void configure_for_use () noexcept { @@ -119,7 +121,7 @@ namespace xamarin::android { while (chunk != nullptr) { TimingEventChunk *next = chunk->next; for (TimingEvent &event : chunk->events) { - delete event.more_info; + std::free (event.more_info); } std::free (chunk); chunk = next; @@ -214,7 +216,7 @@ namespace xamarin::android { event.before_managed ? "[0/" : "[1/", static_cast(event.kind), event_kind_description (event.kind), - event.more_info == nullptr ? "" : event.more_info->c_str (), + event.more_info == nullptr ? "" : event.more_info, static_cast(chrono::duration_cast(interval).count ()), static_cast(chrono::duration_cast(interval).count ()), static_cast((interval % 1ms).count ()) @@ -275,7 +277,7 @@ namespace xamarin::android { return; } - if (skip_log_if_more_info_missing && (event.more_info == nullptr || event.more_info->empty ())) { + if (skip_log_if_more_info_missing && (event.more_info == nullptr || event.more_info[0] == '\0')) { return; } @@ -316,7 +318,7 @@ namespace xamarin::android { [[gnu::always_inline]] void add_more_info (const char *str, size_t length) noexcept { - store_more_info (new std::string (str, length)); + store_more_info (duplicate_more_info (std::string_view { str, length }, {})); } // Builds the message from two parts, so that its exact length is known up front and the @@ -324,9 +326,7 @@ namespace xamarin::android { [[gnu::always_inline]] void add_more_info (std::string_view const& first, std::string_view const& second) noexcept { - auto *more_info = new std::string (first.data (), first.length ()); - more_info->append (second); - store_more_info (more_info); + store_more_info (duplicate_more_info (first, second)); } [[gnu::always_inline]] @@ -396,13 +396,40 @@ namespace xamarin::android { void dump_to_file (size_t entries) noexcept; void dump (size_t entries, bool indent, std::function line_writer) noexcept; + // Returns a NUL-terminated copy of `first` and `second` concatenated, or `nullptr` if it + // cannot be allocated. Timing is a diagnostic facility, so a failure here only costs us the + // extra information attached to a single event and must not bring the application down. + [[gnu::always_inline]] + static auto duplicate_more_info (std::string_view const& first, std::string_view const& second) noexcept -> char* + { + size_t length = Helpers::add_with_overflow_check (first.length (), second.length ()); + auto *more_info = static_cast (std::malloc (Helpers::add_with_overflow_check (length, 1uz))); + if (more_info == nullptr) [[unlikely]] { + return nullptr; + } + + // `memcpy` must not be called with a `nullptr` source, not even for a zero length, and an + // empty `std::string_view` is allowed to have a `nullptr` data pointer. + if (!first.empty ()) { + std::memcpy (more_info, first.data (), first.length ()); + } + + if (!second.empty ()) { + std::memcpy (more_info + first.length (), second.data (), second.length ()); + } + + more_info[length] = '\0'; + + return more_info; + } + // Takes ownership of `more_info`. [[gnu::always_inline]] - void store_more_info (std::string *more_info) noexcept + void store_more_info (char *more_info) noexcept { TimingEvent *event = pop_sequence_event (); if (event == nullptr) [[unlikely]] { - delete more_info; + std::free (more_info); log_warnf (LOG_TIMING, "FastTiming::add_more_info called without prior FastTiming::start_event called"); return; } @@ -575,7 +602,10 @@ namespace xamarin::android { private: std::atomic_size_t next_event_index = 0uz; TimingEventChunk *first_event_chunk = nullptr; - std::unique_ptr output_file_name{}; + // The name is read from the `debug.mono.timing` system property, whose whole value is limited + // to `PROP_VALUE_MAX` (92) bytes, so a fixed buffer is always large enough. Keeping it inline + // also keeps `FastTiming` constant-initialized, so the global instance needs no guard variable. + char output_file_name[MAX_TIMING_FILE_NAME_SIZE] = {}; static inline thread_local OpenSequence *open_sequences = nullptr; static inline thread_local TimingEventChunk *cached_event_chunk = nullptr; diff --git a/src/native/common/runtime-base/timing-internal.cc b/src/native/common/runtime-base/timing-internal.cc index f3e9b4fd219..6a46bb8bf44 100644 --- a/src/native/common/runtime-base/timing-internal.cc +++ b/src/native/common/runtime-base/timing-internal.cc @@ -54,7 +54,14 @@ void FastTiming::parse_options (const char *options) noexcept if (param_length == OPT_TO_FILE.length () && strncmp (param, OPT_TO_FILE.data (), param_length) == 0) { log_to_file = true; } else if (param_length >= OPT_FILE_NAME.length () && strncmp (param, OPT_FILE_NAME.data (), OPT_FILE_NAME.length ()) == 0) { - output_file_name = std::make_unique (param + OPT_FILE_NAME.length (), param_length - OPT_FILE_NAME.length ()); + const char *name = param + OPT_FILE_NAME.length (); + size_t name_length = param_length - OPT_FILE_NAME.length (); + if (name_length >= sizeof (output_file_name)) [[unlikely]] { + log_warnf (LOG_TIMING, "Timing file name '%.*s' is too long, will use the default one", static_cast(name_length), name); + } else { + memcpy (output_file_name, name, name_length); + output_file_name[name_length] = '\0'; + } } else if (param_length >= OPT_DURATION.length () && strncmp (param, OPT_DURATION.data (), OPT_DURATION.length ()) == 0) { const char *duration = param + OPT_DURATION.length (); char *end; @@ -71,7 +78,7 @@ void FastTiming::parse_options (const char *options) noexcept param = separator == nullptr ? nullptr : separator + 1; } - if (output_file_name) { + if (output_file_name[0] != '\0') { log_to_file = true; } @@ -232,7 +239,7 @@ void FastTiming::dump_to_file (size_t entries) noexcept return; } - std::string_view file_name = output_file_name == nullptr ? default_timing_file_name : *output_file_name; + std::string_view file_name = output_file_name[0] == '\0' ? default_timing_file_name : std::string_view { output_file_name }; char stack_buffer [Util::LocalPathBufferSize]; char *timing_log_path = Util::join_paths (stack_buffer, sizeof (stack_buffer), temporary_directory, file_name); From 8607ea299eb8b5b24750c7cb6a46dda40da7f2ba Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 27 Aug 2026 22:10:45 +0200 Subject: [PATCH 09/14] [native] Replace std::function with function pointers `std::function` is a type-erasing wrapper which needs to store, copy and destroy an arbitrary callable, and it pulls `` 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` 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&` 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 --- src/native/clr/host/assembly-store.cc | 8 ++--- src/native/clr/host/host.cc | 2 +- src/native/clr/include/host/assembly-store.hh | 6 ++-- .../include/runtime-base/timing-internal.hh | 7 +++-- .../common/runtime-base/timing-internal.cc | 31 ++++++++++--------- 5 files changed, 28 insertions(+), 26 deletions(-) diff --git a/src/native/clr/host/assembly-store.cc b/src/native/clr/host/assembly-store.cc index 2586b6c6af9..d0ea1684845 100644 --- a/src/native/clr/host/assembly-store.cc +++ b/src/native/clr/host/assembly-store.cc @@ -791,7 +791,7 @@ auto AssemblyStore::open_assembly (std::string_view const& name, int64_t &size) return assembly_data; } -void AssemblyStore::configure_from_payload (const void *payload_start, const std::function& get_full_store_path) noexcept +void AssemblyStore::configure_from_payload (const void *payload_start, const char *store_path) noexcept { auto header = static_cast(payload_start); @@ -800,7 +800,7 @@ void AssemblyStore::configure_from_payload (const void *payload_start, const std LOG_ASSEMBLY, std::source_location::current (), "Assembly store '%s' is not a valid .NET for Android assembly store file", - get_full_store_path ().c_str () + optional_string (store_path) ); } @@ -809,7 +809,7 @@ void AssemblyStore::configure_from_payload (const void *payload_start, const std LOG_ASSEMBLY, std::source_location::current (), "Assembly store '%s' uses format version %x, instead of the expected %x", - get_full_store_path ().c_str (), + optional_string (store_path), header->version, ASSEMBLY_STORE_FORMAT_VERSION ); @@ -841,5 +841,5 @@ void AssemblyStore::configure_from_payload (const void *payload_start, const std names_cursor += name_length; } - log_debugf (LOG_ASSEMBLY, "Mapped assembly store %s; content ID 0x%" PRIx64, get_full_store_path ().c_str (), assembly_store_content_id); + log_debugf (LOG_ASSEMBLY, "Mapped assembly store %s; content ID 0x%" PRIx64, optional_string (store_path), assembly_store_content_id); } diff --git a/src/native/clr/host/host.cc b/src/native/clr/host/host.cc index 3a241b06820..e0abcbde019 100644 --- a/src/native/clr/host/host.cc +++ b/src/native/clr/host/host.cc @@ -197,7 +197,7 @@ void Host::map_assembly_store_via_dlopen (const char *store_path) noexcept } log_debugf (LOG_ASSEMBLY, "Assembly store payload via dynamic symbol: %p (%s)", payload, optional_string (store_path)); - AssemblyStore::configure_from_payload (payload, [store_path]() -> std::string { return std::string { store_path }; }); + AssemblyStore::configure_from_payload (payload, store_path); found_assembly_store = true; } diff --git a/src/native/clr/include/host/assembly-store.hh b/src/native/clr/include/host/assembly-store.hh index 99b6dfce2f4..fa8f128e2db 100644 --- a/src/native/clr/include/host/assembly-store.hh +++ b/src/native/clr/include/host/assembly-store.hh @@ -1,7 +1,6 @@ #pragma once #include -#include #include #include #include @@ -19,9 +18,8 @@ namespace xamarin::android { // Configure the store directly from an in-memory payload pointer (obtained via // dlopen()+dlsym() of the `_assembly_store` dynamic symbol). The payload is mapped // read-only and is never modified, so it (and every pointer derived from it) is `const`. - // `get_full_store_path` is invoked only to build diagnostics if the payload turns out - // to be invalid. - static void configure_from_payload (const void *payload_start, const std::function& get_full_store_path) noexcept; + // `store_path` is used only in diagnostic messages. + static void configure_from_payload (const void *payload_start, const char *store_path) noexcept; private: static void set_assembly_data_and_size (uint8_t* source_assembly_data, uint32_t source_assembly_data_size, uint8_t*& dest_assembly_data, uint32_t& dest_assembly_data_size) noexcept; diff --git a/src/native/common/include/runtime-base/timing-internal.hh b/src/native/common/include/runtime-base/timing-internal.hh index 803c09099ea..53888bc58fe 100644 --- a/src/native/common/include/runtime-base/timing-internal.hh +++ b/src/native/common/include/runtime-base/timing-internal.hh @@ -7,7 +7,6 @@ #include #include #include -#include #include #include #include @@ -391,10 +390,14 @@ namespace xamarin::android { } private: + // Writes a single output line. `context` is the opaque value passed to `dump`, which + // lets a caller thread state through without needing a capturing lambda. + using LineWriter = void (*) (void *context, std::string_view const& line); + bool no_events_logged (size_t entries) noexcept; void dump_to_logcat (size_t entries) noexcept; void dump_to_file (size_t entries) noexcept; - void dump (size_t entries, bool indent, std::function line_writer) noexcept; + void dump (size_t entries, bool indent, LineWriter line_writer, void *context) noexcept; // Returns a NUL-terminated copy of `first` and `second` concatenated, or `nullptr` if it // cannot be allocated. Timing is a diagnostic facility, so a failure here only costs us the diff --git a/src/native/common/runtime-base/timing-internal.cc b/src/native/common/runtime-base/timing-internal.cc index 6a46bb8bf44..0516d415b2e 100644 --- a/src/native/common/runtime-base/timing-internal.cc +++ b/src/native/common/runtime-base/timing-internal.cc @@ -98,15 +98,15 @@ bool FastTiming::no_events_logged (size_t entries) noexcept return true; } -void FastTiming::dump (size_t entries, bool indent, std::function line_writer) noexcept +void FastTiming::dump (size_t entries, bool indent, LineWriter line_writer, void *context) noexcept { char stack_buffer [Constants::MAX_LOGCAT_MESSAGE_LENGTH]; - line_writer ("Startup costs:"sv); + line_writer (context, "Startup costs:"sv); auto log = [&] (TimingEvent const& event) -> uint64_t { size_t message_length; char *message = build_message (event, stack_buffer, sizeof (stack_buffer), &message_length, indent); - line_writer (std::string_view { message, message_length }); + line_writer (context, std::string_view { message, message_length }); if (message != stack_buffer) { std::free (message); } @@ -115,7 +115,7 @@ void FastTiming::dump (size_t entries, bool indent, std::function(msg.length ()), msg.data ()); }; - dump (entries, true /* indent */, line_writer); + dump (entries, true /* indent */, line_writer, nullptr); } void FastTiming::dump_to_file (size_t entries) noexcept @@ -263,14 +263,15 @@ void FastTiming::dump_to_file (size_t entries) noexcept log_infof (LOG_TIMING, "[2/2] Performance measurement results logged to file: %s", timing_log_path); - auto line_writer = [=](std::string_view const& msg) { + auto line_writer = [](void *context, std::string_view const& msg) { + auto *output = static_cast (context); if (!msg.empty ()) { - fwrite (msg.data (), msg.size (), 1, timing_log); + fwrite (msg.data (), msg.size (), 1, output); } - fwrite (Constants::NEWLINE.data (), Constants::NEWLINE.size (), 1, timing_log); + fwrite (Constants::NEWLINE.data (), Constants::NEWLINE.size (), 1, output); }; - dump (entries, true /* indent */, line_writer); + dump (entries, true /* indent */, line_writer, timing_log); fflush (timing_log); fclose (timing_log); if (timing_log_path != stack_buffer) { From 581711e58cf8d16075dd787ab0e1a28dfdf5c4ff Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 27 Aug 2026 22:49:51 +0200 Subject: [PATCH 10/14] [native] Type the timing line writer context as FILE* 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 --- .../include/runtime-base/timing-internal.hh | 8 +++---- .../common/runtime-base/timing-internal.cc | 23 +++++++++---------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/native/common/include/runtime-base/timing-internal.hh b/src/native/common/include/runtime-base/timing-internal.hh index 53888bc58fe..2d7d40ac71b 100644 --- a/src/native/common/include/runtime-base/timing-internal.hh +++ b/src/native/common/include/runtime-base/timing-internal.hh @@ -390,14 +390,14 @@ namespace xamarin::android { } private: - // Writes a single output line. `context` is the opaque value passed to `dump`, which - // lets a caller thread state through without needing a capturing lambda. - using LineWriter = void (*) (void *context, std::string_view const& line); + // Writes a single output line. `output` is the file passed to `dump`, or `nullptr` when + // the caller doesn't write to a file. + using LineWriter = void (*) (FILE *output, std::string_view const& line); bool no_events_logged (size_t entries) noexcept; void dump_to_logcat (size_t entries) noexcept; void dump_to_file (size_t entries) noexcept; - void dump (size_t entries, bool indent, LineWriter line_writer, void *context) noexcept; + void dump (size_t entries, bool indent, LineWriter line_writer, FILE *output) noexcept; // Returns a NUL-terminated copy of `first` and `second` concatenated, or `nullptr` if it // cannot be allocated. Timing is a diagnostic facility, so a failure here only costs us the diff --git a/src/native/common/runtime-base/timing-internal.cc b/src/native/common/runtime-base/timing-internal.cc index 0516d415b2e..a10881d4266 100644 --- a/src/native/common/runtime-base/timing-internal.cc +++ b/src/native/common/runtime-base/timing-internal.cc @@ -98,15 +98,15 @@ bool FastTiming::no_events_logged (size_t entries) noexcept return true; } -void FastTiming::dump (size_t entries, bool indent, LineWriter line_writer, void *context) noexcept +void FastTiming::dump (size_t entries, bool indent, LineWriter line_writer, FILE *output) noexcept { char stack_buffer [Constants::MAX_LOGCAT_MESSAGE_LENGTH]; - line_writer (context, "Startup costs:"sv); + line_writer (output, "Startup costs:"sv); auto log = [&] (TimingEvent const& event) -> uint64_t { size_t message_length; char *message = build_message (event, stack_buffer, sizeof (stack_buffer), &message_length, indent); - line_writer (context, std::string_view { message, message_length }); + line_writer (output, std::string_view { message, message_length }); if (message != stack_buffer) { std::free (message); } @@ -115,7 +115,7 @@ void FastTiming::dump (size_t entries, bool indent, LineWriter line_writer, void log (start_end_event_time); log (get_time_overhead); log (init_time); - line_writer (context, Constants::EMPTY); + line_writer (output, Constants::EMPTY); // Values are in nanoseconds uint64_t total_assembly_load_time = 0u; @@ -123,7 +123,7 @@ void FastTiming::dump (size_t entries, bool indent, LineWriter line_writer, void uint64_t total_managed_to_java_time = 0u; uint64_t total_assembly_decompression_time = 0u; - line_writer (context, "All logged events:"sv); + line_writer (output, "All logged events:"sv); for (size_t i = 0uz; i < entries; i++) { TimingEvent const& event = get_event (i); if (!__atomic_load_n (&event.complete, __ATOMIC_ACQUIRE)) { @@ -154,10 +154,10 @@ void FastTiming::dump (size_t entries, bool indent, LineWriter line_writer, void } } - line_writer (context, Constants::EMPTY); - line_writer (context, "[2/4] Accumulated performance results"sv); + line_writer (output, Constants::EMPTY); + line_writer (output, "[2/4] Accumulated performance results"sv); - auto log_time = [&line_writer, context] (std::string_view const& msg, uint64_t ns) + auto log_time = [&line_writer, output] (std::string_view const& msg, uint64_t ns) { chrono::nanoseconds time_ns (ns); // Do not change the string format after the first colon, its format is required by performance measuring @@ -193,7 +193,7 @@ void FastTiming::dump (size_t entries, bool indent, LineWriter line_writer, void ); } - line_writer (context, std::string_view { buffer, length }); + line_writer (output, std::string_view { buffer, length }); if (buffer != stack_buffer) { std::free (buffer); } @@ -214,7 +214,7 @@ void FastTiming::dump_to_logcat (size_t entries) noexcept return; } - auto line_writer = [](void *, std::string_view const& msg) { + auto line_writer = [](FILE *, std::string_view const& msg) { // Don't add empty messages to the logcat, waste of time if (msg.empty ()) { return; @@ -263,8 +263,7 @@ void FastTiming::dump_to_file (size_t entries) noexcept log_infof (LOG_TIMING, "[2/2] Performance measurement results logged to file: %s", timing_log_path); - auto line_writer = [](void *context, std::string_view const& msg) { - auto *output = static_cast (context); + auto line_writer = [](FILE *output, std::string_view const& msg) { if (!msg.empty ()) { fwrite (msg.data (), msg.size (), 1, output); } From 1ec2f0e9e0a1eb6413fb935bfc8f5a32bcc147af Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 27 Aug 2026 22:58:12 +0200 Subject: [PATCH 11/14] [native] Use plain functions for the timing line writers 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 --- .../common/runtime-base/timing-internal.cc | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/src/native/common/runtime-base/timing-internal.cc b/src/native/common/runtime-base/timing-internal.cc index a10881d4266..4acefc3395e 100644 --- a/src/native/common/runtime-base/timing-internal.cc +++ b/src/native/common/runtime-base/timing-internal.cc @@ -14,6 +14,27 @@ using namespace std::literals; namespace chrono = std::chrono; +namespace { + void write_line_to_logcat ([[maybe_unused]] FILE *output, std::string_view const& line) noexcept + { + // Don't add empty messages to the logcat, waste of time + if (line.empty ()) { + return; + } + + log_writef (LOG_TIMING, LogLevel::Info, "%.*s", static_cast(line.length ()), line.data ()); + } + + void write_line_to_file (FILE *output, std::string_view const& line) noexcept + { + if (!line.empty ()) { + fwrite (line.data (), line.size (), 1, output); + } + + fwrite (Constants::NEWLINE.data (), Constants::NEWLINE.size (), 1, output); + } +} + void FastTiming::really_initialize (bool log_immediately) noexcept { internal_timing.configure_for_use (); @@ -214,14 +235,7 @@ void FastTiming::dump_to_logcat (size_t entries) noexcept return; } - auto line_writer = [](FILE *, std::string_view const& msg) { - // Don't add empty messages to the logcat, waste of time - if (msg.empty ()) { - return; - } - log_writef (LOG_TIMING, LogLevel::Info, "%.*s", static_cast(msg.length ()), msg.data ()); - }; - dump (entries, true /* indent */, line_writer, nullptr); + dump (entries, true /* indent */, write_line_to_logcat, nullptr); } void FastTiming::dump_to_file (size_t entries) noexcept @@ -263,14 +277,7 @@ void FastTiming::dump_to_file (size_t entries) noexcept log_infof (LOG_TIMING, "[2/2] Performance measurement results logged to file: %s", timing_log_path); - auto line_writer = [](FILE *output, std::string_view const& msg) { - if (!msg.empty ()) { - fwrite (msg.data (), msg.size (), 1, output); - } - fwrite (Constants::NEWLINE.data (), Constants::NEWLINE.size (), 1, output); - }; - - dump (entries, true /* indent */, line_writer, timing_log); + dump (entries, true /* indent */, write_line_to_file, timing_log); fflush (timing_log); fclose (timing_log); if (timing_log_path != stack_buffer) { From 278ac99577dfa584b6786303809717c7be767815 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 28 Aug 2026 08:07:53 +0200 Subject: [PATCH 12/14] [native] Document that store_path may be null 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 --- src/native/clr/include/host/assembly-store.hh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/native/clr/include/host/assembly-store.hh b/src/native/clr/include/host/assembly-store.hh index fa8f128e2db..811a2f4be0a 100644 --- a/src/native/clr/include/host/assembly-store.hh +++ b/src/native/clr/include/host/assembly-store.hh @@ -18,7 +18,8 @@ namespace xamarin::android { // Configure the store directly from an in-memory payload pointer (obtained via // dlopen()+dlsym() of the `_assembly_store` dynamic symbol). The payload is mapped // read-only and is never modified, so it (and every pointer derived from it) is `const`. - // `store_path` is used only in diagnostic messages. + // `store_path` is used only in diagnostic messages and may be `nullptr` - every use of it + // goes through `optional_string ()`. static void configure_from_payload (const void *payload_start, const char *store_path) noexcept; private: From 16250157c88f002eff6998148fd578469e094081 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 27 Aug 2026 23:37:04 +0200 Subject: [PATCH 13/14] [native] Drop from the timing code `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 `` 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 - `` 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 --- .../runtime-base/mainthread-dso-loader.hh | 1 - .../include/runtime-base/timing-internal.hh | 40 +++++++++++++------ .../common/include/runtime-base/timing.hh | 14 +++---- .../common/runtime-base/timing-internal.cc | 13 +++--- src/native/mono/monodroid/monodroid-glue.cc | 8 ++-- 5 files changed, 42 insertions(+), 34 deletions(-) diff --git a/src/native/common/include/runtime-base/mainthread-dso-loader.hh b/src/native/common/include/runtime-base/mainthread-dso-loader.hh index e1c16c347e6..eaa7c12526d 100644 --- a/src/native/common/include/runtime-base/mainthread-dso-loader.hh +++ b/src/native/common/include/runtime-base/mainthread-dso-loader.hh @@ -5,7 +5,6 @@ #include #include -#include #include #include diff --git a/src/native/common/include/runtime-base/timing-internal.hh b/src/native/common/include/runtime-base/timing-internal.hh index 2d7d40ac71b..cc7ad288f13 100644 --- a/src/native/common/include/runtime-base/timing-internal.hh +++ b/src/native/common/include/runtime-base/timing-internal.hh @@ -2,7 +2,6 @@ #include #include -#include #include #include #include @@ -28,9 +27,26 @@ using namespace xamarin::android::internal; #include namespace xamarin::android { - namespace chrono = std::chrono; + inline constexpr uint64_t NANOSECONDS_PER_MILLISECOND = 1000000ull; + inline constexpr uint64_t NANOSECONDS_PER_SECOND = 1000000000ull; - using time_point = chrono::time_point; + // A monotonic point in time, or an interval between two such points, in nanoseconds. + using time_point = uint64_t; + + // Splits an interval into the components used by the timing output format: whole seconds, + // whole milliseconds and the nanoseconds left over within the last millisecond. + 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 } + {} + }; // Events should never change their assigned values and no values should be reused. // Values are used by the test runner to determine what measurement was taken. @@ -198,15 +214,13 @@ namespace xamarin::android { [[gnu::always_inline]] static auto event_duration_ns (TimingEvent const& event) noexcept -> uint64_t { - return static_cast((event.end - event.start).count ()); + return event.end - event.start; } // Returns the message length excluding NUL, or the negative required capacity including NUL. static auto format_message (TimingEvent const& event, char *buffer, size_t buffer_size, bool indent) noexcept -> ssize_t { - using namespace std::literals; - - auto interval = event.end - event.start; // nanoseconds + time_interval interval { event.end - event.start }; int length = snprintf ( buffer, buffer_size, @@ -216,9 +230,9 @@ namespace xamarin::android { static_cast(event.kind), event_kind_description (event.kind), event.more_info == nullptr ? "" : event.more_info, - static_cast(chrono::duration_cast(interval).count ()), - static_cast(chrono::duration_cast(interval).count ()), - static_cast((interval % 1ms).count ()) + interval.seconds, + interval.milliseconds, + interval.nanoseconds ); if (length < 0) { if (buffer != nullptr && buffer_size > 0uz) { @@ -382,11 +396,11 @@ namespace xamarin::android { static auto get_time () noexcept -> time_point { struct timespec t; - if (clock_gettime (CLOCK_MONOTONIC_RAW, &t) != 0) [[unlikely]] { - log_warnf (LOG_TIMING, "clock_gettime failed for CLOCK_MONOTONIC_RAW: %s", optional_string (strerror (errno))); + if (clock_gettime (CLOCK_MONOTONIC, &t) != 0) [[unlikely]] { + log_warnf (LOG_TIMING, "clock_gettime failed for CLOCK_MONOTONIC: %s", optional_string (strerror (errno))); return {}; // Results will be nonsensical, but no point in aborting the app } - return time_point (chrono::seconds (t.tv_sec) + chrono::nanoseconds (t.tv_nsec)); + return (static_cast(t.tv_sec) * NANOSECONDS_PER_SECOND) + static_cast(t.tv_nsec); } private: diff --git a/src/native/common/include/runtime-base/timing.hh b/src/native/common/include/runtime-base/timing.hh index ef83d230536..5019ee09f9a 100644 --- a/src/native/common/include/runtime-base/timing.hh +++ b/src/native/common/include/runtime-base/timing.hh @@ -3,7 +3,6 @@ #include #include -#include #include #include @@ -46,8 +45,8 @@ namespace xamarin::android ret = allocate_chunk (); } - ret->start = time_point::min (); - ret->end = time_point::min (); + ret->start = 0; + ret->end = 0; ret->in_use = true; pthread_mutex_unlock (&sequence_lock); @@ -114,16 +113,15 @@ namespace xamarin::android return; } - using namespace std::literals; - auto interval = seq->end - seq->start; // nanoseconds + time_interval interval { seq->end - seq->start }; log_writef ( LOG_TIMING, level, "%s; elapsed: %llu:%llu::%llu", optional_string (message, ""), - static_cast(std::chrono::duration_cast(interval).count ()), - static_cast(std::chrono::duration_cast(interval).count ()), - static_cast((interval % 1ms).count ()) + interval.seconds, + interval.milliseconds, + interval.nanoseconds ); } diff --git a/src/native/common/runtime-base/timing-internal.cc b/src/native/common/runtime-base/timing-internal.cc index 4acefc3395e..53ed4ccdbad 100644 --- a/src/native/common/runtime-base/timing-internal.cc +++ b/src/native/common/runtime-base/timing-internal.cc @@ -1,4 +1,3 @@ -#include #include #include @@ -12,8 +11,6 @@ namespace xamarin::android { using namespace xamarin::android; using namespace std::literals; -namespace chrono = std::chrono; - namespace { void write_line_to_logcat ([[maybe_unused]] FILE *output, std::string_view const& line) noexcept { @@ -180,19 +177,19 @@ void FastTiming::dump (size_t entries, bool indent, LineWriter line_writer, FILE auto log_time = [&line_writer, output] (std::string_view const& msg, uint64_t ns) { - chrono::nanoseconds time_ns (ns); + time_interval interval { ns }; // Do not change the string format after the first colon, its format is required by performance measuring // utilities. auto format_time = [&] (char *buffer, size_t buffer_size) noexcept -> int { return snprintf ( buffer, buffer_size, - " %.*s: %lld:%lld::%lld", + " %.*s: %llu:%llu::%llu", static_cast(msg.length ()), msg.data (), - static_cast(chrono::duration_cast (time_ns).count ()), - static_cast(chrono::duration_cast (time_ns).count ()), - static_cast((time_ns % 1ms).count ()) + interval.seconds, + interval.milliseconds, + interval.nanoseconds ); }; diff --git a/src/native/mono/monodroid/monodroid-glue.cc b/src/native/mono/monodroid/monodroid-glue.cc index ed29d2be6f9..4a5f9e38c40 100644 --- a/src/native/mono/monodroid/monodroid-glue.cc +++ b/src/native/mono/monodroid/monodroid-glue.cc @@ -116,15 +116,15 @@ MonodroidRuntime::log_jit_event (MonoMethod *method, const char *event_name) noe char* name = mono_method_full_name (method, 1); - auto interval = jit_time_end - jit_time_start; // nanoseconds + time_interval interval { jit_time_end - jit_time_start }; fprintf ( jit_log, "JIT method %6s: %s elapsed: %zus:%zu::%zu\n", event_name, name, - static_cast((chrono::duration_cast(interval).count ())), - static_cast((chrono::duration_cast(interval)).count ()), - static_cast((interval % 1ms).count ()) + static_cast(interval.seconds), + static_cast(interval.milliseconds), + static_cast(interval.nanoseconds) ); free (name); From d350d84a4b5c67e881095da3af08e72a82aea3d7 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 28 Aug 2026 08:08:34 +0200 Subject: [PATCH 14/14] [native] Clarify that time_interval's seconds and milliseconds are totals 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 --- src/native/common/include/runtime-base/timing-internal.hh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/native/common/include/runtime-base/timing-internal.hh b/src/native/common/include/runtime-base/timing-internal.hh index cc7ad288f13..93e409860fb 100644 --- a/src/native/common/include/runtime-base/timing-internal.hh +++ b/src/native/common/include/runtime-base/timing-internal.hh @@ -33,8 +33,12 @@ namespace xamarin::android { // A monotonic point in time, or an interval between two such points, in nanoseconds. using time_point = uint64_t; - // Splits an interval into the components used by the timing output format: whole seconds, - // whole milliseconds and the nanoseconds left over within the last millisecond. + // Splits an interval into the components used by the timing output format. Note that `seconds` + // and `milliseconds` are both totals for the *entire* interval rather than a breakdown of it: + // an interval of 1.5s has `seconds == 1` and `milliseconds == 1500`, and both are printed. This + // is what `duration_cast` and `duration_cast` used to return and it must + // not be "corrected" to milliseconds-within-the-second, because the output format is consumed by + // performance measuring utilities. Only `nanoseconds` is a remainder, within the last millisecond. struct time_interval { unsigned long long seconds;