From e14495ac117902d7333ae9b916d5dd2fe481d64e Mon Sep 17 00:00:00 2001 From: Longfang Zhao Date: Wed, 2 Sep 2026 11:26:57 -0700 Subject: [PATCH] Report whether XNNPACK packed weights fell back to heap (#22413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: When the mmap'd packed-weight cache file cannot be used, XNNPACK packs weights into heap and compilation succeeds anyway. Every fallback branch in `XNNWeightsCache` returns `Error::Ok` and at most writes an `ET_LOG`, which does not reach a warehouse. So in production a fallback is indistinguishable from a model that is simply larger — the only symptom is several hundred MiB of extra dirty anonymous memory and no error anywhere. This diff records the outcome inside the cache and exposes it; D118207xxx (the child) consumes it and emits QPL. Split because the two halves have different owners. **Byte accounting, not a status flag.** The first cut of this reported a tri-state "did the file open". That hides the case that matters most: a cache that loads successfully but is only *partial*, so `loaded_from_disk_` routes every subsequent named pack to heap, never persists it, and re-packs it on every launch — permanently, per device, while still reporting `FileBacked`. `reserve_space` has five heap exits and only two were failures. So the cache now counts bytes per reason and reports `heap_bytes` against `mapped_bytes`; the ratio is the signal, and `packed_cache_state` is kept only to separate "feature off" from "perfect cache hit, no new packs". **Counters are atomic and read without the instance mutex.** That mutex is held across the whole of `xnn_create_runtime`, so a telemetry read that waited on it could stall an inference thread for the length of a model compile. The counters are independent accumulators with no invariant spanning them, so relaxed atomics are sufficient and `aggregate_stats()` takes no per-instance lock. Exposed read-only through the existing backend-options channel (`packed_cache_state` / `_errno` / `_heap_mib` / `_mapped_mib` / `_heap_reason` / `_file_mib`) rather than new API surface. **`errno` is deliberately how this answers "was it disk space", instead of sampling free space.** The disk-space APIs are on Apple's Required Reason API list and would force a PrivacyInfo declaration on every iOS consumer of this header — the same reason `load_packed_cache` already uses `lseek` over `fstat`. `ENOSPC` needs no such API. One behaviour change beyond telemetry: the `open(O_RDWR|O_CREAT)` failure branch previously returned `Error::Ok` with no log at all — the branch most likely to fire under disk pressure was the one silent branch. It now logs at ERR like its siblings. Differential Revision: D118206001 --- backends/xnnpack/runtime/XNNPACKBackend.cpp | 12 + backends/xnnpack/runtime/XNNPACKBackend.h | 114 ++++++ backends/xnnpack/runtime/XNNWeightsCache.cpp | 155 ++++++++- backends/xnnpack/runtime/XNNWeightsCache.h | 55 +++ .../runtime/XNNWeightsCacheManager.cpp | 93 +++++ .../xnnpack/runtime/XNNWeightsCacheManager.h | 16 + .../test/runtime/test_weight_cache.cpp | 46 +++ .../test_xnn_weights_cache_manager.cpp | 327 +++++++++++++++++- 8 files changed, 792 insertions(+), 26 deletions(-) diff --git a/backends/xnnpack/runtime/XNNPACKBackend.cpp b/backends/xnnpack/runtime/XNNPACKBackend.cpp index b9b4e82f6ca..a76a0832def 100644 --- a/backends/xnnpack/runtime/XNNPACKBackend.cpp +++ b/backends/xnnpack/runtime/XNNPACKBackend.cpp @@ -255,6 +255,12 @@ class XnnpackBackend final return first_err; } + public: + /** See xnnpack::get_packed_cache_report(). */ + xnnpack::PackedCacheReport packed_cache_report() const { + return options_.weights_cache_manager().report(); + } + private: mutable xnnpack::XnnpackBackendOptions options_; @@ -272,5 +278,11 @@ Backend backend{xnnpack::xnnpack_backend_key, &backend_instance}; static auto success_with_compiler = register_backend(backend); } // namespace +namespace xnnpack { +PackedCacheReport get_packed_cache_report() { + return backend_instance.packed_cache_report(); +} +} // namespace xnnpack + } // namespace backends } // namespace executorch diff --git a/backends/xnnpack/runtime/XNNPACKBackend.h b/backends/xnnpack/runtime/XNNPACKBackend.h index 1053a206360..84429f430ad 100644 --- a/backends/xnnpack/runtime/XNNPACKBackend.h +++ b/backends/xnnpack/runtime/XNNPACKBackend.h @@ -1,5 +1,11 @@ #pragma once +#include +#include +#include +#include +#include + namespace executorch::backends::xnnpack { /// The key for the backend. This is used to register the backend, check /// availability, and get/set options. @@ -31,6 +37,7 @@ const char packed_cache_path_option_key[] = "packed_cache_path"; // @lint-ignore CLANGTIDY facebook-hte-CArray const char save_weight_cache_on_disk_option_key[] = "save_weight_cache_on_disk"; + /// Workspace sharing mode. This is a backend option that can be set via the /// set_option API to control memory sharing between CALL_DELEGATE instances. /// This is useful for reducing memory consumption. @@ -61,4 +68,111 @@ enum class WorkspaceSharingMode { // maximum enum value. Count, }; + +/// Outcome of opening the packed-weight cache file. +enum class PackedCacheState : int32_t { + /// No cache path configured — the caller never opted in. + Disabled = 0, + /// The cache file opened. Does NOT imply zero heap; see PackedCacheStats. + FileBacked = 1, + /// A path was configured but the file could not be used. + HeapFallback = 2, +}; + +/** Why an individual allocation was served from heap. */ +enum class PackedCacheHeapReason : int32_t { + None = 0, + /// The instance has no cache path: it never opted into file backing, so + /// heap is the intended behaviour rather than a fallback. Bucketed + /// separately and excluded from heap_bytes — a process that mixes an + /// opted-in model with a non-opted-in one would otherwise report the + /// latter's packed weights as if the former had fallen back. + NotOptedIn = 1, + /// Unnamed constant — can never be reloaded by name. By design. + UnnamedConstant = 2, + /// Incidental re-pack after a successful load. By design *if* the loaded + /// cache is complete; a large volume here means it was not. + RepackAfterLoad = 3, + /// No usable file descriptor at allocation time. + NoFileBacking = 4, + /// ftruncate() to extend the file failed. + GrowFailed = 5, + /// mmap() of the grown region failed. + MmapFailed = 6, + /// Not a reason; bounds the per-reason counters. Matches the + /// WorkspaceSharingMode convention in this header. + Count, +}; + +/** Which step failed when a configured path still ended up on heap. */ +enum class PackedCacheFailure : int32_t { + None = 0, + OpenFailed = 1, + TruncateFailed = 2, + GrowFailed = 3, + MmapFailed = 4, +}; + +/** + * Per-cache counters. `heap_bytes` against `mapped_bytes` is the signal; + * `state` alone calls a partially-loaded cache healthy. + */ +struct PackedCacheStats { + PackedCacheState state{PackedCacheState::Disabled}; + PackedCacheFailure failure{PackedCacheFailure::None}; + int32_t last_errno{0}; + /// Cache file size as of the last successful save. + int64_t file_bytes{0}; + /// Packed bytes served from heap when the file was supposed to serve them. + /// Excludes NotOptedIn, so this is only ever "bytes that should have been + /// file-backed and were not". + int64_t heap_bytes{0}; + /// Packed bytes served from the mmap'd file (clean, file-backed). + int64_t mapped_bytes{0}; + /// Reason accounting for the largest share of heap_bytes. On the aggregate + /// this is the argmax over per-reason bytes summed across caches, not the + /// local reason of whichever cache happened to allocate the most. + PackedCacheHeapReason heap_reason{PackedCacheHeapReason::None}; + /// Heap bytes split by reason, so callers can sum per reason rather than + /// per cache. Index with PackedCacheHeapReason. Excludes nothing — the + /// NotOptedIn slot is populated here but omitted from `heap_bytes`. + std::array(PackedCacheHeapReason::Count)> + heap_bytes_by_reason{}; +}; + +/** One live cache instance and its own counters. */ +struct PackedCacheEntry { + /// Cache file path. Empty for the shared heap-only instance handed to + /// callers that never configured one. + std::string path; + PackedCacheStats stats; +}; + +/** + * Aggregate plus the per-instance breakdown behind it. + * + * Both come from one pass, so the summary and the detail always describe the + * same instant. The breakdown exists because the aggregate alone cannot be + * attributed: a process running several models folds them into one number, so + * a fallback in one model is indistinguishable from a fallback in another. + * The manager already keys caches by path — this stops discarding that. + * + * Takes no per-instance lock: the counters are atomics, so this never waits + * on a model compile. + */ +struct PackedCacheReport { + /// Summed counters. `failure` / `last_errno` are left unset here; read them + /// from the `dominant_fallback` entry so they stay tied to one cache. + PackedCacheStats aggregate; + /// Sorted by path, so repeated calls agree regardless of map iteration + /// order. + std::vector per_cache; + /// Index into `per_cache` of the cache that best explains a fallback: the + /// largest heap contributor, or if none allocated, the first cache in + /// HeapFallback. -1 when nothing fell back. + int32_t dominant_fallback{-1}; +}; + +PackedCacheReport get_packed_cache_report(); + } // namespace executorch::backends::xnnpack diff --git a/backends/xnnpack/runtime/XNNWeightsCache.cpp b/backends/xnnpack/runtime/XNNWeightsCache.cpp index 34479c1c369..9b2981e172d 100644 --- a/backends/xnnpack/runtime/XNNWeightsCache.cpp +++ b/backends/xnnpack/runtime/XNNWeightsCache.cpp @@ -80,14 +80,28 @@ static T read_le(const uint8_t* src) { // Open the cache file and take an advisory exclusive lock. Returns the // fd, or -1 if open/flock failed (logs the failure). The caller decides // how to recover (typically: skip the mmap path for this init). -static int open_locked(const std::string& path, int flags) { +// out_errno receives the errno of whichever call failed. Reading errno at the +// call site does not work: the flock path closes the fd first, and close() (or +// ET_LOG) can overwrite it. +static int open_locked(const std::string& path, int flags, int* out_errno) { + if (out_errno != nullptr) { + *out_errno = 0; + } int fd = open(path.c_str(), flags, 0600); if (fd < 0) { - ET_LOG(Error, "open(%s) failed (errno=%d)", path.c_str(), errno); + const int err = errno; + if (out_errno != nullptr) { + *out_errno = err; + } + ET_LOG(Error, "open(%s) failed (errno=%d)", path.c_str(), err); return -1; } if (flock(fd, LOCK_EX | LOCK_NB) != 0) { - ET_LOG(Error, "flock(%s) failed (errno=%d)", path.c_str(), errno); + const int err = errno; + if (out_errno != nullptr) { + *out_errno = err; + } + ET_LOG(Error, "flock(%s) failed (errno=%d)", path.c_str(), err); close(fd); return -1; } @@ -128,6 +142,74 @@ void XNNWeightsCache::reset_for_fresh_write() { } #endif +void XNNWeightsCache::record_cache_failure( + PackedCacheFailure failure, + int err) noexcept { + state_.store( + static_cast(PackedCacheState::HeapFallback), + std::memory_order_relaxed); + failure_.store(static_cast(failure), std::memory_order_relaxed); + last_errno_.store(err, std::memory_order_relaxed); +} + +PackedCacheStats XNNWeightsCache::stats() const noexcept { + PackedCacheStats out; + out.state = + static_cast(state_.load(std::memory_order_relaxed)); + out.failure = static_cast( + failure_.load(std::memory_order_relaxed)); + out.last_errno = last_errno_.load(std::memory_order_relaxed); + out.file_bytes = file_bytes_.load(std::memory_order_relaxed); + out.mapped_bytes = mapped_bytes_.load(std::memory_order_relaxed); + int64_t worst = 0; + for (size_t i = 0; i < heap_bytes_by_reason_.size(); ++i) { + const int64_t bytes = + heap_bytes_by_reason_[i].load(std::memory_order_relaxed); + out.heap_bytes_by_reason[i] = bytes; + if (i == static_cast(PackedCacheHeapReason::NotOptedIn)) { + continue; // intended heap use, not a fallback + } + out.heap_bytes += bytes; + if (bytes > worst) { + worst = bytes; + out.heap_reason = static_cast(i); + } + } + return out; +} + +void XNNWeightsCache::record_heap_alloc( + size_t n, + PackedCacheHeapReason reason) noexcept { + // Re-bucket every reason to NotOptedIn when no path was configured. Such an + // instance is the shared heap-only cache handed to callers that never asked + // for file backing; counting its bytes as a fallback would inflate the + // metric for whichever model in the process *did* opt in. + // packed_cache_path_ is set once before the instance is published and never + // mutated, so this read needs no synchronization. + const PackedCacheHeapReason bucket = packed_cache_path_.empty() + ? PackedCacheHeapReason::NotOptedIn + : reason; + heap_bytes_by_reason_[static_cast(bucket)].fetch_add( + static_cast(n), std::memory_order_relaxed); +} + +void XNNWeightsCache::record_mapped_alloc(size_t n) noexcept { + mapped_bytes_.fetch_add( + static_cast(n), std::memory_order_relaxed); +} + +void XNNWeightsCache::mark_cache_file_backed() noexcept { + // Only ever upgrades Disabled -> FileBacked. A fallback already recorded + // describes memory the process is carrying, so a later success must not + // mask it. + int32_t expected = static_cast(PackedCacheState::Disabled); + state_.compare_exchange_strong( + expected, + static_cast(PackedCacheState::FileBacked), + std::memory_order_relaxed); +} + Error XNNWeightsCache::initialize_for_runtime( MemoryAllocator* runtime_allocator, const NamedDataMap* named_data_map) { @@ -147,7 +229,13 @@ Error XNNWeightsCache::initialize_for_runtime( // where fresh-write→save→re-init re-enters load_packed_cache and // double-mmaps the same file. if (!name_to_packed_data_metadata_.empty()) { - packed_file_fd_ = open_locked(packed_cache_path_, O_RDWR); + int open_errno = 0; + packed_file_fd_ = open_locked(packed_cache_path_, O_RDWR, &open_errno); + if (packed_file_fd_ < 0) { + record_cache_failure(PackedCacheFailure::OpenFailed, open_errno); + } else { + mark_cache_file_backed(); + } return Error::Ok; } @@ -160,27 +248,47 @@ Error XNNWeightsCache::initialize_for_runtime( "Loaded packed weight cache: %s (%zu entries)", packed_cache_path_.c_str(), name_to_packed_data_metadata_.size()); - packed_file_fd_ = open_locked(packed_cache_path_, O_RDWR); + int open_errno = 0; + packed_file_fd_ = open_locked(packed_cache_path_, O_RDWR, &open_errno); + // Loaded entries are already mmap'd, so reads stay file-backed even if the + // write fd could not be reopened. Record the errno anyway: without it a + // partial cache silently re-packs to heap every launch with no reason. + if (packed_file_fd_ < 0) { + record_cache_failure(PackedCacheFailure::OpenFailed, open_errno); + } + mark_cache_file_backed(); return Error::Ok; } // Fresh write. Skip O_TRUNC in open_locked so a concurrent holder's // mmap stays valid; truncate explicitly only after we hold the lock. - packed_file_fd_ = open_locked(packed_cache_path_, O_RDWR | O_CREAT); + int create_errno = 0; + packed_file_fd_ = + open_locked(packed_cache_path_, O_RDWR | O_CREAT, &create_errno); if (packed_file_fd_ < 0) { + const int err = create_errno; + ET_LOG( + Error, + "open(O_RDWR|O_CREAT) failed for %s (errno=%d); heap fallback this init", + packed_cache_path_.c_str(), + err); + record_cache_failure(PackedCacheFailure::OpenFailed, err); return Error::Ok; } if (ftruncate(packed_file_fd_, 0) != 0) { + const int err = errno; ET_LOG( Error, "ftruncate(0) failed for %s (errno=%d); heap fallback this init", packed_cache_path_.c_str(), - errno); + err); + record_cache_failure(PackedCacheFailure::TruncateFailed, err); close(packed_file_fd_); packed_file_fd_ = -1; return Error::Ok; } reset_for_fresh_write(); + mark_cache_file_backed(); ET_LOG( Info, "Opened packed weight file for writing: %s", @@ -394,6 +502,11 @@ void* XNNWeightsCache::reserve_space(XNNWeightsCache* context, size_t n) { // instead of re-packing into heap (dirty memory) every time. if (context->last_lookup_unnamed_ || (context->loaded_from_disk_ && !seed_mismatch_repack)) { + context->record_heap_alloc( + n, + context->last_lookup_unnamed_ + ? PackedCacheHeapReason::UnnamedConstant + : PackedCacheHeapReason::RepackAfterLoad); return context->reserve_space_heap(n); } if (context->packed_file_fd_ >= 0) { @@ -403,13 +516,16 @@ void* XNNWeightsCache::reserve_space(XNNWeightsCache* context, size_t n) { size_t map_size = (n + page_size - 1) & ~(page_size - 1); if (ftruncate(context->packed_file_fd_, file_offset + map_size) != 0) { + const int err = errno; ET_LOG( Error, "reserve_space ftruncate to %zu failed (errno=%d)", file_offset + map_size, - errno); + err); + context->record_cache_failure(PackedCacheFailure::GrowFailed, err); close(context->packed_file_fd_); context->packed_file_fd_ = -1; + context->record_heap_alloc(n, PackedCacheHeapReason::GrowFailed); return context->reserve_space_heap(n); } @@ -421,13 +537,16 @@ void* XNNWeightsCache::reserve_space(XNNWeightsCache* context, size_t n) { context->packed_file_fd_, file_offset); if (ptr == MAP_FAILED) { + const int err = errno; ET_LOG( Error, "reserve_space mmap %zu bytes failed (errno=%d)", map_size, - errno); + err); + context->record_cache_failure(PackedCacheFailure::MmapFailed, err); close(context->packed_file_fd_); context->packed_file_fd_ = -1; + context->record_heap_alloc(n, PackedCacheHeapReason::MmapFailed); return context->reserve_space_heap(n); } @@ -439,12 +558,16 @@ void* XNNWeightsCache::reserve_space(XNNWeightsCache* context, size_t n) { kPackedAllocationAlignment); context->packed_file_used_ = file_offset + map_size; + // n, not map_size: the heap side records the raw request too, and the + // heap:mapped ratio is only meaningful if both measure the same thing. + context->record_mapped_alloc(n); context->file_ptr_to_region_index_[ptr] = context->mmap_regions_.size(); context->mmap_regions_.push_back({ptr, map_size}); context->ptr_to_file_offset_[ptr] = file_offset; return ptr; } #endif + context->record_heap_alloc(n, PackedCacheHeapReason::NoFileBacking); return context->reserve_space_heap(n); } @@ -609,6 +732,7 @@ Error XNNWeightsCache::save_packed_index() { // trailer drops the old entry. Monitoring file_bytes over time tells // us when GC or a size cap is needed. const size_t file_bytes = index_start + buf.size(); + file_bytes_.store(static_cast(file_bytes), std::memory_order_relaxed); ET_LOG( Info, "Saved packed weight index: %u entries at offset %zu, file_bytes=%zu", @@ -699,6 +823,9 @@ bool XNNWeightsCache::load_packed_cache() { } mmap_regions_.push_back({map, file_size}); + // Bytes actually referenced by the index. Less than file_size whenever an + // earlier run re-packed a name and orphaned its old bytes. + size_t loaded_bytes = 0; const uint8_t* cursor = static_cast(map) + index_start; const uint8_t* end = static_cast(map) + index_region_end; @@ -766,6 +893,7 @@ bool XNNWeightsCache::load_packed_cache() { meta.in_current_runtime = false; meta.from_load = true; meta.seed = seed; + loaded_bytes += static_cast(data_size); name_to_packed_data_metadata_[name] = meta; } @@ -783,6 +911,15 @@ bool XNNWeightsCache::load_packed_cache() { mmap_regions_at_last_save_ = mmap_regions_.size(); mmap_regions_synced_ = mmap_regions_.size(); loaded_from_disk_ = true; + // Success path only: the truncated-entry branch above munmaps and rolls + // back, so counting at the mmap call would over-report. + // + // loaded_bytes, not file_size. The file is append-only, so a same-name + // re-pack leaves the old bytes behind; file_size counts those orphans and + // would inflate mapped_bytes against heap_bytes. Without this a warm launch + // reports heap=0/mapped=0/file=0, identical to the feature being off. + record_mapped_alloc(loaded_bytes); + file_bytes_.store(static_cast(file_size), std::memory_order_relaxed); return true; #else return false; diff --git a/backends/xnnpack/runtime/XNNWeightsCache.h b/backends/xnnpack/runtime/XNNWeightsCache.h index f584199e307..f1768547677 100644 --- a/backends/xnnpack/runtime/XNNWeightsCache.h +++ b/backends/xnnpack/runtime/XNNWeightsCache.h @@ -10,10 +10,13 @@ #include +#include #include #include #include #include +#include +#include #include #include #include @@ -52,6 +55,13 @@ struct PackedDataMeta { uint32_t seed{0}; }; +// Telemetry types live in XNNPACKBackend.h — hosts read them without pulling +// in xnnpack.h through this header. +using xnnpack::PackedCacheFailure; +using xnnpack::PackedCacheHeapReason; +using xnnpack::PackedCacheState; +using xnnpack::PackedCacheStats; + class XNNWeightsCache { public: XNNWeightsCache(); @@ -162,7 +172,52 @@ class XNNWeightsCache { return instance_mutex_; } + /** + * Outcome of the file-backed path for this instance. HeapFallback is + * sticky: once an init has been served from heap the instance keeps + * reporting it, because that is the memory the process is actually + * carrying for the rest of its life. + */ + PackedCacheStats stats() const noexcept; + private: + /** Record a fallback. Overwrites any previous failure for this instance. */ + void record_cache_failure(PackedCacheFailure failure, int err) noexcept; + /** Note a working file-backed path; never downgrades a recorded fallback. */ + void mark_cache_file_backed() noexcept; + /** Attribute `n` packed bytes to heap under `reason`. */ + void record_heap_alloc(size_t n, PackedCacheHeapReason reason) noexcept; + /** Attribute `n` packed bytes to the mmap'd file. */ + void record_mapped_alloc(size_t n) noexcept; + + // Telemetry counters. Written from the XNNPACK callbacks (which run under + // the caller-held instance mutex) and read by hosts through + // XNNWeightsCacheManager::aggregate_stats() with no lock at all — atomics, + // not the mutex, are what make that read safe. The mutex is held across the + // whole of xnn_create_runtime, so a telemetry read that waited on it could + // stall an inference thread for the length of a model compile. + // + // relaxed ordering throughout: these are independent accumulators, and a + // reader that observes one field slightly ahead of another still gets a + // usable picture. There is no invariant spanning them. + // + // Cumulative for the instance's lifetime — delete_packed_data and + // full_unload do not decrement. Decrementing would need a ptr -> reason map + // kept alive purely for telemetry, and hosts sample right after a load or a + // generate, before anything is released, so the two agree in practice. + // Read them as "bytes this cache ever packed", not current residency. + std::atomic state_{ + static_cast(PackedCacheState::Disabled)}; + std::atomic failure_{ + static_cast(PackedCacheFailure::None)}; + std::atomic last_errno_{0}; + std::atomic file_bytes_{0}; + std::atomic mapped_bytes_{0}; + std::array< + std::atomic, + static_cast(PackedCacheHeapReason::Count)> + heap_bytes_by_reason_{}; + static constexpr uint32_t kCacheMagic = 0x58505743; // "XPWC" // Bump when the on-disk layout (footer or per-entry record) changes. // v2: per-entry seed added — old v1 files don't carry seeds and would diff --git a/backends/xnnpack/runtime/XNNWeightsCacheManager.cpp b/backends/xnnpack/runtime/XNNWeightsCacheManager.cpp index 0f122aa8ab0..59aed221028 100644 --- a/backends/xnnpack/runtime/XNNWeightsCacheManager.cpp +++ b/backends/xnnpack/runtime/XNNWeightsCacheManager.cpp @@ -10,6 +10,7 @@ #include +#include #include #include @@ -77,6 +78,98 @@ Error XNNWeightsCacheManager::save_all() { return first_err; } +xnnpack::PackedCacheReport XNNWeightsCacheManager::report() const { + // Snapshot path + instance under the owning mutexes, then read the counters + // without XNNWeightsCache::mutex(). That mutex is held across the whole of + // xnn_create_runtime, so waiting on it here would let a telemetry read stall + // an inference thread for the length of a model compile. + std::vector>> + live; + { + std::scoped_lock lock(meta_mutex_); + live.reserve(caches_.size()); + for (const auto& entry : caches_) { + if (auto cache = entry.second.lock()) { + live.emplace_back(entry.first, std::move(cache)); + } + } + } + { + std::scoped_lock lock(empty_path_mutex_); + if (auto cache = empty_path_cache_.lock()) { + live.emplace_back(std::string{}, std::move(cache)); + } + } + // caches_ is unordered; sort so the report and the index into it are stable + // across calls. + std::sort(live.begin(), live.end(), [](const auto& a, const auto& b) { + return a.first < b.first; + }); + + xnnpack::PackedCacheReport out; + out.per_cache.reserve(live.size()); + for (const auto& [path, cache] : live) { + out.per_cache.push_back( + xnnpack::PackedCacheEntry{path, cache->stats()}); + } + + int64_t best_heap = -1; + int32_t first_fallback = -1; + for (size_t i = 0; i < out.per_cache.size(); ++i) { + const auto& s = out.per_cache[i].stats; + auto& agg = out.aggregate; + agg.file_bytes += s.file_bytes; + agg.heap_bytes += s.heap_bytes; + agg.mapped_bytes += s.mapped_bytes; + for (size_t r = 0; r < s.heap_bytes_by_reason.size(); ++r) { + agg.heap_bytes_by_reason[r] += s.heap_bytes_by_reason[r]; + } + // A fallback anywhere is the reportable outcome: if any cache on this + // process went to heap, the process is carrying that memory. + if (s.state == delegate::PackedCacheState::HeapFallback) { + agg.state = s.state; + if (first_fallback < 0) { + first_fallback = static_cast(i); + } + } else if ( + s.state == delegate::PackedCacheState::FileBacked && + agg.state == delegate::PackedCacheState::Disabled) { + agg.state = s.state; + } + if (s.heap_bytes > best_heap) { + best_heap = s.heap_bytes; + if (s.heap_bytes > 0) { + out.dominant_fallback = static_cast(i); + } + } + } + // A cache can fail before it ever allocates, so fall back to the first + // cache in HeapFallback rather than reporting no explanation at all. + if (out.dominant_fallback < 0) { + out.dominant_fallback = first_fallback; + } + + // Argmax over per-reason totals summed across caches — not the local reason + // of whichever cache allocated the most, which can disagree with the global + // picture when one cache mixes reasons. + int64_t worst = 0; + for (size_t r = 0; r < out.aggregate.heap_bytes_by_reason.size(); ++r) { + if (r == static_cast(delegate::PackedCacheHeapReason::NotOptedIn)) { + continue; // intended heap use, not a fallback + } + if (out.aggregate.heap_bytes_by_reason[r] > worst) { + worst = out.aggregate.heap_bytes_by_reason[r]; + out.aggregate.heap_reason = + static_cast(r); + } + } + return out; +} + +delegate::PackedCacheStats XNNWeightsCacheManager::aggregate_stats() const { + return report().aggregate; +} + size_t XNNWeightsCacheManager::live_count() const { std::scoped_lock lock(meta_mutex_); size_t count = 0; diff --git a/backends/xnnpack/runtime/XNNWeightsCacheManager.h b/backends/xnnpack/runtime/XNNWeightsCacheManager.h index c35285b6337..39e5c4b711a 100644 --- a/backends/xnnpack/runtime/XNNWeightsCacheManager.h +++ b/backends/xnnpack/runtime/XNNWeightsCacheManager.h @@ -54,6 +54,22 @@ class XNNWeightsCacheManager { * expired weak_ptrs. */ runtime::Error save_all(); + /** + * Worst outcome across live caches, for host telemetry. A HeapFallback + * anywhere wins over a FileBacked elsewhere: if any cache on this process + * went to heap, the process is carrying that memory. `file_bytes` sums + * across live instances. + */ + delegate::PackedCacheStats aggregate_stats() const; + + /** + * Aggregate plus the per-instance breakdown, from a single pass so the two + * always agree. Callers that need to attribute a fallback to a specific + * model use the breakdown; the aggregate answers "is this process carrying + * heap memory at all". + */ + xnnpack::PackedCacheReport report() const; + /** Test-only: count of live (non-expired) entries. */ size_t live_count() const; diff --git a/backends/xnnpack/test/runtime/test_weight_cache.cpp b/backends/xnnpack/test/runtime/test_weight_cache.cpp index d2c079c057a..49156846bef 100644 --- a/backends/xnnpack/test/runtime/test_weight_cache.cpp +++ b/backends/xnnpack/test/runtime/test_weight_cache.cpp @@ -11,6 +11,8 @@ #include #include #include + +#include #include #include #include @@ -18,6 +20,10 @@ using namespace ::testing; +using executorch::backends::xnnpack::get_packed_cache_report; +using executorch::backends::xnnpack::packed_cache_path_option_key; +using executorch::backends::xnnpack::save_weight_cache_on_disk_option_key; +using executorch::backends::xnnpack::PackedCacheHeapReason; using executorch::backends::xnnpack::weight_cache_option_key; using executorch::backends::xnnpack::workspace_sharing_mode_option_key; using executorch::backends::xnnpack::WorkspaceSharingMode; @@ -148,3 +154,43 @@ TEST(RuntimeSpec, OverridesGlobalWeightCache) { get_option(xnnpack_backend_key, read_option); ASSERT_EQ(std::get(read_option.value), true); } + +TEST(PackedCacheStats, GlobalAccessorReachesTheBackendSingleton) { + executorch::runtime::runtime_init(); + + // The wiring under test is get_packed_cache_report() -> the registered + // backend instance -> XnnpackBackendOptions -> XNNWeightsCacheManager. + // Hosts call only this entry point, and nothing else in the suite exercises + // it. Absolute values depend on what else has run in the process, so this + // asserts reachability and invariants rather than specific counts. + const auto report = get_packed_cache_report(); + const auto& stats = report.aggregate; + + EXPECT_GE(stats.heap_bytes, 0); + EXPECT_GE(stats.mapped_bytes, 0); + EXPECT_GE(stats.file_bytes, 0); + EXPECT_LT( + static_cast(stats.heap_reason), + static_cast(PackedCacheHeapReason::Count)); + EXPECT_NE( + stats.heap_reason, PackedCacheHeapReason::NotOptedIn) + << "NotOptedIn is excluded from heap_bytes and must never be reported"; + + // The breakdown must be able to attribute the aggregate: several models + // share a process, and a single folded number cannot say which one fell + // back. + for (const auto& entry : report.per_cache) { + EXPECT_GE(entry.stats.heap_bytes, 0); + EXPECT_GE(entry.stats.mapped_bytes, 0); + } +} + +// NOTE: the warm path — load_packed_cache() succeeding and its mapped bytes +// being counted — is deliberately NOT covered here. Producing a loadable +// cache file needs a model with packed weights and a populated index; the +// models wired into this target (ModuleAddLarge / ModuleSubLarge) are +// elementwise and pack nothing, so a cold run writes a zero-entry trailer +// that load_packed_cache correctly rejects. Covering it needs ModuleLinear +// plus its external .ptd and a NamedDataMap, as test_xnn_data_separation +// does. Until then the warm path is verified on device by the +// PackedWeights log line reporting non-zero mapped/cache_file. diff --git a/backends/xnnpack/test/runtime/test_xnn_weights_cache_manager.cpp b/backends/xnnpack/test/runtime/test_xnn_weights_cache_manager.cpp index 06bc74211ad..a0caa970e14 100644 --- a/backends/xnnpack/test/runtime/test_xnn_weights_cache_manager.cpp +++ b/backends/xnnpack/test/runtime/test_xnn_weights_cache_manager.cpp @@ -15,6 +15,9 @@ #include #include +#include +#include +#include #include #include #include @@ -31,14 +34,35 @@ class XNNWeightsCacheManagerTest : public ::testing::Test { manager_ = std::make_unique(); } + void TearDown() override { + for (const auto& path : temp_paths_) { + std::remove(path.c_str()); + } + } + + // Unique per test and per process. A leftover file from an earlier run + // flips initialize_for_runtime between the load and fresh-create branches, + // and two concurrent runs would race on the same path. + std::string TempPath(const char* tag) { + const auto* info = + ::testing::UnitTest::GetInstance()->current_test_info(); + auto path = std::string(::testing::TempDir()) + "xnnwc_" + + info->name() + "_" + tag + "_" + + std::to_string(static_cast(::getpid())) + ".bin"; + std::remove(path.c_str()); + temp_paths_.push_back(path); + return path; + } + std::unique_ptr manager_; + std::vector temp_paths_; }; // --- Core dedup semantics --- TEST_F(XNNWeightsCacheManagerTest, SamePathReturnsSameInstance) { - auto a = manager_->get_or_create("/tmp/test_cache_same.bin"); - auto b = manager_->get_or_create("/tmp/test_cache_same.bin"); + auto a = manager_->get_or_create(TempPath("same")); + auto b = manager_->get_or_create(TempPath("same")); ASSERT_TRUE(a.ok()); ASSERT_TRUE(b.ok()); EXPECT_EQ(a.get().get(), b.get().get()) @@ -46,8 +70,8 @@ TEST_F(XNNWeightsCacheManagerTest, SamePathReturnsSameInstance) { } TEST_F(XNNWeightsCacheManagerTest, DifferentPathsReturnDifferentInstances) { - auto a = manager_->get_or_create("/tmp/test_cache_a.bin"); - auto b = manager_->get_or_create("/tmp/test_cache_b.bin"); + auto a = manager_->get_or_create(TempPath("a")); + auto b = manager_->get_or_create(TempPath("b")); ASSERT_TRUE(a.ok()); ASSERT_TRUE(b.ok()); EXPECT_NE(a.get().get(), b.get().get()) @@ -85,7 +109,7 @@ TEST_F(XNNWeightsCacheManagerTest, EmptyPathRecreatedAfterAllRefsDrop) { TEST_F(XNNWeightsCacheManagerTest, EmptyPathDoesNotShareWithMmapPath) { auto empty = manager_->get_or_create(""); - auto mmap = manager_->get_or_create("/tmp/test_cache_isolation.bin"); + auto mmap = manager_->get_or_create(TempPath("isolation")); ASSERT_TRUE(empty.ok()); ASSERT_TRUE(mmap.ok()); // Empty-path cache stays separate from any mmap-path cache — @@ -100,7 +124,7 @@ TEST_F(XNNWeightsCacheManagerTest, EmptyPathDoesNotShareWithMmapPath) { TEST_F(XNNWeightsCacheManagerTest, ExpiredEntryDoesNotLeak) { { - auto a = manager_->get_or_create("/tmp/test_cache_expire.bin"); + auto a = manager_->get_or_create(TempPath("expire")); ASSERT_TRUE(a.ok()); EXPECT_EQ(manager_->live_count(), 1u); } @@ -112,13 +136,13 @@ TEST_F(XNNWeightsCacheManagerTest, ExpiredEntryDoesNotLeak) { TEST_F(XNNWeightsCacheManagerTest, ExpiredEntryRecreatedOnNextCall) { void* first_addr = nullptr; { - auto a = manager_->get_or_create("/tmp/test_cache_recreate.bin"); + auto a = manager_->get_or_create(TempPath("recreate")); ASSERT_TRUE(a.ok()); first_addr = a.get().get(); } // Address re-use is allowed but not required; the only guarantee is // that we get a usable instance, not a dangling shared_ptr. - auto b = manager_->get_or_create("/tmp/test_cache_recreate.bin"); + auto b = manager_->get_or_create(TempPath("recreate")); ASSERT_TRUE(b.ok()); ASSERT_NE(b.get(), nullptr); // Live count should be 1 again — the stale entry was erased and @@ -136,15 +160,18 @@ TEST_F(XNNWeightsCacheManagerTest, ConcurrentSamePathSameInstance) { std::vector threads; threads.reserve(kThreads); std::atomic ready{0}; + // Resolve the path up front: TempPath() appends to temp_paths_, which is + // not safe to call from the racing threads. + const std::string race_path = TempPath("race"); for (int i = 0; i < kThreads; ++i) { - threads.emplace_back([this, &results, &ready, i] { + threads.emplace_back([this, &results, &ready, &race_path, i] { // Spin to maximize the chance of true concurrent entry into // get_or_create. ready.fetch_add(1, std::memory_order_acq_rel); while (ready.load(std::memory_order_acquire) < kThreads) { std::this_thread::yield(); } - auto r = manager_->get_or_create("/tmp/test_cache_race.bin"); + auto r = manager_->get_or_create(race_path); ASSERT_TRUE(r.ok()); results[i] = r.get(); }); @@ -169,10 +196,14 @@ TEST_F(XNNWeightsCacheManagerTest, ConcurrentDifferentPathsIndependent) { std::vector> results(kThreads); std::vector threads; threads.reserve(kThreads); + std::vector paths; + paths.reserve(kThreads); for (int i = 0; i < kThreads; ++i) { - threads.emplace_back([this, &results, i] { - std::string path = "/tmp/test_cache_diff_" + std::to_string(i) + ".bin"; - auto r = manager_->get_or_create(path); + paths.push_back(TempPath(("diff_" + std::to_string(i)).c_str())); + } + for (int i = 0; i < kThreads; ++i) { + threads.emplace_back([this, &results, &paths, i] { + auto r = manager_->get_or_create(paths[i]); ASSERT_TRUE(r.ok()); results[i] = r.get(); }); @@ -195,8 +226,8 @@ TEST_F(XNNWeightsCacheManagerTest, SaveAllNoLiveInstancesIsOk) { } TEST_F(XNNWeightsCacheManagerTest, SaveAllWalksLiveCaches) { - auto a = manager_->get_or_create("/tmp/test_cache_save_a.bin"); - auto b = manager_->get_or_create("/tmp/test_cache_save_b.bin"); + auto a = manager_->get_or_create(TempPath("save_a")); + auto b = manager_->get_or_create(TempPath("save_b")); ASSERT_TRUE(a.ok()); ASSERT_TRUE(b.ok()); EXPECT_EQ(manager_->live_count(), 2u); @@ -208,7 +239,7 @@ TEST_F(XNNWeightsCacheManagerTest, SaveAllWalksLiveCaches) { TEST_F(XNNWeightsCacheManagerTest, SaveAllSkipsExpiredEntries) { { - auto a = manager_->get_or_create("/tmp/test_cache_save_expired.bin"); + auto a = manager_->get_or_create(TempPath("save_expired")); ASSERT_TRUE(a.ok()); } // The entry's weak_ptr is now expired. save_all must not crash on @@ -220,7 +251,269 @@ TEST_F(XNNWeightsCacheManagerTest, SaveAllSkipsExpiredEntries) { // --- Path is set on the instance before publishing --- TEST_F(XNNWeightsCacheManagerTest, NonEmptyPathRegistersInMap) { - auto a = manager_->get_or_create("/tmp/test_cache_register.bin"); + auto a = manager_->get_or_create(TempPath("register")); ASSERT_TRUE(a.ok()); EXPECT_EQ(manager_->live_count(), 1u); } + +// --- Packed-cache telemetry (host-visible fallback reporting) --- + +TEST_F(XNNWeightsCacheManagerTest, StatsDisabledWhenNoCacheEverUsed) { + const auto stats = manager_->aggregate_stats(); + EXPECT_EQ( + stats.state, + executorch::backends::xnnpack::delegate::PackedCacheState::Disabled); + EXPECT_EQ(stats.last_errno, 0); + EXPECT_EQ(stats.file_bytes, 0); + EXPECT_EQ(stats.heap_bytes, 0); + EXPECT_EQ(stats.mapped_bytes, 0); +} + +TEST_F(XNNWeightsCacheManagerTest, StatsReportOpenFailureWithErrno) { + // A path whose parent directory does not exist: open(O_RDWR|O_CREAT) fails + // with ENOENT, which is the same branch a full disk takes with ENOSPC. + auto cache = manager_->get_or_create("/nonexistent_dir_xnnwc/cache.bin"); + ASSERT_TRUE(cache.ok()); + { + std::lock_guard lock(cache.get()->mutex()); + ASSERT_EQ( + cache.get()->initialize_for_runtime(nullptr, nullptr), Error::Ok) + << "a fallback must stay non-fatal"; + } + + const auto report = manager_->report(); + EXPECT_EQ( + report.aggregate.state, + executorch::backends::xnnpack::delegate::PackedCacheState::HeapFallback) + << "an unusable path must be reported as a heap fallback, not silently"; + + // failure/errno are deliberately absent from the aggregate: they belong to + // one cache. dominant_fallback names which one, even though this cache + // never allocated (open failed before any pack). + ASSERT_GE(report.dominant_fallback, 0); + const auto& dominant = + report.per_cache[static_cast(report.dominant_fallback)].stats; + EXPECT_EQ( + dominant.failure, + executorch::backends::xnnpack::delegate::PackedCacheFailure::OpenFailed); + EXPECT_NE(dominant.last_errno, 0) + << "errno is what distinguishes ENOSPC from a path problem"; + EXPECT_EQ( + report.aggregate.failure, + executorch::backends::xnnpack::delegate::PackedCacheFailure::None) + << "the aggregate must not adopt one cache's failure"; +} + +TEST_F(XNNWeightsCacheManagerTest, HeapReasonIsGlobalArgmaxNotPerCache) { + // Two caches whose local dominant reasons disagree with the global one. + // Cache A: a failed grow plus a smaller unnamed pack. Cache B: unnamed only, + // larger in total than A's grow contribution. Summing per cache would report + // A's reason; summing per reason reports UnnamedConstant, which is correct. + auto a = manager_->get_or_create(TempPath("argmax_a")); + auto b = manager_->get_or_create(TempPath("argmax_b")); + ASSERT_TRUE(a.ok()); + ASSERT_TRUE(b.ok()); + + const auto unnamed_pack = [](XNNWeightsCache* c, size_t n) { + auto* provider = c->get(); + int dummy = 0; + xnn_weights_cache_look_up_key key{}; + key.kernel = &dummy; + key.bias = nullptr; + provider->look_up(provider->context, &key); + ASSERT_NE(provider->reserve_space(provider->context, n), nullptr); + }; + + { + std::lock_guard lock(a.get()->mutex()); + ASSERT_EQ(a.get()->initialize_for_runtime(nullptr, nullptr), Error::Ok); + unnamed_pack(a.get().get(), 8192); + } + { + std::lock_guard lock(b.get()->mutex()); + ASSERT_EQ(b.get()->initialize_for_runtime(nullptr, nullptr), Error::Ok); + unnamed_pack(b.get().get(), 16384); + unnamed_pack(b.get().get(), 16384); + } + + const auto report = manager_->report(); + EXPECT_EQ( + report.aggregate.heap_reason, + executorch::backends::xnnpack::delegate::PackedCacheHeapReason:: + UnnamedConstant); + const auto unnamed_idx = static_cast( + executorch::backends::xnnpack::delegate::PackedCacheHeapReason:: + UnnamedConstant); + EXPECT_EQ( + report.aggregate.heap_bytes_by_reason[unnamed_idx], + report.aggregate.heap_bytes) + << "per-reason totals must sum to the same heap_bytes"; +} + +TEST_F(XNNWeightsCacheManagerTest, PerCacheIsSortedByPathForStableIndices) { + // dominant_fallback is an index into per_cache, so the order must not + // depend on unordered_map iteration. + auto z = manager_->get_or_create(TempPath("zzz")); + auto a = manager_->get_or_create(TempPath("aaa")); + ASSERT_TRUE(z.ok()); + ASSERT_TRUE(a.ok()); + + const auto report = manager_->report(); + ASSERT_GE(report.per_cache.size(), 2u); + for (size_t i = 1; i < report.per_cache.size(); ++i) { + EXPECT_LE(report.per_cache[i - 1].path, report.per_cache[i].path); + } +} + +TEST_F(XNNWeightsCacheManagerTest, HeapFallbackWinsOverFileBackedInAggregate) { + auto bad = manager_->get_or_create("/nonexistent_dir_xnnwc/cache.bin"); + auto good = manager_->get_or_create(TempPath("stats_ok")); + ASSERT_TRUE(bad.ok()); + ASSERT_TRUE(good.ok()); + { + std::lock_guard lock(good.get()->mutex()); + ASSERT_EQ(good.get()->initialize_for_runtime(nullptr, nullptr), Error::Ok); + } + { + std::lock_guard lock(bad.get()->mutex()); + ASSERT_EQ(bad.get()->initialize_for_runtime(nullptr, nullptr), Error::Ok); + } + + EXPECT_EQ( + manager_->aggregate_stats().state, + executorch::backends::xnnpack::delegate::PackedCacheState::HeapFallback) + << "if any live cache fell back, the process is carrying that memory"; +} + +// A binary "did the file open" flag is not enough: a cache can load +// successfully and still serve most of its packed bytes from heap. These +// cover the byte accounting that distinguishes the two. + +TEST_F(XNNWeightsCacheManagerTest, MappedBytesCountedWhenFileBacked) { + auto cache = manager_->get_or_create(TempPath("bytes_mapped")); + ASSERT_TRUE(cache.ok()); + { + std::lock_guard lock(cache.get()->mutex()); + ASSERT_EQ(cache.get()->initialize_for_runtime(nullptr, nullptr), Error::Ok); + auto* provider = cache.get()->get(); + ASSERT_NE(provider->reserve_space(provider->context, 4096), nullptr); + } + + const auto stats = manager_->aggregate_stats(); + EXPECT_GT(stats.mapped_bytes, 0); + EXPECT_EQ(stats.heap_bytes, 0) << "the healthy case must report zero heap"; +} + +TEST_F(XNNWeightsCacheManagerTest, HeapBytesAttributedToUnnamedConstant) { + auto cache = manager_->get_or_create(TempPath("bytes_unnamed")); + ASSERT_TRUE(cache.ok()); + { + std::lock_guard lock(cache.get()->mutex()); + ASSERT_EQ(cache.get()->initialize_for_runtime(nullptr, nullptr), Error::Ok); + auto* provider = cache.get()->get(); + // A look_up whose kernel pointer was never named marks the next + // reserve_space as an unnamed constant, which routes to heap. + int dummy = 0; + xnn_weights_cache_look_up_key key{}; + key.kernel = &dummy; + key.bias = nullptr; + provider->look_up(provider->context, &key); + ASSERT_NE(provider->reserve_space(provider->context, 4096), nullptr); + } + + const auto stats = manager_->aggregate_stats(); + EXPECT_GT(stats.heap_bytes, 0) << "heap bytes must be counted, not hidden"; + EXPECT_EQ( + stats.heap_reason, + executorch::backends::xnnpack::delegate::PackedCacheHeapReason:: + UnnamedConstant); +} + +TEST_F(XNNWeightsCacheManagerTest, FileBackedStateDoesNotImplyZeroHeap) { + // The case a state flag alone reports as healthy: the file opened fine, so + // state is FileBacked, yet packed bytes still went to heap. Only the byte + // split makes that visible. + auto cache = manager_->get_or_create(TempPath("bytes_split")); + ASSERT_TRUE(cache.ok()); + { + std::lock_guard lock(cache.get()->mutex()); + ASSERT_EQ(cache.get()->initialize_for_runtime(nullptr, nullptr), Error::Ok); + auto* provider = cache.get()->get(); + int dummy = 0; + xnn_weights_cache_look_up_key key{}; + key.kernel = &dummy; + key.bias = nullptr; + provider->look_up(provider->context, &key); + ASSERT_NE(provider->reserve_space(provider->context, 4096), nullptr); + } + + const auto stats = manager_->aggregate_stats(); + EXPECT_EQ( + stats.state, + executorch::backends::xnnpack::delegate::PackedCacheState::FileBacked) + << "the file opened, so state alone looks healthy"; + EXPECT_GT(stats.heap_bytes, 0) + << "but heap bytes are non-zero — this is what state alone hides"; +} + +TEST_F(XNNWeightsCacheManagerTest, AggregateStatsTakesNoInstanceLock) { + // aggregate_stats() must not wait on XNNWeightsCache::mutex(): that mutex is + // held across all of xnn_create_runtime, so a telemetry read that blocked on + // it would stall inference for the length of a model compile. + auto cache = manager_->get_or_create(TempPath("nolock")); + ASSERT_TRUE(cache.ok()); + std::lock_guard held(cache.get()->mutex()); + const auto stats = manager_->aggregate_stats(); // must not deadlock + EXPECT_EQ(stats.heap_bytes, 0); +} + +TEST_F(XNNWeightsCacheManagerTest, EmptyPathHeapIsNotCountedAsFallback) { + // The shared heap-only instance handed to callers that never configured a + // path. Its heap use is intended, so it must not inflate heap_bytes for a + // model in the same process that did opt in. + auto opted_out = manager_->get_or_create(""); + ASSERT_TRUE(opted_out.ok()); + { + std::lock_guard lock(opted_out.get()->mutex()); + ASSERT_EQ( + opted_out.get()->initialize_for_runtime(nullptr, nullptr), Error::Ok); + auto* provider = opted_out.get()->get(); + int dummy = 0; + xnn_weights_cache_look_up_key key{}; + key.kernel = &dummy; + key.bias = nullptr; + provider->look_up(provider->context, &key); + ASSERT_NE(provider->reserve_space(provider->context, 4096), nullptr); + } + + const auto stats = manager_->aggregate_stats(); + EXPECT_EQ(stats.heap_bytes, 0) + << "a cache that never opted into file backing is not a fallback"; +} + +TEST_F(XNNWeightsCacheManagerTest, OptedInHeapStillCountedAlongsideOptedOut) { + // Both kinds live at once: only the opted-in instance's heap bytes count. + auto opted_out = manager_->get_or_create(""); + auto opted_in = manager_->get_or_create(TempPath("mixed")); + ASSERT_TRUE(opted_out.ok()); + ASSERT_TRUE(opted_in.ok()); + for (auto* cache : {opted_out.get().get(), opted_in.get().get()}) { + std::lock_guard lock(cache->mutex()); + ASSERT_EQ(cache->initialize_for_runtime(nullptr, nullptr), Error::Ok); + auto* provider = cache->get(); + int dummy = 0; + xnn_weights_cache_look_up_key key{}; + key.kernel = &dummy; + key.bias = nullptr; + provider->look_up(provider->context, &key); + ASSERT_NE(provider->reserve_space(provider->context, 8192), nullptr); + } + + const auto stats = manager_->aggregate_stats(); + EXPECT_GT(stats.heap_bytes, 0) << "the opted-in instance's heap must count"; + EXPECT_EQ( + stats.heap_reason, + executorch::backends::xnnpack::delegate::PackedCacheHeapReason:: + UnnamedConstant) + << "and NotOptedIn must never win the argmax"; +}