tensoracle 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,184 @@
1
+ """Execute TensorRT engines with caller-owned addresses and CUDA streams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import TYPE_CHECKING, Self
7
+
8
+ import tensorrt as trt
9
+
10
+ if TYPE_CHECKING:
11
+ from ._cache import EngineHandle
12
+
13
+
14
+ class EngineExecution:
15
+ """One independent TensorRT context retained with its checked engine handle.
16
+
17
+ Use ``handle.create_execution()`` or construct this type with an ``EngineHandle``.
18
+ The caller owns buffers, streams, their ordering, and completion. This object
19
+ neither copies data nor synchronizes. Finish outstanding work before changing
20
+ shapes or streams, closing, or reusing the object from another thread.
21
+ """
22
+
23
+ def __init__(self, handle: EngineHandle) -> None:
24
+ self._handle: EngineHandle | None = handle
25
+ self._context: trt.IExecutionContext | None = None
26
+ self._ready = False
27
+ engine = handle.engine
28
+ self._names = tuple(engine.get_tensor_name(i) for i in range(engine.num_io_tensors))
29
+ self._ranges: dict[str, tuple[tuple[int, ...], tuple[int, ...]]] = {}
30
+ initial_shapes = {}
31
+ try:
32
+ for name in self._names:
33
+ if engine.get_tensor_location(name) != trt.TensorLocation.DEVICE:
34
+ raise ValueError(
35
+ f"tensor {name!r} has HOST location; execution requires DEVICE"
36
+ )
37
+ if engine.get_tensor_mode(name) != trt.TensorIOMode.INPUT:
38
+ continue
39
+ if engine.is_shape_inference_io(name):
40
+ raise ValueError(f"input {name!r} is an external shape tensor; not supported")
41
+ shape = tuple(engine.get_tensor_shape(name))
42
+ if -1 in shape:
43
+ low, optimum, high = (
44
+ tuple(part) for part in engine.get_tensor_profile_shape(name, 0)
45
+ )
46
+ initial_shapes[name] = optimum
47
+ if low != high:
48
+ self._ranges[name] = (low, high)
49
+ else:
50
+ initial_shapes[name] = shape
51
+ self._context = engine.create_execution_context()
52
+ if self._context is None:
53
+ raise RuntimeError("TensorRT failed to create an execution context")
54
+ # Probe output dimensions before accepting the engine. A ranged engine
55
+ # still requires an explicit caller setting after this capability check.
56
+ self._set_shapes(initial_shapes, creating=True)
57
+ self._ready = not self._ranges
58
+ except BaseException:
59
+ self.close()
60
+ raise
61
+
62
+ def set_input_shapes(self, shapes: Mapping[str, tuple[int, ...]]) -> None:
63
+ """Set every ranged input together, validating all shapes before TensorRT.
64
+
65
+ Fixed inputs, including single-point profiles, are initialized at creation
66
+ and omitted here. A TensorRT failure invalidates the setting until a complete
67
+ subsequent call succeeds; partial settings are not rolled back.
68
+ """
69
+ self._require_open()
70
+ self._check_names(shapes, self._ranges.keys(), "input shape")
71
+ for name, shape in shapes.items():
72
+ low, high = self._ranges[name]
73
+ if (
74
+ not isinstance(shape, tuple)
75
+ or len(shape) != len(low)
76
+ or any(
77
+ type(value) is not int or not minimum <= value <= maximum
78
+ for value, minimum, maximum in zip(shape, low, high, strict=True)
79
+ )
80
+ ):
81
+ raise ValueError(
82
+ f"input {name!r} shape {shape!r} is outside range {low!r}..{high!r}"
83
+ )
84
+ self._set_shapes(shapes)
85
+
86
+ def get_tensor_shape(self, name: str) -> tuple[int, ...]:
87
+ """Read a concrete input or output shape after all input shapes are ready."""
88
+ context = self._require_ready()
89
+ self._check_tensor(name)
90
+ shape = tuple(context.get_tensor_shape(name))
91
+ if any(dimension < 0 for dimension in shape):
92
+ self._ready = False
93
+ raise RuntimeError(f"tensor {name!r} shape {shape!r} is unresolved")
94
+ return shape
95
+
96
+ def get_tensor_dtype(self, name: str) -> trt.DataType:
97
+ """Read a tensor's TensorRT dtype for allocating a caller-owned buffer."""
98
+ self._require_open()
99
+ self._check_tensor(name)
100
+ return self._handle.engine.get_tensor_dtype(name)
101
+
102
+ def enqueue(self, addresses: Mapping[str, int], *, stream: int) -> None:
103
+ """Enqueue all tensor addresses on the caller's stream without waiting.
104
+
105
+ Addresses must be positive integers, and the stream must be a nonnegative
106
+ integer (0 is the default stream). Bool is rejected. This method cannot
107
+ check buffer dtype, dimensions, layout, size, lifetime, or device selection.
108
+ """
109
+ self._require_open()
110
+ self._check_names(addresses, self._names, "address")
111
+ for name, address in addresses.items():
112
+ if type(address) is not int or address <= 0:
113
+ raise ValueError(f"tensor {name!r} address {address!r} must be a positive integer")
114
+ if type(stream) is not int or stream < 0:
115
+ raise ValueError(f"stream {stream!r} must be a nonnegative integer")
116
+ context = self._require_ready()
117
+ for name in self._names:
118
+ if not context.set_tensor_address(name, addresses[name]):
119
+ raise RuntimeError(f"TensorRT rejected tensor {name!r} address {addresses[name]!r}")
120
+ if not context.execute_async_v3(stream):
121
+ raise RuntimeError(f"TensorRT enqueue failed on stream {stream!r}")
122
+
123
+ def close(self) -> None:
124
+ """Release context before handle without synchronization; repeated calls do nothing.
125
+
126
+ The caller must confirm that all enqueued work has completed before closing.
127
+ """
128
+ self._ready = False
129
+ self._context = None
130
+ self._handle = None
131
+
132
+ def __enter__(self) -> Self:
133
+ self._require_open()
134
+ return self
135
+
136
+ def __exit__(self, exc_type, exc_value, traceback) -> None:
137
+ self.close()
138
+
139
+ def _require_open(self) -> trt.IExecutionContext:
140
+ if self._context is None:
141
+ raise RuntimeError("engine execution is closed")
142
+ return self._context
143
+
144
+ def _require_ready(self) -> trt.IExecutionContext:
145
+ context = self._require_open()
146
+ if not self._ready:
147
+ raise RuntimeError("input shapes need a complete successful setting before execution")
148
+ return context
149
+
150
+ def _check_tensor(self, name: str) -> None:
151
+ if name not in self._names:
152
+ raise ValueError(f"unknown engine tensor {name!r}")
153
+
154
+ @staticmethod
155
+ def _check_names(values: Mapping, names, field: str) -> None:
156
+ expected = set(names)
157
+ for name in sorted(expected - values.keys(), key=repr):
158
+ raise ValueError(f"tensor {name!r} {field} is missing")
159
+ for name in sorted(values.keys() - expected, key=repr):
160
+ raise ValueError(f"unexpected tensor {name!r} {field} {values[name]!r}")
161
+
162
+ def _set_shapes(self, shapes: Mapping[str, tuple[int, ...]], *, creating: bool = False) -> None:
163
+ self._ready = False
164
+ for name, shape in shapes.items():
165
+ try:
166
+ accepted = self._context.set_input_shape(name, shape)
167
+ except Exception as error:
168
+ raise RuntimeError(
169
+ f"TensorRT failed to set input {name!r} shape {shape!r}"
170
+ ) from error
171
+ if not accepted:
172
+ raise RuntimeError(f"TensorRT rejected input {name!r} shape {shape!r}")
173
+ missing = self._context.infer_shapes()
174
+ if missing:
175
+ raise RuntimeError(f"TensorRT shape inference requires tensors {missing!r}")
176
+ for name in self._names:
177
+ shape = tuple(self._context.get_tensor_shape(name))
178
+ if any(dimension < 0 for dimension in shape):
179
+ if creating:
180
+ raise ValueError(
181
+ f"tensor {name!r} has data-dependent shape {shape!r}; not supported"
182
+ )
183
+ raise RuntimeError(f"tensor {name!r} shape {shape!r} is unresolved")
184
+ self._ready = True
tensoracle/_memory.py ADDED
@@ -0,0 +1,18 @@
1
+ """Release unused host memory when the process allocator supports trimming."""
2
+
3
+ import ctypes
4
+ import ctypes.util
5
+
6
+
7
+ def release_host_memory() -> None:
8
+ """Return free glibc heap pages without requiring libc on other platforms."""
9
+ try:
10
+ library = ctypes.util.find_library("c")
11
+ if library is None:
12
+ return
13
+ trim = ctypes.CDLL(library).malloc_trim
14
+ trim.argtypes = [ctypes.c_size_t]
15
+ trim.restype = ctypes.c_int
16
+ trim(0)
17
+ except (OSError, AttributeError):
18
+ pass
tensoracle/_plugins.py ADDED
@@ -0,0 +1,223 @@
1
+ """Validate native plugin exports and retain TensorRT library handles."""
2
+
3
+ import hashlib
4
+ import struct
5
+ import sys
6
+ import threading
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+
10
+
11
+ class PluginLibraryError(ValueError):
12
+ """A native plugin library cannot be registered for this process."""
13
+
14
+
15
+ _lock = threading.RLock()
16
+ _loaded_paths: dict[Path, str] = {}
17
+ _loaded_digests: dict[str, object] = {}
18
+
19
+
20
+ def _read_struct(data: bytes, offset: int, format_: str) -> tuple[int, ...]:
21
+ size = struct.calcsize(format_)
22
+ if offset < 0 or offset + size > len(data):
23
+ raise PluginLibraryError("truncated native library")
24
+ return struct.unpack_from(format_, data, offset)
25
+
26
+
27
+ def _elf_exports(data: bytes) -> set[str]:
28
+ if len(data) < 64 or data[:4] != b"\x7fELF":
29
+ raise PluginLibraryError("invalid ELF plugin library")
30
+ bits, encoding = data[4:6]
31
+ if bits not in (1, 2) or encoding not in (1, 2):
32
+ raise PluginLibraryError("unsupported ELF plugin library")
33
+ endian = "<" if encoding == 1 else ">"
34
+ if bits == 2:
35
+ (section_offset,) = _read_struct(data, 40, endian + "Q")
36
+ section_size, section_count = _read_struct(data, 58, endian + "HH")
37
+ section_format = endian + "IIQQQQIIQQ"
38
+ symbol_format = endian + "IBBHQQ"
39
+ else:
40
+ (section_offset,) = _read_struct(data, 32, endian + "I")
41
+ section_size, section_count = _read_struct(data, 46, endian + "HH")
42
+ section_format = endian + "IIIIIIIIII"
43
+ symbol_format = endian + "IIIBBH"
44
+ if section_count == 0 or section_size < struct.calcsize(section_format):
45
+ raise PluginLibraryError("missing ELF section table")
46
+ sections = [
47
+ _read_struct(data, section_offset + index * section_size, section_format)
48
+ for index in range(section_count)
49
+ ]
50
+ exports = set()
51
+ for section in sections:
52
+ if section[1] != 11: # SHT_DYNSYM
53
+ continue
54
+ string_index = section[6]
55
+ if string_index >= len(sections):
56
+ raise PluginLibraryError("invalid ELF symbol string table")
57
+ strings = sections[string_index]
58
+ string_data = data[strings[4] : strings[4] + strings[5]]
59
+ if len(string_data) != strings[5]:
60
+ raise PluginLibraryError("truncated ELF symbol string table")
61
+ symbols_offset, symbols_size, symbol_stride = section[4], section[5], section[9]
62
+ if symbol_stride < struct.calcsize(symbol_format) or symbols_size % symbol_stride:
63
+ raise PluginLibraryError("invalid ELF symbol table")
64
+ for offset in range(symbols_offset, symbols_offset + symbols_size, symbol_stride):
65
+ symbol = _read_struct(data, offset, symbol_format)
66
+ name_offset = symbol[0]
67
+ if bits == 2:
68
+ info, visibility, section_index = symbol[1:4]
69
+ else:
70
+ info, visibility, section_index = symbol[3:6]
71
+ if section_index == 0 or info >> 4 not in (1, 2) or visibility & 3 not in (0, 3):
72
+ continue
73
+ if name_offset >= len(string_data):
74
+ raise PluginLibraryError("invalid ELF symbol name")
75
+ end = string_data.find(b"\0", name_offset)
76
+ if end < 0:
77
+ raise PluginLibraryError("unterminated ELF symbol name")
78
+ exports.add(string_data[name_offset:end].decode("ascii", errors="replace"))
79
+ return exports
80
+
81
+
82
+ def _pe_export_offsets(data: bytes) -> dict[str, int]:
83
+ """Locate PE export values without executing any library initializers."""
84
+ if data[:2] != b"MZ":
85
+ raise PluginLibraryError("invalid PE plugin library")
86
+ (header,) = _read_struct(data, 0x3C, "<I")
87
+ if data[header : header + 4] != b"PE\0\0":
88
+ raise PluginLibraryError("invalid PE plugin library")
89
+ (section_count,) = _read_struct(data, header + 6, "<H")
90
+ (optional_size,) = _read_struct(data, header + 20, "<H")
91
+ optional = header + 24
92
+ (magic,) = _read_struct(data, optional, "<H")
93
+ if magic not in (0x10B, 0x20B):
94
+ raise PluginLibraryError("unsupported PE plugin library")
95
+ (export_rva,) = _read_struct(data, optional + (112 if magic == 0x20B else 96), "<I")
96
+ sections = [
97
+ _read_struct(data, optional + optional_size + index * 40 + 8, "<IIII")
98
+ for index in range(section_count)
99
+ ]
100
+
101
+ def file_offset(rva: int) -> int:
102
+ for virtual_size, virtual_address, raw_size, raw_offset in sections:
103
+ if virtual_address <= rva < virtual_address + max(virtual_size, raw_size):
104
+ return raw_offset + rva - virtual_address
105
+ raise PluginLibraryError("invalid PE export address")
106
+
107
+ if export_rva == 0:
108
+ return {}
109
+ export = file_offset(export_rva)
110
+ (count,) = _read_struct(data, export + 24, "<I")
111
+ functions_rva, names_rva, ordinals_rva = _read_struct(data, export + 28, "<III")
112
+ if count > 100_000:
113
+ raise PluginLibraryError("invalid PE export count")
114
+ names = {}
115
+ for index in range(count):
116
+ (name_rva,) = _read_struct(data, file_offset(names_rva) + index * 4, "<I")
117
+ offset = file_offset(name_rva)
118
+ end = data.find(b"\0", offset)
119
+ if end < 0:
120
+ raise PluginLibraryError("unterminated PE export name")
121
+ (ordinal,) = _read_struct(data, file_offset(ordinals_rva) + index * 2, "<H")
122
+ (value_rva,) = _read_struct(data, file_offset(functions_rva) + ordinal * 4, "<I")
123
+ names[data[offset:end].decode("ascii", errors="replace")] = file_offset(value_rva)
124
+ return names
125
+
126
+
127
+ def _pe_exports(data: bytes) -> set[str]:
128
+ return set(_pe_export_offsets(data))
129
+
130
+
131
+ def _exports(data: bytes) -> set[str]:
132
+ if sys.platform.startswith("linux"):
133
+ return _elf_exports(data)
134
+ if sys.platform == "win32":
135
+ return _pe_exports(data)
136
+ raise PluginLibraryError(f"native plugin inspection is unsupported on {sys.platform}")
137
+
138
+
139
+ def exported_data(data: bytes, name: str, expected_size: int | None = None) -> bytes:
140
+ """Read an exported constant from a native library without loading the library."""
141
+ if sys.platform == "win32":
142
+ if expected_size is None or expected_size <= 0:
143
+ raise PluginLibraryError("PE data inspection requires a positive size")
144
+ offset = _pe_export_offsets(data).get(name)
145
+ if offset is None or offset + expected_size > len(data):
146
+ raise PluginLibraryError(f"native library does not export {name}")
147
+ return data[offset : offset + expected_size]
148
+ if sys.platform != "linux":
149
+ raise PluginLibraryError(f"native data inspection is unsupported on {sys.platform}")
150
+ if len(data) < 64 or data[:6] != b"\x7fELF\x02\x01":
151
+ raise PluginLibraryError("expected a 64-bit little-endian ELF plugin library")
152
+ (section_offset,) = _read_struct(data, 40, "<Q")
153
+ section_size, section_count = _read_struct(data, 58, "<HH")
154
+ if section_size < 64:
155
+ raise PluginLibraryError("invalid ELF section table")
156
+ sections = [
157
+ _read_struct(data, section_offset + index * section_size, "<IIQQQQIIQQ")
158
+ for index in range(section_count)
159
+ ]
160
+ for section in sections:
161
+ if section[1] != 11:
162
+ continue
163
+ if section[6] >= len(sections):
164
+ raise PluginLibraryError("invalid ELF symbol string table")
165
+ strings = sections[section[6]]
166
+ names = data[strings[4] : strings[4] + strings[5]]
167
+ stride = section[9]
168
+ if stride < 24:
169
+ raise PluginLibraryError("invalid ELF symbol stride")
170
+ for offset in range(section[4], section[4] + section[5], stride):
171
+ name_offset, _, _, section_index, address, size = _read_struct(data, offset, "<IBBHQQ")
172
+ if name_offset >= len(names) or section_index >= len(sections):
173
+ continue
174
+ end = names.find(b"\0", name_offset)
175
+ if end < 0 or names[name_offset:end] != name.encode("ascii"):
176
+ continue
177
+ owner = sections[section_index]
178
+ file_offset = owner[4] + address - owner[3]
179
+ if size <= 0 or file_offset < 0 or file_offset + size > len(data):
180
+ raise PluginLibraryError(f"invalid native data symbol {name}")
181
+ if expected_size is not None and expected_size != size:
182
+ raise PluginLibraryError(f"native data symbol {name} has the wrong size")
183
+ return data[file_offset : file_offset + size]
184
+ raise PluginLibraryError(f"native library does not export {name}")
185
+
186
+
187
+ def register_libraries(paths: Sequence[Path]) -> tuple[str, ...]:
188
+ """Register each distinct library once and return the digests of its loaded bytes."""
189
+ import tensorrt as trt
190
+
191
+ digests = []
192
+ with _lock:
193
+ for candidate in paths:
194
+ try:
195
+ path = Path(candidate).resolve(strict=True)
196
+ data = path.read_bytes()
197
+ except OSError as error:
198
+ raise PluginLibraryError(
199
+ f"plugin library is missing or unreadable: {candidate}"
200
+ ) from error
201
+ digest = hashlib.sha256(data).hexdigest()
202
+ if path in _loaded_paths:
203
+ if _loaded_paths[path] != digest:
204
+ raise PluginLibraryError(f"loaded plugin library changed: {path}")
205
+ digests.append(digest)
206
+ continue
207
+ if "getCreators" not in _exports(data):
208
+ raise PluginLibraryError(f"plugin library does not export getCreators: {path}")
209
+ if digest not in _loaded_digests:
210
+ try:
211
+ handle = trt.get_plugin_registry().load_library(str(path))
212
+ except Exception as error:
213
+ raise PluginLibraryError(
214
+ f"TensorRT could not register plugin library: {path}"
215
+ ) from error
216
+ if handle is None:
217
+ raise PluginLibraryError(f"TensorRT could not register plugin library: {path}")
218
+ if hashlib.sha256(path.read_bytes()).hexdigest() != digest:
219
+ raise PluginLibraryError(f"plugin library changed while loading: {path}")
220
+ _loaded_digests[digest] = handle
221
+ _loaded_paths[path] = digest
222
+ digests.append(digest)
223
+ return tuple(digests)
tensoracle/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.3.1'
22
+ __version_tuple__ = version_tuple = (0, 3, 1)
23
+
24
+ __commit_id__ = commit_id = None
tensoracle/bundle.py ADDED
@@ -0,0 +1,206 @@
1
+ """Inspect a cubin bundle and its native plugin without a GPU or producer."""
2
+
3
+ import hashlib
4
+ import json
5
+ import struct
6
+ import tempfile
7
+ from pathlib import Path
8
+ from zipfile import ZipFile
9
+
10
+ from ._plugins import PluginLibraryError, exported_data
11
+
12
+
13
+ class BundleError(ValueError):
14
+ """A cubin bundle does not match its manifest or native plugin."""
15
+
16
+
17
+ class MissingCubinError(BundleError):
18
+ """A declared cubin is missing."""
19
+
20
+
21
+ class CorruptCubinError(BundleError):
22
+ """A cubin's bytes differ from its declared digest or length."""
23
+
24
+
25
+ class CubinArchitectureError(BundleError):
26
+ """A cubin's ELF architecture differs from its declared SM."""
27
+
28
+
29
+ class PluginBundleMismatchError(BundleError):
30
+ """A native plugin was built for a different cubin bundle."""
31
+
32
+
33
+ def _canonical(value: object) -> bytes:
34
+ return json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")
35
+
36
+
37
+ def bundle_sha256(manifest: dict) -> str:
38
+ """Return the identity of a manifest, excluding its own digest field."""
39
+ return hashlib.sha256(
40
+ _canonical({k: v for k, v in manifest.items() if k != "bundle_sha256"})
41
+ ).hexdigest()
42
+
43
+
44
+ def cubin_sm(data: bytes) -> str:
45
+ """Read the NVIDIA architecture from a cubin ELF header."""
46
+ if len(data) < 64 or data[:6] != b"\x7fELF\x02\x01":
47
+ raise CubinArchitectureError("cubin is not a 64-bit little-endian ELF")
48
+ if struct.unpack_from("<H", data, 18)[0] != 190:
49
+ raise CubinArchitectureError("cubin has no NVIDIA CUDA ELF machine")
50
+ flags = struct.unpack_from("<I", data, 48)[0]
51
+ old_sm = (flags >> 16) & 0xFF
52
+ new_sm = (flags >> 8) & 0xFF
53
+ if old_sm in (86, 89):
54
+ return f"sm{old_sm}"
55
+ if new_sm == 120:
56
+ return "sm120"
57
+ raise CubinArchitectureError(f"unsupported cubin architecture in ELF flags: {flags:#x}")
58
+
59
+
60
+ def _validate_entry(entry: dict) -> None:
61
+ if entry.get("target_sm") not in ("sm86", "sm89", "sm120"):
62
+ raise BundleError("unsupported target SM")
63
+ hidden = entry.get("hidden_trailing_parameters")
64
+ if hidden != [
65
+ {"name": "global_scratch", "parameter_bytes": 8},
66
+ {"name": "profile_scratch", "parameter_bytes": 8},
67
+ ]:
68
+ raise BundleError("unsupported hidden trailing parameters")
69
+ resources = entry.get("resources")
70
+ if not isinstance(resources, dict):
71
+ raise BundleError("missing cubin resources")
72
+ if resources.get("global_scratch_bytes") != 0 or resources.get("profile_scratch_bytes") != 0:
73
+ raise BundleError("nonzero scratch is unsupported")
74
+ for key in (
75
+ "compiled_shared_memory_bytes",
76
+ "launch_dynamic_shared_memory_bytes",
77
+ "registers_per_thread",
78
+ ):
79
+ if type(resources.get(key)) is not int or resources[key] < 0:
80
+ raise BundleError(f"invalid cubin resource {key}")
81
+ if resources["registers_per_thread"] == 0 or (
82
+ resources["launch_dynamic_shared_memory_bytes"] < resources["compiled_shared_memory_bytes"]
83
+ ):
84
+ raise BundleError("unsupported cubin resource range")
85
+ signature = entry.get("signature")
86
+ if (
87
+ not isinstance(entry.get("entry_symbol"), str)
88
+ or not entry["entry_symbol"]
89
+ or not isinstance(signature, list)
90
+ ):
91
+ raise BundleError("missing cubin symbol or signature")
92
+ if not 1 <= len(signature) <= 14:
93
+ raise BundleError("unsupported public argument count")
94
+ names = set()
95
+ for argument in signature:
96
+ if not isinstance(argument, dict) or not isinstance(argument.get("name"), str):
97
+ raise BundleError("invalid public argument")
98
+ if argument["name"] in names or argument.get("type") not in (
99
+ "*fp16",
100
+ "*fp32",
101
+ "*i8",
102
+ "*i32",
103
+ "i32",
104
+ "fp32",
105
+ ):
106
+ raise BundleError("unsupported public argument")
107
+ names.add(argument["name"])
108
+ specialization = entry.get("specialization")
109
+ if not isinstance(specialization, dict) or set(specialization) != {"divisible_by"}:
110
+ raise BundleError("unsupported specialization rule")
111
+ divisors = specialization["divisible_by"]
112
+ if not isinstance(divisors, dict) or any(
113
+ name not in names
114
+ or type(divisor) is not int
115
+ or divisor < 1
116
+ or divisor > (1 << 30)
117
+ or divisor & (divisor - 1)
118
+ for name, divisor in divisors.items()
119
+ ):
120
+ raise BundleError("unsupported specialization rule")
121
+ if any(
122
+ argument["type"] == "fp32" and divisors.get(argument["name"], 1) != 1
123
+ for argument in signature
124
+ ):
125
+ raise BundleError("unsupported floating point specialization rule")
126
+
127
+
128
+ def inspect_bundle(root: Path, *, plugin: Path | None = None) -> str:
129
+ """Verify cubin bytes, ELF SM, supported launch metadata, and plugin identity."""
130
+ root = Path(root)
131
+ manifest = json.loads((root / "bundle.json").read_text(encoding="utf-8"))
132
+ if not isinstance(manifest, dict) or manifest.get("schema_version") != 1:
133
+ raise BundleError("unsupported bundle manifest")
134
+ entries = manifest.get("entries")
135
+ if not isinstance(entries, list) or not entries:
136
+ raise BundleError("bundle has no cubin entries")
137
+ digest = bundle_sha256(manifest)
138
+ if "bundle_sha256" in manifest and manifest["bundle_sha256"] != digest:
139
+ raise BundleError("bundle manifest digest differs")
140
+ visited = set()
141
+ cubins = {}
142
+ for entry in entries:
143
+ if not isinstance(entry, dict):
144
+ raise BundleError("invalid cubin entry")
145
+ _validate_entry(entry)
146
+ name = entry.get("file")
147
+ if not isinstance(name, str) or Path(name).is_absolute() or ".." in Path(name).parts:
148
+ raise BundleError("invalid cubin path")
149
+ if name in visited:
150
+ raise BundleError("duplicate cubin path")
151
+ visited.add(name)
152
+ path = root / name
153
+ if not path.is_file():
154
+ raise MissingCubinError(f"missing cubin: {path}")
155
+ data = path.read_bytes()
156
+ if len(data) != entry.get("bytes") or hashlib.sha256(data).hexdigest() != entry.get(
157
+ "sha256"
158
+ ):
159
+ raise CorruptCubinError(f"cubin digest or size differs: {path}")
160
+ actual_sm = cubin_sm(data)
161
+ if actual_sm != entry["target_sm"]:
162
+ raise CubinArchitectureError(
163
+ f"cubin {path} declares {entry['target_sm']} but ELF contains {actual_sm}"
164
+ )
165
+ if "embedded_symbol" in entry:
166
+ cubins[entry["embedded_symbol"]] = data
167
+ if plugin is not None:
168
+ try:
169
+ library = Path(plugin).read_bytes()
170
+ embedded = exported_data(library, "tensoracle_bundle_sha256", 65)
171
+ except (OSError, PluginLibraryError) as error:
172
+ raise PluginBundleMismatchError(
173
+ f"plugin bundle identity is unavailable: {plugin}"
174
+ ) from error
175
+ if embedded.rstrip(b"\0") != digest.encode("ascii"):
176
+ raise PluginBundleMismatchError(f"plugin and cubin bundle differ: {plugin}")
177
+ for symbol, data in cubins.items():
178
+ try:
179
+ native = exported_data(library, symbol, len(data))
180
+ except PluginLibraryError as error:
181
+ raise PluginBundleMismatchError(f"plugin lacks embedded cubin {symbol}") from error
182
+ if native != data:
183
+ raise PluginBundleMismatchError(f"plugin embedded cubin differs: {symbol}")
184
+ return digest
185
+
186
+
187
+ def inspect_wheel(path: Path, *, plugin: str | None = None) -> str:
188
+ """Inspect the one cubin bundle inside a wheel without installing it."""
189
+ with tempfile.TemporaryDirectory() as directory, ZipFile(path) as archive:
190
+ root = Path(directory)
191
+ for info in archive.infolist():
192
+ member = Path(info.filename)
193
+ if member.is_absolute() or ".." in member.parts:
194
+ raise BundleError("wheel contains an invalid path")
195
+ if info.is_dir():
196
+ continue
197
+ destination = root / member
198
+ destination.parent.mkdir(parents=True, exist_ok=True)
199
+ destination.write_bytes(archive.read(info))
200
+ manifests = list(root.rglob("bundle.json"))
201
+ if len(manifests) != 1:
202
+ raise BundleError("wheel must contain exactly one cubin bundle")
203
+ library = root / plugin if plugin is not None else None
204
+ if library is not None and (Path(plugin).is_absolute() or ".." in Path(plugin).parts):
205
+ raise BundleError("invalid plugin path in wheel")
206
+ return inspect_bundle(manifests[0].parent, plugin=library)