diff --git a/.clangd b/.clangd new file mode 100644 index 0000000..a26fa83 --- /dev/null +++ b/.clangd @@ -0,0 +1,20 @@ +# clangd config for cthreads (Cursor / VS Code clangd). +# Paths are relative to this file (repo root), except Vulkan SDK. + +CompileFlags: + Add: + - -std=c++17 + # Match CMake CTHREADS_GPU=ON so #ifdef CTHREADS_WITH_GPU paths analyze correctly. + - -DCTHREADS_WITH_GPU=1 + # Project includes (same as CMake target_include_directories for GPU builds). + - -Isrc/cthreads/cpp/headers + - -Isrc/cthreads/cpp/gpu/headers + # Vulkan headers from local SDK (VULKAN_SDK=C:\VulkanSDK\1.4.357.0). + # Update this -I if you install a newer SDK version. + - -IC:/VulkanSDK/1.4.357.0/Include + +Diagnostics: + UnusedIncludes: None + +Index: + Background: Build diff --git a/.gitignore b/.gitignore index 3be454b..2e111dc 100644 --- a/.gitignore +++ b/.gitignore @@ -59,4 +59,6 @@ libcthreads_kernels.so libcthreads_kernels.dylib # <<< cthreads (auto) -todo.md \ No newline at end of file +todo.md +# demo artifacts +*.gif \ No newline at end of file diff --git a/src/cthreads/cpp/CMakeLists.txt b/src/cthreads/cpp/CMakeLists.txt index 84f84ce..ea20f6c 100644 --- a/src/cthreads/cpp/CMakeLists.txt +++ b/src/cthreads/cpp/CMakeLists.txt @@ -16,6 +16,13 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_POSITION_INDEPENDENT_CODE ON) +# Enable locally (pick one shell): +# cmd: set CMAKE_ARGS=-DCTHREADS_GPU=ON +# PowerShell: $env:CMAKE_ARGS="-DCTHREADS_GPU=ON" +# either: pip install -e . --config-settings=cmake.define.CTHREADS_GPU=ON +# Wipe build/ if toggling ON/OFF so CMake reconfigures (cached OFF sticks otherwise). +option(CTHREADS_GPU "Build Vulkan GPU support into _ext" OFF) + # --- Python + pybind11 ------------------------------------------------------- find_package(Python COMPONENTS Interpreter Development.Module REQUIRED) @@ -132,3 +139,20 @@ message(STATUS "Python: ${Python_EXECUTABLE} (${Python_VERSION})") if(DEFINED SKBUILD_STATE) message(STATUS "SKBUILD_STATE: ${SKBUILD_STATE}") endif() + +# --- GPU ---- + +if(CTHREADS_GPU) + find_package(Vulkan REQUIRED) # vulkan sdk for headers/includes + message(STATUS "cthreads GPU: ON (Vulkan)") + target_sources(_ext PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/gpu/impl/context.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/bindings/gpu_module.cpp" + # extend more source when implemented (current stage: GPU-01) + ) + target_include_directories(_ext PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/gpu/headers + ${Vulkan_INCLUDE_DIRS} + ) + target_compile_definitions(_ext PRIVATE CTHREADS_WITH_GPU=1) +endif() \ No newline at end of file diff --git a/src/cthreads/cpp/bindings/gpu_module.cpp b/src/cthreads/cpp/bindings/gpu_module.cpp new file mode 100644 index 0000000..49f897e --- /dev/null +++ b/src/cthreads/cpp/bindings/gpu_module.cpp @@ -0,0 +1,42 @@ +// Copyright (c) 2026 Tobias Karusseit +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +#include "gpu_module.hpp" + +#include "../gpu/headers/context.hpp" + +#include + +namespace py = pybind11; + +void bind_gpu(py::module_& parent) { + py::module_ g = parent.def_submodule( + "gpu", + "Vulkan GPU runtime (loader dynamically loaded at init)" + ); + + g.def( + "available", + &cthreads::gpu::available, + "True if Vulkan loader + compute device initialized successfully." + ); + + g.def( + "device_name", + &cthreads::gpu::device_name, + "GPU deviceName from Vulkan; calls init() (may raise)." + ); + + g.def( + "init", + &cthreads::gpu::init, + "Explicitly initialize Vulkan context (optional; available/device_name also init)." + ); + + g.def( + "shutdown", + &cthreads::gpu::shutdown, + "Destroy device/instance and unload the Vulkan loader." + ); +} diff --git a/src/cthreads/cpp/bindings/gpu_module.hpp b/src/cthreads/cpp/bindings/gpu_module.hpp new file mode 100644 index 0000000..1897633 --- /dev/null +++ b/src/cthreads/cpp/bindings/gpu_module.hpp @@ -0,0 +1,8 @@ +#pragma once + +#include + +namespace py = pybind11; + +/** Register ``cthreads._ext.gpu`` (Vulkan context probe API). */ +void bind_gpu(py::module_& parent); diff --git a/src/cthreads/cpp/bindings/module.cpp b/src/cthreads/cpp/bindings/module.cpp index 1a96d48..25f5e41 100644 --- a/src/cthreads/cpp/bindings/module.cpp +++ b/src/cthreads/cpp/bindings/module.cpp @@ -21,6 +21,10 @@ #include "../headers/pool/threadPool.hpp" #include "../headers/shared_host.hpp" +#ifdef CTHREADS_WITH_GPU +#include "gpu_module.hpp" +#endif + #include #include #include @@ -1255,4 +1259,9 @@ PYBIND11_MODULE(_ext, m) { bind_linalg(m); bind_pool(m); + +// runs when the user has the gpu capable version +#ifdef CTHREADS_WITH_GPU + bind_gpu(m); +#endif } diff --git a/src/cthreads/cpp/gpu/headers/context.hpp b/src/cthreads/cpp/gpu/headers/context.hpp new file mode 100644 index 0000000..0fd3f0f --- /dev/null +++ b/src/cthreads/cpp/gpu/headers/context.hpp @@ -0,0 +1,51 @@ +#pragma once +#include +#include +#include + +namespace cthreads::gpu { + +struct Context { + // OS handle to the loader shared library (HMODULE on Windows, void* on Linux). + // Purpose: keep the DLL mapped and FreeLibrary/dlclose on shutdown. + void* loader_module = nullptr; + + // Bootstrap entry from the loader. Type: pointer-to-function matching + // VkResult-less GetInstanceProcAddr signature from the headers. + // Usage: resolve almost every other Vulkan function by name string. + // Naming Note: GetPRocAddress => GetFunctionAddress (Proc -> procedure -> function) + PFN_vkGetInstanceProcAddr vkGetInstanceProcAddr = nullptr; + // Global (pre-instance) / instance-level entry points. + // Each is a typed function pointer; assigned in init() via GetInstanceProcAddr. + PFN_vkCreateInstance vkCreateInstance = nullptr; + PFN_vkDestroyInstance vkDestroyInstance = nullptr; + PFN_vkEnumeratePhysicalDevices vkEnumeratePhysicalDevices = nullptr; + PFN_vkGetPhysicalDeviceProperties vkGetPhysicalDeviceProperties = nullptr; + PFN_vkGetPhysicalDeviceQueueFamilyProperties vkGetPhysicalDeviceQueueFamilyProperties = nullptr; + PFN_vkCreateDevice vkCreateDevice = nullptr; + PFN_vkDestroyDevice vkDestroyDevice = nullptr; + PFN_vkGetDeviceQueue vkGetDeviceQueue = nullptr; + // Opaque Vulkan handles. + VkInstance instance = VK_NULL_HANDLE; // connection to the loader/app + VkPhysicalDevice physical_device = VK_NULL_HANDLE; // chosen GPU + VkDevice device = VK_NULL_HANDLE; // logical device (opened GPU) + VkQueue queue = VK_NULL_HANDLE; // compute submission port + // Which queue family index we passed to vkCreateDevice (needed for pools). + uint32_t queue_family = 0; + // Human-readable GPU name from VkPhysicalDeviceProperties::deviceName. + std::string device_name; + // True only after init() fully succeeded. + bool ready = false; +}; +// Process-wide singleton accessor. +Context& context(); +// Create loader + instance + device + queue. Throws on failure. +void init(); +// Destroy device/instance; unload loader; clear pointers. Safe to call if not ready. +void shutdown(); +// If not ready, try init once; return ready without throwing (for available()). +bool available(); +// Requires ready context; returns device_name. +const std::string& device_name(); + +} // namespace cthreads::gpu \ No newline at end of file diff --git a/src/cthreads/cpp/gpu/impl/context.cpp b/src/cthreads/cpp/gpu/impl/context.cpp new file mode 100644 index 0000000..0826dbb --- /dev/null +++ b/src/cthreads/cpp/gpu/impl/context.cpp @@ -0,0 +1,277 @@ +#include "../headers/context.hpp" +#include +#include +#include +#include +#include + +#if defined(_WIN32) + // Windows (32-bit or 64-bit) + #include +#elif defined(__linux__) + // Linux + #include +#else + // No MacOs support yet !!!! + // Unknown -> throw error + #error "cthreads: Unsupported OS" +#endif + +namespace cthreads::gpu { + +namespace { + // Look up one export inside the already-loaded loader module. + // module: void* (HMODULE on Windows). name: C string export name. + // returns: raw code address, or nullptr if missing. + static void* load_fn(void* module, const char* name) { +#if defined(_WIN32) + return reinterpret_cast( + // gets the function ptr address by name from the module lookup table + GetProcAddress(static_cast(module), name)); +#else + return dlsym(module, name); +#endif + } + + // Resolve a Vulkan entry point by name and cast to the typed PFN_*. + // c: Context with vkGetInstanceProcAddr already set. + // instance: VK_NULL_HANDLE for global functions; real instance after create. + // name: e.g. "vkCreateInstance". + template + static PFN get_fn(Context& c, VkInstance instance, const char* name) { + // PFN_vkVoidFunction = generic "pointer to some Vulkan fn". + PFN_vkVoidFunction raw = c.vkGetInstanceProcAddr(instance, name); + if (!raw) { + throw std::runtime_error( + std::string("cthreads.gpu.VulkanInitFailed: missing ") + name); + } + return reinterpret_cast(raw); // PFN = e.g. PFN_vkCreateInstance + } + + // Open the Vulkan DLL / shared library and put the file pointer on the Context.loader_module + // Throws on failure. + // also assign the vkGetInstanceProcAddr. + // Naming Note: GetPRocAddress 0> GetFunctionAddress + static void open_loader(Context& c) { +#if defined(_WIN32) + // Map the Vulkan loader DLL into this process (driver-installed). + HMODULE mod = LoadLibraryA("vulkan-1.dll"); + if (!mod) { + throw std::runtime_error( + "cthreads.gpu.VulkanLoaderNotFound: vulkan-1.dll not found"); + } + c.loader_module = static_cast(mod); +#else + void* mod = dlopen("libvulkan.so.1", RTLD_NOW); + if (!mod) { + throw std::runtime_error( + "cthreads.gpu.VulkanLoaderNotFound: libvulkan.so.1 not found"); + } + c.loader_module = mod; +#endif + // Only this first symbol comes from GetProcAddress/dlsym. + // Everything else goes through vkGetInstanceProcAddr. + c.vkGetInstanceProcAddr = + reinterpret_cast( + load_fn(c.loader_module, "vkGetInstanceProcAddr")); + if (!c.vkGetInstanceProcAddr) { + throw std::runtime_error( + "cthreads.gpu.VulkanInitFailed: vkGetInstanceProcAddr missing"); + } + } + + static void create_instance_and_device(Context& c) { + // Only globals may be resolved with VK_NULL_HANDLE (Vulkan loader rules). + // Instance-level procs (DestroyInstance, EnumeratePhysicalDevices, …) + // must be resolved after vkCreateInstance with the real instance. + c.vkCreateInstance = get_fn( + c, VK_NULL_HANDLE, "vkCreateInstance"); + // VkApplicationInfo: tells the loader who we are (required sType pattern). + VkApplicationInfo app{}; // vulkan app metadata + app.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO; // set type for vulkan to interprete this struct + app.pApplicationName = "cthreads"; + app.applicationVersion = VK_MAKE_VERSION(0, 1, 0); + app.pEngineName = "cthreads"; + app.engineVersion = VK_MAKE_VERSION(0, 1, 0); + app.apiVersion = VK_API_VERSION_1_1; // request 1.1 + // VkInstanceCreateInfo: parameters for vkCreateInstance. + VkInstanceCreateInfo ici{}; + ici.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO; // set type for vulkan to interprete this struct + ici.pApplicationInfo = &app; + // no layers/extensions for Issue 1 + // Out-param: writes the new VkInstance into c.instance. + if (c.vkCreateInstance(&ici, nullptr, &c.instance) != VK_SUCCESS) { + throw std::runtime_error( + "cthreads.gpu.VulkanInitFailed: vkCreateInstance failed"); + } + // Instance-level procs (pass c.instance). + c.vkDestroyInstance = get_fn( + c, c.instance, "vkDestroyInstance"); + c.vkEnumeratePhysicalDevices = get_fn( + c, c.instance, "vkEnumeratePhysicalDevices"); + c.vkGetPhysicalDeviceProperties = + get_fn( // get fn from vulkan (gets the properties of the physical device) + c, c.instance, "vkGetPhysicalDeviceProperties"); + c.vkGetPhysicalDeviceQueueFamilyProperties = + get_fn( + c, c.instance, "vkGetPhysicalDeviceQueueFamilyProperties"); + c.vkCreateDevice = get_fn( + c, c.instance, "vkCreateDevice"); + c.vkDestroyDevice = get_fn( + c, c.instance, "vkDestroyDevice"); + c.vkGetDeviceQueue = get_fn( + c, c.instance, "vkGetDeviceQueue"); + // --- list GPUs (two-call idiom: count, then data) --- + uint32_t dev_count = 0; + c.vkEnumeratePhysicalDevices(c.instance, &dev_count, nullptr); + if (dev_count == 0) { + throw std::runtime_error( + "cthreads.gpu.VulkanNoDevice: no physical devices"); + } + std::vector devices(dev_count); + c.vkEnumeratePhysicalDevices(c.instance, &dev_count, devices.data()); + // Pick: need COMPUTE queue; prefer discrete GPU. + int best_score = -1; + for (VkPhysicalDevice pd : devices) { + VkPhysicalDeviceProperties props{}; + c.vkGetPhysicalDeviceProperties(pd, &props); + uint32_t qcount = 0; + c.vkGetPhysicalDeviceQueueFamilyProperties(pd, &qcount, nullptr); + std::vector qprops(qcount); + c.vkGetPhysicalDeviceQueueFamilyProperties(pd, &qcount, qprops.data()); + for (uint32_t fi = 0; fi < qcount; ++fi) { + if (!(qprops[fi].queueFlags & VK_QUEUE_COMPUTE_BIT)) { + continue; // graphics-only family: skip + } + int score = (props.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU) + ? 1000 + : 100; + if (score > best_score) { + best_score = score; + c.physical_device = pd; + c.queue_family = fi; + c.device_name = props.deviceName; // C string -> std::string + } + } + } + if (c.physical_device == VK_NULL_HANDLE) { + throw std::runtime_error( + "cthreads.gpu.VulkanNoDevice: no compute queue family"); + } + // --- logical device = "open" that GPU for our process --- + float priority = 1.0f; // single queue, max priority in [0,1] + VkDeviceQueueCreateInfo qci{}; + qci.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO; + qci.queueFamilyIndex = c.queue_family; + qci.queueCount = 1; + qci.pQueuePriorities = &priority; + VkDeviceCreateInfo dci{}; + dci.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO; + dci.queueCreateInfoCount = 1; + dci.pQueueCreateInfos = &qci; + if (c.vkCreateDevice(c.physical_device, &dci, nullptr, &c.device) != VK_SUCCESS) { + throw std::runtime_error( + "cthreads.gpu.VulkanInitFailed: vkCreateDevice failed"); + } + // Queue handle is owned by the device; index 0 of that family. + c.vkGetDeviceQueue(c.device, c.queue_family, 0, &c.queue); + c.ready = true; + } + + void shutdown_unlocked(Context& c) { + + // 1) release logical device + if (c.device != VK_NULL_HANDLE && c.vkDestroyDevice) { // check if device is set and if theres a destroy fn for it + c.vkDestroyDevice(c.device, nullptr); // set nullptr + c.device = VK_NULL_HANDLE; // must be nulled out + c.queue = VK_NULL_HANDLE; // must be nulled out + } + + // 2) release instance + if (c.instance != VK_NULL_HANDLE && c.vkDestroyInstance) { // ensure instance is set and if theres a destroy fn for it + c.vkDestroyInstance(c.instance, nullptr); // set to nullptr + c.instance = VK_NULL_HANDLE; // null the handle to avoid dangling pointers + } + c.physical_device = VK_NULL_HANDLE; // can be nulled now that the instance and logical device are destroyed + + // 3) Unmap loader DLL so OS can unload it. + if (c.loader_module) { + // closes the files and nulls the pointer to the module + #if defined(_WIN32) + FreeLibrary(static_cast(c.loader_module)); + #else + dlclose(c.loader_module); + #endif + c.loader_module = nullptr; + } + + // 4) Clear function pointers so a buggy late call can't jump into freed DLL! + c.vkGetInstanceProcAddr = nullptr; + c.vkCreateInstance = nullptr; + c.vkDestroyInstance = nullptr; + c.vkEnumeratePhysicalDevices = nullptr; + c.vkGetPhysicalDeviceProperties = nullptr; + c.vkGetPhysicalDeviceQueueFamilyProperties = nullptr; + c.vkCreateDevice = nullptr; + c.vkDestroyDevice = nullptr; + c.vkGetDeviceQueue = nullptr; + + c.queue_family = 0; + c.device_name.clear(); + c.ready = false; + } + +} // namespace anonymous + + // to lock the init and shutdown functions aswell as any thread unsafe gpu functions + static std::mutex& gpu_mutex() { + static std::mutex m; + return m; + } + + Context& context() { + static Context ctx; + return ctx; + } + + const std::string& device_name() { + try { + init(); // try to initialize (noops if already initialized) + return context().device_name; + } catch (const std::exception& e) { + throw std::runtime_error("cthreads: " + std::string(e.what())); // this could be cleaner but isnt relevant for now + } + } + + bool available() { + try { + init(); // try to initialize (noops if already initialized) + return context().ready; // return success/failure + } catch (...) { + return false; // initialization failed + } + } + + void init() { + Context& c = context(); + if (c.ready) return; + + std::lock_guard lock(gpu_mutex()); + if (c.ready) return; + + try { + open_loader(c); + create_instance_and_device(c); + } catch (...) { + shutdown_unlocked(c); + throw; // original exception, nothing stored + } + } + + + void shutdown() { + std::lock_guard lock(gpu_mutex()); + shutdown_unlocked(context()); + } + +} // namespace cthreads::gpu \ No newline at end of file diff --git a/src/cthreads/python/cthreads/frontend/Gpu/Wrapper.py b/src/cthreads/python/cthreads/frontend/Gpu/Wrapper.py new file mode 100644 index 0000000..10e9542 --- /dev/null +++ b/src/cthreads/python/cthreads/frontend/Gpu/Wrapper.py @@ -0,0 +1,3 @@ + + +def Gpu(fn, device=None): pass \ No newline at end of file diff --git a/src/cthreads/python/cthreads/frontend/Gpu/__init__.py b/src/cthreads/python/cthreads/frontend/Gpu/__init__.py new file mode 100644 index 0000000..1df7f83 --- /dev/null +++ b/src/cthreads/python/cthreads/frontend/Gpu/__init__.py @@ -0,0 +1,3 @@ +from .Wrapper import Gpu + +__all__ = ["Gpu"] \ No newline at end of file diff --git a/src/cthreads/python/cthreads/gpu/__init__.py b/src/cthreads/python/cthreads/gpu/__init__.py new file mode 100644 index 0000000..35348b9 --- /dev/null +++ b/src/cthreads/python/cthreads/gpu/__init__.py @@ -0,0 +1,85 @@ +"""Vulkan GPU runtime probe API (Issue 1). + +Soft-imports ``cthreads._ext.gpu`` so CPU-only builds still import cleanly. +""" + +from __future__ import annotations + +from .errors import ( + CThreadsGPUError, + GPUNotAvailable, + VulkanInitFailed, + VulkanLoaderNotFound, + VulkanNoDevice, + VulkanNotBuiltError, +) + +try: + from cthreads._ext import gpu as _gpu +except ImportError: + _gpu = None + + +def _map_error(exc: BaseException) -> CThreadsGPUError: + msg = str(exc) + if "VulkanLoaderNotFound" in msg: + return VulkanLoaderNotFound(msg) + if "VulkanNoDevice" in msg: + return VulkanNoDevice(msg) + if "VulkanInitFailed" in msg: + return VulkanInitFailed(msg) + if "VulkanNotBuilt" in msg: + return VulkanNotBuiltError(msg) + return VulkanInitFailed(msg) + + +def available() -> bool: + """Return True if Vulkan loader + compute device can be initialized.""" + if _gpu is None: + return False + return bool(_gpu.available()) + + +def device_name() -> str: + """Return the active GPU name (calls init). Raises on failure / not built.""" + if _gpu is None: + raise VulkanNotBuiltError( + "cthreads built without CTHREADS_GPU; rebuild with -DCTHREADS_GPU=ON" + ) + try: + return str(_gpu.device_name()) + except Exception as exc: + raise _map_error(exc) from exc + + +def init() -> None: + """Explicitly initialize the Vulkan context.""" + if _gpu is None: + raise VulkanNotBuiltError( + "cthreads built without CTHREADS_GPU; rebuild with -DCTHREADS_GPU=ON" + ) + try: + _gpu.init() + except Exception as exc: + raise _map_error(exc) from exc + + +def shutdown() -> None: + """Destroy device/instance and unload the Vulkan loader (no-op if not built).""" + if _gpu is None: + return + _gpu.shutdown() + + +__all__ = [ + "CThreadsGPUError", + "GPUNotAvailable", + "VulkanInitFailed", + "VulkanLoaderNotFound", + "VulkanNoDevice", + "VulkanNotBuiltError", + "available", + "device_name", + "init", + "shutdown", +] diff --git a/src/cthreads/python/cthreads/gpu/errors.py b/src/cthreads/python/cthreads/gpu/errors.py new file mode 100644 index 0000000..d201a85 --- /dev/null +++ b/src/cthreads/python/cthreads/gpu/errors.py @@ -0,0 +1,46 @@ +"""ctypes-style error types for cthreads.gpu (mapped from C++ message prefixes).""" + + +class CThreadsGPUError(Exception): + def __init__(self, detail: str = "Unknown Error") -> None: + self.detail = detail + self.message = f"\033[91mcthreads gpu error\033[0m: {detail}" + super().__init__(self.message) + + def __str__(self) -> str: + return self.message + + +class VulkanNotBuiltError(CThreadsGPUError): + """_ext was compiled without CTHREADS_GPU.""" + + def __init__(self, detail: str = "cthreads built without CTHREADS_GPU") -> None: + super().__init__(detail) + + +class VulkanLoaderNotFound(CThreadsGPUError): + """vulkan-1.dll / libvulkan.so.1 could not be loaded.""" + + def __init__(self, detail: str = "Vulkan loader not found") -> None: + super().__init__(detail) + + +class VulkanNoDevice(CThreadsGPUError): + """Loader ok but no compute-capable device.""" + + def __init__(self, detail: str = "No Vulkan compute device") -> None: + super().__init__(detail) + + +class VulkanInitFailed(CThreadsGPUError): + """Instance/device creation or missing Vulkan entry point.""" + + def __init__(self, detail: str = "Vulkan init failed") -> None: + super().__init__(detail) + + +class GPUNotAvailable(CThreadsGPUError): + """Generic: GPU path not usable (not built, no loader, or no device).""" + + def __init__(self, detail: str = "GPU not available") -> None: + super().__init__(detail) diff --git a/tests/unit/test_gpu_context.py b/tests/unit/test_gpu_context.py new file mode 100644 index 0000000..e8acd10 --- /dev/null +++ b/tests/unit/test_gpu_context.py @@ -0,0 +1,242 @@ +""" +Issue GPU-01: cthreads.gpu Vulkan context probe. + +Always-safe paths run everywhere. Live Vulkan checks skip when the +extension was built without CTHREADS_GPU or when no compute device +is available (CI / headless / no driver). +""" + +from __future__ import annotations + +import pytest + +from cthreads import gpu +from cthreads.gpu.errors import ( + CThreadsGPUError, + GPUNotAvailable, + VulkanInitFailed, + VulkanLoaderNotFound, + VulkanNoDevice, + VulkanNotBuiltError, +) + + +# --------------------------------------------------------------------------- +# Error types (no native / GPU required) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "cls", + [ + CThreadsGPUError, + VulkanNotBuiltError, + VulkanLoaderNotFound, + VulkanNoDevice, + VulkanInitFailed, + GPUNotAvailable, + ], +) +def test_error_is_cthreads_gpu_error(cls): + err = cls() + assert isinstance(err, CThreadsGPUError) + assert isinstance(err, Exception) + assert "cthreads gpu error" in str(err) + assert err.detail + + +def test_error_custom_detail(): + err = VulkanInitFailed("cthreads.gpu.VulkanInitFailed: boom") + assert err.detail == "cthreads.gpu.VulkanInitFailed: boom" + assert "boom" in str(err) + + +def test_gpu_module_exports(): + for name in ( + "available", + "device_name", + "init", + "shutdown", + "CThreadsGPUError", + "VulkanNotBuiltError", + "VulkanLoaderNotFound", + "VulkanNoDevice", + "VulkanInitFailed", + "GPUNotAvailable", + ): + assert hasattr(gpu, name) + + +# --------------------------------------------------------------------------- +# Soft path: extension built without CTHREADS_GPU (_gpu is None) +# --------------------------------------------------------------------------- + + +def test_not_built_available_false(monkeypatch): + monkeypatch.setattr(gpu, "_gpu", None) + assert gpu.available() is False + + +def test_not_built_device_name_raises(monkeypatch): + monkeypatch.setattr(gpu, "_gpu", None) + with pytest.raises(VulkanNotBuiltError, match="CTHREADS_GPU"): + gpu.device_name() + + +def test_not_built_init_raises(monkeypatch): + monkeypatch.setattr(gpu, "_gpu", None) + with pytest.raises(VulkanNotBuiltError, match="CTHREADS_GPU"): + gpu.init() + + +def test_not_built_shutdown_noop(monkeypatch): + monkeypatch.setattr(gpu, "_gpu", None) + gpu.shutdown() # must not raise + + +# --------------------------------------------------------------------------- +# Error mapping from C++ message prefixes (fake _ext.gpu) +# --------------------------------------------------------------------------- + + +class _FakeGpu: + def __init__(self, exc: BaseException | None = None, ready: bool = False): + self._exc = exc + self._ready = ready + self.init_calls = 0 + self.shutdown_calls = 0 + + def available(self) -> bool: + if self._exc is not None: + raise self._exc + return self._ready + + def device_name(self) -> str: + if self._exc is not None: + raise self._exc + return "FakeGPU" + + def init(self) -> None: + self.init_calls += 1 + if self._exc is not None: + raise self._exc + + def shutdown(self) -> None: + self.shutdown_calls += 1 + + +@pytest.mark.parametrize( + "msg,exc_type", + [ + ("cthreads.gpu.VulkanLoaderNotFound: vulkan-1.dll not found", VulkanLoaderNotFound), + ("cthreads.gpu.VulkanNoDevice: no physical devices", VulkanNoDevice), + ("cthreads.gpu.VulkanInitFailed: vkCreateInstance failed", VulkanInitFailed), + ("cthreads.gpu.VulkanNotBuilt: should not happen from C++", VulkanNotBuiltError), + ("something else entirely", VulkanInitFailed), + ], +) +def test_map_error_via_device_name(monkeypatch, msg, exc_type): + monkeypatch.setattr(gpu, "_gpu", _FakeGpu(RuntimeError(msg))) + with pytest.raises(exc_type) as ei: + gpu.device_name() + assert msg in str(ei.value) + + +def test_map_error_via_init(monkeypatch): + monkeypatch.setattr( + gpu, + "_gpu", + _FakeGpu(RuntimeError("cthreads.gpu.VulkanLoaderNotFound: missing")), + ) + with pytest.raises(VulkanLoaderNotFound): + gpu.init() + + +def test_fake_available_false_without_raising(monkeypatch): + """Mirrors C++ available(): False when init would fail, no exception.""" + monkeypatch.setattr(gpu, "_gpu", _FakeGpu(ready=False)) + assert gpu.available() is False + + +def test_fake_ready_device_name(monkeypatch): + monkeypatch.setattr(gpu, "_gpu", _FakeGpu(ready=True)) + assert gpu.available() is True + assert gpu.device_name() == "FakeGPU" + gpu.init() + gpu.shutdown() + assert gpu._gpu.shutdown_calls == 1 + + +# --------------------------------------------------------------------------- +# Live Vulkan (skip when not built / no device) +# --------------------------------------------------------------------------- + + +def _ext_gpu_built() -> bool: + return gpu._gpu is not None + + +def _require_gpu(): + if not _ext_gpu_built(): + pytest.skip("cthreads built without CTHREADS_GPU (_ext.gpu missing)") + if not gpu.available(): + pytest.skip("Vulkan loader/device not available in this environment") + + +def test_live_available_is_bool(): + """available() must never raise; False is fine without GPU.""" + assert isinstance(gpu.available(), bool) + + +def test_live_shutdown_safe_without_init(): + """shutdown is always safe (no-op if not built / not ready).""" + gpu.shutdown() + + +def test_live_unavailable_device_name_raises_mapped(): + """When built but init fails, device_name raises a mapped CThreadsGPUError.""" + if not _ext_gpu_built(): + pytest.skip("cthreads built without CTHREADS_GPU") + if gpu.available(): + pytest.skip("GPU is available — covered by live success tests") + with pytest.raises(CThreadsGPUError): + gpu.device_name() + + +def test_live_unavailable_init_raises_mapped(): + if not _ext_gpu_built(): + pytest.skip("cthreads built without CTHREADS_GPU") + if gpu.available(): + pytest.skip("GPU is available — covered by live success tests") + with pytest.raises(CThreadsGPUError): + gpu.init() + + +def test_live_device_name_nonempty(): + _require_gpu() + name = gpu.device_name() + assert isinstance(name, str) + assert len(name.strip()) > 0 + + +def test_live_init_idempotent_then_shutdown_reinit(): + _require_gpu() + try: + gpu.init() + gpu.init() # second call no-ops + name1 = gpu.device_name() + gpu.shutdown() + assert gpu.available() is True # re-inits + name2 = gpu.device_name() + assert name1 == name2 + finally: + gpu.shutdown() + + +def test_live_available_true_matches_device_name(): + _require_gpu() + try: + assert gpu.available() is True + assert gpu.device_name() + finally: + gpu.shutdown()