Skip to content

fix(cuda): make the CudaImageData device-buffer path work against the shipped binding - #98

Merged
birkholz-cubert merged 2 commits into
mainfrom
fix/cuda-device-buffer-path
Sep 11, 2026
Merged

birkholz-cubert merged 2 commits into
mainfrom
fix/cuda-device-buffer-path

Conversation

@birkholz-cubert

@birkholz-cubert birkholz-cubert commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

CudaImageData cannot hand out a device cube at all in 3.6.0.0rc1 — every consumer path raises on first use. Three defects, all in this wrapper: the native SDK and the cuvis-il binding are both correct.

Found while wiring the same-process device path into cuvis-ai-dataloader, which currently has to repair this at runtime.

The defects

1. _view() calls a function that does not exist.

return int(cuvis_il.cuvis_cuda_view_ptr(v)), int(v.size), int(v.device_ordinal)

cuvis_cuda_view_ptr is exported by no cuvis-il build and appears nowhere in cuvis.swig or cuvis.c — it was never written. So to_torch() and __cuda_array_interface__ both raise AttributeError on a perfectly good device buffer. SWIG already converts the void * field to an int, so the pointer now comes off the struct directly and there is no helper left to go missing.

2. make_ipc() calls another one. cuvis_cuda_descriptor_bytes is absent the same way, so make_ipc() / export_payload() raise too. The descriptor is now serialized here, to the same 184-byte layout cuvis_ipc already documents and parses. Since that layout now exists in two places, a test pins them together.

3. Device buffers are never freed. __del__ and the DLPack capsule deleter both call cuvis_cuda_mem_free(self._handle), but the C API declares:

SDK_CAPI CUVIS_STATUS SDK_CCALL cuvis_cuda_mem_free(CUVIS_CUDA_MEM* i_mem);

a pointer, exactly like cuvis_cuda_ipc_handle_free. Every call raises TypeError out of the binding — ignored in __del__, and invisible in the deleter — so no buffer is ever returned to the SDK's pool and a long-running consumer exhausts the card. The handle is boxed now, and the IPC free is corrected the same way. (The doxygen @param[in] i_mem reads as if it took the handle by value, which is probably how this happened; the signature is the authority and is consistent with its sibling.)

Note cuvis.cuda.capabilities().same_process returns True throughout, because it probes the native symbols in cuvis.dll via binding.available and cannot see the Python layer over them. The failure lands at first use, not at the capability check, so gating on capabilities() does not protect a caller.

Why it shipped, and what stops it recurring

Nothing ever executed this code. test_cuda.py covers capability reporting and the enable/disable toggle; CudaImageData otherwise appears only in test_handle_lifecycle.py, constructed with a deliberately invalid argument. And CI cannot close that gap — it runs in the cuvis_pyil container on ubuntu-latest, with no GPU.

So the new tests lead with ones that need no device:

  • test_every_binding_symbol_the_cuda_path_uses_exists walks cube_utils.py with ast and asserts every cuvis_il.<name> it references exists on the installed binding. Checked against main: it fails and names both cuvis_cuda_view_ptr and cuvis_cuda_descriptor_bytes. This is the one that would have caught the original bug, and it runs on the GPU-less runner.
  • test_descriptor_serialises_to_the_locked_wire_layout and test_the_wire_layout_matches_the_consumer_half cover the descriptor bytes and keep cube_utils and cuvis_ipc from drifting apart.
  • test_free_passes_the_handle_the_way_the_c_api_takes_it pins the calling convention, asserting that the by-value form still raises.

The five device tests skip with an actionable reason.

Verification

Against native SDK 3.6.0 on an RTX 4070 Laptop, real .cu3s data:

check result
_view() ptr=0x2667e4e00 size=65880000 device=0, exactly the cube's bytes
to_torch() (1000, 1080, 61) torch.uint8 cuda:0, bit-identical to the host cube
__cuda_array_interface__ answers instead of raising
40 read-and-drop cycles 0 MiB device-memory growth (was one 66 MB buffer leaked per cube)
make_ipc(BACKEND_LEGACY) 184 bytes, round-trips through cuvis_ipc's own parser with the right backend, size and geometry

Full suite: 172 passed, 5 skipped with a GPU; 168 passed, 9 skipped without one. ruff check and ruff format --check clean.

Not covered

  • The cross-process IPC import is not verified, only the descriptor serialization. Completing it needs cuda-python on the consumer side, which is not installed here; and this device reports ipc_pool=False, ipc_vmm=False, so only the legacy copy-on-export backend was exercisable at all.
  • ptr_blob is serialized from ptr_blob_len bytes, which the legacy backend leaves at 0. The pool backend's path through it is untested for the same reason.

CudaImageData could not hand out a device cube at all in 3.6.0.0rc1. Three
defects, all in this wrapper; the native SDK and the binding are correct.

- _view() called cuvis_il.cuvis_cuda_view_ptr, which no cuvis-il build
  exports, so to_torch() and __cuda_array_interface__ both raised
  AttributeError on a perfectly good device buffer. SWIG already converts
  the void* field to an int, so the pointer is read from the struct
  directly and no binding helper can go missing again.
- make_ipc() called cuvis_il.cuvis_cuda_descriptor_bytes, absent the same
  way. The descriptor is now serialized here, to the same 184-byte layout
  cuvis_ipc already parses; a test pins the two copies of that layout
  together.
- __del__ and the DLPack capsule deleter freed with
  cuvis_cuda_mem_free(handle), but the C API declares
  cuvis_cuda_mem_free(CUVIS_CUDA_MEM*), like cuvis_cuda_ipc_handle_free.
  Every call raised TypeError out of the binding: ignored in __del__,
  invisible in the deleter, so no device buffer was ever returned to the
  SDK's pool and a long-running consumer exhausted the card. The handle is
  boxed now, and the ipc free is corrected the same way.

Verified against SDK 3.6.0 on an RTX 4070: to_torch() is bit-identical to
the host cube, and 40 read-and-drop cycles now grow device memory by 0 MiB.
make_ipc(legacy) produces a 184-byte descriptor that round-trips through
cuvis_ipc's own parser with the right backend, size and geometry; the
cross-process import itself is not covered, since it needs cuda-python.

Why this shipped: nothing ever executed the path. test_cuda.py covers
capability reporting and the enable/disable toggle, and CudaImageData
appeared only in test_handle_lifecycle with a deliberately invalid
argument. CI cannot close that gap, since it runs in the cuvis_pyil
container on a GPU-less runner. So the new tests lead with checks that
need no device: a static assertion that cube_utils references no absent
cuvis_il symbol, which fails on main and names both missing functions, and
the descriptor layout. The device tests skip with an actionable reason,
including when the SDK is processing on the host, which since 3.6.0 is
what a cuvis.init that did not ask for the GPU leaves you with.
…o CUDA

CI runs against a CPU-only libcuvis.so that exports none of the cuvis_cuda_*
functions, so calling one raises RuntimeError from the binding's own guard
rather than the TypeError the test is about. Gated on binding.available, the
same way test_cuda.py gates its device-function tests.

The static symbol test is deliberately not gated and passed on that runner:
SWIG defines the Python attribute either way, so it still catches a wrapper
calling a function the binding never declared.
@birkholz-cubert
birkholz-cubert merged commit dde507b into main Sep 11, 2026
4 checks passed
@birkholz-cubert
birkholz-cubert deleted the fix/cuda-device-buffer-path branch September 11, 2026 09:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant