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.
tensoracle/__init__.py ADDED
@@ -0,0 +1,52 @@
1
+ """Build, store, reuse, and execute TensorRT engines from safetensors weights."""
2
+
3
+ from importlib import import_module
4
+
5
+ __all__ = [
6
+ "BuilderSettings",
7
+ "CorruptEngineError",
8
+ "EngineBuild",
9
+ "EngineCacheError",
10
+ "EngineConditionError",
11
+ "EngineExecution",
12
+ "EngineHandle",
13
+ "EngineIO",
14
+ "EngineIOError",
15
+ "InputShape",
16
+ "OutputComparison",
17
+ "PluginLibraryError",
18
+ "TensorSpec",
19
+ "build_serialized_engine",
20
+ "compare_outputs",
21
+ "open_engine",
22
+ ]
23
+
24
+ _MODULES = {
25
+ **dict.fromkeys(
26
+ ("BuilderSettings", "EngineBuild", "InputShape", "build_serialized_engine"), "._build"
27
+ ),
28
+ **dict.fromkeys(
29
+ (
30
+ "CorruptEngineError",
31
+ "EngineCacheError",
32
+ "EngineConditionError",
33
+ "EngineHandle",
34
+ "EngineIO",
35
+ "EngineIOError",
36
+ "TensorSpec",
37
+ "open_engine",
38
+ ),
39
+ "._cache",
40
+ ),
41
+ **dict.fromkeys(("OutputComparison", "compare_outputs"), "._comparison"),
42
+ "PluginLibraryError": "._plugins",
43
+ "EngineExecution": "._execution",
44
+ }
45
+
46
+
47
+ def __getattr__(name: str):
48
+ if name not in _MODULES:
49
+ raise AttributeError(name)
50
+ value = getattr(import_module(_MODULES[name], __name__), name)
51
+ globals()[name] = value
52
+ return value
tensoracle/_build.py ADDED
@@ -0,0 +1,137 @@
1
+ """Build serialized TensorRT engines from caller-defined network graphs."""
2
+
3
+ from collections.abc import Callable, Mapping, Sequence
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Literal
7
+
8
+ import tensorrt as trt
9
+
10
+ from ._memory import release_host_memory
11
+ from ._plugins import register_libraries
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class InputShape:
16
+ """The minimum, preferred, and maximum dimensions of one input."""
17
+
18
+ minimum: tuple[int, ...]
19
+ optimum: tuple[int, ...]
20
+ maximum: tuple[int, ...]
21
+
22
+ def __post_init__(self) -> None:
23
+ if (
24
+ not self.minimum
25
+ or len(self.minimum) != len(self.optimum)
26
+ or len(self.minimum) != len(self.maximum)
27
+ ):
28
+ raise ValueError("input shapes must have the same nonzero rank")
29
+ if any(
30
+ not isinstance(low, int)
31
+ or not isinstance(mid, int)
32
+ or not isinstance(high, int)
33
+ or not 0 < low <= mid <= high
34
+ for low, mid, high in zip(self.minimum, self.optimum, self.maximum, strict=True)
35
+ ):
36
+ raise ValueError(
37
+ "input dimensions must be positive and ordered minimum <= optimum <= maximum"
38
+ )
39
+
40
+ @classmethod
41
+ def fixed(cls, shape: tuple[int, ...]) -> "InputShape":
42
+ """Declare one fixed input shape."""
43
+ return cls(shape, shape, shape)
44
+
45
+ @classmethod
46
+ def ranged(
47
+ cls, minimum: tuple[int, ...], optimum: tuple[int, ...], maximum: tuple[int, ...]
48
+ ) -> "InputShape":
49
+ """Declare the three shapes of one TensorRT optimization profile."""
50
+ return cls(minimum, optimum, maximum)
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class BuilderSettings:
55
+ """TensorRT builder options that affect the serialized engine."""
56
+
57
+ workspace_limit: int = 1 << 30
58
+ optimization_level: int = 3
59
+ tf32: bool = False
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class EngineBuild:
64
+ """One engine's precision, named input shapes, and builder settings."""
65
+
66
+ precision: Literal["fp32", "fp16"]
67
+ inputs: Mapping[str, InputShape]
68
+ settings: BuilderSettings = field(default_factory=BuilderSettings)
69
+
70
+
71
+ def build_serialized_engine(
72
+ graph: Callable[[trt.INetworkDefinition], None],
73
+ declaration: EngineBuild,
74
+ *,
75
+ plugins: Sequence[Path] = (),
76
+ logger: trt.ILogger | None = None,
77
+ ) -> bytes:
78
+ """Register ``plugins`` before calling ``graph`` and serializing its engine."""
79
+ if declaration.precision not in ("fp32", "fp16"):
80
+ raise ValueError(f"unsupported precision: {declaration.precision}")
81
+ if declaration.settings.tf32:
82
+ raise ValueError("TF32 cannot be enabled")
83
+ if declaration.settings.workspace_limit <= 0:
84
+ raise ValueError("workspace_limit must be positive")
85
+ if not 0 <= declaration.settings.optimization_level <= 5:
86
+ raise ValueError("optimization_level must be between 0 and 5")
87
+
88
+ register_libraries(plugins)
89
+
90
+ builder = network = config = profile = serialized = tensor = None
91
+ inputs = {}
92
+ try:
93
+ builder = trt.Builder(logger or trt.Logger(trt.Logger.ERROR))
94
+ network = builder.create_network(0)
95
+ graph(network)
96
+ config = builder.create_builder_config()
97
+ config.clear_flag(trt.BuilderFlag.TF32)
98
+ config.set_memory_pool_limit(
99
+ trt.MemoryPoolType.WORKSPACE, declaration.settings.workspace_limit
100
+ )
101
+ config.builder_optimization_level = declaration.settings.optimization_level
102
+
103
+ inputs = {
104
+ network.get_input(index).name: network.get_input(index)
105
+ for index in range(network.num_inputs)
106
+ }
107
+ if inputs.keys() != declaration.inputs.keys():
108
+ raise ValueError("declared input names must match the graph inputs")
109
+
110
+ for name, tensor in inputs.items():
111
+ shape = declaration.inputs[name]
112
+ network_shape = tuple(tensor.shape)
113
+ if len(network_shape) != len(shape.minimum):
114
+ raise ValueError(f"declared rank does not match graph input {name}")
115
+ for dimension, low, mid, high in zip(
116
+ network_shape, shape.minimum, shape.optimum, shape.maximum, strict=True
117
+ ):
118
+ if dimension != -1 and (dimension != low or dimension != mid or dimension != high):
119
+ raise ValueError(f"declared dimensions do not match graph input {name}")
120
+ if -1 in network_shape:
121
+ if profile is None:
122
+ profile = builder.create_optimization_profile()
123
+ profile.set_shape(name, shape.minimum, shape.optimum, shape.maximum)
124
+ if not profile:
125
+ raise ValueError(f"TensorRT rejected the shape range for graph input {name}")
126
+ if profile is not None:
127
+ config.add_optimization_profile(profile)
128
+
129
+ serialized = builder.build_serialized_network(network, config)
130
+ if serialized is None:
131
+ raise RuntimeError("TensorRT failed to build the serialized engine")
132
+ return bytes(serialized)
133
+ finally:
134
+ # Tensor references also keep the network alive, including in exception frames.
135
+ inputs.clear()
136
+ del tensor, profile, serialized, config, network, builder
137
+ release_host_memory()
tensoracle/_cache.py ADDED
@@ -0,0 +1,359 @@
1
+ """Open TensorRT engines from the user cache."""
2
+
3
+ import ctypes
4
+ import ctypes.util
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import platform
9
+ import tempfile
10
+ from collections.abc import Callable, Mapping, Sequence
11
+ from dataclasses import asdict, dataclass
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING
14
+
15
+ import tensorrt as trt
16
+
17
+ from ._build import EngineBuild, build_serialized_engine
18
+ from ._plugins import register_libraries
19
+
20
+ if TYPE_CHECKING:
21
+ from ._execution import EngineExecution
22
+
23
+ _MAGIC = b"TENSORACLE\x01"
24
+ _WEIGHT_RECORD_VERSION = 1
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class TensorSpec:
29
+ """Expected dtype, build-time dimensions, and optional location and format."""
30
+
31
+ dtype: trt.DataType
32
+ shape: tuple[int, ...]
33
+ location: trt.TensorLocation | None = None
34
+ format: trt.TensorFormat | None = None
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class EngineIO:
39
+ """Expected named inputs and outputs of a TensorRT engine."""
40
+
41
+ inputs: Mapping[str, TensorSpec]
42
+ outputs: Mapping[str, TensorSpec]
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class EngineHandle:
47
+ """Keep the deserialized engine and its owning runtime alive together."""
48
+
49
+ engine: trt.ICudaEngine
50
+ runtime: trt.Runtime
51
+ path: Path
52
+
53
+ def create_execution(self) -> "EngineExecution":
54
+ """Create an independent execution context with explicit close semantics."""
55
+ from ._execution import EngineExecution
56
+
57
+ return EngineExecution(self)
58
+
59
+
60
+ class EngineCacheError(ValueError):
61
+ """A stored engine cannot be used for this declaration."""
62
+
63
+
64
+ class CorruptEngineError(EngineCacheError):
65
+ """A stored engine is incomplete or corrupt."""
66
+
67
+
68
+ class EngineConditionError(EngineCacheError):
69
+ """A stored engine belongs to different build conditions."""
70
+
71
+
72
+ class EngineIOError(EngineCacheError):
73
+ """The engine's input or output signature differs from the declaration."""
74
+
75
+
76
+ def open_engine(
77
+ graph: Callable[[trt.INetworkDefinition], None],
78
+ declaration: EngineBuild,
79
+ *,
80
+ name: str,
81
+ graph_version: str,
82
+ weights: Sequence[Path],
83
+ io: EngineIO,
84
+ plugins: Sequence[Path] = (),
85
+ force_rebuild: bool = False,
86
+ logger: trt.ILogger | None = None,
87
+ ) -> EngineHandle:
88
+ """Register ``plugins`` and build or load an engine for the supplied conditions."""
89
+ registered_digests = register_libraries(plugins)
90
+ conditions = _conditions(
91
+ name, graph_version, weights, plugins, declaration, force_rebuild=force_rebuild
92
+ )
93
+ if tuple(conditions["plugins"]) != registered_digests:
94
+ raise ValueError("plugin library changed while determining engine conditions")
95
+ digest = hashlib.sha256(_json_bytes(conditions)).hexdigest()
96
+ path = _cache_directory() / f"{digest}.engine"
97
+ path.parent.mkdir(parents=True, exist_ok=True)
98
+ actual_logger = logger or trt.Logger(trt.Logger.ERROR)
99
+
100
+ if force_rebuild:
101
+ path.write_bytes(b"")
102
+ else:
103
+ try:
104
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
105
+ except FileExistsError:
106
+ plan = _read_plan(path, conditions)
107
+ return _open_plan(plan, path, declaration, io, actual_logger)
108
+ else:
109
+ os.close(fd)
110
+
111
+ temporary: Path | None = None
112
+ try:
113
+ plan = build_serialized_engine(graph, declaration, plugins=plugins, logger=actual_logger)
114
+ handle = _open_plan(plan, path, declaration, io, actual_logger)
115
+ header = {"conditions": conditions, "plan_sha256": hashlib.sha256(plan).hexdigest()}
116
+ header_bytes = _json_bytes(header)
117
+ fd, filename = tempfile.mkstemp(prefix=f".{digest}.", dir=path.parent)
118
+ temporary = Path(filename)
119
+ with os.fdopen(fd, "wb") as stream:
120
+ stream.write(_MAGIC + len(header_bytes).to_bytes(8, "big") + header_bytes)
121
+ stream.write(plan)
122
+ stream.flush()
123
+ os.fsync(stream.fileno())
124
+ os.replace(temporary, path)
125
+ return handle
126
+ except BaseException:
127
+ if temporary is not None:
128
+ temporary.unlink(missing_ok=True)
129
+ path.unlink(missing_ok=True)
130
+ raise
131
+
132
+
133
+ def _json_bytes(value: object) -> bytes:
134
+ return json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")
135
+
136
+
137
+ def _file_sha256(path: Path) -> str:
138
+ digest = hashlib.sha256()
139
+ with path.open("rb") as stream:
140
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
141
+ digest.update(chunk)
142
+ return digest.hexdigest()
143
+
144
+
145
+ def _cache_directory() -> Path:
146
+ cache_root = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache")
147
+ return cache_root / "tensoracle"
148
+
149
+
150
+ def _weight_sha256(path: Path, *, force_rebuild: bool) -> str:
151
+ absolute_path = Path(os.path.abspath(path))
152
+ before = absolute_path.stat()
153
+ identity = {
154
+ "path": str(absolute_path),
155
+ "size": before.st_size,
156
+ "mtime_ns": before.st_mtime_ns,
157
+ "inode": before.st_ino,
158
+ }
159
+ path_digest = hashlib.sha256(os.fsencode(absolute_path)).hexdigest()
160
+ record_path = _cache_directory() / f"weight-{path_digest}.json"
161
+
162
+ if not force_rebuild:
163
+ try:
164
+ record = json.loads(record_path.read_bytes())
165
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
166
+ record = None
167
+ if (
168
+ isinstance(record, dict)
169
+ and record.keys() == identity.keys() | {"version", "sha256", "checksum"}
170
+ and record["version"] == _WEIGHT_RECORD_VERSION
171
+ and type(record["version"]) is int
172
+ and all(
173
+ type(record[key]) is type(value) and record[key] == value
174
+ for key, value in identity.items()
175
+ )
176
+ and isinstance(record["sha256"], str)
177
+ and len(record["sha256"]) == 64
178
+ and all(character in "0123456789abcdef" for character in record["sha256"])
179
+ and record["checksum"]
180
+ == hashlib.sha256(
181
+ _json_bytes({key: value for key, value in record.items() if key != "checksum"})
182
+ ).hexdigest()
183
+ ):
184
+ return record["sha256"]
185
+
186
+ digest = _file_sha256(absolute_path)
187
+ try:
188
+ after = absolute_path.stat()
189
+ except OSError:
190
+ return digest
191
+ if (before.st_size, before.st_mtime_ns, before.st_ino) == (
192
+ after.st_size,
193
+ after.st_mtime_ns,
194
+ after.st_ino,
195
+ ):
196
+ payload = {"version": _WEIGHT_RECORD_VERSION, **identity, "sha256": digest}
197
+ _write_weight_record(
198
+ record_path, {**payload, "checksum": hashlib.sha256(_json_bytes(payload)).hexdigest()}
199
+ )
200
+ return digest
201
+
202
+
203
+ def _write_weight_record(path: Path, record: dict) -> None:
204
+ temporary: Path | None = None
205
+ try:
206
+ path.parent.mkdir(parents=True, exist_ok=True)
207
+ fd, filename = tempfile.mkstemp(prefix=f".{path.stem}.", dir=path.parent)
208
+ temporary = Path(filename)
209
+ with os.fdopen(fd, "wb") as stream:
210
+ stream.write(_json_bytes(record))
211
+ stream.flush()
212
+ os.fsync(stream.fileno())
213
+ os.replace(temporary, path)
214
+ except OSError:
215
+ pass
216
+ finally:
217
+ if temporary is not None:
218
+ try:
219
+ temporary.unlink(missing_ok=True)
220
+ except OSError:
221
+ pass
222
+
223
+
224
+ def _gpu_name() -> str:
225
+ library_name = ctypes.util.find_library("cuda") or ctypes.util.find_library("nvcuda")
226
+ if library_name is None:
227
+ raise RuntimeError("CUDA driver library was not found")
228
+ cuda = ctypes.CDLL(library_name)
229
+ cuda.cuInit.argtypes = [ctypes.c_uint]
230
+ cuda.cuCtxGetDevice.argtypes = [ctypes.POINTER(ctypes.c_int)]
231
+ cuda.cuDeviceGet.argtypes = [ctypes.POINTER(ctypes.c_int), ctypes.c_int]
232
+ cuda.cuDeviceGetName.argtypes = [ctypes.c_char_p, ctypes.c_int, ctypes.c_int]
233
+ if cuda.cuInit(0) != 0:
234
+ raise RuntimeError("CUDA driver initialization failed")
235
+ device = ctypes.c_int()
236
+ if (
237
+ cuda.cuCtxGetDevice(ctypes.byref(device)) != 0
238
+ and cuda.cuDeviceGet(ctypes.byref(device), 0) != 0
239
+ ):
240
+ raise RuntimeError("CUDA device selection failed")
241
+ name = ctypes.create_string_buffer(256)
242
+ if cuda.cuDeviceGetName(name, len(name), device.value) != 0:
243
+ raise RuntimeError("CUDA device name lookup failed")
244
+ return name.value.decode("utf-8")
245
+
246
+
247
+ def _conditions(
248
+ name: str,
249
+ graph_version: str,
250
+ weights: Sequence[Path],
251
+ plugins: Sequence[Path],
252
+ declaration: EngineBuild,
253
+ *,
254
+ force_rebuild: bool = False,
255
+ ) -> dict:
256
+ if not name or not graph_version or not weights:
257
+ raise ValueError("engine name, graph version, and at least one weight file are required")
258
+ if declaration.settings.tf32:
259
+ raise ValueError("TF32 cannot be enabled")
260
+ return {
261
+ "name": name,
262
+ "graph_version": graph_version,
263
+ "weights": [_weight_sha256(Path(path), force_rebuild=force_rebuild) for path in weights],
264
+ "plugins": [_file_sha256(Path(path)) for path in plugins],
265
+ "precision": declaration.precision,
266
+ "inputs": {
267
+ name: {
268
+ "minimum": list(shape.minimum),
269
+ "optimum": list(shape.optimum),
270
+ "maximum": list(shape.maximum),
271
+ }
272
+ for name, shape in declaration.inputs.items()
273
+ },
274
+ "builder_settings": asdict(declaration.settings),
275
+ "gpu_name": _gpu_name(),
276
+ "os": platform.system(),
277
+ "architecture": platform.machine(),
278
+ "tensorrt_version": trt.__version__,
279
+ }
280
+
281
+
282
+ def _read_plan(path: Path, conditions: dict) -> memoryview:
283
+ data = path.read_bytes()
284
+ offset = len(_MAGIC)
285
+ if not data.startswith(_MAGIC) or len(data) < offset + 8:
286
+ raise CorruptEngineError(f"incomplete TensorRT engine file: {path}")
287
+ header_size = int.from_bytes(data[offset : offset + 8], "big")
288
+ header_end = offset + 8 + header_size
289
+ if header_size == 0 or header_end >= len(data):
290
+ raise CorruptEngineError(f"invalid TensorRT engine header: {path}")
291
+ try:
292
+ header = json.loads(data[offset + 8 : header_end])
293
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
294
+ raise CorruptEngineError(f"invalid TensorRT engine header: {path}") from error
295
+ if not isinstance(header, dict) or not isinstance(header.get("conditions"), dict):
296
+ raise CorruptEngineError(f"invalid TensorRT engine header: {path}")
297
+ if header["conditions"] != conditions:
298
+ raise EngineConditionError(f"TensorRT engine conditions differ: {path}")
299
+ plan = memoryview(data)[header_end:]
300
+ if header.get("plan_sha256") != hashlib.sha256(plan).hexdigest():
301
+ raise CorruptEngineError(f"TensorRT engine checksum differs: {path}")
302
+ return plan
303
+
304
+
305
+ def _open_plan(
306
+ plan: bytes | memoryview,
307
+ path: Path,
308
+ declaration: EngineBuild,
309
+ io: EngineIO,
310
+ logger: trt.ILogger,
311
+ ) -> EngineHandle:
312
+ runtime = trt.Runtime(logger)
313
+ engine = runtime.deserialize_cuda_engine(plan)
314
+ if engine is None:
315
+ raise CorruptEngineError(
316
+ f"TensorRT could not deserialize engine: {path}. Check that plugins= includes the "
317
+ "libraries providing the plugin creators (name, version, namespace) used by this engine."
318
+ )
319
+ names = {engine.get_tensor_name(index) for index in range(engine.num_io_tensors)}
320
+ expected_names = io.inputs.keys() | io.outputs.keys()
321
+ for name in sorted(names ^ expected_names):
322
+ _io_mismatch(path, name, "name", sorted(names), sorted(expected_names))
323
+ for specs, mode in ((io.inputs, trt.TensorIOMode.INPUT), (io.outputs, trt.TensorIOMode.OUTPUT)):
324
+ for name, expected in specs.items():
325
+ for field, observed, value in (
326
+ ("mode", engine.get_tensor_mode(name), mode),
327
+ ("dtype", engine.get_tensor_dtype(name), expected.dtype),
328
+ ("shape", tuple(engine.get_tensor_shape(name)), expected.shape),
329
+ ):
330
+ if observed != value:
331
+ _io_mismatch(path, name, field, observed, value)
332
+ if expected.location is not None:
333
+ observed = engine.get_tensor_location(name)
334
+ if observed != expected.location:
335
+ _io_mismatch(path, name, "location", observed, expected.location)
336
+ if expected.format is not None:
337
+ observed = engine.get_tensor_format(name)
338
+ if observed != expected.format:
339
+ _io_mismatch(path, name, "format", observed, expected.format)
340
+ for name in sorted(declaration.inputs.keys() ^ io.inputs.keys()):
341
+ _io_mismatch(
342
+ path, name, "name (build input)", sorted(io.inputs), sorted(declaration.inputs)
343
+ )
344
+ for name, shape in declaration.inputs.items():
345
+ expected_range = (shape.minimum, shape.optimum, shape.maximum)
346
+ if -1 in io.inputs[name].shape:
347
+ actual_range = tuple(tuple(part) for part in engine.get_tensor_profile_shape(name, 0))
348
+ else:
349
+ actual_range = (io.inputs[name].shape,) * 3
350
+ if actual_range != expected_range:
351
+ _io_mismatch(path, name, "profile range", actual_range, expected_range)
352
+ return EngineHandle(engine, runtime, path)
353
+
354
+
355
+ def _io_mismatch(path: Path, name: str, field: str, observed: object, expected: object) -> None:
356
+ raise EngineIOError(
357
+ f"TensorRT engine tensor {name!r} {field} differs: "
358
+ f"observed={observed!r}, expected={expected!r}; path={path}"
359
+ )
@@ -0,0 +1,117 @@
1
+ """Model-independent measurements for one pair of CPU output arrays."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import numpy as np
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class OutputComparison:
10
+ """Measurements for a candidate output against its reference output.
11
+
12
+ Float measurements are ``None`` for integer or boolean arrays and when
13
+ either float array contains a non-finite value. Tolerance measurements are
14
+ also ``None`` when the caller has not supplied both tolerances. Mismatch
15
+ measurements apply only to integer and boolean arrays.
16
+ """
17
+
18
+ candidate_nonfinite_count: int
19
+ reference_nonfinite_count: int
20
+ max_absolute_error: float | None = None
21
+ relative_l2_error: float | None = None
22
+ cosine_similarity: float | None = None
23
+ outside_tolerance_count: int | None = None
24
+ outside_tolerance_fraction: float | None = None
25
+ mismatch_count: int | None = None
26
+ mismatch_fraction: float | None = None
27
+
28
+
29
+ def compare_outputs(
30
+ candidate: np.ndarray,
31
+ reference: np.ndarray,
32
+ *,
33
+ atol: float | None = None,
34
+ rtol: float | None = None,
35
+ ) -> OutputComparison:
36
+ """Measure one candidate output against the corresponding reference output.
37
+
38
+ Both inputs must be nonempty NumPy arrays of equal shape and the same kind:
39
+ floating point, integer, or boolean. Floating point inputs are converted
40
+ to float64 before arithmetic. Tolerances must be finite, nonnegative, and
41
+ supplied together. This function reports measurements; the caller chooses
42
+ the limits that constitute an acceptable output.
43
+ """
44
+ if not isinstance(candidate, np.ndarray) or not isinstance(reference, np.ndarray):
45
+ raise TypeError("candidate and reference must be NumPy arrays")
46
+ if candidate.shape != reference.shape or candidate.size == 0:
47
+ raise ValueError("candidate and reference must have the same nonempty shape")
48
+
49
+ candidate_kind = candidate.dtype.kind
50
+ reference_kind = reference.dtype.kind
51
+ floats = candidate_kind == reference_kind == "f"
52
+ integers = candidate_kind in "iu" and reference_kind in "iu"
53
+ booleans = candidate_kind == reference_kind == "b"
54
+ if not (floats or integers or booleans):
55
+ raise ValueError("candidate and reference must have the same numeric kind")
56
+
57
+ if (atol is None) != (rtol is None):
58
+ raise ValueError("atol and rtol must be supplied together")
59
+ if atol is not None and rtol is not None:
60
+ try:
61
+ atol = float(atol)
62
+ rtol = float(rtol)
63
+ except (TypeError, ValueError) as error:
64
+ raise ValueError("atol and rtol must be finite nonnegative numbers") from error
65
+ if not (np.isfinite(atol) and np.isfinite(rtol) and atol >= 0 and rtol >= 0):
66
+ raise ValueError("atol and rtol must be finite nonnegative numbers")
67
+
68
+ if not floats:
69
+ # Mixed signed and unsigned widths can otherwise promote to float64,
70
+ # which would erase differences between neighboring large integers.
71
+ if candidate_kind != reference_kind:
72
+ unequal = candidate.astype(object) != reference.astype(object)
73
+ else:
74
+ unequal = candidate != reference
75
+ mismatch_count = int(np.count_nonzero(unequal))
76
+ return OutputComparison(
77
+ candidate_nonfinite_count=0,
78
+ reference_nonfinite_count=0,
79
+ mismatch_count=mismatch_count,
80
+ mismatch_fraction=mismatch_count / candidate.size,
81
+ )
82
+
83
+ candidate_nonfinite_count = int(np.count_nonzero(~np.isfinite(candidate)))
84
+ reference_nonfinite_count = int(np.count_nonzero(~np.isfinite(reference)))
85
+ if candidate_nonfinite_count or reference_nonfinite_count:
86
+ return OutputComparison(candidate_nonfinite_count, reference_nonfinite_count)
87
+
88
+ candidate64 = candidate.astype(np.float64)
89
+ reference64 = reference.astype(np.float64)
90
+ difference = candidate64 - reference64
91
+ reference_norm = float(np.hypot.reduce(reference64.ravel()))
92
+ candidate_norm = float(np.hypot.reduce(candidate64.ravel()))
93
+ difference_norm = float(np.hypot.reduce(difference.ravel()))
94
+
95
+ outside_count = None
96
+ outside_fraction = None
97
+ if atol is not None and rtol is not None:
98
+ outside_count = int(
99
+ np.count_nonzero(~np.isclose(candidate64, reference64, atol=atol, rtol=rtol))
100
+ )
101
+ outside_fraction = outside_count / candidate.size
102
+
103
+ cosine_similarity = None
104
+ if candidate_norm and reference_norm:
105
+ cosine_similarity = float(
106
+ np.dot(candidate64.ravel() / candidate_norm, reference64.ravel() / reference_norm)
107
+ )
108
+
109
+ return OutputComparison(
110
+ candidate_nonfinite_count=0,
111
+ reference_nonfinite_count=0,
112
+ max_absolute_error=float(np.max(np.abs(difference))),
113
+ relative_l2_error=difference_norm / reference_norm if reference_norm else None,
114
+ cosine_similarity=cosine_similarity,
115
+ outside_tolerance_count=outside_count,
116
+ outside_tolerance_fraction=outside_fraction,
117
+ )