Repository navigation
[cuda.compute] Fix device memory leaks caused by caching #12197
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3decf2d
cec1cec
65a3369
9be4474
508da50
7a57154
8a66f08
dc60e83
88dea82
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ | |
| from __future__ import annotations | ||
|
|
||
| import functools | ||
| import hashlib | ||
| import threading | ||
| import types | ||
| import weakref | ||
|
|
@@ -19,7 +20,13 @@ | |
| except ImportError: | ||
| from cuda.core.experimental._utils.cuda_utils import CUDAError | ||
|
|
||
| from ._utils.protocols import get_dtype, get_shape, is_device_array | ||
| from ._utils.protocols import ( | ||
| get_data_pointer, | ||
| get_dtype, | ||
| get_shape, | ||
| is_contiguous, | ||
| is_device_array, | ||
| ) | ||
| from .struct import _Struct | ||
|
|
||
| try: | ||
|
|
@@ -499,6 +506,32 @@ def build_privately(): | |
| ) | ||
|
|
||
|
|
||
| def _proxy_device_arrays(args: tuple, kwargs: dict) -> tuple[tuple, dict]: | ||
| """Replace device-array arguments with dtype-only ``ProxyArray`` placeholders. | ||
|
|
||
| A factory builds an algorithm from its arguments' *types*; the arrays' data | ||
| is bound at ``__call__``. Building from proxies therefore produces the same | ||
| algorithm, but the wrapper, which lives in the process-wide wrapper cache, | ||
| is never constructed from (and so can never retain) a caller's allocation, | ||
| and the single-phase and ahead-of-time (``ProxyArray``) build paths are | ||
| identical. Non-contiguous arrays are left as is so the build keeps raising | ||
| the same error it does today. Objects that merely contain arrays (iterators, | ||
| ``DoubleBuffer``) are not device arrays and pass through unchanged. | ||
| """ | ||
| from ._proxy import ProxyArray | ||
|
|
||
| def proxy(value): | ||
| if ( | ||
| is_device_array(value) | ||
| and not isinstance(value, ProxyArray) | ||
| and is_contiguous(value) | ||
| ): | ||
| return ProxyArray(get_dtype(value)) | ||
| return value | ||
|
|
||
| return tuple(proxy(a) for a in args), {k: proxy(v) for k, v in kwargs.items()} | ||
|
|
||
|
|
||
| class _CacheWithRegisteredKeyFunctions: | ||
| """ | ||
| Decorator to cache the result of the decorated function. | ||
|
|
@@ -573,8 +606,9 @@ def inner(*args, **kwargs): | |
| ) | ||
| _thread_local.factory_device_info = target | ||
| try: | ||
| build_args, build_kwargs = _proxy_device_arrays(args, kwargs) | ||
| with target_cc(target_cc_arg): | ||
| result = func(*args, **kwargs) | ||
| result = func(*build_args, **build_kwargs) | ||
| finally: | ||
| _thread_local.factory_device_info = previous_device_info | ||
| cache[cache_key] = result | ||
|
|
@@ -604,19 +638,65 @@ def register(self, type_: type, key_function: Callable[[Any], Hashable]) -> None | |
| cache_with_registered_key_functions = _CacheWithRegisteredKeyFunctions() | ||
|
|
||
|
|
||
| def _make_hashable(value): | ||
| """Hash a global/closure variable for caching purposes.""" | ||
| def _function_key(func, in_progress: tuple): | ||
| """Key a captured function by content, unless its key is already being | ||
| built further up the walk (the operator refers to itself, directly or via | ||
| another function). Such a back-reference is keyed by the position of that | ||
| function in ``in_progress``; identical walks produce identical positions, | ||
| and descending again would never terminate. | ||
| """ | ||
| for depth, active in enumerate(in_progress): | ||
| if active is func: | ||
| return ("recursive", depth) | ||
| return CachableFunction(func, _in_progress=in_progress) | ||
|
|
||
|
|
||
| def _make_hashable(value, keep_alive: list, in_progress: tuple = ()): | ||
| """Hash a global/closure variable for caching purposes. | ||
|
|
||
| Values that cannot be keyed by content fall back to ``id()``. Such a value | ||
| is also appended to ``keep_alive`` so the caller can keep it alive. This | ||
| prevents cache key collisions: once the object is freed, Python may give | ||
| its address to an unrelated object, and two different operators would | ||
| then produce equal keys. | ||
|
|
||
| ``in_progress`` holds the functions whose keys are being built up the | ||
| walk; see :func:`_function_key`. | ||
| """ | ||
| # duck-type check for a JIT backend's dispatcher: | ||
| if hasattr(value, "py_func") and callable(value.py_func): | ||
| return CachableFunction(value.py_func) | ||
| return _function_key(value.py_func, in_progress) | ||
| elif isinstance(value, types.FunctionType): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A module-level operator such as
|
||
| # A captured plain Python function (e.g. a helper called from the | ||
| # operator) is keyed by its own content, recursively. | ||
| return _function_key(value, in_progress) | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| elif is_device_array(value): | ||
| if len(in_progress) > 1: | ||
| # A device array captured by a *nested* function (a helper the | ||
| # operator calls) is not threaded through the runtime state; the | ||
| # compiler bakes its pointer, shape and strides into the device | ||
| # code as constants. Key it by exactly those, so two operators | ||
| # whose helpers capture different arrays never share a kernel. | ||
| cai = value.__cuda_array_interface__ | ||
| return ( | ||
| "device_array.baked", | ||
| get_dtype(value), | ||
| tuple(cai["shape"]), | ||
| cai.get("strides"), | ||
| get_data_pointer(value), | ||
| ) | ||
| # The stateful op machinery threads the array's pointer and | ||
| # shape through the *runtime* state buffer. Thus, the key | ||
| # includes only dtype and rank, not pointer or shape. | ||
| return (get_dtype(value), len(get_shape(value))) | ||
| elif isinstance(value, np.ndarray): | ||
| # A captured host array is frozen into the compiled operator as a | ||
| # constant, so arrays with different contents are different operators. | ||
| digest = hashlib.sha256(np.ascontiguousarray(value).tobytes()).digest() | ||
| return ("numpy.ndarray", value.dtype, value.shape, digest) | ||
| elif isinstance(value, (np.number, np.bool_)): | ||
| return ("numpy.scalar", value.dtype.str, value.tobytes()) | ||
| elif isinstance(value, (bool, int, float)): | ||
| elif isinstance(value, (bool, int, float, str, bytes)): | ||
| # Python scalars are immutable values; key them by type and value so | ||
| # equal-valued scalars share a cache entry. Without this they fall | ||
| # through to ``id(value)`` below, and a fresh (non-interned) ``int``/ | ||
|
|
@@ -625,12 +705,19 @@ def _make_hashable(value): | |
| # collisions between like-named scalar subclasses from other modules). | ||
| return ("python.scalar", _type_fqn(value), value) | ||
| elif isinstance(value, (list, tuple)): | ||
| return tuple(_make_hashable(v) for v in value) | ||
| return tuple(_make_hashable(v, keep_alive, in_progress) for v in value) | ||
| elif isinstance(value, dict): | ||
| return tuple( | ||
| sorted((_make_hashable(k), _make_hashable(v)) for k, v in value.items()) | ||
| sorted( | ||
| ( | ||
| _make_hashable(k, keep_alive, in_progress), | ||
| _make_hashable(v, keep_alive, in_progress), | ||
| ) | ||
| for k, v in value.items() | ||
| ) | ||
| ) | ||
| else: | ||
| keep_alive.append(value) | ||
| return id(value) | ||
|
|
||
|
|
||
|
|
@@ -662,7 +749,7 @@ def clear_all_caches(): | |
| _clear_wrapper_caches() | ||
| _process_wide_build_results_cache.clear() | ||
| # Auxiliary caches registered process-wide (_jit._infer_return_type, the | ||
| # compiled-operator memo _jit._compile_op_impl, and the NVRTC memos in | ||
| # compiled-operator memo _jit.compile_op, and the NVRTC memos in | ||
| # _cpp_compile) must be cleared too, so builds after a clear really are | ||
| # cold. Factory entries' cache_clear is idempotent with | ||
| # _clear_wrapper_caches above. Snapshot the registry first: a lazy import | ||
|
|
@@ -674,25 +761,31 @@ def clear_all_caches(): | |
|
|
||
| class CachableFunction: | ||
| """ | ||
| A type that wraps a function and provides custom comparison | ||
| (__eq__) and hash (__hash__) implementations. | ||
|
|
||
| The purpose of this class is to enable caching and comparison of | ||
| functions based on their bytecode, constants, and closures, while | ||
| ignoring other attributes such as their names or docstrings. | ||
| A cache key for a function, with comparison (__eq__) and hash (__hash__) | ||
| based on its bytecode, constants, and closures, while ignoring other | ||
| attributes such as its docstring. | ||
|
|
||
| It does not hold a reference to the function it was built from. It does | ||
| hold a reference to any captured object that could only be keyed by its | ||
| ``id()`` (see :func:`_make_hashable`), so that address stays valid for | ||
| the lifetime of the key. | ||
| """ | ||
|
|
||
| # TODO: eventually, move this class to _jit.py as it only | ||
| # has to do with caching of Python callables that will be | ||
| # JIT compiled. | ||
| def __init__(self, func): | ||
| self._func = func | ||
| def __init__(self, func, _in_progress: tuple = ()): | ||
| self._name = func.__name__ | ||
|
|
||
| # Functions whose keys are being built, outermost first, so a | ||
| # reference back to one of them does not recurse forever. | ||
| in_progress = (*_in_progress, func) | ||
| closure = func.__closure__ if func.__closure__ is not None else [] | ||
| keep_alive: list = [] | ||
| contents = [] | ||
| # Make closure contents hashable | ||
| for cell in closure: | ||
| contents.append(_make_hashable(cell.cell_contents)) | ||
| contents.append(_make_hashable(cell.cell_contents, keep_alive, in_progress)) | ||
| self._identity = ( | ||
| func.__name__, | ||
| func.__code__.co_code, | ||
|
|
@@ -706,10 +799,13 @@ def __init__(self, func): | |
| # use the hash of the name itself. Assumes numba | ||
| # known how to interpret the dotted name at JIT | ||
| # time. | ||
| _make_hashable(func.__globals__.get(name, name)) | ||
| _make_hashable( | ||
| func.__globals__.get(name, name), keep_alive, in_progress | ||
| ) | ||
| for name in func.__code__.co_names | ||
| ), | ||
| ) | ||
| self._keep_alive = tuple(keep_alive) | ||
|
|
||
| def __eq__(self, other): | ||
| return self._identity == other._identity | ||
|
|
@@ -718,7 +814,12 @@ def __hash__(self): | |
| return hash(self._identity) | ||
|
|
||
| def __repr__(self): | ||
| return str(self._func) | ||
| # Iterators derive device symbol names from str() of their cache key | ||
| # (see IteratorBase._get_uid), so the repr must distinguish functions | ||
| # that compare unequal: two same-named operators with different bodies | ||
| # must not share a symbol. Hash the identity rather than printing it. | ||
| digest = hashlib.sha256(repr(self._identity).encode()).hexdigest()[:16] | ||
| return f"<CachableFunction {self._name} {digest}>" | ||
|
|
||
|
|
||
| # Register keyers for built-in types | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -97,18 +97,22 @@ def _is_well_known_op(op: OpKind) -> bool: | |
|
|
||
|
|
||
| def _device_array_to_cccl_iter(array: DeviceArrayLike) -> Iterator: | ||
| from ._proxy import ProxyArray | ||
| from ._proxy import PROXY_PLACEHOLDER_ADDRESS, ProxyArray | ||
|
|
||
| if not is_contiguous(array): | ||
| raise ValueError("Non-contiguous arrays are not supported.") | ||
| dtype = get_dtype(array) | ||
|
|
||
| info = _type_info_from_dtype(dtype) | ||
| state_info = _type_info_from_dtype(np.intp) | ||
| # A ProxyArray has no GPU allocation: leave the pointer NULL for build-time | ||
| # (ahead-of-time) compilation. The real pointer is bound at __call__ via | ||
| # set_cccl_iterator_state(). | ||
| state = None if isinstance(array, ProxyArray) else get_data_pointer(array) | ||
| # A ProxyArray has no GPU allocation: bind a placeholder address for the | ||
| # build (see PROXY_PLACEHOLDER_ADDRESS for why it is not NULL). The real | ||
| # pointer is bound at __call__ via set_cccl_iterator_state(). | ||
| state = ( | ||
| PROXY_PLACEHOLDER_ADDRESS | ||
| if isinstance(array, ProxyArray) | ||
| else get_data_pointer(array) | ||
| ) | ||
| return Iterator( | ||
| state_info.alignment, | ||
| IteratorKind.POINTER, | ||
|
|
@@ -228,15 +232,22 @@ def get_value_type( | |
|
|
||
|
|
||
| def set_cccl_iterator_state(cccl_it: Iterator, input_it): | ||
| """Bind the per-call state of ``input_it`` to the cached ``cccl_it``. | ||
|
|
||
| Only the pointer value (or state bytes) is bound; ``input_it`` itself is | ||
| not kept alive by ``cccl_it``. The caller holds ``input_it`` for the | ||
| duration of the call, which is all the launch needs, whereas ``cccl_it`` | ||
|
Comment on lines
+237
to
+239
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -euo pipefail
fd -i 'device_array.py|protocols.py'
rg -n -C 4 'def (__del__|close|free|sync)\b|cudaFreeAsync|cudaFree|set_cccl_iterator_state\(' python/cuda_ccclRepository: NVIDIA/cccl Length of output: 20385 🏁 Script executed: set -euo pipefail
printf '%s\n' '--- changed hunk and helper ---'
nl -ba python/cuda_cccl/cuda/compute/_cccl_interop.py | sed -n '210,255p'
printf '%s\n' '--- array protocols ---'
nl -ba python/cuda_cccl/cuda/compute/_utils/protocols.py | sed -n '1,260p'
printf '%s\n' '--- test device array ---'
nl -ba python/cuda_cccl/tests/_utils/device_array.py | sed -n '1,260p'
printf '%s\n' '--- relevant launch call sites ---'
nl -ba python/cuda_cccl/cuda/compute/algorithms/_transform.py | sed -n '60,105p'
nl -ba python/cuda_cccl/cuda/compute/algorithms/_reduce.py | sed -n '145,205p'
printf '%s\n' '--- stream and lifetime references ---'
rg -n -F --glob 'python/cuda_cccl/**' -e 'synchronize' -e 'lifetime' -e 'keep alive' -e 'cudaFree' -e 'cudaMalloc' -e '__cuda_array_interface__' -e 'compute(' python/cuda_cccl | sed -n '1,260p'
printf '%s\n' '--- changed files from merge base ---'
git diff --stat dcee23c7a0e4c5a2036d12c6572c7ac5db89bd89 3decf2d554be2e85130fe71c388a18daefa0d2fb -- python/cuda_cccl/cuda/compute/_cccl_interop.py python/cuda_cccl/testsRepository: NVIDIA/cccl Length of output: 35536 🏁 Script executed: set -euo pipefail
printf '%s\n' '--- binding iterator ownership and compute implementation ---'
nl -ba python/cuda_cccl/cuda/compute/_bindings_impl.pyx | sed -n '220,270p'
nl -ba python/cuda_cccl/cuda/compute/_bindings_impl.pyx | sed -n '590,635p'
nl -ba python/cuda_cccl/cuda/compute/_bindings_impl.pyx | sed -n '1120,1175p'
printf '%s\n' '--- pointer iterator and public typing ---'
nl -ba python/cuda_cccl/cuda/compute/iterators/_pointer.py | sed -n '1,120p'
nl -ba python/cuda_cccl/cuda/compute/typing.py | sed -n '1,100p'
printf '%s\n' '--- docs and tests mentioning async lifetime ---'
rg -n -i -F --glob 'python/cuda_cccl/**' --glob 'docs/**' --glob '*.md' -e 'keep.*alive' -e 'lifetime' -e 'asynchronous' -e 'async' -e 'synchroniz' -e '__cuda_array_interface__' | sed -n '1,320p'
printf '%s\n' '--- PR test diff ---'
git diff --unified=20 dcee23c7a0e4c5a2036d12c6572c7ac5db89bd89 3decf2d554be2e85130fe71c388a18daefa0d2fb -- python/cuda_cccl/tests/compute/test_caching.py | sed -n '1,280p'Repository: NVIDIA/cccl Length of output: 41770 🏁 Script executed: set -euo pipefail
printf '%s\n' '--- CCCL compute declarations and docs ---'
rg -n -C 5 -F --glob '*' -e 'cccl_device_reduce' -e 'cccl_device_transform' -e 'cccl_device_unique_by_key' -e 'stream' python/cuda_cccl/cuda/compute/_bindings_impl.pyx python/cuda_cccl/cuda/compute/_bindings.pyi python/cuda_cccl | sed -n '1,360p'
printf '%s\n' '--- public algorithm entrypoints and docstrings ---'
rg -n -C 8 -F --glob 'python/cuda_cccl/cuda/compute/**' -e 'def reduce_into' -e 'def unary_transform' -e 'def unique_by_key' -e 'stream=None' | sed -n '1,360p'
printf '%s\n' '--- array-interface lifetime wording in repository ---'
rg -n -i -C 5 --glob '*.py' --glob '*.rst' --glob '*.md' -e 'producer.*alive' -e 'consumer.*alive' -e 'keep.*reference' -e 'keep.*alive' -e 'until.*sync' -e 'until.*complete' -e 'lifetime' python docs README.md | sed -n '1,260p'Repository: NVIDIA/cccl Length of output: 41826 🌐 Web query:
💡 Result: 🌐 Web query:
💡 Result: important: Correct the input lifetime contract.
|
||
| belongs to an algorithm object that lives in the process-wide wrapper | ||
| cache. | ||
| """ | ||
| if cccl_it.is_ptr_kind: | ||
| ptr = get_data_pointer(input_it) | ||
| cccl_it.bind_pointer_state(ptr, input_it) | ||
|
oleksandr-pavlyk marked this conversation as resolved.
Resolved
|
||
| cccl_it.bind_pointer_state(get_data_pointer(input_it)) | ||
| else: | ||
| state_ = input_it.state | ||
| if isinstance(state_, (IteratorState, Pointer)): | ||
| cccl_it.state = state_ | ||
| else: | ||
| cccl_it.state = make_pointer_object(state_, input_it) | ||
|
oleksandr-pavlyk marked this conversation as resolved.
Resolved
|
||
| cccl_it.state = make_pointer_object(state_) | ||
|
|
||
|
|
||
| @functools.lru_cache() | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
important: Include captured arrays in the lifetime rule. The phrase “arrays you pass” does not cover an array captured by an operator, as in
test_caching.py. The stateful operator packs that array’s device pointer into runtime state. If the caller releases the array before asynchronous work completes, GPU work can use a freed allocation. Tell callers to retain captured arrays and arrays behind iterators until stream work completes. (raw.githubusercontent.com) As per path instructions, documentation changes must “focus on technical accuracy.”Source: Path instructions