diff --git a/CMakeLists.txt b/CMakeLists.txt index 5669fa8ae31..8ae05f3afe3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -840,6 +840,9 @@ configure_file( install(FILES ${CMAKE_CURRENT_BINARY_DIR}/executorch-backend-dependencies.cmake DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/ExecuTorch ) +if(EXECUTORCH_BUILD_VULKAN OR EXECUTORCH_BUILD_VGF) + add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/backends/vulkan_shared) +endif() if(EXECUTORCH_BUILD_ARM_BAREMETAL OR EXECUTORCH_BUILD_ARM_ETHOSU_LINUX diff --git a/backends/vulkan_shared/BUCK b/backends/vulkan_shared/BUCK new file mode 100644 index 00000000000..18ea84d0bd7 --- /dev/null +++ b/backends/vulkan_shared/BUCK @@ -0,0 +1,11 @@ +load( + "@fbcode_macros//build_defs:build_file_migration.bzl", + "fbcode_target", + "non_fbcode_target", +) +load(":targets.bzl", "define_common_targets") + +oncall("executorch") + +non_fbcode_target(_kind = define_common_targets,) +fbcode_target(_kind = define_common_targets,) diff --git a/backends/vulkan_shared/CMakeLists.txt b/backends/vulkan_shared/CMakeLists.txt new file mode 100644 index 00000000000..c384bd625e8 --- /dev/null +++ b/backends/vulkan_shared/CMakeLists.txt @@ -0,0 +1,97 @@ +# Copyright 2026 Arm Limited and/or its affiliates. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +cmake_minimum_required(VERSION 3.19) + +if(NOT EXECUTORCH_ROOT) + set(EXECUTORCH_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/../..) +endif() + +include(GNUInstallDirs) +find_package(Threads REQUIRED) + +set(VULKAN_SHARED_HEADERS_PATH + ${EXECUTORCH_ROOT}/backends/vulkan/third-party/Vulkan-Headers +) + +if(NOT EXISTS "${VULKAN_SHARED_HEADERS_PATH}/include/vulkan/vulkan.h") + message( + FATAL_ERROR + "The shared Vulkan runtime requires the vendored Vulkan-Headers submodule. " + "Run from the repository root:\n" + " git submodule update --init " + "backends/vulkan/third-party/Vulkan-Headers" + ) +endif() + +# SHARED on purpose: VGF and Vulkan may be separate delegate DSOs, but they must +# observe one process-wide registry. A static copy in each DSO would create +# independent registries and defeat context sharing. +add_library( + executorch_vulkan_shared_runtime SHARED + ${CMAKE_CURRENT_SOURCE_DIR}/runtime/SharedVulkanContext.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/runtime/SharedVulkanContextRegistry.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/runtime/SharedVulkanRuntimeConfig.cpp +) +add_library( + executorch::vulkan_shared_runtime ALIAS executorch_vulkan_shared_runtime +) + +target_compile_definitions( + executorch_vulkan_shared_runtime PRIVATE EXECUTORCH_VULKAN_SHARED_BUILDING +) + +target_include_directories( + executorch_vulkan_shared_runtime + PUBLIC $ + $ + $ + $ +) + +target_link_libraries( + executorch_vulkan_shared_runtime + PUBLIC executorch_core + PRIVATE Threads::Threads +) + +set_target_properties( + executorch_vulkan_shared_runtime + PROPERTIES CXX_STANDARD 17 + CXX_STANDARD_REQUIRED YES + CXX_VISIBILITY_PRESET hidden + VISIBILITY_INLINES_HIDDEN YES +) + +install( + TARGETS executorch_vulkan_shared_runtime + EXPORT ExecuTorchTargets + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} + INCLUDES + DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} +) + +install( + DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/runtime/ + DESTINATION + ${CMAKE_INSTALL_INCLUDEDIR}/executorch/backends/vulkan_shared/runtime + FILES_MATCHING + PATTERN "*.h" + PATTERN "test" EXCLUDE +) + +# SharedVulkanContext.h is a public installed header and includes +# . Install the same vendored headers used by the build so an +# installed ExecuTorch package does not depend on an unrelated system Vulkan +# SDK. +install(DIRECTORY ${VULKAN_SHARED_HEADERS_PATH}/include/ + DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} +) + +if(EXECUTORCH_BUILD_TESTS) + add_subdirectory(runtime/test) +endif() diff --git a/backends/vulkan_shared/README.md b/backends/vulkan_shared/README.md new file mode 100644 index 00000000000..1708e26007d --- /dev/null +++ b/backends/vulkan_shared/README.md @@ -0,0 +1,64 @@ +# Shared Vulkan runtime + +This component is not a standalone ExecuTorch backend. It does not +partition graphs, register a backend, or execute delegated operators. + +This component is the backend-neutral runtime bridge used by the VGF and +ExecuTorch Vulkan delegates. It deliberately does not introduce a unified +partitioner or a wrapper delegate. + +The standard AOT flow remains explicit partitioner composition, with VGF +claiming supported regions first and Vulkan filling the remaining regions: + +```python +lowered = to_edge_transform_and_lower( + exported, + partitioner=[ + VgfPartitioner(vgf_compile_spec), + VulkanPartitioner(vulkan_compile_spec), + ], +) +``` + +## Runtime options + +Context selection is configured at model load time through `RuntimeSpec` / +`BackendOptions`, not through serialized `CompileSpec` values: + +| Key | Type | Default | Accepted values | +| --- | --- | --- | --- | +| `vulkan_shared_context_name` | string | `default` | Any non-empty context_name | +| `vulkan_shared_context_mode` | string | `lookup_or_create` | `disabled`, `lookup_only`, `lookup_or_create`, `create_only` | +| `vulkan_shared_group_id` | int | `0` | Any `int` value | + +Both delegates must receive the same option values to resolve the same registry +key. + +## Ownership and validation + +`SharedVulkanContext` carries Vulkan handles but never calls Vulkan entry points +itself. Every registered context must provide a non-null `lifetime_anchor` whose +lifetime guarantees that the Vulkan instance, physical device, device, and queue +remain valid until the final `SharedVulkanContextPtr` is released. For a +backend-created context, the anchor can own the backend runtime and perform +teardown through that backend's Vulkan dispatch mechanism. For externally +created Vulkan objects, the application must provide an anchor whose ownership +keeps those objects alive for the same period. + +`unregister_context()` removes the context from the registry and prevents new +lookups; it does not revoke `SharedVulkanContextPtr` instances already held by +delegates. Actual Vulkan teardown is therefore safe only after the final +outstanding context reference releases its `lifetime_anchor`. The registry +itself is intentionally process-lifetime; call `unregister_context()` to remove +registry discoverability before deterministic teardown. + +The `VkQueue` is shared process state and Vulkan queue operations require +external host synchronization. Consumers must issue queue operations through +`SharedVulkanContext::with_locked_queue()` so independently initialized delegates +serialize access using the mutex stored in the shared context rather than +backend-local locks. + +The registrant also declares the device extensions enabled at `VkDevice` +creation. A consuming backend must check its required extensions with +`has_device_extension()` before using the context; Vulkan does not expose a +post-creation query for the list of extensions that were enabled. diff --git a/backends/vulkan_shared/runtime/SharedVulkanContext.cpp b/backends/vulkan_shared/runtime/SharedVulkanContext.cpp new file mode 100644 index 00000000000..e854128ec0b --- /dev/null +++ b/backends/vulkan_shared/runtime/SharedVulkanContext.cpp @@ -0,0 +1,64 @@ +/* + * Copyright 2026 Arm Limited and/or its affiliates. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include + +#include +#include + +namespace executorch { +namespace backends { +namespace vulkan_shared { + +SharedVulkanContext::SharedVulkanContext( + SharedVulkanContextCreateInfo create_info) + : create_info_(std::move(create_info)) {} + +SharedVulkanContext::~SharedVulkanContext() = default; + +const SharedVulkanContextKey& SharedVulkanContext::key() const { + return create_info_.key; +} + +VkInstance SharedVulkanContext::instance() const { + return create_info_.instance; +} + +VkPhysicalDevice SharedVulkanContext::physical_device() const { + return create_info_.physical_device; +} + +VkDevice SharedVulkanContext::device() const { + return create_info_.device; +} + +uint32_t SharedVulkanContext::queue_family_index() const { + return create_info_.queue_family_index; +} + +bool SharedVulkanContext::has_device_extension( + std::string_view extension_name) const { + return std::any_of( + create_info_.enabled_device_extensions.begin(), + create_info_.enabled_device_extensions.end(), + [extension_name](const std::string& enabled_extension) { + return enabled_extension == extension_name; + }); +} + +bool SharedVulkanContext::is_valid() const { + return create_info_.key.valid() && create_info_.instance != VK_NULL_HANDLE && + create_info_.physical_device != VK_NULL_HANDLE && + create_info_.device != VK_NULL_HANDLE && + create_info_.queue != VK_NULL_HANDLE && + create_info_.queue_family_index != std::numeric_limits::max() && + create_info_.lifetime_anchor != nullptr; +} + +} // namespace vulkan_shared +} // namespace backends +} // namespace executorch diff --git a/backends/vulkan_shared/runtime/SharedVulkanContext.h b/backends/vulkan_shared/runtime/SharedVulkanContext.h new file mode 100644 index 00000000000..b4bba8d3913 --- /dev/null +++ b/backends/vulkan_shared/runtime/SharedVulkanContext.h @@ -0,0 +1,100 @@ +/* + * Copyright 2026 Arm Limited and/or its affiliates. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace executorch { +namespace backends { +namespace vulkan_shared { + +struct SharedVulkanContextKey final { + std::string context_name; + int group_id = 0; + + bool valid() const { + return !context_name.empty(); + } + + friend bool operator==( + const SharedVulkanContextKey& lhs, + const SharedVulkanContextKey& rhs) { + return lhs.group_id == rhs.group_id && lhs.context_name == rhs.context_name; + } + + friend bool operator!=( + const SharedVulkanContextKey& lhs, + const SharedVulkanContextKey& rhs) { + return !(lhs == rhs); + } +}; + +// The shared layer carries Vulkan handles but deliberately does not call Vulkan +// entry points itself. Every registered context must provide a lifetime_anchor +// whose lifetime guarantees that instance, physical_device, device, and queue +// remain valid until the final SharedVulkanContext reference is released. The +// anchor destructor may perform backend/application Vulkan teardown. +struct SharedVulkanContextCreateInfo final { + SharedVulkanContextKey key; + VkInstance instance = VK_NULL_HANDLE; + VkPhysicalDevice physical_device = VK_NULL_HANDLE; + VkDevice device = VK_NULL_HANDLE; + VkQueue queue = VK_NULL_HANDLE; + uint32_t queue_family_index = std::numeric_limits::max(); + std::vector enabled_device_extensions; + std::shared_ptr lifetime_anchor; +}; + +class EXECUTORCH_VULKAN_SHARED_API SharedVulkanContext final { + public: + explicit SharedVulkanContext(SharedVulkanContextCreateInfo create_info); + ~SharedVulkanContext(); + + SharedVulkanContext(const SharedVulkanContext&) = delete; + SharedVulkanContext& operator=(const SharedVulkanContext&) = delete; + + const SharedVulkanContextKey& key() const; + VkInstance instance() const; + VkPhysicalDevice physical_device() const; + VkDevice device() const; + + // Vulkan queue operations require external host synchronization. All + // delegates sharing this context must issue queue operations through this + // callback so they synchronize on the same mutex. The VkQueue must not be + // retained and used after the callback returns. + template + decltype(auto) with_locked_queue(Fn&& fn) const { + std::lock_guard lock(queue_mutex_); + return std::forward(fn)(create_info_.queue); + } + + uint32_t queue_family_index() const; + bool has_device_extension(std::string_view extension_name) const; + bool is_valid() const; + + private: + SharedVulkanContextCreateInfo create_info_; + mutable std::mutex queue_mutex_; +}; + +using SharedVulkanContextPtr = std::shared_ptr; + +} // namespace vulkan_shared +} // namespace backends +} // namespace executorch diff --git a/backends/vulkan_shared/runtime/SharedVulkanContextRegistry.cpp b/backends/vulkan_shared/runtime/SharedVulkanContextRegistry.cpp new file mode 100644 index 00000000000..d3771cfc82d --- /dev/null +++ b/backends/vulkan_shared/runtime/SharedVulkanContextRegistry.cpp @@ -0,0 +1,215 @@ +/* + * Copyright 2026 Arm Limited and/or its affiliates. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include + +#include + +namespace executorch { +namespace backends { +namespace vulkan_shared { + +SharedVulkanContextRegistry& SharedVulkanContextRegistry::Get() { + // The default context is process-persistent. Intentionally do not register a + // static destructor: delegate DSOs may be unloaded before their lifetime + // anchors, so teardown must be explicit through unregister_context(). + static auto* registry = new SharedVulkanContextRegistry(); + return *registry; +} + +size_t SharedVulkanContextRegistry::KeyHash::operator()( + const SharedVulkanContextKey& key) const { + const size_t token_hash = std::hash{}(key.context_name); + const size_t group_hash = std::hash{}(key.group_id); + return token_hash ^ + (group_hash + static_cast(0x9e3779b9) + (token_hash << 6) + + (token_hash >> 2)); +} + +SharedVulkanContextPtr SharedVulkanContextRegistry::lookup( + const SharedVulkanContextKey& key) { + if (!key.valid()) { + return nullptr; + } + + SharedVulkanContextPtr stale_context; + { + std::lock_guard lock(mutex_); + auto it = registry_.find(key); + if (it == registry_.end()) { + return nullptr; + } + + const auto& entry = it->second; + if (entry->context && entry->context->is_valid()) { + return entry->context; + } + + // Move any stale context out while holding the registry lock, but release + // it only after unlocking: its lifetime_anchor may run backend teardown and + // re-enter this registry. + stale_context = std::move(entry->context); + if (!entry->creating) { + registry_.erase(it); + } + } + + stale_context.reset(); + return nullptr; +} + +runtime::Result +SharedVulkanContextRegistry::lookup_or_create( + const SharedVulkanContextKey& key, + CreateFn create_fn) { + if (!key.valid() || !create_fn) { + return runtime::Error::InvalidArgument; + } + + std::shared_ptr entry; + SharedVulkanContextPtr stale_context; + + { + std::unique_lock lock(mutex_); + auto [it, inserted] = registry_.try_emplace(key, std::make_shared()); + (void)inserted; + entry = it->second; + + while (entry->creating) { + entry->creation_complete.wait(lock); + } + + if (entry->context && entry->context->is_valid()) { + return entry->context; + } + + stale_context = std::move(entry->context); + entry->creating = true; + } + + // lifetime_anchor destruction may re-enter the registry. + stale_context.reset(); + + auto maybe_created = create_fn(); + runtime::Error create_error = runtime::Error::Ok; + SharedVulkanContextPtr created; + if (!maybe_created.ok()) { + create_error = maybe_created.error(); + } else { + created = maybe_created.get(); + if (!created || !created->is_valid() || created->key() != key) { + created.reset(); + create_error = runtime::Error::InvalidArgument; + } + } + + SharedVulkanContextPtr selected; + { + std::lock_guard lock(mutex_); + + // register_context() is allowed to win a race with a creator. In that + // case, discard the newly created context and return the registered one. + if (entry->context && entry->context->is_valid()) { + selected = entry->context; + } else if (created) { + entry->context = std::move(created); + selected = entry->context; + } + + entry->creating = false; + entry->creation_complete.notify_all(); + } + + if (selected) { + return selected; + } + return create_error == runtime::Error::Ok ? runtime::Error::Internal + : create_error; +} + +runtime::Error SharedVulkanContextRegistry::register_context( + SharedVulkanContextPtr context) { + if (!context || !context->is_valid()) { + return runtime::Error::InvalidArgument; + } + + std::lock_guard lock(mutex_); + auto [it, inserted] = + registry_.try_emplace(context->key(), std::make_shared()); + (void)inserted; + auto& entry = it->second; + + if (entry->context && entry->context->is_valid()) { + return entry->context.get() == context.get() + ? runtime::Error::Ok + : runtime::Error::AlreadyLoaded; + } + + entry->context = std::move(context); + entry->creation_complete.notify_all(); + return runtime::Error::Ok; +} + +runtime::Result +SharedVulkanContextRegistry::register_external_context( + SharedVulkanContextCreateInfo create_info) { + auto context = std::make_shared(std::move(create_info)); + const runtime::Error error = register_context(context); + if (error != runtime::Error::Ok) { + return error; + } + return context; +} + +runtime::Error SharedVulkanContextRegistry::unregister_context( + const SharedVulkanContextKey& key) { + if (!key.valid()) { + return runtime::Error::InvalidArgument; + } + + std::shared_ptr removed_entry; + { + std::lock_guard lock(mutex_); + auto it = registry_.find(key); + if (it == registry_.end()) { + return runtime::Error::NotFound; + } + if (it->second->creating) { + return runtime::Error::InvalidState; + } + + // Unlink the entry under the registry lock, but retain ownership locally so + // SharedVulkanContext/lifetime_anchor destruction cannot run while mutex_ + // is held. Backend teardown is allowed to re-enter this registry. + removed_entry = std::move(it->second); + registry_.erase(it); + } + + removed_entry.reset(); + return runtime::Error::Ok; +} + +void SharedVulkanContextRegistry::clear_for_testing() { + decltype(registry_) removed_entries; + { + std::lock_guard lock(mutex_); + for (const auto& item : registry_) { + item.second->creating = false; + item.second->creation_complete.notify_all(); + } + + // Remove everything atomically from the live registry, then allow entries + // (and their lifetime anchors) to be destroyed after mutex_ is released. + removed_entries.swap(registry_); + } + + removed_entries.clear(); +} + +} // namespace vulkan_shared +} // namespace backends +} // namespace executorch diff --git a/backends/vulkan_shared/runtime/SharedVulkanContextRegistry.h b/backends/vulkan_shared/runtime/SharedVulkanContextRegistry.h new file mode 100644 index 00000000000..b41cb660ce2 --- /dev/null +++ b/backends/vulkan_shared/runtime/SharedVulkanContextRegistry.h @@ -0,0 +1,77 @@ +/* + * Copyright 2026 Arm Limited and/or its affiliates. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace executorch { +namespace backends { +namespace vulkan_shared { + +// Process-local registry used by independently initialized GPU delegates. The +// canonical target is a shared library so all delegate DSOs observe the same +// registry instance. +class EXECUTORCH_VULKAN_SHARED_API SharedVulkanContextRegistry final { + public: + using CreateFn = std::function()>; + + static SharedVulkanContextRegistry& Get(); + + SharedVulkanContextRegistry(const SharedVulkanContextRegistry&) = delete; + SharedVulkanContextRegistry& operator=(const SharedVulkanContextRegistry&) = + delete; + + SharedVulkanContextPtr lookup(const SharedVulkanContextKey& key); + + runtime::Result lookup_or_create( + const SharedVulkanContextKey& key, + CreateFn create_fn); + + runtime::Error register_context(SharedVulkanContextPtr context); + + runtime::Result register_external_context( + SharedVulkanContextCreateInfo create_info); + + runtime::Error unregister_context(const SharedVulkanContextKey& key); + + // Test-only. The caller must ensure that there are no concurrent + // registry operations or in-flight lookup_or_create() calls. + void clear_for_testing(); + + private: + struct Entry final { + SharedVulkanContextPtr context; + bool creating = false; + std::condition_variable creation_complete; + }; + + struct KeyHash final { + size_t operator()(const SharedVulkanContextKey& key) const; + }; + + SharedVulkanContextRegistry() = default; + + // Never release a SharedVulkanContext/lifetime_anchor while this mutex is + // held: backend teardown may re-enter the registry. + std::mutex mutex_; + std::unordered_map, KeyHash> + registry_; +}; + +} // namespace vulkan_shared +} // namespace backends +} // namespace executorch diff --git a/backends/vulkan_shared/runtime/SharedVulkanRuntimeConfig.cpp b/backends/vulkan_shared/runtime/SharedVulkanRuntimeConfig.cpp new file mode 100644 index 00000000000..4fc75641fff --- /dev/null +++ b/backends/vulkan_shared/runtime/SharedVulkanRuntimeConfig.cpp @@ -0,0 +1,80 @@ +/* + * Copyright 2026 Arm Limited and/or its affiliates. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include + +#include + +namespace executorch { +namespace backends { +namespace vulkan_shared { +namespace { + +runtime::Result parse_context_mode(const char* value) { + if (value == nullptr) { + return runtime::Error::InvalidArgument; + } + if (std::strcmp(value, "disabled") == 0) { + return SharedContextMode::kDisabled; + } + if (std::strcmp(value, "lookup_only") == 0) { + return SharedContextMode::kLookupOnly; + } + if (std::strcmp(value, "lookup_or_create") == 0) { + return SharedContextMode::kLookupOrCreate; + } + if (std::strcmp(value, "create_only") == 0) { + return SharedContextMode::kCreateOnly; + } + return runtime::Error::InvalidArgument; +} + +} // namespace + +// This API is consumed by the Vulkan/VGF delegate integration follow-up PRs. +// The phase-2 runtime library intentionally has no production caller yet. +// cppcheck-suppress unusedFunction +runtime::Result parse_shared_vulkan_runtime_config( + const runtime::BackendInitContext& context) { + SharedVulkanRuntimeConfig config; + + auto context_name = + context.get_runtime_spec(kSharedContextNameOption); + if (context_name.ok()) { + config.context_name = context_name.get(); + } else if (context_name.error() != runtime::Error::NotFound) { + return context_name.error(); + } + + auto mode = context.get_runtime_spec(kSharedContextModeOption); + if (mode.ok()) { + auto parsed_mode = parse_context_mode(mode.get()); + if (!parsed_mode.ok()) { + return parsed_mode.error(); + } + config.context_mode = parsed_mode.get(); + } else if (mode.error() != runtime::Error::NotFound) { + return mode.error(); + } + + auto group_id = context.get_runtime_spec(kSharedGroupIdOption); + if (group_id.ok()) { + config.group_id = group_id.get(); + } else if (group_id.error() != runtime::Error::NotFound) { + return group_id.error(); + } + + if (config.enabled() && config.context_name.empty()) { + return runtime::Error::InvalidArgument; + } + + return config; +} + +} // namespace vulkan_shared +} // namespace backends +} // namespace executorch diff --git a/backends/vulkan_shared/runtime/SharedVulkanRuntimeConfig.h b/backends/vulkan_shared/runtime/SharedVulkanRuntimeConfig.h new file mode 100644 index 00000000000..bae9f051c24 --- /dev/null +++ b/backends/vulkan_shared/runtime/SharedVulkanRuntimeConfig.h @@ -0,0 +1,62 @@ +/* + * Copyright 2026 Arm Limited and/or its affiliates. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include +#include +#include + +#include +#include + +namespace executorch { +namespace backends { +namespace vulkan_shared { + +inline constexpr char kSharedContextNameOption[] = "vulkan_shared_context_name"; +inline constexpr char kSharedContextModeOption[] = "vulkan_shared_context_mode"; +inline constexpr char kSharedGroupIdOption[] = "vulkan_shared_group_id"; + +enum class SharedContextMode : uint8_t { + kDisabled = 0, + kLookupOnly = 1, + kLookupOrCreate = 2, + kCreateOnly = 3, +}; + +// Load-time configuration shared by the VGF and Vulkan delegates. These values +// are RuntimeSpec options: context selection is a deployment concern and must +// not be serialized into a backend CompileSpec or a .pte file. +struct SharedVulkanRuntimeConfig final { + std::string context_name = "default"; + int group_id = 0; + SharedContextMode context_mode = SharedContextMode::kLookupOrCreate; + + bool enabled() const { + return context_mode != SharedContextMode::kDisabled; + } + + bool lookup_only() const { + return context_mode == SharedContextMode::kLookupOnly; + } + + bool lookup_or_create() const { + return context_mode == SharedContextMode::kLookupOrCreate; + } + + bool create_only() const { + return context_mode == SharedContextMode::kCreateOnly; + } +}; + +EXECUTORCH_VULKAN_SHARED_API runtime::Result +parse_shared_vulkan_runtime_config(const runtime::BackendInitContext& context); + +} // namespace vulkan_shared +} // namespace backends +} // namespace executorch diff --git a/backends/vulkan_shared/runtime/export.h b/backends/vulkan_shared/runtime/export.h new file mode 100644 index 00000000000..f780d5ba1c0 --- /dev/null +++ b/backends/vulkan_shared/runtime/export.h @@ -0,0 +1,21 @@ +/* + * Copyright 2026 Arm Limited and/or its affiliates. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +// The shared Vulkan registry must have one definition across all delegate +// shared objects in a process. Export its public API from +// executorch_vulkan_shared_runtime. +#if defined(_WIN32) +#if defined(EXECUTORCH_VULKAN_SHARED_BUILDING) +#define EXECUTORCH_VULKAN_SHARED_API __declspec(dllexport) +#else +#define EXECUTORCH_VULKAN_SHARED_API __declspec(dllimport) +#endif +#else +#define EXECUTORCH_VULKAN_SHARED_API __attribute__((visibility("default"))) +#endif diff --git a/backends/vulkan_shared/runtime/test/CMakeLists.txt b/backends/vulkan_shared/runtime/test/CMakeLists.txt new file mode 100644 index 00000000000..a4904ad477a --- /dev/null +++ b/backends/vulkan_shared/runtime/test/CMakeLists.txt @@ -0,0 +1,23 @@ +# Copyright 2026 Arm Limited and/or its affiliates. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +add_executable( + shared_vulkan_runtime_test + ${CMAKE_CURRENT_SOURCE_DIR}/SharedVulkanContextRegistryTest.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/SharedVulkanRuntimeConfigTest.cpp +) + +target_link_libraries( + shared_vulkan_runtime_test PRIVATE executorch_vulkan_shared_runtime + Threads::Threads +) + +if(TARGET GTest::gtest_main) + target_link_libraries(shared_vulkan_runtime_test PRIVATE GTest::gtest_main) +else() + target_link_libraries(shared_vulkan_runtime_test PRIVATE gtest gtest_main) +endif() + +add_test(NAME shared_vulkan_runtime_test COMMAND shared_vulkan_runtime_test) diff --git a/backends/vulkan_shared/runtime/test/SharedVulkanContextRegistryTest.cpp b/backends/vulkan_shared/runtime/test/SharedVulkanContextRegistryTest.cpp new file mode 100644 index 00000000000..59a1b61c478 --- /dev/null +++ b/backends/vulkan_shared/runtime/test/SharedVulkanContextRegistryTest.cpp @@ -0,0 +1,422 @@ +/* + * Copyright 2026 Arm Limited and/or its affiliates. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include + +#include +// Cppcheck's lint environment may not expand the gtest macros. +#ifndef TEST +#define TEST(test_suite_name, test_name) void test_suite_name##_##test_name() +#endif +#ifndef TEST_F +#define TEST_F(test_fixture, test_name) void test_fixture##_##test_name() +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using executorch::backends::vulkan_shared::SharedVulkanContext; +using executorch::backends::vulkan_shared::SharedVulkanContextCreateInfo; +using executorch::backends::vulkan_shared::SharedVulkanContextKey; +using executorch::backends::vulkan_shared::SharedVulkanContextPtr; +using executorch::backends::vulkan_shared::SharedVulkanContextRegistry; +using executorch::runtime::Error; +using executorch::runtime::Result; + +namespace { + +template +Handle fake_handle(uintptr_t value) { + return reinterpret_cast(value); +} + +SharedVulkanContextCreateInfo make_create_info( + SharedVulkanContextKey key, + uintptr_t handle_base = 1) { + SharedVulkanContextCreateInfo info; + info.key = std::move(key); + info.instance = fake_handle(handle_base); + info.physical_device = fake_handle(handle_base + 1); + info.device = fake_handle(handle_base + 2); + info.queue = fake_handle(handle_base + 3); + info.queue_family_index = 4; + // Tests use a dummy owner by default now that every valid context requires a + // lifetime anchor. Tests that exercise ownership replace this anchor. + info.lifetime_anchor = std::make_shared(0); + return info; +} + +struct RegistryLookupProbeState final { + std::mutex mutex; + std::condition_variable cv; + bool complete = false; +}; + +class RegistryLookupLifetimeAnchor final { + public: + RegistryLookupLifetimeAnchor( + SharedVulkanContextKey lookup_key, + std::shared_ptr state, + std::atomic* lookup_completed_during_destruction) + : lookup_key_(std::move(lookup_key)), + state_(std::move(state)), + lookup_completed_during_destruction_( + lookup_completed_during_destruction) {} + + ~RegistryLookupLifetimeAnchor() { + // Probe the registry from another thread. If this destructor runs while the + // registry mutex is held, lookup() cannot complete until destruction + // returns and unregister_context() releases the mutex. The timeout prevents + // the regression test itself from deadlocking on the buggy implementation. + std::thread([lookup_key = lookup_key_, state = state_]() { + (void)SharedVulkanContextRegistry::Get().lookup(lookup_key); + { + std::lock_guard lock(state->mutex); + state->complete = true; + } + state->cv.notify_all(); + }).detach(); + + std::unique_lock lock(state_->mutex); + const bool completed = + state_->cv.wait_for(lock, std::chrono::seconds(1), [state = state_]() { + return state->complete; + }); + lookup_completed_during_destruction_->store(completed); + } + + private: + SharedVulkanContextKey lookup_key_; + std::shared_ptr state_; + std::atomic* lookup_completed_during_destruction_; +}; + +class SharedVulkanContextRegistryTest : public ::testing::Test { + protected: + void SetUp() override { + SharedVulkanContextRegistry::Get().clear_for_testing(); + } + + void TearDown() override { + SharedVulkanContextRegistry::Get().clear_for_testing(); + } +}; + +// cppcheck-suppress unusedFunction +TEST_F(SharedVulkanContextRegistryTest, RegistryOwnsPersistentContext) { + const SharedVulkanContextKey key{"scene0", 3}; + std::weak_ptr owner_weak; + + { + auto owner = std::make_shared(17); + owner_weak = owner; + auto info = make_create_info(key); + info.lifetime_anchor = owner; + + auto registered = + SharedVulkanContextRegistry::Get().register_external_context( + std::move(info)); + ASSERT_TRUE(registered.ok()); + } + + EXPECT_FALSE(owner_weak.expired()); + EXPECT_NE(SharedVulkanContextRegistry::Get().lookup(key), nullptr); + EXPECT_EQ( + SharedVulkanContextRegistry::Get().unregister_context(key), Error::Ok); + EXPECT_TRUE(owner_weak.expired()); +} + +// cppcheck-suppress unusedFunction +TEST_F( + SharedVulkanContextRegistryTest, + UnregisterDestroysLifetimeAnchorOutsideRegistryLock) { + const SharedVulkanContextKey key{"scene0", 9}; + const SharedVulkanContextKey probe_key{"probe", 9}; + std::atomic lookup_completed_during_destruction{false}; + auto probe_state = std::make_shared(); + + { + auto info = make_create_info(key); + info.lifetime_anchor = std::make_shared( + probe_key, probe_state, &lookup_completed_during_destruction); + + auto registered = + SharedVulkanContextRegistry::Get().register_external_context( + std::move(info)); + ASSERT_TRUE(registered.ok()); + } + + // The registry is now the only owner of the SharedVulkanContext. + // Unregistering therefore destroys its lifetime anchor. The probe must be + // able to acquire the registry mutex before that destruction returns. + EXPECT_EQ( + SharedVulkanContextRegistry::Get().unregister_context(key), Error::Ok); + EXPECT_TRUE(lookup_completed_during_destruction.load()); + + // On a broken implementation the probe only completes after unregister has + // released the mutex. Wait for it here so the detached thread cannot escape + // the test and race fixture teardown. + std::unique_lock lock(probe_state->mutex); + EXPECT_TRUE( + probe_state->cv.wait_for(lock, std::chrono::seconds(1), [probe_state]() { + return probe_state->complete; + })); +} + +// cppcheck-suppress unusedFunction +TEST_F(SharedVulkanContextRegistryTest, LookupOrCreateRunsCreatorOnce) { + const SharedVulkanContextKey key{"scene0", 4}; + std::atomic create_count{0}; + std::mutex creator_mutex; + std::condition_variable creator_cv; + bool creator_entered = false; + bool allow_creator_to_finish = false; + + auto create_fn = [&]() -> Result { + ++create_count; + { + std::unique_lock lock(creator_mutex); + creator_entered = true; + creator_cv.notify_all(); + creator_cv.wait(lock, [&]() { return allow_creator_to_finish; }); + } + return std::make_shared(make_create_info(key)); + }; + + constexpr size_t kThreadCount = 8; + std::vector results(kThreadCount); + std::vector errors(kThreadCount, Error::Internal); + std::vector threads; + threads.reserve(kThreadCount); + for (size_t i = 0; i < kThreadCount; ++i) { + threads.emplace_back([&, i]() { + auto result = + SharedVulkanContextRegistry::Get().lookup_or_create(key, create_fn); + errors[i] = result.error(); + if (result.ok()) { + results[i] = result.get(); + } + }); + } + + { + std::unique_lock lock(creator_mutex); + creator_cv.wait(lock, [&]() { return creator_entered; }); + allow_creator_to_finish = true; + } + creator_cv.notify_all(); + + for (auto& thread : threads) { + thread.join(); + } + + EXPECT_EQ(create_count.load(), 1); + for (size_t i = 0; i < kThreadCount; ++i) { + EXPECT_EQ(errors[i], Error::Ok); + EXPECT_EQ(results[i], results[0]); + } +} + +// cppcheck-suppress unusedFunction +TEST_F(SharedVulkanContextRegistryTest, RejectsDifferentDuplicateContext) { + const SharedVulkanContextKey key{"scene0", 5}; + auto first = std::make_shared(make_create_info(key, 10)); + auto second = + std::make_shared(make_create_info(key, 20)); + + EXPECT_EQ( + SharedVulkanContextRegistry::Get().register_context(first), Error::Ok); + EXPECT_EQ( + SharedVulkanContextRegistry::Get().register_context(second), + Error::AlreadyLoaded); + EXPECT_EQ(SharedVulkanContextRegistry::Get().lookup(key), first); +} + +// cppcheck-suppress unusedFunction +TEST_F(SharedVulkanContextRegistryTest, ValidatesContextIdentity) { + const SharedVulkanContextKey requested_key{"scene0", 6}; + const SharedVulkanContextKey returned_key{"other", 6}; + + auto result = SharedVulkanContextRegistry::Get().lookup_or_create( + requested_key, [&]() -> Result { + return std::make_shared( + make_create_info(returned_key)); + }); + + ASSERT_FALSE(result.ok()); + EXPECT_EQ(result.error(), Error::InvalidArgument); + EXPECT_EQ(SharedVulkanContextRegistry::Get().lookup(requested_key), nullptr); +} + +// cppcheck-suppress unusedFunction +TEST_F(SharedVulkanContextRegistryTest, ReportsDeclaredDeviceExtensions) { + const SharedVulkanContextKey key{"scene0", 7}; + auto info = make_create_info(key); + info.enabled_device_extensions = {"VK_ARM_tensors", "VK_ARM_data_graph"}; + SharedVulkanContext context(std::move(info)); + + EXPECT_TRUE(context.has_device_extension("VK_ARM_tensors")); + EXPECT_TRUE(context.has_device_extension("VK_ARM_data_graph")); + EXPECT_FALSE(context.has_device_extension("VK_KHR_nonexistent")); +} + +// cppcheck-suppress unusedFunction +TEST_F(SharedVulkanContextRegistryTest, RejectsContextWithoutLifetimeAnchor) { + const SharedVulkanContextKey key{"scene0", 10}; + auto info = make_create_info(key); + info.lifetime_anchor.reset(); + + auto result = SharedVulkanContextRegistry::Get().register_external_context( + std::move(info)); + + ASSERT_FALSE(result.ok()); + EXPECT_EQ(result.error(), Error::InvalidArgument); +} + +// cppcheck-suppress unusedFunction +TEST_F( + SharedVulkanContextRegistryTest, + UnregisterKeepsLifetimeAnchorAliveWhileContextIsReferenced) { + const SharedVulkanContextKey key{"scene0", 11}; + std::weak_ptr owner_weak; + SharedVulkanContextPtr held_context; + + { + auto owner = std::make_shared(17); + owner_weak = owner; + + auto info = make_create_info(key); + info.lifetime_anchor = owner; + + auto registered = + SharedVulkanContextRegistry::Get().register_external_context( + std::move(info)); + ASSERT_TRUE(registered.ok()); + + held_context = SharedVulkanContextRegistry::Get().lookup(key); + ASSERT_NE(held_context, nullptr); + } + + EXPECT_FALSE(owner_weak.expired()); + EXPECT_EQ( + SharedVulkanContextRegistry::Get().unregister_context(key), Error::Ok); + EXPECT_EQ(SharedVulkanContextRegistry::Get().lookup(key), nullptr); + + // unregister_context() removes registry ownership only. An existing delegate + // reference must continue to keep the underlying Vulkan objects alive. + EXPECT_FALSE(owner_weak.expired()); + + held_context.reset(); + EXPECT_TRUE(owner_weak.expired()); +} + +// cppcheck-suppress unusedFunction +TEST_F(SharedVulkanContextRegistryTest, SerializesSharedQueueAccess) { + const SharedVulkanContextKey key{"scene0", 12}; + auto context = std::make_shared(make_create_info(key)); + + std::mutex state_mutex; + std::condition_variable state_cv; + bool first_entered = false; + bool release_first = false; + bool second_started = false; + bool second_entered = false; + + std::thread first([&]() { + context->with_locked_queue([&](VkQueue) { + std::unique_lock lock(state_mutex); + first_entered = true; + state_cv.notify_all(); + state_cv.wait(lock, [&]() { return release_first; }); + }); + }); + + bool first_ready = false; + { + std::unique_lock lock(state_mutex); + first_ready = state_cv.wait_for( + lock, std::chrono::seconds(1), [&]() { return first_entered; }); + } + EXPECT_TRUE(first_ready); + if (!first_ready) { + { + std::lock_guard lock(state_mutex); + release_first = true; + } + state_cv.notify_all(); + first.join(); + return; + } + + std::thread second([&]() { + { + std::lock_guard lock(state_mutex); + second_started = true; + } + state_cv.notify_all(); + + context->with_locked_queue([&](VkQueue) { + { + std::lock_guard lock(state_mutex); + second_entered = true; + } + state_cv.notify_all(); + }); + }); + + bool second_ready = false; + bool entered_while_first_held_queue_lock = false; + { + std::unique_lock lock(state_mutex); + second_ready = state_cv.wait_for( + lock, std::chrono::seconds(1), [&]() { return second_started; }); + if (second_ready) { + entered_while_first_held_queue_lock = + state_cv.wait_for(lock, std::chrono::milliseconds(100), [&]() { + return second_entered; + }); + } + + release_first = true; + } + state_cv.notify_all(); + + EXPECT_TRUE(second_ready); + EXPECT_FALSE(entered_while_first_held_queue_lock); + + bool second_completed = false; + { + std::unique_lock lock(state_mutex); + second_completed = state_cv.wait_for( + lock, std::chrono::seconds(1), [&]() { return second_entered; }); + } + EXPECT_TRUE(second_completed); + + first.join(); + second.join(); +} + +// cppcheck-suppress unusedFunction +TEST_F(SharedVulkanContextRegistryTest, RejectsIncompleteExternalContext) { + SharedVulkanContextCreateInfo info; + info.key = {"scene0", 8}; + + auto result = SharedVulkanContextRegistry::Get().register_external_context( + std::move(info)); + + ASSERT_FALSE(result.ok()); + EXPECT_EQ(result.error(), Error::InvalidArgument); +} + +} // namespace diff --git a/backends/vulkan_shared/runtime/test/SharedVulkanRuntimeConfigTest.cpp b/backends/vulkan_shared/runtime/test/SharedVulkanRuntimeConfigTest.cpp new file mode 100644 index 00000000000..372f7eda3ba --- /dev/null +++ b/backends/vulkan_shared/runtime/test/SharedVulkanRuntimeConfigTest.cpp @@ -0,0 +1,117 @@ +/* + * Copyright 2026 Arm Limited and/or its affiliates. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include +#include +#include + +#include +// Cppcheck's lint environment may not expand the gtest macros. +#ifndef TEST +#define TEST(test_suite_name, test_name) void test_suite_name##_##test_name() +#endif + +using executorch::backends::vulkan_shared::kSharedContextModeOption; +using executorch::backends::vulkan_shared::kSharedContextNameOption; +using executorch::backends::vulkan_shared::kSharedGroupIdOption; +using executorch::backends::vulkan_shared::parse_shared_vulkan_runtime_config; +using executorch::backends::vulkan_shared::SharedContextMode; +using executorch::runtime::BackendInitContext; +using executorch::runtime::BackendOption; +using executorch::runtime::BackendOptions; +using executorch::runtime::Error; +using executorch::runtime::Span; + +namespace { + +template +BackendInitContext make_context(BackendOptions& options) { + auto view = options.view(); + Span specs(view.data(), view.size()); + return BackendInitContext(nullptr, nullptr, nullptr, nullptr, specs); +} + +// cppcheck-suppress unusedFunction +TEST(SharedVulkanRuntimeConfigTest, UsesPersistentSharedDefaults) { + BackendInitContext context(nullptr); + + auto result = parse_shared_vulkan_runtime_config(context); + + ASSERT_TRUE(result.ok()); + EXPECT_EQ(result->context_name, "default"); + EXPECT_EQ(result->group_id, 0); + EXPECT_EQ(result->context_mode, SharedContextMode::kLookupOrCreate); +} + +// cppcheck-suppress unusedFunction +TEST(SharedVulkanRuntimeConfigTest, ParsesRuntimeOptions) { + BackendOptions<3> options; + ASSERT_EQ(options.set_option(kSharedContextNameOption, "scene0"), Error::Ok); + ASSERT_EQ( + options.set_option(kSharedContextModeOption, "lookup_only"), Error::Ok); + ASSERT_EQ(options.set_option(kSharedGroupIdOption, 7), Error::Ok); + auto context = make_context(options); + + auto result = parse_shared_vulkan_runtime_config(context); + + ASSERT_TRUE(result.ok()); + EXPECT_EQ(result->context_name, "scene0"); + EXPECT_EQ(result->group_id, 7); + EXPECT_TRUE(result->lookup_only()); +} + +// cppcheck-suppress unusedFunction +TEST(SharedVulkanRuntimeConfigTest, ParsesDisabledMode) { + BackendOptions<1> options; + ASSERT_EQ( + options.set_option(kSharedContextModeOption, "disabled"), Error::Ok); + auto context = make_context(options); + + auto result = parse_shared_vulkan_runtime_config(context); + + ASSERT_TRUE(result.ok()); + EXPECT_FALSE(result->enabled()); +} + +// cppcheck-suppress unusedFunction +TEST(SharedVulkanRuntimeConfigTest, RejectsUnknownMode) { + BackendOptions<1> options; + ASSERT_EQ( + options.set_option(kSharedContextModeOption, "automatic"), Error::Ok); + auto context = make_context(options); + + auto result = parse_shared_vulkan_runtime_config(context); + + ASSERT_FALSE(result.ok()); + EXPECT_EQ(result.error(), Error::InvalidArgument); +} + +// cppcheck-suppress unusedFunction +TEST(SharedVulkanRuntimeConfigTest, RejectsWrongRuntimeOptionType) { + BackendOptions<1> options; + ASSERT_EQ(options.set_option(kSharedGroupIdOption, "seven"), Error::Ok); + auto context = make_context(options); + + auto result = parse_shared_vulkan_runtime_config(context); + + ASSERT_FALSE(result.ok()); + EXPECT_EQ(result.error(), Error::InvalidArgument); +} + +// cppcheck-suppress unusedFunction +TEST(SharedVulkanRuntimeConfigTest, RejectsEmptyTokenWhenEnabled) { + BackendOptions<1> options; + ASSERT_EQ(options.set_option(kSharedContextNameOption, ""), Error::Ok); + auto context = make_context(options); + + auto result = parse_shared_vulkan_runtime_config(context); + + ASSERT_FALSE(result.ok()); + EXPECT_EQ(result.error(), Error::InvalidArgument); +} + +} // namespace diff --git a/backends/vulkan_shared/targets.bzl b/backends/vulkan_shared/targets.bzl new file mode 100644 index 00000000000..886f85a9f68 --- /dev/null +++ b/backends/vulkan_shared/targets.bzl @@ -0,0 +1,51 @@ +load("@fbsource//xplat/executorch/build:runtime_wrapper.bzl", "runtime") + + +def define_common_targets(): + """Defines the shared Vulkan runtime and its unit tests.""" + + # This target must be shareable rather than force-static. VGF and Vulkan can + # live in separate DSOs, but both must resolve the same process registry. + runtime.cxx_library( + name = "runtime", + srcs = [ + "runtime/SharedVulkanContext.cpp", + "runtime/SharedVulkanContextRegistry.cpp", + "runtime/SharedVulkanRuntimeConfig.cpp", + ], + exported_headers = [ + "runtime/SharedVulkanContext.h", + "runtime/SharedVulkanContextRegistry.h", + "runtime/SharedVulkanRuntimeConfig.h", + "runtime/export.h", + ], + force_static = False, + preprocessor_flags = [ + "-DEXECUTORCH_VULKAN_SHARED_BUILDING", + ], + visibility = ["PUBLIC"], + exported_deps = [ + "//executorch/runtime/backend:interface", + "//executorch/runtime/core:core", + "fbsource//third-party/khronos:vulkan-headers", + ], + ) + + runtime.cxx_test( + name = "shared_vulkan_runtime_config_test", + srcs = ["runtime/test/SharedVulkanRuntimeConfigTest.cpp"], + deps = [ + ":runtime", + "//executorch/runtime/backend:interface", + "//executorch/runtime/core:core", + ], + ) + + runtime.cxx_test( + name = "shared_gpu_context_registry_test", + srcs = ["runtime/test/SharedVulkanContextRegistryTest.cpp"], + deps = [ + ":runtime", + "//executorch/runtime/core:core", + ], + )