Skip to content

Commit dde507b

Browse files
fix(cuda): make the CudaImageData device-buffer path work against the shipped binding (#98)
* fix(cuda): make the device-buffer path work against the shipped binding
1 parent d52ba0a commit dde507b

3 files changed

Lines changed: 300 additions & 7 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,14 @@ Pre-releases (`b*`, `rc*`) are not listed.
113113
- `cuvis.AcquisitionContext.capture` - `to_internal=True` raised `TypeError` instead of queueing the measurement, because it passed a Python `0` where SWIG requires a null pointer.
114114
- `cuvis.Calibration`, `cuvis.AcquisitionContext`, `cuvis.ProcessingContext`, `cuvis.SessionFile`, `cuvis.Measurement`, `cuvis.Viewer`, `cuvis.Worker`, `cuvis.CubeExporter`, `cuvis.EnviExporter`, `cuvis.TiffExporter`, `cuvis.ViewExporter` - `__del__` raised `TypeError` after a failed construction, because it freed a handle that was still `None`.
115115
- `cuvis.Worker.get_next_result_async` - a timeout returned a `cuvis.Worker.WorkerResult` built from handles the SDK never filled in; it now raises `cuvis.cuvis_aux.SDKException`.
116+
- `cuvis.CudaImageData.to_torch`, `cuvis.CudaImageData.__cuda_array_interface__` - raised `AttributeError` on every call, because `_view` used `cuvis_il.cuvis_cuda_view_ptr`, which no `cuvis-il` build exports.
117+
The device pointer is now read from the `cuvis_cuda_mem_view_t` field directly, so no binding helper is involved and none can go missing.
118+
- `cuvis.CudaImageData.make_ipc`, `cuvis.CudaImageData.export_payload` - raised `AttributeError`, because they used `cuvis_il.cuvis_cuda_descriptor_bytes`, which no `cuvis-il` build exports.
119+
The descriptor is now serialized in Python to the same 184-byte layout `cuvis_ipc` parses.
120+
- `cuvis.CudaImageData.__del__`, `cuvis.CudaImageData.to_torch` - device buffers were never released: both freed through `cuvis_cuda_mem_free(handle)`, but the C API declares `cuvis_cuda_mem_free(CUVIS_CUDA_MEM*)`, so every call raised `TypeError` out of the binding.
121+
In `__del__` that surfaced only as an ignored exception, and in the DLPack capsule deleter not at all, so a long-running consumer exhausted the card. The handle is now boxed, and `cuvis_cuda_ipc_handle_free` is corrected the same way.
122+
- `tests/test_cuda_image_data.py` - behavior tests for the device-buffer path, which nothing previously executed: a static check that `cube_utils` references no absent `cuvis_il` symbol, the descriptor's wire layout and its agreement with `cuvis_ipc`, the free calling convention, and, on a machine with a CUDA device, that the zero-copy tensor equals the host cube and that repeated read-and-drop cycles return their memory.
123+
The static and layout checks need no device, which matters because CI has none and therefore never ran this code.
116124

117125
## [3.5.3.2] - 2026-08-19
118126

‎cuvis/cube_utils.py‎

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from ._cuvis_il import cuvis_il
44
import numpy as np
55
import operator
6+
import struct
67
from .cuvis_aux import SDKException
78
from .cuvis_types import DataFormat
89
from . import cuda
@@ -422,6 +423,57 @@ def apply(self, other):
422423
del _op, _reflected, _method
423424

424425

426+
# Wire format of cuvis_cuda_ipc_descriptor_t, mirroring the offsets cuvis_ipc parses. Kept
427+
# here rather than imported from cuvis_ipc, which is the consumer half and lives outside this
428+
# package precisely so a consumer process need not be able to import cuvis.
429+
_IPC_HEAD = struct.Struct(
430+
"<iiiIQQQQ"
431+
) # backend, device_ordinal, handle_type, blob_len, size, alloc_size, offset, exporter_pid
432+
_IPC_BLOB_OFF = 48
433+
_IPC_PTR_LEN_OFF = 112
434+
_IPC_PTR_BLOB_OFF = 120
435+
_IPC_BLOB_MAX = 64
436+
_IPC_DESC_LEN = 184
437+
438+
439+
def _descriptor_bytes(desc) -> bytes:
440+
"""Serialise a cuvis_cuda_ipc_descriptor_t to its transportable form.
441+
442+
Done field by field rather than as one memcpy of the struct: SWIG exposes the scalars
443+
directly and the two blobs only as `unsigned char *`, so there is no way to hand the whole
444+
struct out as bytes from Python. The layout is fixed by the C header and asserted below.
445+
"""
446+
447+
def blob(pointer, length):
448+
"""`length` bytes out of an unsigned char array, padded to the fixed field width."""
449+
if length > _IPC_BLOB_MAX:
450+
raise ValueError("blob length {} exceeds {}".format(length, _IPC_BLOB_MAX))
451+
taken = bytes(
452+
cuvis_il.p_unsigned_char_getitem(pointer, i) for i in range(length)
453+
)
454+
return taken.ljust(_IPC_BLOB_MAX, bytes(1))
455+
456+
out = bytearray(_IPC_DESC_LEN)
457+
_IPC_HEAD.pack_into(
458+
out,
459+
0,
460+
int(desc.backend),
461+
int(desc.device_ordinal),
462+
int(desc.handle_type),
463+
int(desc.blob_len),
464+
int(desc.size),
465+
int(desc.alloc_size),
466+
int(desc.offset),
467+
int(desc.exporter_pid),
468+
)
469+
out[_IPC_BLOB_OFF : _IPC_BLOB_OFF + _IPC_BLOB_MAX] = blob(desc.blob, desc.blob_len)
470+
struct.pack_into("<I", out, _IPC_PTR_LEN_OFF, int(desc.ptr_blob_len))
471+
out[_IPC_PTR_BLOB_OFF : _IPC_PTR_BLOB_OFF + _IPC_BLOB_MAX] = blob(
472+
desc.ptr_blob, desc.ptr_blob_len
473+
)
474+
return bytes(out)
475+
476+
425477
class CudaImageData(object):
426478
"""Device-resident image data backed by a shareable CUDA buffer.
427479
@@ -467,12 +519,26 @@ def __init__(self, cuda_buf):
467519
# bytes of the transportable IPC descriptor, filled by make_ipc()
468520
self.descriptor = None
469521

522+
@staticmethod
523+
def _free(handle):
524+
"""Release one CUVIS_CUDA_MEM.
525+
526+
cuvis_cuda_mem_free takes CUVIS_CUDA_MEM* (like cuvis_cuda_ipc_handle_free), not the
527+
handle by value, so the handle has to be boxed. Passing it by value raises TypeError
528+
out of the binding and the buffer is never returned to the SDK's pool.
529+
"""
530+
box = cuvis_il.new_p_int()
531+
cuvis_il.p_int_assign(box, int(handle))
532+
return cuvis_il.cuvis_cuda_mem_free(box)
533+
470534
def _view(self):
471535
"""(device_ptr:int, size_bytes:int, device_ordinal:int)."""
472536
v = cuvis_il.cuvis_cuda_mem_view_t()
473537
if cuvis_il.status_ok != cuvis_il.cuvis_cuda_mem_get_view(self._handle, v):
474538
raise SDKException()
475-
return int(cuvis_il.cuvis_cuda_view_ptr(v)), int(v.size), int(v.device_ordinal)
539+
# int() on the void* SWIG object yields the address directly; no binding helper
540+
# is involved, so this cannot drift out of sync with one.
541+
return int(v.device_ptr), int(v.size), int(v.device_ordinal)
476542

477543
@property
478544
def __cuda_array_interface__(self):
@@ -519,9 +585,7 @@ def to_torch(self):
519585
):
520586
raise SDKException()
521587
ref = cuvis_il.p_int_value(pref)
522-
producer = make_cuda_dlpack(
523-
ptr, size, dev, on_delete=lambda: cuvis_il.cuvis_cuda_mem_free(ref)
524-
)
588+
producer = make_cuda_dlpack(ptr, size, dev, on_delete=lambda: self._free(ref))
525589
t = torch.from_dlpack(producer) # flat uint8
526590
t = t.view(getattr(torch, self._TORCH_DTYPE[self._format]))
527591
return t.reshape(self.height, self.width, self.channels)
@@ -548,7 +612,7 @@ def make_ipc(self, backend: int = 0):
548612
self._ipc_handle, desc
549613
):
550614
raise SDKException()
551-
self.descriptor = cuvis_il.cuvis_cuda_descriptor_bytes(desc)
615+
self.descriptor = _descriptor_bytes(desc)
552616
return self.descriptor
553617

554618
def export_payload(self, backend: int = 0) -> bytes:
@@ -576,7 +640,9 @@ def __del__(self):
576640
# Not wrapped in try/except: a device buffer that fails to free is a VRAM leak,
577641
# and swallowing it here would hide the leak as well as the reason for it.
578642
if self._ipc_handle is not None:
579-
cuvis_il.cuvis_cuda_ipc_handle_free(self._ipc_handle)
643+
box = cuvis_il.new_p_int()
644+
cuvis_il.p_int_assign(box, int(self._ipc_handle))
645+
cuvis_il.cuvis_cuda_ipc_handle_free(box)
580646
self._ipc_handle = None
581-
cuvis_il.cuvis_cuda_mem_free(self._handle)
647+
self._free(self._handle)
582648
self._handle = None

‎tests/test_cuda_image_data.py‎

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
"""Tests for the CudaImageData device-buffer path.
2+
3+
The device tests need two things: a CUDA device the SDK accepts, and an SDK actually
4+
processing on it. Since 3.6.0 the latter is not the default -- a process whose `cuvis.init`
5+
did not ask for it processes on the host, and then no cube is backed by device memory -- so
6+
point CUVIS_SETTINGS at a directory whose `cuvis.settings` carries
7+
`force_gpu_mode="cuda"` before running these::
8+
9+
python -c "import cuvis; cuvis.SdkSettings(force_gpu_mode='cuda').save('/tmp/cuvis-gpu')"
10+
CUVIS_SETTINGS=/tmp/cuvis-gpu python -m pytest tests/test_cuda_image_data.py
11+
12+
They skip otherwise, which CI always is: it runs in the cuvis_pyil container on a GPU-less
13+
runner. That is precisely how a release went out calling two cuvis_il functions that do not
14+
exist, so the tests that matter most here are the ones needing no device at all.
15+
"""
16+
17+
import ast
18+
import struct
19+
from pathlib import Path
20+
21+
import pytest
22+
23+
import cuvis
24+
from cuvis import binding, cuda
25+
from cuvis._cuvis_il import cuvis_il
26+
from cuvis.cube_utils import _descriptor_bytes
27+
28+
# Two different denials: the loaded library may not export the CUDA functions at all (the
29+
# CPU-only build CI runs against), and the device may not support them. The first makes even
30+
# calling into the binding raise, so it gates separately from the device tests below.
31+
_HAS_CUDA_EXPORTS = binding.available("cuvis_cuda_mem_free")
32+
requires_cuda_exports = pytest.mark.skipif(
33+
not _HAS_CUDA_EXPORTS, reason="loaded cuvis library exports no CUDA functions"
34+
)
35+
requires_device = pytest.mark.skipif(
36+
not cuda.capabilities().same_process, reason="needs a CUDA device the SDK accepts"
37+
)
38+
39+
40+
def _binding_names(module_path):
41+
"""Every `cuvis_il.<name>` this module reaches for, found without importing it."""
42+
tree = ast.parse(Path(module_path).read_text(encoding="utf-8"))
43+
return {
44+
node.attr
45+
for node in ast.walk(tree)
46+
if isinstance(node, ast.Attribute)
47+
and isinstance(node.value, ast.Name)
48+
and node.value.id == "cuvis_il"
49+
}
50+
51+
52+
def test_every_binding_symbol_the_cuda_path_uses_exists():
53+
"""Test cube_utils calls no cuvis_il function the installed binding lacks.
54+
55+
Nothing on a GPU-less runner ever executes the device path, so a missing symbol there
56+
surfaces only as an AttributeError in a user's process. This catches it at any commit.
57+
"""
58+
used = _binding_names(cuvis.cube_utils.__file__)
59+
missing = sorted(name for name in used if not hasattr(cuvis_il, name))
60+
assert not missing, f"cube_utils references absent cuvis_il symbols: {missing}"
61+
62+
63+
@requires_cuda_exports
64+
def test_free_passes_the_handle_the_way_the_c_api_takes_it():
65+
"""Test cuvis_cuda_mem_free is called with CUVIS_CUDA_MEM*, not the handle by value.
66+
67+
The C API declares `cuvis_cuda_mem_free(CUVIS_CUDA_MEM* i_mem)`, like
68+
`cuvis_cuda_ipc_handle_free`. Passing the int raises TypeError out of the binding, and the
69+
buffer is then never returned to the SDK's pool.
70+
"""
71+
with pytest.raises(TypeError):
72+
cuvis_il.cuvis_cuda_mem_free(0) # the shape that silently leaked
73+
74+
box = cuvis_il.new_p_int()
75+
cuvis_il.p_int_assign(box, 0)
76+
cuvis_il.cuvis_cuda_mem_free(box) # freeing a null handle is a no-op, not an error
77+
78+
79+
# --- the IPC descriptor, which needs no device to serialise -------------------------------
80+
def test_descriptor_serialises_to_the_locked_wire_layout():
81+
"""Test _descriptor_bytes emits the 184-byte layout cuvis_ipc parses, blobs included."""
82+
desc = cuvis_il.cuvis_cuda_ipc_descriptor_t()
83+
desc.backend = 2
84+
desc.device_ordinal = 1
85+
desc.handle_type = 3
86+
desc.blob_len = 4
87+
desc.size = 65880000
88+
desc.alloc_size = 67108864
89+
desc.offset = 128
90+
desc.exporter_pid = 4321
91+
desc.ptr_blob_len = 2
92+
for index, value in enumerate((0xDE, 0xAD, 0xBE, 0xEF)):
93+
cuvis_il.p_unsigned_char_setitem(desc.blob, index, value)
94+
for index, value in enumerate((0x01, 0x02)):
95+
cuvis_il.p_unsigned_char_setitem(desc.ptr_blob, index, value)
96+
97+
raw = _descriptor_bytes(desc)
98+
99+
assert len(raw) == 184
100+
head = struct.unpack_from("<iiiIQQQQ", raw, 0)
101+
assert head == (2, 1, 3, 4, 65880000, 67108864, 128, 4321)
102+
assert raw[48:52] == bytes((0xDE, 0xAD, 0xBE, 0xEF))
103+
assert struct.unpack_from("<I", raw, 112) == (2,)
104+
assert raw[120:122] == bytes((0x01, 0x02))
105+
106+
107+
def test_descriptor_rejects_an_over_long_blob():
108+
"""Test a blob_len past the fixed field width fails loudly rather than truncating."""
109+
desc = cuvis_il.cuvis_cuda_ipc_descriptor_t()
110+
desc.blob_len = 65
111+
with pytest.raises(ValueError, match="exceeds 64"):
112+
_descriptor_bytes(desc)
113+
114+
115+
def test_the_wire_layout_matches_the_consumer_half():
116+
"""Test cuvis_ipc and cube_utils agree on the offsets; they are two copies of one layout."""
117+
cuvis_ipc = pytest.importorskip("cuvis_ipc")
118+
from cuvis import cube_utils
119+
120+
assert cube_utils._IPC_HEAD.format == cuvis_ipc._HEAD.format
121+
assert cube_utils._IPC_BLOB_OFF == cuvis_ipc._BLOB_OFF
122+
assert cube_utils._IPC_PTR_LEN_OFF == cuvis_ipc._PTR_LEN_OFF
123+
assert cube_utils._IPC_PTR_BLOB_OFF == cuvis_ipc._PTR_BLOB_OFF
124+
assert cube_utils._IPC_BLOB_MAX == cuvis_ipc._BLOB_MAX
125+
assert cube_utils._IPC_DESC_LEN == cuvis_ipc._DESC_LEN
126+
127+
128+
# --- the real device path ------------------------------------------------------------------
129+
def _device_cube(pc, session):
130+
"""A cube left in device memory, or a skip naming why the SDK did not produce one."""
131+
try:
132+
return pc.apply(session.get_measurement(0)).get_cube_cuda()
133+
except cuvis.cuvis_aux.SDKException as exc:
134+
pytest.skip(
135+
"SDK is not processing on the device ({}); set CUVIS_SETTINGS to a directory "
136+
"whose cuvis.settings has force_gpu_mode=cuda".format(exc)
137+
)
138+
139+
140+
@pytest.fixture
141+
def raw_context(test_session_file):
142+
"""A Raw ProcessingContext shared with the session, as the reader path uses it."""
143+
pc = cuvis.ProcessingContext(test_session_file)
144+
test_session_file._pc = pc
145+
pc.processing_mode = cuvis.ProcessingMode.Raw
146+
return pc
147+
148+
149+
@pytest.fixture
150+
def device_cube(test_session_file, raw_context):
151+
"""A processed cube left in device memory, plus the same cube fetched to the host."""
152+
import numpy as np
153+
154+
host = np.ascontiguousarray(
155+
raw_context.apply(test_session_file.get_measurement(0)).cube.array
156+
)
157+
cuda.enable() # one way for this process, so it happens after the host reference
158+
try:
159+
yield _device_cube(raw_context, test_session_file), host
160+
finally:
161+
cuda.disable()
162+
163+
164+
@requires_device
165+
def test_view_describes_the_device_buffer(device_cube):
166+
"""Test _view() returns a usable pointer and the cube's exact byte count."""
167+
cimg, host = device_cube
168+
ptr, size, ordinal = cimg._view()
169+
assert ptr > 0
170+
assert size == host.nbytes
171+
assert ordinal >= 0
172+
173+
174+
@requires_device
175+
def test_to_torch_is_the_same_cube_on_the_device(device_cube):
176+
"""Test the zero-copy tensor holds exactly what the host path would have produced."""
177+
torch = pytest.importorskip("torch")
178+
cimg, host = device_cube
179+
tensor = cimg.to_torch()
180+
assert tensor.is_cuda
181+
assert tuple(tensor.shape) == host.shape
182+
assert torch.equal(tensor.cpu(), torch.from_numpy(host))
183+
184+
185+
@requires_device
186+
def test_cuda_array_interface_reports_the_buffer(device_cube):
187+
"""Test the fallback interop path answers instead of raising from the binding."""
188+
cimg, host = device_cube
189+
with pytest.warns(UserWarning, match="lifecycle"):
190+
interface = cimg.__cuda_array_interface__
191+
assert interface["shape"] == host.shape
192+
assert interface["data"][0] > 0
193+
194+
195+
@requires_device
196+
def test_device_buffers_are_returned_to_the_sdk(test_session_file, raw_context):
197+
"""Test repeated read-and-drop cycles do not accumulate device memory.
198+
199+
A free that raises leaves every cube's buffer outstanding, which is invisible until a
200+
long run exhausts the card. The SDK pools the buffers, so this asserts a plateau rather
201+
than a return to the starting value.
202+
"""
203+
torch = pytest.importorskip("torch")
204+
cuda.enable()
205+
try:
206+
_device_cube(raw_context, test_session_file).to_torch()
207+
torch.cuda.synchronize()
208+
settled = torch.cuda.mem_get_info()[0]
209+
for _ in range(20):
210+
held = _device_cube(raw_context, test_session_file).to_torch()
211+
del held
212+
torch.cuda.synchronize()
213+
free_now = torch.cuda.mem_get_info()[0]
214+
finally:
215+
cuda.disable()
216+
lost_mib = (settled - free_now) / 2**20
217+
assert lost_mib < 256, (
218+
f"{lost_mib:.0f} MiB of device memory not returned over 20 cycles"
219+
)

0 commit comments

Comments
 (0)