|
| 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