diff --git a/codegen/apipatcher.py b/codegen/apipatcher.py index c11b9b95..cbe28fbc 100644 --- a/codegen/apipatcher.py +++ b/codegen/apipatcher.py @@ -38,11 +38,13 @@ def patch_base_api(code): idl = get_idl_parser() # Write __all__ + extra_public_classes = ["GPUPromise"] + all_public_classes = [*idl.classes.keys(), *extra_public_classes] part1, found_all, part2 = code.partition("\n__all__ =") if found_all: part2 = part2.split("]", 1)[-1] line = "\n__all__ = [" - line += ", ".join(f'"{name}"' for name in sorted(idl.classes.keys())) + line += ", ".join(f'"{name}"' for name in sorted(all_public_classes)) line += "]" code = part1 + line + part2 @@ -158,7 +160,13 @@ def patch_classes(self): for classname, i1, i2 in self.iter_classes(): seen_classes.add(classname) self._apidiffs = set() + pre_lines = "\n".join(self.lines[i1 - 3 : i1]) + self._apidiffs_from_lines(pre_lines, classname) if self.class_is_known(classname): + if "@apidiff.add" in pre_lines: + print(f"ERROR: apidiff.add for known {classname}") + elif "@apidiff.hide" in pre_lines: + pass # continue as normal old_line = self.lines[i1] new_line = self.get_class_def(classname) if old_line != new_line: @@ -166,6 +174,8 @@ def patch_classes(self): self.replace_line(i1, f"{fixme_line}\n{new_line}") self.patch_properties(classname, i1 + 1, i2) self.patch_methods(classname, i1 + 1, i2) + elif "@apidiff.add" in pre_lines: + pass else: msg = f"unknown api: class {classname}" self.insert_line(i1, "# FIXME: " + msg) @@ -422,14 +432,15 @@ def get_property_def(self, classname, propname) -> str: print(f"Error resolving type for {classname}.{propname}: {err}") prop_type = None + if prop_type and propname.endswith("_async"): + prop_type = f"GPUPromise[{prop_type}]" + line = ( "def " + to_snake_case(propname) + "(self)" + f"{f' -> {prop_type}' if prop_type else ''}:" ) - if propname.endswith("_async"): - line = "async " + line return " " + line def get_method_def(self, classname, methodname) -> str: @@ -439,8 +450,6 @@ def get_method_def(self, classname, methodname) -> str: # Construct preamble preamble = "def " + to_snake_case(methodname) + "(" - if methodname.endswith("_async"): - preamble = "async " + preamble # Get arg names and types idl_line = functions[name_idl] @@ -455,6 +464,8 @@ def get_method_def(self, classname, methodname) -> str: return_type = None if return_type: return_type = self.idl.resolve_type(return_type) + if methodname.endswith("_async"): + return_type = f"GPUPromise[{return_type}]" # If one arg that is a dict, flatten dict to kwargs if len(args) == 1 and args[0].typename.endswith( @@ -601,6 +612,8 @@ def __init__(self, base_api_code): pre_lines = "\n".join(p1.lines[j1 - 3 : j1]) if "@apidiff.hide" in pre_lines: continue # method (currently) not part of our API + if methodname.endswith("_sync"): + continue # the base class implements _sync versions (using promise.sync_wait()) body = "\n".join(p1.lines[j1 + 1 : j2 + 1]) must_overload = "raise NotImplementedError()" in body methods[methodname] = p1.lines[j1], must_overload diff --git a/codegen/tests/test_codegen_result.py b/codegen/tests/test_codegen_result.py index 6f5c05b6..2fabb295 100644 --- a/codegen/tests/test_codegen_result.py +++ b/codegen/tests/test_codegen_result.py @@ -1,17 +1,19 @@ """Test some aspects of the generated code.""" from codegen.files import read_file +from codegen.utils import format_code def test_async_methods_and_props(): - # Test that only and all async methods are suffixed with '_async' + # Test that async methods return a promise for fname in ["_classes.py", "backends/wgpu_native/_api.py"]: - code = read_file(fname) + code = format_code(read_file(fname), singleline=True) for line in code.splitlines(): line = line.strip() if line.startswith("def "): - assert not line.endswith("_async"), line - elif line.startswith("async def "): - name = line.split("def", 1)[1].split("(")[0].strip() - assert name.endswith("_async"), line + res_type = line.split("->")[-1].strip() + if "_async(" in line: + assert res_type.startswith("GPUPromise") + else: + assert "GPUPromise" not in line diff --git a/docs/wgpu.rst b/docs/wgpu.rst index 0b1c8656..9631ed52 100644 --- a/docs/wgpu.rst +++ b/docs/wgpu.rst @@ -65,6 +65,12 @@ come in two flafours: not part of the WebGPU spec, and as a consequence, code that uses this method is less portable (to e.g. pyodide/pyscript). +The async methods return a :class:`GPUPromise`, which resolves to the actual result. You can wait for it to resolve in three ways: + +* In async code, use ``await promise``. +* In sync code, use ``promise.then(callback)`` to register a callback that is executed when the promise resolves. +* In sync code, you can use ``promise.sync_wait()``. This is simular to the ``_sync()`` flavour mentioned above (it makes your code less portable). + Canvas API ---------- @@ -241,6 +247,7 @@ List of GPU classes ~GPUPipelineBase ~GPUPipelineError ~GPUPipelineLayout + ~GPUPromise ~GPUQuerySet ~GPUQueue ~GPURenderBundle diff --git a/tests/test_api.py b/tests/test_api.py index d5b02d6b..39f1da67 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -22,8 +22,10 @@ def test_basic_api(): code1 = wgpu.GPU.request_adapter_sync.__code__ code2 = wgpu.GPU.request_adapter_async.__code__ + varnames1 = set(code1.co_varnames) - {"gpu", "promise"} + varnames2 = set(code2.co_varnames) - {"gpu", "promise"} # nargs1 = code1.co_argcount + code1.co_kwonlyargcount - assert code1.co_varnames == code2.co_varnames + assert varnames1 == varnames2 assert repr(wgpu.classes.GPU()).startswith( "= 3: callback(10) - awaitable = WgpuAwaitable("test", callback, finalizer, poll_function) + awaitable = GPUPromise("test", finalizer, callback, poll_function) if use_async: result = await awaitable diff --git a/wgpu/_classes.py b/wgpu/_classes.py index 881f888b..992e2711 100644 --- a/wgpu/_classes.py +++ b/wgpu/_classes.py @@ -13,7 +13,7 @@ import weakref import logging -from typing import Sequence +from typing import Sequence, Callable, Awaitable, Generic, TypeVar from ._coreutils import ApiDiff, str_flag_to_int, ArrayLike, CanvasLike from ._diagnostics import diagnostics, texture_format_to_bpp @@ -46,6 +46,7 @@ "GPUPipelineBase", "GPUPipelineError", "GPUPipelineLayout", + "GPUPromise", "GPUQuerySet", "GPUQueue", "GPURenderBundle", @@ -101,26 +102,24 @@ def request_adapter_sync( Provided by wgpu-py, but not compatible with WebGPU. """ - # If this method gets called, no backend has been loaded yet, let's do that now! - from .backends.auto import gpu - - return gpu.request_adapter_sync( + promise = gpu.request_adapter_async( feature_level=feature_level, power_preference=power_preference, force_fallback_adapter=force_fallback_adapter, canvas=canvas, ) + return promise.sync_wait() # IDL: Promise requestAdapter(optional GPURequestAdapterOptions options = {}); -> DOMString featureLevel = "core", GPUPowerPreference powerPreference, boolean forceFallbackAdapter = false, boolean xrCompatible = false @apidiff.change("arguments include canvas") - async def request_adapter_async( + def request_adapter_async( self, *, feature_level: str = "core", power_preference: enums.PowerPreferenceEnum | None = None, force_fallback_adapter: bool = False, canvas: CanvasLike = None, - ) -> GPUAdapter: + ) -> GPUPromise[GPUAdapter]: """Create a `GPUAdapter`, the object that represents an abstract wgpu implementation, from which one can request a `GPUDevice`. @@ -138,7 +137,7 @@ async def request_adapter_async( # note, feature_level current' does nothing: # not used currently: https://gpuweb.github.io/gpuweb/#dom-gpurequestadapteroptions-featurelevel - return await gpu.request_adapter_async( + return gpu.request_adapter_async( feature_level=feature_level, power_preference=power_preference, force_fallback_adapter=force_fallback_adapter, @@ -151,14 +150,11 @@ def enumerate_adapters_sync(self) -> list[GPUAdapter]: Provided by wgpu-py, but not compatible with WebGPU. """ - - # If this method gets called, no backend has been loaded yet, let's do that now! - from .backends.auto import gpu - - return gpu.enumerate_adapters_sync() + promise = gpu.enumerate_adapters_async() + return promise.sync_wait() @apidiff.add("Method useful for multi-gpu environments") - async def enumerate_adapters_async(self) -> list[GPUAdapter]: + def enumerate_adapters_async(self) -> GPUPromise[list[GPUAdapter]]: """Get a list of adapter objects available on the current system. An adapter can then be selected (e.g. using its summary), and a device @@ -185,7 +181,7 @@ async def enumerate_adapters_async(self) -> list[GPUAdapter]: # If this method gets called, no backend has been loaded yet, let's do that now! from .backends.auto import gpu - return await gpu.enumerate_adapters_async() + return gpu.enumerate_adapters_async() # IDL: GPUTextureFormat getPreferredCanvasFormat(); @apidiff.change("Disabled because we put it on the canvas context") @@ -336,6 +332,7 @@ def configure( when read, displayed, or used as an image source. Default "opaque". """ # Check types + tone_mapping = {} if tone_mapping is None else tone_mapping if not isinstance(device, GPUDevice): raise TypeError("Given device is not a device.") @@ -556,6 +553,128 @@ def _release(self): self._drop_texture() +# TODO: GPUFuture or GPUPromise; Python API or JS? +# Leaning towards the JS +# +# JS: +# promise.then(lambda result: ...) +# promise.then(handle_result, handle_exception) +# promise.catch(handle_exception) +# primise.finally() +# +# Python: +# future.result() +# future.set_result() +# future.set_exception() +# future.done() +# future.cancelled() +# future.add_done_callback(lambda future: ...) +# future.remove_done_callback() +# future.cancel() +# future.exception() +# future.get_loop() + + +AwaitedType = TypeVar("AwaitedType") + + +@apidiff.add("Add a GPU-specific Future") +class GPUPromise(Awaitable[AwaitedType], Generic[AwaitedType]): + """A GPUPromise represents the eventual result of an asynchronous wgpu operation. + + A ``GPUPromise`` is a bit like an ``asyncio.Future``, but specific for wgpu, and with + an API more similar to JavaScript's ``Promise``. + + Some methods of the wgpu API are asynchronous. They return a ``GPUPromise``, + which provides a few different ways handle it: + + * It can be awaited using ``await future``. This is the "cleanest" way, but + can only be used from a co-routine (i.e. an async code path). + * A callback can be registered using ``future.then(callback)``, which will + be called when the future resolves. + * You can sync-wait for it, using ``future.wait()``. This is simple, but + makes code less portable and potentially slower. + + A ``GPUPromise`` is in one of these states: + + * pending: initial state, neither fulfilled nor rejected. + * fulfilled: meaning that the operation was completed successfully. + * rejected: meaning that the operation failed. + """ + + def __init__(self, title: str, finalizer: Callable | None, *args): + self._title = title + self._finalizer = finalizer # function to finish the result + self._result_or_error = None + self._callback = None + + def __repr__(self): + state = "pending" + value_repr = "" + if self._result_or_error is not None: + if self._result_or_error[0] is not None: + state = "fulfilled" + value_repr = repr(self._result_or_error[0]).split("\n", 1)[0] + if len(value_repr) > 30: + value_repr = value_repr[:29] + "…" + value_repr = f"'{value_repr}'" + else: + state = "rejected" + return f"" + + def _wgpu_set_result(self, result): + self._result_or_error = result, None + + def _wgpu_set_error(self, error): + self._result_or_error = None, error + + def _finish(self): + try: + result, error = self._result_or_error + if error: + raise RuntimeError(error) + else: + result = self._finalizer(result) + if self._callback is not None: + # TODO: wrap in a try-except, or a log_exception thingy? + self._callback(result) + return result + finally: + # Reset attrs to prevent potential memory leaks + self._finalizer = self._result_or_error = self._callback = None + + def sync_wait(self) -> AwaitedType: + """Synchronously wait for the future to resolve and return the result. + + Note that this method should be avoided in event callbacks, since it can + make them slow. + + Note that this method may not be supported by all backends (e.g. the + upcoming JavaScript/Pyodide one), and using it will make your code less + portable. + """ + # TODO: allow calling multiple times + raise NotImplementedError() + + def then(self, callback: Callable[[AwaitedType], None]): + """Set a callback that will be called when the future resolves. + + The callback will receive one argument: the result of the future. + """ + # TODO: allow calling multiple times + # TODO: allow calling after being resolved -> tests! + # TODO: return another promise, so we can do chaining? Or maybe not interesting for this use-case... + if callable(callback): + self._callback = callback + else: + raise TypeError( + f"GPUPromise.then() got a callback that is not callable: {callback!r}" + ) + + def __await__(self): + raise NotImplementedError() + + class GPUAdapterInfo(dict): """Represents information about an adapter.""" @@ -665,17 +784,23 @@ def request_device_sync( Provided by wgpu-py, but not compatible with WebGPU. """ - raise NotImplementedError() + promise = self.request_device_async( + label=label, + required_features=required_features, + required_limits=required_limits, + default_queue=default_queue, + ) + return promise.sync_wait() # IDL: Promise requestDevice(optional GPUDeviceDescriptor descriptor = {}); -> USVString label = "", sequence requiredFeatures = [], record requiredLimits = {}, GPUQueueDescriptor defaultQueue = {} - async def request_device_async( + def request_device_async( self, *, label: str = "", required_features: Sequence[enums.FeatureNameEnum] = (), required_limits: dict[str, int | None] | None = None, default_queue: structs.QueueDescriptorStruct | None = None, - ) -> GPUDevice: + ) -> GPUPromise[GPUDevice]: """Request a `GPUDevice` from the adapter. Arguments: @@ -805,24 +930,18 @@ def lost_sync(self) -> GPUDeviceLostInfo: Provided by wgpu-py, but not compatible with WebGPU. """ - return self._get_lost_sync() + promise = self.lost_async + return promise.sync_wait() # IDL: readonly attribute Promise lost; @apidiff.hide("Not a Pythonic API") @property - async def lost_async(self) -> GPUDeviceLostInfo: - """Provides information about why the device is lost.""" - # In JS you can device.lost.then ... to handle lost devices. - # We may want to eventually support something similar async-like? - # at some point - + def lost_async(self) -> GPUPromise[GPUDeviceLostInfo]: + """Resolves to GPUDeviceLostInfo, providing information about why the device is lost.""" # Properties don't get repeated at _api.py, so we use a proxy method. - return await self._get_lost_async() - - def _get_lost_sync(self): - raise NotImplementedError() + return self._get_lost_async() - async def _get_lost_async(self): + def _get_lost_async(self) -> GPUPromise[GPUDeviceLostInfo]: raise NotImplementedError() # IDL: attribute EventHandler onuncapturederror; @@ -1114,13 +1233,13 @@ def create_compute_pipeline( raise NotImplementedError() # IDL: Promise createComputePipelineAsync(GPUComputePipelineDescriptor descriptor); -> USVString label = "", required (GPUPipelineLayout or GPUAutoLayoutMode) layout, required GPUProgrammableStage compute - async def create_compute_pipeline_async( + def create_compute_pipeline_async( self, *, label: str = "", layout: GPUPipelineLayout | enums.AutoLayoutModeEnum, compute: structs.ProgrammableStageStruct, - ) -> GPUComputePipeline: + ) -> GPUPromise[GPUComputePipeline]: """Async version of `create_compute_pipeline()`. Both versions are compatible with WebGPU.""" @@ -1271,7 +1390,7 @@ def create_render_pipeline( raise NotImplementedError() # IDL: Promise createRenderPipelineAsync(GPURenderPipelineDescriptor descriptor); -> USVString label = "", required (GPUPipelineLayout or GPUAutoLayoutMode) layout, required GPUVertexState vertex, GPUPrimitiveState primitive = {}, GPUDepthStencilState depthStencil, GPUMultisampleState multisample = {}, GPUFragmentState fragment - async def create_render_pipeline_async( + def create_render_pipeline_async( self, *, label: str = "", @@ -1281,7 +1400,7 @@ async def create_render_pipeline_async( depth_stencil: structs.DepthStencilStateStruct | None = None, multisample: structs.MultisampleStateStruct | None = None, fragment: structs.FragmentStateStruct | None = None, - ) -> GPURenderPipeline: + ) -> GPUPromise[GPURenderPipeline]: """Async version of `create_render_pipeline()`. Both versions are compatible with WebGPU.""" @@ -1345,11 +1464,12 @@ def pop_error_scope_sync(self) -> GPUError: Provided by wgpu-py, but not compatible with WebGPU. """ - raise NotImplementedError() + promise = self.pop_error_scope_async() + return promise.sync_wait() # IDL: Promise popErrorScope(); @apidiff.hide - async def pop_error_scope_async(self) -> GPUError: + def pop_error_scope_async(self) -> GPUPromise[GPUError]: """Pops a GPU error scope from the stack.""" raise NotImplementedError() @@ -1430,15 +1550,16 @@ def map_sync( Provided by wgpu-py, but not compatible with WebGPU. """ - raise NotImplementedError() + promise = self.map_async(mode=mode, offset=offset, size=size) + return promise.sync_wait() # IDL: Promise mapAsync(GPUMapModeFlags mode, optional GPUSize64 offset = 0, optional GPUSize64 size); - async def map_async( + def map_async( self, mode: flags.MapModeFlags | None = None, offset: int = 0, size: int | None = None, - ) -> None: + ) -> GPUPromise[None]: """Maps the given range of the GPUBuffer. When this call returns, the buffer content is ready to be @@ -1753,11 +1874,12 @@ def get_compilation_info_sync(self) -> GPUCompilationInfo: Provided by wgpu-py, but not compatible with WebGPU. """ - raise NotImplementedError() + promise = self.get_compilation_info_async() + return promise.sync_wait() # IDL: Promise getCompilationInfo(); - async def get_compilation_info_async(self) -> GPUCompilationInfo: - """Get shader compilation info. Always returns empty list at the moment.""" + def get_compilation_info_async(self) -> GPUPromise[GPUCompilationInfo]: + """Get shader compilation info. Always resolves to an empty list at the moment.""" # How can this return shader errors if one cannot create a # shader module when the shader source has errors? raise NotImplementedError() @@ -2472,10 +2594,11 @@ def on_submitted_work_done_sync(self) -> None: Provided by wgpu-py, but not compatible with WebGPU. """ - raise NotImplementedError() + promise = self.on_submitted_work_done_async() + return promise.sync_wait() # IDL: Promise onSubmittedWorkDone(); - async def on_submitted_work_done_async(self) -> None: + def on_submitted_work_done_async(self) -> GPUPromise[None]: """TODO""" raise NotImplementedError() @@ -2689,7 +2812,7 @@ def generic_repr(self): def _set_repr_methods(): - exceptions = ["GPUAdapterInfo"] + exceptions = ["GPUAdapterInfo", "GPUPromise"] m = globals() for class_name in __all__: if class_name in exceptions: diff --git a/wgpu/backends/wgpu_native/__init__.py b/wgpu/backends/wgpu_native/__init__.py index ae7d62e5..cc0ab8ba 100644 --- a/wgpu/backends/wgpu_native/__init__.py +++ b/wgpu/backends/wgpu_native/__init__.py @@ -21,4 +21,3 @@ _register_backend(gpu) from .extras import request_device_sync, request_device -from ._helpers import WgpuAwaitable diff --git a/wgpu/backends/wgpu_native/_api.py b/wgpu/backends/wgpu_native/_api.py index 1d20d3de..a0270924 100644 --- a/wgpu/backends/wgpu_native/_api.py +++ b/wgpu/backends/wgpu_native/_api.py @@ -21,10 +21,11 @@ import time import logging from weakref import WeakKeyDictionary -from typing import NoReturn, Sequence +from typing import NoReturn, Sequence, Generator, Callable from ... import classes, flags, enums, structs from ..._coreutils import str_flag_to_int, ArrayLike, CanvasLike +from ..._classes import AwaitedType from ._ffi import ffi, lib from ._mappings import cstructfield2enum, enummap, enum_str2int, enum_int2str @@ -36,7 +37,7 @@ to_snake_case, ErrorHandler, SafeLibCalls, - WgpuAwaitable, + async_sleep, ) logger = logging.getLogger("wgpu") @@ -52,11 +53,6 @@ optional = None -def check_can_use_sync_variants(): - if False: # placeholder, let's implement a little wgpu config thingy - raise RuntimeError("Disallowed use of '_sync' API.") - - # Object to be able to bind the lifetime of objects to other objects _refs_per_struct = WeakKeyDictionary() @@ -438,35 +434,14 @@ def _get_features(id: int, device: bool = False, adapter: bool = False): class GPU(classes.GPU): - def request_adapter_sync( - self, - *, - feature_level: str = "core", - power_preference: enums.PowerPreferenceEnum | None = None, - force_fallback_adapter: bool = False, - canvas: CanvasLike = None, - ) -> GPUAdapter: - """Sync version of ``request_adapter_async()``. - This is the implementation based on wgpu-native. - """ - check_can_use_sync_variants() - awaitable = self._request_adapter( - feature_level=feature_level, - power_preference=power_preference, - force_fallback_adapter=force_fallback_adapter, - canvas=canvas, - ) - - return awaitable.sync_wait() - - async def request_adapter_async( + def request_adapter_async( self, *, feature_level: str = "core", power_preference: enums.PowerPreferenceEnum | None = None, force_fallback_adapter: bool = False, canvas: CanvasLike = None, - ) -> GPUAdapter: + ) -> GPUPromise[GPUAdapter]: """Create a `GPUAdapter`, the object that represents an abstract wgpu implementation, from which one can request a `GPUDevice`. @@ -479,17 +454,7 @@ async def request_adapter_async( canvas : The canvas that the adapter should be able to render to. This can typically be left to None. If given, the object must implement ``WgpuCanvasInterface``. """ - awaitable = self._request_adapter( - feature_level=feature_level, - power_preference=power_preference, - force_fallback_adapter=force_fallback_adapter, - canvas=canvas, - ) # no-cover - return await awaitable - - def _request_adapter( - self, *, feature_level, power_preference, force_fallback_adapter, canvas - ): + # Similar to https://github.com/gfx-rs/wgpu?tab=readme-ov-file#environment-variables # It seems that the environment variables are only respected in their # testing environments maybe???? @@ -503,12 +468,8 @@ def _request_adapter( adapters_llvm = [a for a in adapters if adapter_name in a.summary] if not adapters_llvm: raise ValueError(f"Adapter with name '{adapter_name}' not found.") - awaitable = WgpuAwaitable( - "llvm adapter", - callback=lambda: (), - finalizer=lambda x: x, - ) - awaitable.set_result(adapters_llvm[0]) + awaitable = GPUPromise("llm adapter", lambda x: x, None, None) + awaitable._wgpu_set_result(adapters_llvm[0]) return awaitable # ----- Surface ID @@ -560,9 +521,9 @@ def _request_adapter( def request_adapter_callback(status, result, c_message, _userdata1, _userdata2): if status != lib.WGPURequestAdapterStatus_Success: msg = from_c_string_view(c_message) - awaitable.set_error(f"Request adapter failed ({status}): {msg}") + awaitable._wgpu_set_error(f"Request adapter failed ({status}): {msg}") else: - awaitable.set_result(result) + awaitable._wgpu_set_result(result) # H: nextInChain: WGPUChainedStruct *, mode: WGPUCallbackMode, callback: WGPURequestAdapterCallback, userdata1: void*, userdata2: void* callback_info = new_struct( @@ -579,8 +540,8 @@ def finalizer(adapter_id): # Note that although we claim this is an asynchronous method, the callback # happens within libf.wgpuInstanceRequestAdapter - awaitable = WgpuAwaitable( - "request_adapter", request_adapter_callback, finalizer + awaitable = GPUPromise( + "request_adapter", finalizer, request_adapter_callback, None ) # H: WGPUFuture f(WGPUInstance instance, WGPURequestAdapterOptions const * options, WGPURequestAdapterCallbackInfo callbackInfo) @@ -588,20 +549,18 @@ def finalizer(adapter_id): return awaitable - def enumerate_adapters_sync(self) -> list[GPUAdapter]: - """Sync version of ``enumerate_adapters_async()``. - This is the implementation based on wgpu-native. - """ - check_can_use_sync_variants() - return self._enumerate_adapters() - - async def enumerate_adapters_async(self) -> list[GPUAdapter]: + def enumerate_adapters_async(self) -> GPUPromise[list[GPUAdapter]]: """Get a list of adapter objects available on the current system. This is the implementation based on wgpu-native. """ - return self._enumerate_adapters() + result = self._enumerate_adapters() + # We already have the result, so we return a resolved promise. + # The reason this is async is to allow this to work on backends where we cannot actually enumerate adapters. + awaitable = GPUPromise("enumerate_adapters", lambda x: x, None, None) + awaitable._wgpu_set_result(result) + return awaitable - def _enumerate_adapters(self): + def _enumerate_adapters(self) -> list[GPUAdapter]: # The first call is to get the number of adapters, and the second call # is to get the actual adapters. Note that the second arg (now NULL) can # be a `WGPUInstanceEnumerateAdapterOptions` to filter by backend. @@ -1069,56 +1028,103 @@ def _release(self): function(internal) +class GPUPromise(classes.GPUPromise): + def __init__(self, title: str, finalizer: Callable | None, *args): + wgpu_callback, poll_function = args + super().__init__(title, finalizer) + self._wgpu_callback = wgpu_callback # only used to prevent it from being gc'd + self._poll_function = poll_function # call this to poll wgpu + + def _finish(self): + result = super()._finish() + self._wgpu_callback = self._poll_function = None + return result + + def sync_wait(self) -> AwaitedType: + if self._result_or_error is not None: + pass + elif self._poll_function is None: + raise RuntimeError("Expected callback to have already happened") + else: + backoff_time_generator = self._get_backoff_time_generator() + while True: + self._poll_function() + if self._result_or_error is not None: + break + time.sleep(next(backoff_time_generator)) + # We check the result after sleeping just in case another thread + # causes the callback to happen + if self._result_or_error is not None: + break + + return self._finish() + + def __await__(self): + # There is no documentation on what __await__() is supposed to return, but we + # can certainly copy from a function that *does* know what to return. + # It would also be nice if wait_for_callback and sync_wait() could be merged, + # but Python has no wait of combining them. + async def wait_for_callback(): + if self._result_or_error is not None: + pass + elif self._poll_function is None: + raise RuntimeError("Expected callback to have already happened") + else: + backoff_time_generator = self._get_backoff_time_generator() + while True: + self._poll_function() + if self._result_or_error is not None: + break + await async_sleep(next(backoff_time_generator)) + # We check the result after sleeping just in case another + # flow of control causes the callback to happen + if self._result_or_error is not None: + break + return self._finish() + + return (yield from wait_for_callback().__await__()) + + def _get_backoff_time_generator(self) -> Generator[float, None, None]: + for _ in range(5): + yield 0 + for i in range(1, 20): + yield i / 2000.0 # ramp up from 0ms to 10ms + while True: + yield 0.01 + + class GPUAdapterInfo(classes.GPUAdapterInfo): pass class GPUAdapter(classes.GPUAdapter): - def request_device_sync( + def request_device_async( self, *, label: str = "", required_features: Sequence[enums.FeatureNameEnum] = (), required_limits: dict[str, int | None] | None = None, default_queue: structs.QueueDescriptorStruct | None = None, - ) -> GPUDevice: - check_can_use_sync_variants() + ) -> GPUPromise[GPUDevice]: required_limits = {} if required_limits is None else required_limits if default_queue: check_struct("QueueDescriptor", default_queue) - else: - default_queue = {} - awaitable = self._request_device( - label, required_features, required_limits, default_queue, "" - ) - return awaitable.sync_wait() - - async def request_device_async( - self, - *, - label: str = "", - required_features: Sequence[enums.FeatureNameEnum] = (), - required_limits: dict[str, int | None] | None = None, - default_queue: structs.QueueDescriptorStruct | None = None, - ) -> GPUDevice: - required_limits = {} if required_limits is None else required_limits - if default_queue: - check_struct("QueueDescriptor", default_queue) - awaitable = self._request_device( - label, required_features, required_limits, default_queue, "" - ) # Note that although we claim this function is async, the callback always # happens inside the call to libf.wgpuAdapterRequestDevice - return await awaitable + return self._request_device_async( + label, required_features, required_limits, default_queue, "" + ) - def _request_device( + def _request_device_async( self, label: str, required_features: Sequence[enums.FeatureNameEnum], required_limits: dict[str, int], default_queue: structs.QueueDescriptorStruct, trace_path: str, - ): + ) -> GPUPromise[GPUDevice]: + # Note that this method is used in extras.py + # ---- Handle features assert isinstance(required_features, (tuple, list, set)) @@ -1332,9 +1338,9 @@ def uncaptured_error_callback( def request_device_callback(status, result, c_message, userdata1, userdata2): if status != lib.WGPURequestDeviceStatus_Success: msg = from_c_string_view(c_message) - awaitable.set_error(f"Request device failed ({status}): {msg}") + awaitable._wgpu_set_error(f"Request device failed ({status}): {msg}") else: - awaitable.set_result(result) + awaitable._wgpu_set_result(result) # H: nextInChain: WGPUChainedStruct *, mode: WGPUCallbackMode, callback: WGPURequestDeviceCallback, userdata1: void*, userdata2: void* callback_info = new_struct( @@ -1361,7 +1367,9 @@ def finalizer(device_id): return device - awaitable = WgpuAwaitable("request_device", request_device_callback, finalizer) + awaitable = GPUPromise( + "request_device", finalizer, request_device_callback, None + ) # H: WGPUFuture f(WGPUAdapter adapter, WGPUDeviceDescriptor const * descriptor, WGPURequestDeviceCallbackInfo callbackInfo) libf.wgpuAdapterRequestDevice(self._internal, struct, callback_info) @@ -1878,13 +1886,13 @@ def create_compute_pipeline( id = libf.wgpuDeviceCreateComputePipeline(self._internal, descriptor) return GPUComputePipeline(label, id, self) - async def create_compute_pipeline_async( + def create_compute_pipeline_async( self, *, label: str = "", layout: GPUPipelineLayout | enums.AutoLayoutModeEnum, compute: structs.ProgrammableStageStruct, - ) -> GPUComputePipeline: + ) -> GPUPromise[GPUComputePipeline]: descriptor = self._create_compute_pipeline_descriptor(label, layout, compute) if not self._CREATE_PIPELINE_ASYNC_IS_IMPLEMENTED: @@ -1900,9 +1908,11 @@ async def create_compute_pipeline_async( def callback(status, result, c_message, _userdata1, _userdata2): if status != lib.WGPUCreatePipelineAsyncStatus_Success: msg = from_c_string_view(c_message) - awaitable.set_error(f"create_compute_pipeline failed ({status}): {msg}") + awaitable._wgpu_set_error( + f"create_compute_pipeline failed ({status}): {msg}" + ) else: - awaitable.set_result(result) + awaitable._wgpu_set_result(result) # H: nextInChain: WGPUChainedStruct *, mode: WGPUCallbackMode, callback: WGPUCreateComputePipelineAsyncCallback, userdata1: void*, userdata2: void* callback_info = new_struct( @@ -1917,8 +1927,8 @@ def callback(status, result, c_message, _userdata1, _userdata2): def finalizer(id): return GPUComputePipeline(label, id, self) - awaitable = WgpuAwaitable( - "create_compute_pipeline", callback, finalizer, self._device._poll + awaitable = GPUPromise( + "create_compute_pipeline", finalizer, callback, self._device._poll ) # H: WGPUFuture f(WGPUDevice device, WGPUComputePipelineDescriptor const * descriptor, WGPUCreateComputePipelineAsyncCallbackInfo callbackInfo) @@ -1926,7 +1936,7 @@ def finalizer(id): self._internal, descriptor, callback_info ) - return await awaitable + return awaitable def _create_compute_pipeline_descriptor( self, @@ -1985,7 +1995,7 @@ def create_render_pipeline( id = libf.wgpuDeviceCreateRenderPipeline(self._internal, descriptor) return GPURenderPipeline(label, id, self) - async def create_render_pipeline_async( + def create_render_pipeline_async( self, *, label: str = "", @@ -1995,7 +2005,7 @@ async def create_render_pipeline_async( depth_stencil: structs.DepthStencilStateStruct | None = None, multisample: structs.MultisampleStateStruct | None = None, fragment: structs.FragmentStateStruct | None = None, - ) -> GPURenderPipeline: + ) -> GPUPromise[GPURenderPipeline]: primitive = {} if primitive is None else primitive multisample = {} if multisample is None else multisample # TODO: wgpuDeviceCreateRenderPipelineAsync is not yet implemented in wgpu-native @@ -2014,9 +2024,11 @@ async def create_render_pipeline_async( def callback(status, result, c_message, _userdata1, _userdata2): if status != lib.WGPUCreatePipelineAsyncStatus_Success: msg = from_c_string_view(c_message) - awaitable.set_error(f"Create renderPipeline failed ({status}): {msg}") + awaitable._wgpu_set_error( + f"Create renderPipeline failed ({status}): {msg}" + ) else: - awaitable.set_result(result) + awaitable._wgpu_set_result(result) # H: nextInChain: WGPUChainedStruct *, mode: WGPUCallbackMode, callback: WGPUCreateRenderPipelineAsyncCallback, userdata1: void*, userdata2: void* callback_info = new_struct( @@ -2031,8 +2043,8 @@ def callback(status, result, c_message, _userdata1, _userdata2): def finalizer(id): return GPURenderPipeline(label, id, self) - awaitable = WgpuAwaitable( - "create_render_pipeline", callback, finalizer, self._device._poll + awaitable = GPUPromise( + "create_render_pipeline", finalizer, callback, self._device._poll ) # H: WGPUFuture f(WGPUDevice device, WGPURenderPipelineDescriptor const * descriptor, WGPUCreateRenderPipelineAsyncCallbackInfo callbackInfo) @@ -2042,7 +2054,7 @@ def finalizer(id): callback_info, ) - return await awaitable + return awaitable def _create_render_pipeline_descriptor( self, @@ -2344,11 +2356,7 @@ def _create_query_set(self, label, type, count, statistics): query_id = libf.wgpuDeviceCreateQuerySet(self._internal, query_set_descriptor) return GPUQuerySet(label, query_id, self, type, count) - def _get_lost_sync(self): - check_can_use_sync_variants() - raise NotImplementedError() - - async def _get_lost_async(self): + def _get_lost_async(self) -> GPUPromise[GPUDeviceLostInfo]: raise NotImplementedError() def destroy(self) -> None: @@ -2409,26 +2417,12 @@ def _check_range(self, offset, size): raise ValueError("Mapped range must not extend beyond total buffer size.") return offset, size - def map_sync( + def map_async( self, mode: flags.MapModeFlags | None = None, offset: int = 0, size: int | None = None, - ) -> None: - check_can_use_sync_variants() - awaitable = self._map(mode, offset, size) - return awaitable.sync_wait() - - async def map_async( - self, - mode: flags.MapModeFlags | None = None, - offset: int = 0, - size: int | None = None, - ) -> None: - awaitable = self._map(mode, offset, size) # for now - return await awaitable - - def _map(self, mode, offset=0, size=None): + ) -> GPUPromise[None]: sync_on_read = True # Check mode @@ -2460,9 +2454,9 @@ def _map(self, mode, offset=0, size=None): def buffer_map_callback(status, c_message, _userdata1, _userdata2): if status != lib.WGPUMapAsyncStatus_Success: msg = from_c_string_view(c_message) - awaitable.set_error(f"Could not map buffer ({status} : {msg}).") + awaitable._wgpu_set_error(f"Could not map buffer ({status} : {msg}).") else: - awaitable.set_result(status) + awaitable._wgpu_set_result(status) # H: nextInChain: WGPUChainedStruct *, mode: WGPUCallbackMode, callback: WGPUBufferMapCallback, userdata1: void*, userdata2: void* buffer_map_callback_info = new_struct( @@ -2479,8 +2473,8 @@ def finalizer(_status): self._mapped_status = offset, offset + size, mode self._mapped_memoryviews = [] - awaitable = WgpuAwaitable( - "buffer.map", buffer_map_callback, finalizer, self._device._poll + awaitable = GPUPromise( + "buffer.map", finalizer, buffer_map_callback, self._device._poll ) # Map it @@ -2715,14 +2709,7 @@ class GPUShaderModule(classes.GPUShaderModule, GPUObjectBase): # GPUObjectBaseMixin _release_function = libf.wgpuShaderModuleRelease - def get_compilation_info_sync(self) -> GPUCompilationInfo: - check_can_use_sync_variants() - return self._get_compilation_info() - - async def get_compilation_info_async(self) -> GPUCompilationInfo: - return self._get_compilation_info() - - def _get_compilation_info(self): + def get_compilation_info_async(self) -> GPUPromise[GPUCompilationInfo]: # Here's a little setup to implement this method. Unfortunately, # this is not yet implemented in wgpu-native. Another problem # is that if there is an error in the shader source, we raise @@ -2751,7 +2738,12 @@ def _get_compilation_info(self): # # ... and then turn these WGPUCompilationInfoRequestStatus objects into Python objects ... - return [] + result = [] + + # Return a resolved promise + promise = GPUPromise("get_compilation_info", lambda x: x, None, None) + promise._wgpu_set_result(result) + return promise class GPUPipelineBase(classes.GPUPipelineBase): @@ -3847,7 +3839,7 @@ def read_buffer( self.submit([command_buffer]) # Download from mappable buffer - tmp_buffer._map("READ_NOSYNC").sync_wait() + tmp_buffer.map_async("READ_NOSYNC").sync_wait() data = tmp_buffer.read_mapped() # Explicit drop. @@ -3981,7 +3973,7 @@ def read_texture( command_buffer = encoder.finish() self.submit([command_buffer]) - awaitable = copy_buffer._map("READ_NOSYNC", 0, data_length) + awaitable = copy_buffer.map_async("READ_NOSYNC", 0, data_length) # Download from mappable buffer # Because we use `copy=False``, we *must* copy the data. @@ -4021,27 +4013,18 @@ def read_texture( return data - def on_submitted_work_done_sync(self) -> None: - check_can_use_sync_variants() - awaitable = self._on_submitted_work_done() - awaitable.sync_wait() - - async def on_submitted_work_done_async(self) -> None: - awaitable = self._on_submitted_work_done() - await awaitable - - def _on_submitted_work_done(self): + def on_submitted_work_done_async(self) -> GPUPromise[None]: @ffi.callback("void(WGPUQueueWorkDoneStatus, void *, void *)") def work_done_callback(status, _userdata1, _userdata2): if status == lib.WGPUQueueWorkDoneStatus_Success: - awaitable.set_result(True) + awaitable._wgpu_set_result(True) else: result = { lib.WGPUQueueWorkDoneStatus_InstanceDropped: "InstanceDropped", lib.WGPUQueueWorkDoneStatus_Error: "Error", lib.WGPUQueueWorkDoneStatus_Unknown: "Unknown", }.get(status, "Other") - awaitable.set_error(f"Queue work done status: {result}") + awaitable._wgpu_set_error(f"Queue work done status: {result}") # H: nextInChain: WGPUChainedStruct *, mode: WGPUCallbackMode, callback: WGPUQueueWorkDoneCallback, userdata1: void*, userdata2: void* work_done_callback_info = new_struct( @@ -4056,10 +4039,10 @@ def work_done_callback(status, _userdata1, _userdata2): def finalizer(_value): return None - awaitable = WgpuAwaitable( + awaitable = GPUPromise( "on_submitted_work_done", - work_done_callback, finalizer, + work_done_callback, self._device._poll_wait, ) diff --git a/wgpu/backends/wgpu_native/_helpers.py b/wgpu/backends/wgpu_native/_helpers.py index 160d6101..c764fe50 100644 --- a/wgpu/backends/wgpu_native/_helpers.py +++ b/wgpu/backends/wgpu_native/_helpers.py @@ -1,13 +1,11 @@ """Utilities used in the wgpu-native backend.""" import sys -import time import types import ctypes import inspect import threading from queue import deque -from collections.abc import Generator import sniffio @@ -248,90 +246,6 @@ async def async_sleep(delay): await sleep(delay) -class WgpuAwaitable: - """An object that can be waited for, either synchronously using sync_wait() or asynchronously using await. - - The purpose of this class is to implememt the asynchronous methods in a - truely async manner, as well as to support a synchronous version of them. - """ - - def __init__(self, title, callback, finalizer, poll_function=None): - self.title = title # for context in error messages - self.callback = callback # only used to prevent it from being gc'd - self.finalizer = finalizer # function to finish the result - self.poll_function = poll_function # call this to poll wgpu - self.result = None - - def set_result(self, result): - self.result = (result, None) - - def set_error(self, error): - self.result = (None, error) - - def _finish(self): - try: - result, error = self.result - if error: - raise RuntimeError(error) - else: - return self.finalizer(result) - finally: - # Reset attrs to prevent potential memory leaks - self.callback = self.finalizer = self.poll_function = self.result = None - - def sync_wait(self): - if self.result is not None: - pass - elif not self.poll_function: - raise RuntimeError("Expected callback to have already happened") - else: - backoff_time_generator = self._get_backoff_time_generator() - while True: - self.poll_function() - if self.result is not None: - break - time.sleep(next(backoff_time_generator)) - # We check the result after sleeping just in case another thread - # causes the callback to happen - if self.result is not None: - break - - return self._finish() - - def __await__(self): - # There is no documentation on what __await__() is supposed to return, but we - # can certainly copy from a function that *does* know what to return. - # It would also be nice if wait_for_callback and sync_wait() could be merged, - # but Python has no wait of combining them. - async def wait_for_callback(): - if self.result is not None: - pass - elif not self.poll_function: - raise RuntimeError("Expected callback to have already happened") - else: - backoff_time_generator = self._get_backoff_time_generator() - while True: - self.poll_function() - if self.result is not None: - break - await async_sleep(next(backoff_time_generator)) - # We check the result after sleeping just in case another - # flow of control causes the callback to happen - if self.result is not None: - break - return self._finish() - - return (yield from wait_for_callback().__await__()) - - def _get_backoff_time_generator(self) -> Generator[float, None, None]: - for _ in range(5): - yield 0 - for i in range(1, 20): - yield i / 2000.0 # ramp up from 0ms to 10ms - while True: - yield 0.01 - - class ErrorSlot: __slot__ = ["name", "type", "message"] diff --git a/wgpu/backends/wgpu_native/extras.py b/wgpu/backends/wgpu_native/extras.py index d4e01843..d348f318 100644 --- a/wgpu/backends/wgpu_native/extras.py +++ b/wgpu/backends/wgpu_native/extras.py @@ -60,9 +60,10 @@ def request_device_sync( os.makedirs(trace_path, exist_ok=True) elif os.listdir(trace_path): logger.warning(f"Trace directory not empty: {trace_path}") - return adapter._request_device( + promise = adapter._request_device_async( label, required_features, required_limits, default_queue, trace_path ) + return promise.sync_wait() # Backwards compat for deprecated function diff --git a/wgpu/resources/codegen_report.md b/wgpu/resources/codegen_report.md index 297319df..d34d79db 100644 --- a/wgpu/resources/codegen_report.md +++ b/wgpu/resources/codegen_report.md @@ -11,6 +11,7 @@ ### Patching API for _classes.py * Diffs for GPU: add enumerate_adapters_async, add enumerate_adapters_sync, change get_preferred_canvas_format, change request_adapter_async, change request_adapter_sync * Diffs for GPUCanvasContext: add get_preferred_format, add present +* Diffs for GPUPromise: add GPUPromise * Diffs for GPUAdapter: add summary * Diffs for GPUDevice: add adapter, add create_buffer_with_data, hide import_external_texture, hide lost_async, hide lost_sync, hide onuncapturederror, hide pop_error_scope_async, hide pop_error_scope_sync, hide push_error_scope * Diffs for GPUBuffer: add read_mapped, add write_mapped, hide get_mapped_range @@ -18,9 +19,9 @@ * Diffs for GPUTextureView: add size, add texture * Diffs for GPUBindingCommandsMixin: change set_bind_group * Diffs for GPUQueue: add read_buffer, add read_texture, hide copy_external_image_to_texture -* Validated 37 classes, 122 methods, 49 properties +* Validated 38 classes, 121 methods, 49 properties ### Patching API for backends/wgpu_native/_api.py -* Validated 37 classes, 124 methods, 0 properties +* Validated 38 classes, 118 methods, 0 properties ## Validating backends/wgpu_native/_api.py * Enum field FeatureName.core-features-and-limits missing in webgpu.h/wgpu.h * Enum field FeatureName.subgroups missing in webgpu.h/wgpu.h