klongpy 0.6.9__py3-none-any.whl → 0.7.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.
Files changed (70) hide show
  1. klongpy/__init__.py +17 -1
  2. klongpy/adverbs.py +84 -82
  3. klongpy/autograd.py +299 -0
  4. klongpy/backend.py +38 -103
  5. klongpy/backends/__init__.py +26 -0
  6. klongpy/backends/base.py +469 -0
  7. klongpy/backends/numpy_backend.py +123 -0
  8. klongpy/backends/registry.py +76 -0
  9. klongpy/backends/torch_backend.py +1047 -0
  10. klongpy-0.6.9.data/scripts/kgpy → klongpy/cli.py +110 -90
  11. klongpy/core.py +113 -974
  12. klongpy/db/sys_fn_db.py +7 -6
  13. klongpy/db/sys_fn_kvs.py +2 -4
  14. klongpy/dyads.py +332 -160
  15. klongpy/interpreter.py +60 -15
  16. klongpy/monads.py +121 -75
  17. klongpy/parser.py +328 -0
  18. klongpy/repl.py +23 -5
  19. klongpy/sys_fn.py +170 -21
  20. klongpy/sys_fn_autograd.py +290 -0
  21. klongpy/sys_fn_ipc.py +22 -15
  22. klongpy/sys_fn_timer.py +13 -3
  23. klongpy/types.py +503 -0
  24. klongpy/web/sys_fn_web.py +14 -4
  25. klongpy/writer.py +122 -0
  26. klongpy/ws/sys_fn_ws.py +5 -8
  27. klongpy-0.7.1.dist-info/METADATA +544 -0
  28. klongpy-0.7.1.dist-info/RECORD +52 -0
  29. {klongpy-0.6.9.dist-info → klongpy-0.7.1.dist-info}/WHEEL +1 -1
  30. klongpy-0.7.1.dist-info/entry_points.txt +2 -0
  31. {klongpy-0.6.9.dist-info → klongpy-0.7.1.dist-info}/top_level.txt +0 -1
  32. klongpy-0.6.9.dist-info/METADATA +0 -448
  33. klongpy-0.6.9.dist-info/RECORD +0 -77
  34. tests/__init__.py +0 -6
  35. tests/gen_join_over.py +0 -119
  36. tests/gen_py_suite.py +0 -77
  37. tests/gen_test_fn.py +0 -259
  38. tests/perf_async.py +0 -25
  39. tests/perf_avg.py +0 -18
  40. tests/perf_duckdb.py +0 -32
  41. tests/perf_gen.py +0 -38
  42. tests/perf_ipc_overhead.py +0 -34
  43. tests/perf_join.py +0 -53
  44. tests/perf_load.py +0 -17
  45. tests/perf_prog.py +0 -18
  46. tests/perf_serdes.py +0 -52
  47. tests/perf_sys_fn_db.py +0 -263
  48. tests/perf_vector.py +0 -40
  49. tests/test_accel.py +0 -227
  50. tests/test_df_cache.py +0 -85
  51. tests/test_eval_monad_list.py +0 -34
  52. tests/test_examples.py +0 -64
  53. tests/test_extra_suite.py +0 -382
  54. tests/test_file_cache.py +0 -185
  55. tests/test_interop.py +0 -180
  56. tests/test_kg_asarray.py +0 -94
  57. tests/test_kgtests.py +0 -65
  58. tests/test_known_bugs.py +0 -206
  59. tests/test_prog.py +0 -107
  60. tests/test_reshape_strings.py +0 -33
  61. tests/test_suite.py +0 -1480
  62. tests/test_suite_file.py +0 -153
  63. tests/test_sys_fn.py +0 -420
  64. tests/test_sys_fn_db.py +0 -88
  65. tests/test_sys_fn_ipc.py +0 -587
  66. tests/test_sys_fn_timer.py +0 -133
  67. tests/test_sys_fn_web.py +0 -50
  68. tests/test_util.py +0 -233
  69. tests/utils.py +0 -126
  70. {klongpy-0.6.9.dist-info → klongpy-0.7.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1047 @@
1
+ """
2
+ PyTorch backend provider for KlongPy.
3
+
4
+ This backend uses PyTorch tensors for array operations, enabling GPU acceleration.
5
+ It does not support object dtype or string operations.
6
+ """
7
+ import math
8
+ import numpy
9
+ import torch
10
+ import torch.autograd.functional as torch_autograd_functional
11
+
12
+ from .base import BackendProvider, UnsupportedDtypeError, is_jagged_array
13
+ from ..autograd import AutogradChainBrokenError, NonScalarLossError, _invoke_fn
14
+
15
+ # numpy 2.x moved VisibleDeprecationWarning to numpy.exceptions
16
+ from numpy.exceptions import VisibleDeprecationWarning as NumpyVisibleDeprecationWarning
17
+
18
+
19
+ class TorchUnsupportedDtypeError(UnsupportedDtypeError):
20
+ """Raised when an operation requires object dtype which is not supported by PyTorch."""
21
+ pass
22
+
23
+
24
+ class TorchRandomModule:
25
+ """NumPy-compatible random module using PyTorch tensors."""
26
+ def __init__(self, backend):
27
+ self._backend = backend
28
+
29
+ def random(self, size=None):
30
+ """Return random floats in the half-open interval [0.0, 1.0)."""
31
+ if size is None:
32
+ return torch.rand(1, device=self._backend.device).item()
33
+ if isinstance(size, int):
34
+ size = (size,)
35
+ return torch.rand(*size, device=self._backend.device)
36
+
37
+ def rand(self, *shape):
38
+ if len(shape) == 0:
39
+ return torch.rand(1, device=self._backend.device).item()
40
+ return torch.rand(*shape, device=self._backend.device)
41
+
42
+ def randn(self, *shape):
43
+ if len(shape) == 0:
44
+ return torch.randn(1, device=self._backend.device).item()
45
+ return torch.randn(*shape, device=self._backend.device)
46
+
47
+ def randint(self, low, high=None, size=None):
48
+ if high is None:
49
+ high = low
50
+ low = 0
51
+ if size is None:
52
+ return torch.randint(low, high, (1,), device=self._backend.device).item()
53
+ if isinstance(size, int):
54
+ size = (size,)
55
+ return torch.randint(low, high, size, device=self._backend.device)
56
+
57
+ def choice(self, a, size=None, replace=True):
58
+ if isinstance(a, int):
59
+ a = torch.arange(a, device=self._backend.device)
60
+ elif not isinstance(a, torch.Tensor):
61
+ a = torch.tensor(a, device=self._backend.device)
62
+ n = len(a)
63
+ if size is None:
64
+ idx = torch.randint(0, n, (1,), device=self._backend.device).item()
65
+ return a[idx]
66
+ if isinstance(size, int):
67
+ size = (size,)
68
+ total = 1
69
+ for s in size:
70
+ total *= s
71
+ if replace:
72
+ indices = torch.randint(0, n, (total,), device=self._backend.device)
73
+ else:
74
+ indices = torch.randperm(n, device=self._backend.device)[:total]
75
+ return a[indices].reshape(size)
76
+
77
+ def seed(self, seed):
78
+ torch.manual_seed(seed)
79
+
80
+
81
+ class TorchDtype:
82
+ """Wrapper for torch dtype providing numpy-compatible 'kind' attribute."""
83
+
84
+ def __init__(self, torch_dtype):
85
+ self._dtype = torch_dtype
86
+ kind_map = {
87
+ torch.float16: 'f',
88
+ torch.float32: 'f',
89
+ torch.float64: 'f',
90
+ torch.bfloat16: 'f',
91
+ torch.int8: 'i',
92
+ torch.int16: 'i',
93
+ torch.int32: 'i',
94
+ torch.int64: 'i',
95
+ torch.uint8: 'u',
96
+ torch.bool: 'b',
97
+ torch.complex64: 'c',
98
+ torch.complex128: 'c',
99
+ }
100
+ self.kind = kind_map.get(torch_dtype, 'f') # default to float
101
+
102
+ def __eq__(self, other):
103
+ if isinstance(other, TorchDtype):
104
+ return self._dtype == other._dtype
105
+ if isinstance(other, str):
106
+ return False # torch dtype != string like 'O'
107
+ return self._dtype == other
108
+
109
+ def __ne__(self, other):
110
+ return not self.__eq__(other)
111
+
112
+ def __repr__(self):
113
+ return repr(self._dtype)
114
+
115
+
116
+ class TorchBackend:
117
+ """NumPy-compatible interface using PyTorch tensors for GPU acceleration."""
118
+
119
+ def __init__(self, device=None):
120
+ self._numpy = numpy
121
+ self._torch = torch
122
+ self._random = None
123
+ self._add = None
124
+ self._subtract = None
125
+ self._multiply = None
126
+ self._divide = None
127
+
128
+ # Device priority: explicit > CUDA > MPS (Apple Silicon) > CPU
129
+ if device is not None:
130
+ self.device = torch.device(device)
131
+ elif torch.cuda.is_available():
132
+ self.device = torch.device('cuda')
133
+ elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
134
+ self.device = torch.device('mps')
135
+ else:
136
+ self.device = torch.device('cpu')
137
+
138
+ @property
139
+ def random(self):
140
+ if self._random is None:
141
+ self._random = TorchRandomModule(self)
142
+ return self._random
143
+
144
+ def __getattr__(self, name):
145
+ # First check if torch has this attribute
146
+ if hasattr(self._torch, name):
147
+ attr = getattr(self._torch, name)
148
+ if callable(attr):
149
+ return self._wrap_torch_func(attr, name)
150
+ return attr
151
+ # Fall back to numpy for things torch doesn't have
152
+ if hasattr(self._numpy, name):
153
+ return getattr(self._numpy, name)
154
+ raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
155
+
156
+ def _wrap_torch_func(self, func, name):
157
+ # Functions that require tensor inputs (not Python scalars)
158
+ tensor_required_funcs = {
159
+ 'abs', 'trunc', 'floor', 'ceil', 'round', 'sign',
160
+ 'sin', 'cos', 'tan', 'exp', 'log', 'sqrt',
161
+ 'isinf', 'isnan', 'isfinite',
162
+ 'minimum', 'maximum', 'fmod',
163
+ 'less', 'greater', 'less_equal', 'greater_equal',
164
+ }
165
+
166
+ def wrapper(*args, **kwargs):
167
+ converted_args = []
168
+ needs_tensor = name in tensor_required_funcs
169
+ for arg in args:
170
+ if isinstance(arg, numpy.ndarray):
171
+ # Handle float64 arrays on MPS by converting to float32
172
+ if arg.dtype == numpy.float64 and self.device.type == 'mps':
173
+ arg = arg.astype(numpy.float32)
174
+ converted_args.append(torch.from_numpy(arg).to(self.device))
175
+ elif isinstance(arg, list):
176
+ try:
177
+ converted_args.append(torch.tensor(arg, device=self.device))
178
+ except (ValueError, TypeError):
179
+ converted_args.append(arg)
180
+ elif needs_tensor and isinstance(arg, (int, float)):
181
+ # Convert Python scalars to tensors for functions that require it
182
+ dtype = torch.float32 if isinstance(arg, float) else torch.int64
183
+ converted_args.append(torch.tensor(arg, dtype=dtype, device=self.device))
184
+ else:
185
+ converted_args.append(arg)
186
+ return func(*converted_args, **kwargs)
187
+ return wrapper
188
+
189
+ def asarray(self, a, dtype=None):
190
+ """Convert input to a torch tensor.
191
+
192
+ Note: MPS (Apple Silicon) doesn't support float64, so we convert to float32.
193
+ Object dtypes are not supported - use numpy backend for heterogeneous data.
194
+ """
195
+ if dtype is not None and (dtype == object or (hasattr(dtype, 'kind') and dtype.kind == 'O')):
196
+ raise TorchUnsupportedDtypeError(
197
+ "PyTorch backend does not support object dtype."
198
+ )
199
+ if isinstance(a, torch.Tensor):
200
+ if a.device != self.device:
201
+ return a.to(self.device)
202
+ return a
203
+ if isinstance(a, numpy.ndarray):
204
+ if a.dtype == object:
205
+ raise TorchUnsupportedDtypeError(
206
+ "PyTorch backend does not support object dtype arrays."
207
+ )
208
+ if a.dtype == numpy.float64 and self.device.type == 'mps':
209
+ a = a.astype(numpy.float32)
210
+ return torch.from_numpy(a).to(self.device)
211
+ # Check if input is a list/tuple of tensors - use stack to preserve gradients
212
+ if isinstance(a, (list, tuple)) and len(a) > 0 and all(isinstance(x, torch.Tensor) for x in a):
213
+ result = torch.stack(a)
214
+ if result.device != self.device:
215
+ result = result.to(self.device)
216
+ if result.dtype == torch.float64 and self.device.type == 'mps':
217
+ result = result.to(torch.float32)
218
+ return result
219
+ # For all other lists/tuples, convert via numpy (faster than torch.tensor for nested/mixed data)
220
+ if isinstance(a, (list, tuple)):
221
+ arr = numpy.asarray(a)
222
+ if arr.dtype == object:
223
+ raise TorchUnsupportedDtypeError(
224
+ "PyTorch backend does not support object dtype arrays."
225
+ )
226
+ # Convert float64 to float32 to match torch.tensor's default behavior
227
+ if arr.dtype == numpy.float64:
228
+ arr = arr.astype(numpy.float32)
229
+ return torch.from_numpy(arr).to(self.device)
230
+ # Scalar or other type
231
+ try:
232
+ t = torch.tensor(a, device=self.device)
233
+ if t.dtype == torch.float64 and self.device.type == 'mps':
234
+ t = t.to(torch.float32)
235
+ return t
236
+ except (ValueError, TypeError, RuntimeError) as e:
237
+ raise TorchUnsupportedDtypeError(
238
+ f"PyTorch backend cannot convert this data: {e}"
239
+ )
240
+
241
+ def array(self, a, dtype=None):
242
+ """Create a torch tensor."""
243
+ return self.asarray(a, dtype=dtype)
244
+
245
+ def isarray(self, x):
246
+ """Check if x is an array (numpy or torch tensor)."""
247
+ return isinstance(x, (numpy.ndarray, torch.Tensor))
248
+
249
+ def zeros(self, shape, dtype=None):
250
+ return torch.zeros(shape, device=self.device)
251
+
252
+ def ones(self, shape, dtype=None):
253
+ return torch.ones(shape, device=self.device)
254
+
255
+ def arange(self, *args, **kwargs):
256
+ return torch.arange(*args, device=self.device, **kwargs)
257
+
258
+ def concatenate(self, arrays, axis=0):
259
+ tensors = [self.asarray(a) for a in arrays]
260
+ return torch.cat(tensors, dim=axis)
261
+
262
+ def hstack(self, arrays):
263
+ tensors = [self.asarray(a) for a in arrays]
264
+ return torch.hstack(tensors)
265
+
266
+ def vstack(self, arrays):
267
+ tensors = [self.asarray(a) for a in arrays]
268
+ return torch.vstack(tensors)
269
+
270
+ def stack(self, arrays, axis=0):
271
+ tensors = [self.asarray(a) for a in arrays]
272
+ return torch.stack(tensors, dim=axis)
273
+
274
+ @property
275
+ def ndarray(self):
276
+ """Return the tensor class for isinstance checks."""
277
+ return torch.Tensor
278
+
279
+ @property
280
+ def integer(self):
281
+ return numpy.integer
282
+
283
+ @property
284
+ def floating(self):
285
+ return numpy.floating
286
+
287
+ def copy(self, a):
288
+ if isinstance(a, torch.Tensor):
289
+ return a.clone()
290
+ return self.asarray(a).clone()
291
+
292
+ def isclose(self, a, b, rtol=1e-05, atol=1e-08):
293
+ # For scalars, use numpy's isclose to avoid tensor conversion issues
294
+ if not hasattr(a, '__len__') and not hasattr(b, '__len__'):
295
+ return self._numpy.isclose(float(a), float(b), rtol=rtol, atol=atol)
296
+ a_t = self.asarray(a) if not isinstance(a, torch.Tensor) else a
297
+ b_t = self.asarray(b) if not isinstance(b, torch.Tensor) else b
298
+ # torch.isclose requires same dtype, convert to float if needed
299
+ if a_t.dtype != b_t.dtype:
300
+ # Use float32 for MPS compatibility (MPS doesn't support float64)
301
+ a_t = a_t.to(torch.float32)
302
+ b_t = b_t.to(torch.float32)
303
+ return torch.isclose(a_t, b_t, rtol=rtol, atol=atol)
304
+
305
+ def array_equal(self, a, b):
306
+ a_t = self.asarray(a) if not isinstance(a, torch.Tensor) else a
307
+ b_t = self.asarray(b) if not isinstance(b, torch.Tensor) else b
308
+ return torch.equal(a_t, b_t)
309
+
310
+ def take(self, a, indices, axis=None):
311
+ a_t = self.asarray(a) if not isinstance(a, torch.Tensor) else a
312
+ indices_t = self.asarray(indices) if not isinstance(indices, torch.Tensor) else indices
313
+ if axis is None:
314
+ return a_t.flatten()[indices_t.long()]
315
+ return torch.index_select(a_t, axis, indices_t.long())
316
+
317
+ def transpose(self, a, axes=None):
318
+ """Transpose a tensor."""
319
+ a_t = self.asarray(a) if not isinstance(a, torch.Tensor) else a
320
+ if axes is None:
321
+ return a_t.T if a_t.ndim >= 2 else a_t
322
+ return a_t.permute(*axes)
323
+
324
+ def sum(self, a, axis=None, dtype=None, out=None, keepdims=False):
325
+ """Sum array elements."""
326
+ a_t = self.asarray(a) if not isinstance(a, torch.Tensor) else a
327
+ if axis is None:
328
+ return a_t.sum()
329
+ return a_t.sum(dim=axis, keepdim=keepdims)
330
+
331
+ def abs(self, a):
332
+ """Absolute value."""
333
+ if isinstance(a, (int, float)):
334
+ return abs(a)
335
+ a_t = self.asarray(a) if not isinstance(a, torch.Tensor) else a
336
+ return torch.abs(a_t)
337
+
338
+ def minimum(self, a, b):
339
+ """Element-wise minimum."""
340
+ a_t = self.asarray(a) if not isinstance(a, torch.Tensor) else a
341
+ b_t = self.asarray(b) if not isinstance(b, torch.Tensor) else b
342
+ return torch.minimum(a_t, b_t)
343
+
344
+ def maximum(self, a, b):
345
+ """Element-wise maximum."""
346
+ a_t = self.asarray(a) if not isinstance(a, torch.Tensor) else a
347
+ b_t = self.asarray(b) if not isinstance(b, torch.Tensor) else b
348
+ return torch.maximum(a_t, b_t)
349
+
350
+ def floor(self, a):
351
+ """Floor of input."""
352
+ if isinstance(a, (int, float)):
353
+ return math.floor(a)
354
+ a_t = self.asarray(a) if not isinstance(a, torch.Tensor) else a
355
+ return torch.floor(a_t)
356
+
357
+ def ceil(self, a):
358
+ """Ceiling of input."""
359
+ if isinstance(a, (int, float)):
360
+ return math.ceil(a)
361
+ a_t = self.asarray(a) if not isinstance(a, torch.Tensor) else a
362
+ return torch.ceil(a_t)
363
+
364
+ def trunc(self, a):
365
+ """Truncate to integer."""
366
+ if isinstance(a, (int, float)):
367
+ return math.trunc(a)
368
+ a_t = self.asarray(a) if not isinstance(a, torch.Tensor) else a
369
+ return torch.trunc(a_t)
370
+
371
+ def isinf(self, a):
372
+ """Check for infinity."""
373
+ if isinstance(a, (int, float)):
374
+ return math.isinf(a)
375
+ a_t = self.asarray(a) if not isinstance(a, torch.Tensor) else a
376
+ return torch.isinf(a_t)
377
+
378
+ def isnan(self, a):
379
+ """Check for NaN."""
380
+ if isinstance(a, (int, float)):
381
+ return math.isnan(a)
382
+ a_t = self.asarray(a) if not isinstance(a, torch.Tensor) else a
383
+ return torch.isnan(a_t)
384
+
385
+ def sign(self, a):
386
+ """Sign of elements."""
387
+ if isinstance(a, (int, float)):
388
+ return (a > 0) - (a < 0)
389
+ a_t = self.asarray(a) if not isinstance(a, torch.Tensor) else a
390
+ return torch.sign(a_t)
391
+
392
+ class TorchUfunc:
393
+ """Wraps torch ops to support numpy ufunc interface (reduce, accumulate).
394
+
395
+ Falls back to numpy for object arrays since torch doesn't support them.
396
+ """
397
+ def __init__(self, backend, op, reduce_op, accumulate_op=None, numpy_ufunc=None):
398
+ self._backend = backend
399
+ self._op = op
400
+ self._reduce_op = reduce_op
401
+ self._accumulate_op = accumulate_op
402
+ self._torch = torch
403
+ self._numpy_ufunc = numpy_ufunc
404
+
405
+ def _is_object_array(self, x):
406
+ return isinstance(x, numpy.ndarray) and x.dtype == object
407
+
408
+ def _to_numpy(self, x):
409
+ if isinstance(x, self._torch.Tensor):
410
+ return x.detach().cpu().numpy()
411
+ return x
412
+
413
+ def __call__(self, a, b):
414
+ a_is_tensor = isinstance(a, self._torch.Tensor)
415
+ b_is_tensor = isinstance(b, self._torch.Tensor)
416
+ # Fast path for tensor operations
417
+ if a_is_tensor and b_is_tensor and a.device == b.device:
418
+ return self._op(a, b)
419
+ if (a_is_tensor and isinstance(b, (int, float))) or \
420
+ (b_is_tensor and isinstance(a, (int, float))):
421
+ return self._op(a, b)
422
+ # Numpy fallback for object arrays
423
+ if self._numpy_ufunc and (self._is_object_array(a) or self._is_object_array(b)):
424
+ return self._numpy_ufunc(self._to_numpy(a), self._to_numpy(b))
425
+ try:
426
+ return self._op(self._backend.asarray(a), self._backend.asarray(b))
427
+ except TorchUnsupportedDtypeError:
428
+ if self._numpy_ufunc:
429
+ return self._numpy_ufunc(self._to_numpy(a), self._to_numpy(b))
430
+ raise
431
+
432
+ def reduce(self, a, axis=None):
433
+ if self._numpy_ufunc and self._is_object_array(a):
434
+ return self._numpy_ufunc.reduce(self._to_numpy(a), axis=axis)
435
+ try:
436
+ arr = self._backend.asarray(a)
437
+ if axis is None:
438
+ return self._reduce_op(arr)
439
+ return self._reduce_op(arr, dim=axis)
440
+ except TorchUnsupportedDtypeError:
441
+ if self._numpy_ufunc:
442
+ return self._numpy_ufunc.reduce(self._to_numpy(a), axis=axis)
443
+ raise
444
+
445
+ def accumulate(self, a, axis=0):
446
+ if self._numpy_ufunc and self._is_object_array(a):
447
+ return self._numpy_ufunc.accumulate(self._to_numpy(a), axis=axis)
448
+ try:
449
+ arr = self._backend.asarray(a)
450
+ if self._accumulate_op:
451
+ return self._accumulate_op(arr, dim=axis)
452
+ result = [arr[0]]
453
+ for i in range(1, len(arr)):
454
+ result.append(self._op(result[-1], arr[i]))
455
+ return self._torch.stack(result)
456
+ except TorchUnsupportedDtypeError:
457
+ if self._numpy_ufunc:
458
+ return self._numpy_ufunc.accumulate(self._to_numpy(a), axis=axis)
459
+ raise
460
+
461
+ @property
462
+ def add(self):
463
+ if self._add is None:
464
+ self._add = self.TorchUfunc(self, torch.add, torch.sum, torch.cumsum, numpy.add)
465
+ return self._add
466
+
467
+ @property
468
+ def subtract(self):
469
+ def cumulative_subtract(a, dim=0):
470
+ result = [a[0]]
471
+ for i in range(1, a.shape[dim]):
472
+ result.append(result[-1] - a[i])
473
+ return torch.stack(result)
474
+ return self.TorchUfunc(
475
+ self, torch.subtract,
476
+ lambda a, dim=None: a[0] - torch.sum(a[1:]) if dim is None else None,
477
+ cumulative_subtract,
478
+ numpy.subtract
479
+ )
480
+
481
+ @property
482
+ def multiply(self):
483
+ if self._multiply is None:
484
+ self._multiply = self.TorchUfunc(self, torch.multiply, torch.prod, torch.cumprod, numpy.multiply)
485
+ return self._multiply
486
+
487
+ @property
488
+ def divide(self):
489
+ def reduce_divide(a, dim=None):
490
+ if dim is None:
491
+ result = a.flatten()[0]
492
+ for x in a.flatten()[1:]:
493
+ result = result / x
494
+ return result
495
+ return None
496
+ return self.TorchUfunc(self, torch.divide, reduce_divide, None, numpy.divide)
497
+
498
+ @property
499
+ def inf(self):
500
+ return float('inf')
501
+
502
+ def seterr(self, **kwargs):
503
+ pass
504
+
505
+ @property
506
+ def VisibleDeprecationWarning(self):
507
+ return NumpyVisibleDeprecationWarning
508
+
509
+
510
+ class TorchBackendProvider(BackendProvider):
511
+ """PyTorch-based backend provider."""
512
+
513
+ def __init__(self, device=None):
514
+ if device is not None:
515
+ try:
516
+ torch_device = torch.device(device)
517
+ except Exception as exc:
518
+ raise ValueError(f"Invalid torch device '{device}': {exc}")
519
+ if torch_device.type == 'cuda':
520
+ if not torch.cuda.is_available():
521
+ raise ValueError(f"Torch device '{device}' is not available (cuda not available)")
522
+ if torch_device.index is not None and torch_device.index >= torch.cuda.device_count():
523
+ raise ValueError(f"Torch device '{device}' is not available (device index out of range)")
524
+ if torch_device.type == 'mps':
525
+ if not (hasattr(torch.backends, 'mps') and torch.backends.mps.is_available()):
526
+ raise ValueError(f"Torch device '{device}' is not available (mps not available)")
527
+ if torch_device.type not in {'cpu', 'cuda', 'mps'}:
528
+ raise ValueError(f"Torch device type '{torch_device.type}' is not supported")
529
+ self._torch_backend = TorchBackend(device)
530
+ self._device = device
531
+
532
+ @property
533
+ def name(self) -> str:
534
+ return 'torch'
535
+
536
+ @property
537
+ def np(self):
538
+ return self._torch_backend
539
+
540
+ @property
541
+ def device(self):
542
+ return self._torch_backend.device
543
+
544
+ def list_devices(self):
545
+ """List available torch devices (cpu, cuda, mps)."""
546
+ devices = ['cpu']
547
+ if torch.cuda.is_available():
548
+ devices.append('cuda')
549
+ for i in range(torch.cuda.device_count()):
550
+ devices.append(f'cuda:{i}')
551
+ if hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
552
+ devices.append('mps')
553
+ return devices
554
+
555
+ def supports_object_dtype(self) -> bool:
556
+ return False
557
+
558
+ def supports_strings(self) -> bool:
559
+ return False
560
+
561
+ def supports_float64(self) -> bool:
562
+ # MPS device doesn't support float64
563
+ return 'mps' not in str(self.device).lower()
564
+
565
+ def is_array(self, x) -> bool:
566
+ return isinstance(x, (numpy.ndarray, torch.Tensor))
567
+
568
+ def is_backend_array(self, x) -> bool:
569
+ """Check if x is specifically a torch tensor (not numpy)."""
570
+ return isinstance(x, torch.Tensor)
571
+
572
+ def get_dtype_kind(self, arr) -> str:
573
+ if hasattr(arr, 'dtype'):
574
+ dtype = arr.dtype
575
+ # numpy arrays have dtype.kind
576
+ if hasattr(dtype, 'kind'):
577
+ return dtype.kind
578
+ # torch tensors need manual mapping
579
+ kind_map = {
580
+ torch.float16: 'f',
581
+ torch.float32: 'f',
582
+ torch.float64: 'f',
583
+ torch.bfloat16: 'f',
584
+ torch.int8: 'i',
585
+ torch.int16: 'i',
586
+ torch.int32: 'i',
587
+ torch.int64: 'i',
588
+ torch.uint8: 'u',
589
+ torch.bool: 'b',
590
+ torch.complex64: 'c',
591
+ torch.complex128: 'c',
592
+ }
593
+ return kind_map.get(dtype, 'f')
594
+ return None
595
+
596
+ def to_numpy(self, x):
597
+ """Convert torch tensor to numpy array."""
598
+ if isinstance(x, torch.Tensor):
599
+ return x.detach().cpu().numpy()
600
+ return x
601
+
602
+ def is_scalar_integer(self, x) -> bool:
603
+ if isinstance(x, torch.Tensor) and x.ndim == 0:
604
+ return x.dtype in (torch.int8, torch.int16, torch.int32, torch.int64, torch.uint8)
605
+ return False
606
+
607
+ def is_scalar_float(self, x) -> bool:
608
+ if isinstance(x, torch.Tensor) and x.ndim == 0:
609
+ return x.dtype in (torch.float16, torch.float32, torch.float64, torch.bfloat16)
610
+ return False
611
+
612
+ def argsort(self, a, descending=False):
613
+ """Return indices that would sort the array."""
614
+ if not isinstance(a, torch.Tensor):
615
+ a = self._torch_backend.asarray(a)
616
+ return torch.argsort(a, descending=descending)
617
+
618
+ def array_size(self, a):
619
+ """Get the total number of elements in an array/tensor."""
620
+ if isinstance(a, torch.Tensor):
621
+ return a.numel()
622
+ if hasattr(a, 'size'):
623
+ size = a.size
624
+ return size if isinstance(size, int) else size()
625
+ return len(a) if hasattr(a, '__len__') else 1
626
+
627
+ def safe_equal(self, x, y):
628
+ """Compare two values, handling torch tensors correctly."""
629
+ if isinstance(x, torch.Tensor) or isinstance(y, torch.Tensor):
630
+ # Convert scalars to tensors for comparison
631
+ if isinstance(x, torch.Tensor) and x.dim() == 0:
632
+ x = x.item()
633
+ if isinstance(y, torch.Tensor) and y.dim() == 0:
634
+ y = y.item()
635
+ return x == y
636
+ # Default numpy comparison
637
+ return numpy.asarray(x, dtype=object) == numpy.asarray(y, dtype=object)
638
+
639
+ def array_equal(self, a, b) -> bool:
640
+ """Backend-native exact equality for torch tensors."""
641
+ if not isinstance(a, torch.Tensor) or not isinstance(b, torch.Tensor):
642
+ return False
643
+ try:
644
+ return bool(torch.equal(a, b))
645
+ except RuntimeError:
646
+ # Fall back to CPU comparison if devices mismatch
647
+ return bool(torch.equal(a.cpu(), b.cpu()))
648
+
649
+ def detach_if_needed(self, x):
650
+ """Detach tensor if it requires grad, to allow type conversions."""
651
+ if isinstance(x, torch.Tensor) and x.requires_grad:
652
+ return x.detach()
653
+ return x
654
+
655
+ def to_int_array(self, a):
656
+ """Convert array/tensor to integer type."""
657
+ if isinstance(a, torch.Tensor):
658
+ return a.to(int)
659
+ return numpy.asarray(a, dtype=int) if isinstance(a, numpy.ndarray) else int(a)
660
+
661
+ def floor_to_int(self, a):
662
+ """Floor a value and convert to integer."""
663
+ if not isinstance(a, torch.Tensor):
664
+ a = self.kg_asarray(a)
665
+ return torch.floor(a.float()).to(int)
666
+
667
+ def power(self, a, b):
668
+ """Compute a^b, handling gradient tracking for torch tensors."""
669
+ if isinstance(a, torch.Tensor):
670
+ # Handle negative exponents - torch doesn't support int^negative
671
+ if isinstance(b, torch.Tensor) and b.dtype in (torch.int8, torch.int16, torch.int32, torch.int64) and (b < 0).any():
672
+ base = a.float() if a.dtype in (torch.int8, torch.int16, torch.int32, torch.int64) else a
673
+ result = base.pow(b.abs()).float()
674
+ return torch.where(b < 0, 1.0 / result, result)
675
+ b_val = b.item() if isinstance(b, torch.Tensor) and b.ndim == 0 else b
676
+ if isinstance(b_val, (int, numpy.integer)) and b_val < 0:
677
+ a = a.float()
678
+ return a.pow(b)
679
+ # For numpy arrays or scalars
680
+ a_val = float(a) if isinstance(a, (int, numpy.integer)) else a
681
+ b_val = b.item() if isinstance(b, torch.Tensor) and b.ndim == 0 else (b.cpu().numpy() if isinstance(b, torch.Tensor) else b)
682
+ return numpy.power(a_val, b_val)
683
+
684
+ def has_gradient(self, x) -> bool:
685
+ """Check if x is tracking gradients."""
686
+ return isinstance(x, torch.Tensor) and x.requires_grad
687
+
688
+ def supports_autograd(self) -> bool:
689
+ """Torch backend supports automatic differentiation."""
690
+ return True
691
+
692
+ def create_grad_tensor(self, x):
693
+ """Create a tensor that tracks gradients."""
694
+ if isinstance(x, torch.Tensor):
695
+ return x.clone().detach().float().requires_grad_(True)
696
+ elif isinstance(x, numpy.ndarray):
697
+ return torch.from_numpy(x.astype(numpy.float64)).float().requires_grad_(True)
698
+ else:
699
+ return torch.tensor(x, dtype=torch.float32, requires_grad=True)
700
+
701
+ def compute_autograd(self, func, x):
702
+ """Compute gradient using PyTorch automatic differentiation."""
703
+ x_tensor = self.create_grad_tensor(x)
704
+
705
+ # Compute the function value
706
+ y = func(x_tensor)
707
+
708
+ # Check result type - must be a tensor for autograd to work
709
+ if not isinstance(y, torch.Tensor):
710
+ if isinstance(y, numpy.ndarray):
711
+ raise AutogradChainBrokenError(
712
+ "function output",
713
+ "torch.Tensor",
714
+ "numpy.ndarray",
715
+ "Avoid numpy operations. Use torch-compatible functions."
716
+ )
717
+ raise AutogradChainBrokenError(
718
+ "function output",
719
+ "torch.Tensor",
720
+ type(y).__name__,
721
+ "For autograd, use torch-compatible operations."
722
+ )
723
+
724
+ # Ensure y is a scalar
725
+ if y.numel() != 1:
726
+ raise NonScalarLossError(tuple(y.shape))
727
+
728
+ # Check requires_grad
729
+ if not y.requires_grad:
730
+ raise AutogradChainBrokenError(
731
+ "gradient computation",
732
+ "requires_grad=True",
733
+ "requires_grad=False",
734
+ "Output lost gradient tracking. Avoid .item(), .numpy(), or Python float()."
735
+ )
736
+
737
+ # Compute gradient
738
+ y.backward()
739
+
740
+ return x_tensor.grad
741
+
742
+ def compute_multi_autograd(self, func, params):
743
+ """
744
+ Compute gradients for multiple parameters using torch.autograd.grad().
745
+
746
+ Args:
747
+ func: Callable that takes a list of tensors and returns a scalar loss
748
+ params: List of parameter values to compute gradients for
749
+
750
+ Returns:
751
+ List of gradients, one per parameter
752
+ """
753
+ # Create grad tensors for all parameters
754
+ grad_tensors = [self.create_grad_tensor(p) for p in params]
755
+
756
+ # Compute the function value (loss)
757
+ y = func(grad_tensors)
758
+
759
+ # Validate output is a tensor
760
+ if not isinstance(y, torch.Tensor):
761
+ if isinstance(y, numpy.ndarray):
762
+ raise AutogradChainBrokenError(
763
+ "loss computation",
764
+ "torch.Tensor",
765
+ "numpy.ndarray",
766
+ "Avoid numpy operations in the loss function."
767
+ )
768
+ raise AutogradChainBrokenError(
769
+ "loss computation",
770
+ "torch.Tensor",
771
+ type(y).__name__,
772
+ "For autograd, use torch-compatible operations."
773
+ )
774
+
775
+ # Ensure y is a scalar
776
+ if y.numel() != 1:
777
+ raise NonScalarLossError(tuple(y.shape))
778
+
779
+ # Compute all gradients in one backward pass using torch.autograd.grad
780
+ grads = torch.autograd.grad(y, grad_tensors, create_graph=False)
781
+
782
+ return list(grads)
783
+
784
+ def compute_jacobian(self, func, x):
785
+ """
786
+ Compute Jacobian matrix using torch.autograd.functional.jacobian().
787
+
788
+ Args:
789
+ func: Callable that takes x and returns a vector
790
+ x: Input point
791
+
792
+ Returns:
793
+ Jacobian matrix J where J[i,j] = df_i/dx_j
794
+ """
795
+ x_tensor = self.create_grad_tensor(x)
796
+
797
+ # torch.autograd.functional.jacobian expects func(inputs) -> outputs
798
+ jacobian = torch_autograd_functional.jacobian(func, x_tensor)
799
+
800
+ return jacobian
801
+
802
+ def str_to_char_array(self, s):
803
+ """Not supported in torch backend."""
804
+ raise TorchUnsupportedDtypeError(
805
+ "PyTorch backend does not support string-to-character array conversion."
806
+ )
807
+
808
+ def compile_function(self, func, example_input, output_path=None, mode="default",
809
+ backend="inductor", fullgraph=False, dynamic=None):
810
+ """
811
+ Compile a function using torch.compile with configurable options.
812
+
813
+ Args:
814
+ func: Callable to compile
815
+ example_input: Example input for tracing the function
816
+ output_path: Optional path to save the exported graph (.pt2 file)
817
+ mode: Compilation mode - affects speed/quality tradeoff
818
+ - "default": Balanced compilation (default)
819
+ - "reduce-overhead": Faster compile, less optimization
820
+ - "max-autotune": Slower compile, maximum runtime performance
821
+ backend: Compilation backend
822
+ - "inductor": Default backend with C++/Triton codegen
823
+ - "eager": No compilation (for debugging)
824
+ - "aot_eager": Ahead-of-time eager (debugging with autograd)
825
+ - "cudagraphs": CUDA graphs for GPU (reduces launch overhead)
826
+ fullgraph: If True, requires entire function to compile as one graph
827
+ dynamic: If True, enables dynamic shapes; if False, assumes static shapes
828
+
829
+ Returns:
830
+ If output_path is None: compiled function
831
+ If output_path is provided: dict with compiled function and export info
832
+
833
+ Compilation Modes Comparison:
834
+ | Mode | Compile Time | Runtime Speed | Best For |
835
+ |-----------------|--------------|---------------|---------------------|
836
+ | default | Medium | Good | General use |
837
+ | reduce-overhead | Fast | Moderate | Quick iteration |
838
+ | max-autotune | Slow | Best | Production/training |
839
+ | (eager backend) | None | Baseline | Debugging |
840
+ """
841
+ # Convert example input to tensor if needed
842
+ if not isinstance(example_input, torch.Tensor):
843
+ example_input = self.create_grad_tensor(example_input)
844
+
845
+ # Build compile options
846
+ compile_kwargs = {
847
+ 'mode': mode,
848
+ 'backend': backend,
849
+ 'fullgraph': fullgraph,
850
+ }
851
+ if dynamic is not None:
852
+ compile_kwargs['dynamic'] = dynamic
853
+
854
+ # Compile the function with specified options
855
+ compiled_fn = torch.compile(func, **compile_kwargs)
856
+
857
+ # Warm up the compiled function (triggers actual compilation)
858
+ _ = compiled_fn(example_input)
859
+
860
+ if output_path is None:
861
+ # Wrap with Klong-convention parameter name (x) so that
862
+ # KGLambda introspection binds the argument correctly when
863
+ # the compiled function is stored via ::
864
+ def klong_compiled(x):
865
+ if not isinstance(x, torch.Tensor):
866
+ x = self.create_grad_tensor(x)
867
+ return compiled_fn(x)
868
+ return klong_compiled
869
+
870
+ # Export the function graph for inspection
871
+ try:
872
+ # Use torch.export for graph capture
873
+ exported = torch.export.export(func, (example_input,))
874
+
875
+ # Save the exported program
876
+ torch.export.save(exported, output_path)
877
+
878
+ # Get graph representation for inspection
879
+ graph_str = str(exported.graph_module.graph)
880
+
881
+ return {
882
+ 'compiled_fn': compiled_fn,
883
+ 'export_path': output_path,
884
+ 'graph': graph_str,
885
+ 'graph_module': exported.graph_module,
886
+ 'mode': mode,
887
+ 'backend': backend,
888
+ }
889
+ except Exception as e:
890
+ # If export fails, still return the compiled function
891
+ return {
892
+ 'compiled_fn': compiled_fn,
893
+ 'export_path': None,
894
+ 'export_error': str(e),
895
+ 'mode': mode,
896
+ 'backend': backend,
897
+ }
898
+
899
+ def get_compile_modes(self):
900
+ """
901
+ Return information about available compilation modes.
902
+
903
+ Returns:
904
+ Dict with mode descriptions and recommendations
905
+ """
906
+ return {
907
+ 'modes': {
908
+ 'default': 'Balanced compilation - good for most cases',
909
+ 'reduce-overhead': 'Faster compile time, less optimization - good for development',
910
+ 'max-autotune': 'Maximum optimization - best for production/training loops',
911
+ },
912
+ 'backends': {
913
+ 'inductor': 'Default backend with C++/Triton code generation',
914
+ 'eager': 'No compilation - runs original Python (for debugging)',
915
+ 'aot_eager': 'Ahead-of-time eager - captures autograd graph (debugging)',
916
+ 'cudagraphs': 'CUDA graphs - reduces kernel launch overhead (GPU only)',
917
+ },
918
+ 'recommendations': {
919
+ 'development': {'mode': 'reduce-overhead', 'backend': 'inductor'},
920
+ 'production': {'mode': 'max-autotune', 'backend': 'inductor'},
921
+ 'debugging': {'mode': 'default', 'backend': 'eager'},
922
+ 'gpu_inference': {'mode': 'max-autotune', 'backend': 'cudagraphs'},
923
+ }
924
+ }
925
+
926
+ def gradcheck(self, func, inputs, eps=1e-6, atol=1e-5, rtol=1e-3):
927
+ """
928
+ Check gradients computed by autograd against numeric gradients.
929
+
930
+ Uses torch.autograd.gradcheck to verify correctness.
931
+
932
+ Args:
933
+ func: Function to check (should return scalar or tensor)
934
+ inputs: Tuple of input tensors (must have requires_grad=True)
935
+ eps: Step size for numeric differentiation
936
+ atol: Absolute tolerance
937
+ rtol: Relative tolerance
938
+
939
+ Returns:
940
+ True if gradients match, raises GradcheckError otherwise
941
+ """
942
+ # Ensure inputs are tensors with gradients
943
+ tensor_inputs = []
944
+ for inp in inputs:
945
+ if isinstance(inp, torch.Tensor):
946
+ if not inp.requires_grad:
947
+ inp = inp.clone().detach().float().requires_grad_(True)
948
+ tensor_inputs.append(inp)
949
+ else:
950
+ tensor_inputs.append(self.create_grad_tensor(inp))
951
+
952
+ return torch.autograd.gradcheck(
953
+ func,
954
+ tuple(tensor_inputs),
955
+ eps=eps,
956
+ atol=atol,
957
+ rtol=rtol,
958
+ raise_exception=True
959
+ )
960
+
961
+ def klong_gradcheck(self, klong, fn, inputs):
962
+ """
963
+ Check gradients for a Klong function.
964
+
965
+ Handles wrapping the Klong function, dtype selection based on device,
966
+ and tolerance adjustment for float32 (MPS).
967
+
968
+ Args:
969
+ klong: KlongInterpreter instance
970
+ fn: Klong function to check
971
+ inputs: Input value or list of inputs
972
+
973
+ Returns:
974
+ 1 if gradients are correct, raises error otherwise
975
+ """
976
+ # Gradcheck requires float64 which is only supported on CPU
977
+ if self.device.type != 'cpu':
978
+ raise RuntimeError(
979
+ f".gradcheck() requires CPU device, got '{self.device.type}'. "
980
+ "Run with: kgpy --backend torch --device cpu"
981
+ )
982
+
983
+ dtype = torch.float64
984
+
985
+ # Wrap the Klong function
986
+ def wrapped_fn(v):
987
+ result = _invoke_fn(klong, fn, [v])
988
+ # Ensure result is a scalar tensor for gradcheck
989
+ if isinstance(result, torch.Tensor) and result.numel() > 1:
990
+ result = result.sum()
991
+ return result
992
+
993
+ # Convert inputs to tensor on CPU for gradcheck
994
+ if isinstance(inputs, (list, tuple)) and not isinstance(inputs[0], torch.Tensor):
995
+ tensor_inputs = torch.tensor(inputs, dtype=dtype, device='cpu', requires_grad=True)
996
+ elif not isinstance(inputs, torch.Tensor):
997
+ tensor_inputs = torch.tensor([inputs], dtype=dtype, device='cpu', requires_grad=True)
998
+ else:
999
+ tensor_inputs = inputs.detach().cpu().to(dtype=dtype).requires_grad_(True)
1000
+
1001
+ result = self.gradcheck(wrapped_fn, (tensor_inputs,))
1002
+
1003
+ return 1 if result else 0
1004
+
1005
+ def kg_asarray(self, a):
1006
+ """
1007
+ Converts input data into a PyTorch tensor for KlongPy.
1008
+
1009
+ For data that can't be converted to tensors (strings, heterogeneous
1010
+ types, jagged arrays), falls back to numpy object arrays to maintain
1011
+ compatibility with Klong's list semantics.
1012
+ """
1013
+ if isinstance(a, str):
1014
+ # Strings become numpy character arrays like in numpy backend
1015
+ return numpy.array(list(a))
1016
+ try:
1017
+ # Check for jagged arrays early - torch converts them incorrectly
1018
+ if is_jagged_array(a):
1019
+ raise TorchUnsupportedDtypeError("Jagged arrays not supported")
1020
+ arr = self._torch_backend.asarray(a)
1021
+ if hasattr(arr, 'dtype'):
1022
+ # For torch tensors, dtype doesn't have .kind attribute
1023
+ if hasattr(arr.dtype, 'kind'):
1024
+ if arr.dtype.kind not in ['O', 'i', 'f']:
1025
+ raise ValueError
1026
+ return arr
1027
+ except (NumpyVisibleDeprecationWarning, ValueError, TypeError, RuntimeError, TorchUnsupportedDtypeError):
1028
+ # Fall back to numpy object array for heterogeneous/unsupported data
1029
+ # Use numpy for inner conversions to avoid MPS tensor issues
1030
+ def _numpy_convert(x):
1031
+ if isinstance(x, list):
1032
+ try:
1033
+ return numpy.asarray(x)
1034
+ except (ValueError, TypeError):
1035
+ return numpy.asarray([_numpy_convert(i) for i in x], dtype=object)
1036
+ return x
1037
+ try:
1038
+ arr = numpy.asarray(a, dtype=object)
1039
+ # Recursively convert inner lists to numpy arrays
1040
+ arr = numpy.asarray(
1041
+ [_numpy_convert(x) if isinstance(x, list) else x for x in arr],
1042
+ dtype=object
1043
+ )
1044
+ return arr
1045
+ except (ValueError, TypeError):
1046
+ # Last resort: keep as list
1047
+ return a