Skip to content

Commit 11a2109

Browse files
committed
[PyBindings] Reject host buffers for device inputs, release the GIL during device waits, and wait for every output
Three problems remained in the Python bindings for methods whose inputs or outputs live on a CUDA device. A NumPy array or another host buffer passed for an input that the method expects on the device was accepted, and the method then read a host pointer as device memory. Torch tensors and DLPack inputs were already checked. Now a non-empty buffer is rejected the same way, with the same message: "Input 0 of method 'forward' is on device cpu, but the method expects cuda:0". Every wait for the device held the GIL, so all other Python threads stopped until the device finished all its queued work. CUDA-enabled bindings now release the GIL around cudaDeviceSynchronize, when the calling thread holds it. The fallback that waits through torch keeps the GIL. Before a run, the bindings wait in case another library is still reading the memory of an output from an earlier run. That wait covered only outputs in memory the bindings allocated, and a Module call passed no outputs at all. Now it covers the device of every non-empty tensor output, for Method and Module, including memory-planned outputs and inputs the method returns. Input errors now name the input and method the same way: a DLPack device mismatch names both devices, a changed storage no longer tells Module users to call set_inputs(), an error while choosing how to read an input gets the name too, and a Method checks the input count before it reads any input. The README now says that each wait is for the whole device, that the GIL is released during it, and what a wait costs on an idle device. It also says that returning CUDA outputs without a copy needs MemoryPlanningPass(alloc_graph_output=False).
1 parent ab3bc3d commit 11a2109

7 files changed

Lines changed: 286 additions & 162 deletions

File tree

‎extension/pybindings/README.md‎

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@ on the output's original device. With `clone_outputs=False`, results share
4545
runtime or input storage and later execution may overwrite that storage.
4646

4747
A CUDA output that fills memory the bindings allocated for it is returned
48-
without a copy when `clone_outputs=True`. The result keeps that memory, so a
48+
without a copy when `clone_outputs=True`. That needs a method exported with
49+
`MemoryPlanningPass(alloc_graph_output=False)`, so the method does not plan its
50+
outputs into its own memory. The result keeps that memory, so a
4951
method writes its next execution into a second buffer, and the two take turns;
5052
a module allocates new memory each call. Within one execution, reading the
5153
outputs again with `clone_outputs=True` returns the same object, so an in-place
@@ -105,18 +107,24 @@ Copying CUDA memory requires CUDA-enabled bindings: `from_host()`, `to_host()`
105107
of a CUDA result, and a CUDA `ExecuTorchResult` with `clone_outputs=True`.
106108
Bindings built without CUDA support synchronize through Python Torch instead,
107109
so while torch is loaded they still read CUDA inputs, torch tensors or DLPack,
108-
and return borrowed CUDA results. Execution is synchronous at the Python
109-
boundary: a call returns after its device work has finished, so its outputs are
110-
ready on any stream. Each call waits once per device after it runs, rather than
111-
once per output. Before running, it waits once per device for any CUDA input
112-
that another library may still be writing: a torch CUDA tensor, a CUDA DLPack
113-
input, or a result of these bindings passed back in, since that result can share
114-
memory a caller handed to another library. The same wait covers the memory of a
115-
CUDA output that a method returned without a copy: once that result is dropped,
116-
the method writes the memory again, and another library may still be reading
117-
it. A torch CUDA input in another layout also waits before torch copies it into
118-
the exported layout. Reading a CUDA result through `__dlpack__` or `to_host()`
119-
waits once as well. The synchronization has no C++ ATen dependency.
110+
and return borrowed CUDA results.
111+
112+
Execution is synchronous at the Python boundary: a call returns after its device
113+
work has finished, so its outputs are ready on any stream. Every wait is for the
114+
whole device (`cudaDeviceSynchronize`), not for one stream, so it also waits for
115+
unrelated work queued on that device, for example by torch. Before it runs, a
116+
call waits once for each device that holds a non-empty CUDA input or output.
117+
Another library may still be writing such an input: a torch CUDA tensor, a CUDA
118+
DLPack input, or a result of these bindings passed back in, which can share
119+
memory a caller handed to that library. It may also still be reading the memory
120+
of an output from an earlier call, which this call writes again. After it runs,
121+
a call waits once for each device that holds a CUDA output. Each cloned CUDA
122+
output waits once more for its copy. A torch CUDA input in another layout also
123+
waits before torch copies it into the exported layout. Reading a CUDA result
124+
through `__dlpack__` or `to_host()` waits once as well. CUDA-enabled bindings
125+
release the GIL while they wait, so other Python threads keep running. On an
126+
idle device a wait is short: `cudaDeviceSynchronize` took a median of 1.5
127+
microseconds on an A100. The synchronization has no C++ ATen dependency.
120128

121129
For bfloat16 inputs, raw byte buffers use the shape declared by the exported
122130
method. Bfloat16 results expose flat bytes through the buffer protocol because

0 commit comments

Comments
 (0)