From 70abcb2030e0f45f46c056f6cb017ff4de14f5a2 Mon Sep 17 00:00:00 2001 From: iwubcode Date: Sat, 1 Mar 2025 21:51:21 -0600 Subject: [PATCH] VideoCommon: add resource manager and new asset loader; the resource manager uses a least recently used cache to determine which assets get priority for loading. Additionally, if the system is low on memory, assets will be purged with the less requested assets being the first to go. The loader is multithreaded now and loads assets as quickly as possible as long as memory is available Co-authored-by: Jordan Woyak --- Source/Core/DolphinLib.props | 4 + .../VideoCommon/Assets/CustomAssetLoader.cpp | 157 ++++++++++++ .../VideoCommon/Assets/CustomAssetLoader.h | 82 +++++++ .../Assets/CustomResourceManager.cpp | 229 ++++++++++++++++++ .../Assets/CustomResourceManager.h | 215 ++++++++++++++++ Source/Core/VideoCommon/CMakeLists.txt | 2 + 6 files changed, 689 insertions(+) create mode 100644 Source/Core/VideoCommon/Assets/CustomAssetLoader.cpp create mode 100644 Source/Core/VideoCommon/Assets/CustomAssetLoader.h create mode 100644 Source/Core/VideoCommon/Assets/CustomResourceManager.cpp create mode 100644 Source/Core/VideoCommon/Assets/CustomResourceManager.h diff --git a/Source/Core/DolphinLib.props b/Source/Core/DolphinLib.props index 737a2a16d6..b08aa2042b 100644 --- a/Source/Core/DolphinLib.props +++ b/Source/Core/DolphinLib.props @@ -669,6 +669,8 @@ + + @@ -1323,6 +1325,8 @@ + + diff --git a/Source/Core/VideoCommon/Assets/CustomAssetLoader.cpp b/Source/Core/VideoCommon/Assets/CustomAssetLoader.cpp new file mode 100644 index 0000000000..2ca227546c --- /dev/null +++ b/Source/Core/VideoCommon/Assets/CustomAssetLoader.cpp @@ -0,0 +1,157 @@ +// Copyright 2025 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "VideoCommon/Assets/CustomAssetLoader.h" + +#include + +#include "Common/Logging/Log.h" +#include "Common/Thread.h" + +#include "UICommon/UICommon.h" + +namespace VideoCommon +{ +void CustomAssetLoader::Initialize() +{ + ResizeWorkerThreads(2); +} + +void CustomAssetLoader::Shutdown() +{ + Reset(false); +} + +bool CustomAssetLoader::StartWorkerThreads(u32 num_worker_threads) +{ + for (u32 i = 0; i < num_worker_threads; i++) + { + m_worker_threads.emplace_back(&CustomAssetLoader::WorkerThreadRun, this, i); + } + + return HasWorkerThreads(); +} + +bool CustomAssetLoader::ResizeWorkerThreads(u32 num_worker_threads) +{ + if (m_worker_threads.size() == num_worker_threads) + return true; + + StopWorkerThreads(); + return StartWorkerThreads(num_worker_threads); +} + +bool CustomAssetLoader::HasWorkerThreads() const +{ + return !m_worker_threads.empty(); +} + +void CustomAssetLoader::StopWorkerThreads() +{ + if (!HasWorkerThreads()) + return; + + // Signal worker threads to stop, and wake all of them. + { + std::lock_guard guard(m_assets_to_load_lock); + m_exit_flag.Set(); + m_worker_thread_wake.notify_all(); + } + + // Wait for worker threads to exit. + for (std::thread& thr : m_worker_threads) + thr.join(); + m_worker_threads.clear(); + m_exit_flag.Clear(); +} + +void CustomAssetLoader::WorkerThreadRun(u32 thread_index) +{ + Common::SetCurrentThreadName(fmt::format("Asset Loader {}", thread_index).c_str()); + + std::unique_lock load_lock(m_assets_to_load_lock); + while (true) + { + m_worker_thread_wake.wait(load_lock, + [&] { return !m_assets_to_load.empty() || m_exit_flag.IsSet(); }); + + if (m_exit_flag.IsSet()) + return; + + // If more memory than allowed has already been loaded, we will load nothing more + // until the next ScheduleAssetsToLoad from Manager. + if (m_change_in_memory > m_allowed_memory) + { + m_assets_to_load.clear(); + continue; + } + + auto* const item = m_assets_to_load.front(); + m_assets_to_load.pop_front(); + + // Make sure another thread isn't loading this handle. + if (!m_handles_in_progress.insert(item->GetHandle()).second) + continue; + + load_lock.unlock(); + + // Unload previously loaded asset. + m_change_in_memory -= item->Unload(); + + const std::size_t bytes_loaded = item->Load(); + m_change_in_memory += s64(bytes_loaded); + + load_lock.lock(); + + { + INFO_LOG_FMT(VIDEO, "CustomAssetLoader thread {} loaded: {} ({})", thread_index, + item->GetAssetId(), UICommon::FormatSize(bytes_loaded)); + + std::lock_guard lk{m_assets_loaded_lock}; + m_asset_handles_loaded.emplace_back(item->GetHandle(), bytes_loaded > 0); + + // Make sure no other threads try to re-process this item. + // Manager will take the handles and re-ScheduleAssetsToLoad based on timestamps if needed. + std::erase(m_assets_to_load, item); + } + + m_handles_in_progress.erase(item->GetHandle()); + } +} + +auto CustomAssetLoader::TakeLoadResults() -> LoadResults +{ + std::lock_guard guard(m_assets_loaded_lock); + return {std::move(m_asset_handles_loaded), m_change_in_memory.exchange(0)}; +} + +void CustomAssetLoader::ScheduleAssetsToLoad(std::list assets_to_load, + u64 allowed_memory) +{ + if (assets_to_load.empty()) [[unlikely]] + return; + + // There's new assets to process, notify worker threads + std::lock_guard guard(m_assets_to_load_lock); + m_allowed_memory = allowed_memory; + m_assets_to_load = std::move(assets_to_load); + m_worker_thread_wake.notify_all(); +} + +void CustomAssetLoader::Reset(bool restart_worker_threads) +{ + const std::size_t worker_thread_count = m_worker_threads.size(); + StopWorkerThreads(); + + m_assets_to_load.clear(); + m_asset_handles_loaded.clear(); + m_allowed_memory = 0; + m_change_in_memory = 0; + + if (restart_worker_threads) + { + StartWorkerThreads(static_cast(worker_thread_count)); + } +} + +} // namespace VideoCommon diff --git a/Source/Core/VideoCommon/Assets/CustomAssetLoader.h b/Source/Core/VideoCommon/Assets/CustomAssetLoader.h new file mode 100644 index 0000000000..8b67da65a9 --- /dev/null +++ b/Source/Core/VideoCommon/Assets/CustomAssetLoader.h @@ -0,0 +1,82 @@ +// Copyright 2025 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "Common/Flag.h" +#include "VideoCommon/Assets/CustomAsset.h" + +namespace VideoCommon +{ +// This class takes any number of assets +// and loads them across a configurable +// thread pool +class CustomAssetLoader +{ +public: + CustomAssetLoader() = default; + ~CustomAssetLoader() = default; + CustomAssetLoader(const CustomAssetLoader&) = delete; + CustomAssetLoader(CustomAssetLoader&&) = delete; + CustomAssetLoader& operator=(const CustomAssetLoader&) = delete; + CustomAssetLoader& operator=(CustomAssetLoader&&) = delete; + + void Initialize(); + void Shutdown(); + + using AssetHandle = std::pair; + struct LoadResults + + { + std::vector asset_handles; + s64 change_in_memory; + }; + + // Returns a vector of loaded asset handle / loaded result pairs + // and the change in memory. + LoadResults TakeLoadResults(); + + // Schedule assets to load on the worker threads + // and set how much memory is available for loading these additional assets. + void ScheduleAssetsToLoad(std::list assets_to_load, u64 allowed_memory); + + void Reset(bool restart_worker_threads = true); + +private: + bool StartWorkerThreads(u32 num_worker_threads); + bool ResizeWorkerThreads(u32 num_worker_threads); + bool HasWorkerThreads() const; + void StopWorkerThreads(); + + void WorkerThreadRun(u32 thread_index); + + Common::Flag m_exit_flag; + + std::vector m_worker_threads; + + std::mutex m_assets_to_load_lock; + std::list m_assets_to_load; + + std::condition_variable m_worker_thread_wake; + + std::vector m_asset_handles_loaded; + + // Memory available to load new assets. + s64 m_allowed_memory = 0; + + // Change in memory from just-loaded/unloaded asset results yet to be taken by the Manager. + std::atomic m_change_in_memory = 0; + + std::mutex m_assets_loaded_lock; + + std::set m_handles_in_progress; +}; +} // namespace VideoCommon diff --git a/Source/Core/VideoCommon/Assets/CustomResourceManager.cpp b/Source/Core/VideoCommon/Assets/CustomResourceManager.cpp new file mode 100644 index 0000000000..f2af4ccbea --- /dev/null +++ b/Source/Core/VideoCommon/Assets/CustomResourceManager.cpp @@ -0,0 +1,229 @@ +// Copyright 2025 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "VideoCommon/Assets/CustomResourceManager.h" + +#include "Common/Logging/Log.h" +#include "Common/MemoryUtil.h" + +#include "UICommon/UICommon.h" + +#include "VideoCommon/Assets/CustomAsset.h" +#include "VideoCommon/Assets/TextureAsset.h" +#include "VideoCommon/VideoEvents.h" + +namespace VideoCommon +{ +void CustomResourceManager::Initialize() +{ + // Use half of available system memory but leave at least 2GiB unused for system stability. + constexpr size_t must_keep_unused = 2 * size_t(1024 * 1024 * 1024); + + const size_t sys_mem = Common::MemPhysical(); + const size_t keep_unused_mem = std::max(sys_mem / 2, std::min(sys_mem, must_keep_unused)); + + m_max_ram_available = sys_mem - keep_unused_mem; + + if (m_max_ram_available == 0) + ERROR_LOG_FMT(VIDEO, "Not enough system memory for custom resources."); + + m_asset_loader.Initialize(); + + m_xfb_event = + AfterFrameEvent::Register([this](Core::System&) { XFBTriggered(); }, "CustomResourceManager"); +} + +void CustomResourceManager::Shutdown() +{ + Reset(); + + m_asset_loader.Shutdown(); +} + +void CustomResourceManager::Reset() +{ + m_asset_loader.Reset(true); + + m_active_assets = {}; + m_pending_assets = {}; + m_asset_handle_to_data.clear(); + m_asset_id_to_handle.clear(); + m_texture_data_asset_cache.clear(); + m_dirty_assets.clear(); + m_ram_used = 0; +} + +void CustomResourceManager::MarkAssetDirty(const CustomAssetLibrary::AssetID& asset_id) +{ + std::lock_guard guard(m_dirty_mutex); + m_dirty_assets.insert(asset_id); +} + +CustomResourceManager::TextureTimePair CustomResourceManager::GetTextureDataFromAsset( + const CustomAssetLibrary::AssetID& asset_id, + std::shared_ptr library) +{ + auto& resource = m_texture_data_asset_cache[asset_id]; + if (resource.asset_data != nullptr && + resource.asset_data->load_status == AssetData::LoadStatus::ResourceDataAvailable) + { + m_active_assets.MakeAssetHighestPriority(resource.asset->GetHandle(), resource.asset); + return {resource.texture_data, resource.asset->GetLastLoadedTime()}; + } + + // If there is an error, don't try and load again until the error is fixed + if (resource.asset_data != nullptr && resource.asset_data->has_load_error) + return {}; + + LoadTextureDataAsset(asset_id, std::move(library), &resource); + m_active_assets.MakeAssetHighestPriority(resource.asset->GetHandle(), resource.asset); + + return {}; +} + +void CustomResourceManager::LoadTextureDataAsset( + const CustomAssetLibrary::AssetID& asset_id, + std::shared_ptr library, InternalTextureDataResource* resource) +{ + if (!resource->asset) + { + resource->asset = + CreateAsset(asset_id, AssetData::AssetType::TextureData, std::move(library)); + resource->asset_data = &m_asset_handle_to_data[resource->asset->GetHandle()]; + } + + auto texture_data = resource->asset->GetData(); + if (!texture_data || resource->asset_data->load_status == AssetData::LoadStatus::PendingReload) + { + // Tell the system we are still interested in loading this asset + const auto asset_handle = resource->asset->GetHandle(); + m_pending_assets.MakeAssetHighestPriority(asset_handle, + m_asset_handle_to_data[asset_handle].asset.get()); + } + else if (resource->asset_data->load_status == AssetData::LoadStatus::LoadFinished) + { + resource->texture_data = std::move(texture_data); + resource->asset_data->load_status = AssetData::LoadStatus::ResourceDataAvailable; + } +} + +void CustomResourceManager::XFBTriggered() +{ + ProcessDirtyAssets(); + ProcessLoadedAssets(); + + if (m_ram_used > m_max_ram_available) + { + RemoveAssetsUntilBelowMemoryLimit(); + } + + if (m_pending_assets.IsEmpty()) + return; + + if (m_ram_used > m_max_ram_available) + return; + + const u64 allowed_memory = m_max_ram_available - m_ram_used; + m_asset_loader.ScheduleAssetsToLoad(m_pending_assets.Elements(), allowed_memory); +} + +void CustomResourceManager::ProcessDirtyAssets() +{ + decltype(m_dirty_assets) dirty_assets; + + if (const auto lk = std::unique_lock{m_dirty_mutex, std::try_to_lock}) + std::swap(dirty_assets, m_dirty_assets); + + const auto now = CustomAsset::ClockType::now(); + for (const auto& asset_id : dirty_assets) + { + if (const auto it = m_asset_id_to_handle.find(asset_id); it != m_asset_id_to_handle.end()) + { + const auto asset_handle = it->second; + AssetData& asset_data = m_asset_handle_to_data[asset_handle]; + asset_data.load_status = AssetData::LoadStatus::PendingReload; + asset_data.load_request_time = now; + + // Asset was reloaded, clear any errors we might have + asset_data.has_load_error = false; + + m_pending_assets.InsertAsset(it->second, asset_data.asset.get()); + + DEBUG_LOG_FMT(VIDEO, "Dirty asset pending reload: {}", asset_data.asset->GetAssetId()); + } + } +} + +void CustomResourceManager::ProcessLoadedAssets() +{ + const auto load_results = m_asset_loader.TakeLoadResults(); + + // Update the ram with the change in memory from the loader + // + // Note: Assets with outstanding reload requests will have + // two copies in memory temporarily (the old data stored in + // the asset shared_ptr that the resource manager owns, and + // the new data loaded from the loader in the asset's shared_ptr) + // This temporary duplication will not be reflected in the + // resource manager's ram used + m_ram_used += load_results.change_in_memory; + + for (const auto& [handle, load_successful] : load_results.asset_handles) + { + AssetData& asset_data = m_asset_handle_to_data[handle]; + + // If we have a reload request that is newer than our loaded time + // we need to wait for another reload. + if (asset_data.load_request_time > asset_data.asset->GetLastLoadedTime()) + continue; + + m_pending_assets.RemoveAsset(handle); + + asset_data.load_request_time = {}; + if (!load_successful) + { + asset_data.has_load_error = true; + } + else + { + m_active_assets.InsertAsset(handle, asset_data.asset.get()); + asset_data.load_status = AssetData::LoadStatus::LoadFinished; + } + } +} + +void CustomResourceManager::RemoveAssetsUntilBelowMemoryLimit() +{ + const u64 threshold_ram = m_max_ram_available * 8 / 10; + + if (m_ram_used > threshold_ram) + { + INFO_LOG_FMT(VIDEO, "Memory usage over threshold: {}", UICommon::FormatSize(m_ram_used)); + } + + // Clear out least recently used resources until + // we get safely in our threshold + while (m_ram_used > threshold_ram && m_active_assets.Size() > 0) + { + auto* const asset = m_active_assets.RemoveLowestPriorityAsset(); + + AssetData& asset_data = m_asset_handle_to_data[asset->GetHandle()]; + + // Remove the resource manager's cached entry with its asset data + if (asset_data.type == AssetData::AssetType::TextureData) + { + m_texture_data_asset_cache.erase(asset->GetAssetId()); + } + // Remove the asset's copy + const std::size_t bytes_unloaded = asset_data.asset->Unload(); + m_ram_used -= bytes_unloaded; + + asset_data.load_status = AssetData::LoadStatus::Unloaded; + asset_data.load_request_time = {}; + + INFO_LOG_FMT(VIDEO, "Unloading asset: {} ({})", asset_data.asset->GetAssetId(), + UICommon::FormatSize(bytes_unloaded)); + } +} + +} // namespace VideoCommon diff --git a/Source/Core/VideoCommon/Assets/CustomResourceManager.h b/Source/Core/VideoCommon/Assets/CustomResourceManager.h new file mode 100644 index 0000000000..6a2b5cbbe3 --- /dev/null +++ b/Source/Core/VideoCommon/Assets/CustomResourceManager.h @@ -0,0 +1,215 @@ +// Copyright 2025 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include +#include + +#include "Common/CommonTypes.h" +#include "Common/HookableEvent.h" + +#include "VideoCommon/Assets/CustomAsset.h" +#include "VideoCommon/Assets/CustomAssetLibrary.h" +#include "VideoCommon/Assets/CustomAssetLoader.h" +#include "VideoCommon/Assets/CustomTextureData.h" + +namespace VideoCommon +{ +class TextureAsset; + +// The resource manager manages custom resources (textures, shaders, meshes) +// called assets. These assets are loaded using a priority system, +// where assets requested more often gets loaded first. This system +// also tracks memory usage and if memory usage goes over a calculated limit, +// then assets will be purged with older assets being targeted first. +class CustomResourceManager +{ +public: + void Initialize(); + void Shutdown(); + + void Reset(); + + // Request that an asset be reloaded + void MarkAssetDirty(const CustomAssetLibrary::AssetID& asset_id); + + void XFBTriggered(); + + using TextureTimePair = std::pair, CustomAsset::TimeType>; + + // Returns a pair with the custom texture data and the time it was last loaded + // Callees are not expected to hold onto the shared_ptr as that will prevent + // the resource manager from being able to properly release data + TextureTimePair GetTextureDataFromAsset(const CustomAssetLibrary::AssetID& asset_id, + std::shared_ptr library); + +private: + // A generic interface to describe an assets' type + // and load state + struct AssetData + { + std::unique_ptr asset; + CustomAsset::TimeType load_request_time = {}; + bool has_load_error = false; + + enum class AssetType + { + TextureData + }; + AssetType type; + + enum class LoadStatus + { + PendingReload, + LoadFinished, + ResourceDataAvailable, + Unloaded, + }; + LoadStatus load_status = LoadStatus::PendingReload; + }; + + // A structure to represent some raw texture data + // (this data hasn't hit the GPU yet, used for custom textures) + struct InternalTextureDataResource + { + AssetData* asset_data = nullptr; + VideoCommon::TextureAsset* asset = nullptr; + std::shared_ptr texture_data; + }; + + void LoadTextureDataAsset(const CustomAssetLibrary::AssetID& asset_id, + std::shared_ptr library, + InternalTextureDataResource* resource); + + void ProcessDirtyAssets(); + void ProcessLoadedAssets(); + void RemoveAssetsUntilBelowMemoryLimit(); + + template + T* CreateAsset(const CustomAssetLibrary::AssetID& asset_id, AssetData::AssetType asset_type, + std::shared_ptr library) + { + const auto [it, added] = + m_asset_id_to_handle.try_emplace(asset_id, m_asset_handle_to_data.size()); + + if (added) + { + AssetData asset_data; + asset_data.asset = std::make_unique(library, asset_id, it->second); + asset_data.type = asset_type; + asset_data.load_request_time = {}; + asset_data.has_load_error = false; + + m_asset_handle_to_data.insert_or_assign(it->second, std::move(asset_data)); + } + auto& asset_data_from_handle = m_asset_handle_to_data[it->second]; + asset_data_from_handle.load_status = AssetData::LoadStatus::PendingReload; + + return static_cast(asset_data_from_handle.asset.get()); + } + + // Maintains a priority-sorted list of assets. + // Used to figure out which assets to load or unload first. + // Most recently used assets get marked with highest priority. + class AssetPriorityQueue + { + public: + const auto& Elements() const { return m_assets; } + + // Inserts or moves the asset to the top of the queue. + void MakeAssetHighestPriority(u64 asset_handle, CustomAsset* asset) + { + RemoveAsset(asset_handle); + m_assets.push_front(asset); + + // See CreateAsset for how a handle gets defined + if (asset_handle >= m_iterator_lookup.size()) + m_iterator_lookup.resize(asset_handle + 1, m_assets.end()); + + m_iterator_lookup[asset_handle] = m_assets.begin(); + } + + // Inserts an asset at lowest priority or + // does nothing if asset is already in the queue. + void InsertAsset(u64 asset_handle, CustomAsset* asset) + { + if (asset_handle >= m_iterator_lookup.size()) + m_iterator_lookup.resize(asset_handle + 1, m_assets.end()); + + if (m_iterator_lookup[asset_handle] == m_assets.end()) + { + m_assets.push_back(asset); + m_iterator_lookup[asset_handle] = std::prev(m_assets.end()); + } + } + + CustomAsset* RemoveLowestPriorityAsset() + { + if (m_assets.empty()) [[unlikely]] + return nullptr; + auto* const ret = m_assets.back(); + if (ret != nullptr) + { + m_iterator_lookup[ret->GetHandle()] = m_assets.end(); + } + m_assets.pop_back(); + return ret; + } + + void RemoveAsset(u64 asset_handle) + { + if (asset_handle >= m_iterator_lookup.size()) + return; + + const auto iter = m_iterator_lookup[asset_handle]; + if (iter != m_assets.end()) + { + m_assets.erase(iter); + m_iterator_lookup[asset_handle] = m_assets.end(); + } + } + + bool IsEmpty() const { return m_assets.empty(); } + + std::size_t Size() const { return m_assets.size(); } + + private: + std::list m_assets; + + // Handle-to-iterator lookup for fast access. + // Grows as needed on insert. + std::vector m_iterator_lookup; + }; + + // Assets that are currently active in memory, in order of most recently used by the game. + AssetPriorityQueue m_active_assets; + + // Assets that need to be loaded. + // e.g. Because the game tried to use them or because they changed on disk. + // Ordered by most recently used. + AssetPriorityQueue m_pending_assets; + + std::map m_asset_handle_to_data; + std::map m_asset_id_to_handle; + + // Memory used by currently "loaded" assets. + u64 m_ram_used = 0; + + // A calculated amount of memory to avoid exceeding. + u64 m_max_ram_available = 0; + + std::map m_texture_data_asset_cache; + + std::mutex m_dirty_mutex; + std::set m_dirty_assets; + + CustomAssetLoader m_asset_loader; + + Common::EventHook m_xfb_event; +}; + +} // namespace VideoCommon diff --git a/Source/Core/VideoCommon/CMakeLists.txt b/Source/Core/VideoCommon/CMakeLists.txt index b712599ebc..0ee9722c42 100644 --- a/Source/Core/VideoCommon/CMakeLists.txt +++ b/Source/Core/VideoCommon/CMakeLists.txt @@ -13,6 +13,8 @@ add_library(videocommon Assets/CustomAssetLibrary.h Assets/CustomAssetLoader.cpp Assets/CustomAssetLoader.h + Assets/CustomResourceManager.cpp + Assets/CustomResourceManager.h Assets/CustomTextureData.cpp Assets/CustomTextureData.h Assets/DirectFilesystemAssetLibrary.cpp