Compare commits

..
Author SHA1 Message Date
geohot f0c9287199 something 2026-09-06 22:29:02 -07:00
George HotzandGitHub 4c64311bd2 Merge branch 'master' into clone_grad_issues 2026-09-06 15:02:36 -07:00
geohot 6f306649d5 more 2026-09-06 15:01:08 -07:00
geohot ae588e9be5 fix some clone grad issues 2026-09-06 14:06:40 -07:00
19 changed files with 368 additions and 315 deletions
BIN
View File
Binary file not shown.
+18 -1
View File
@@ -150,6 +150,18 @@ A value \op{Call} is void: its \op{Sink} body stores to output \op{Param}s bound
\smallskip
Assign is \op{Store} followed by \op{After}: write the value, then return the buffer with an ordering dependency.
\op{After} orders consumers after its dependencies; it neither declares a write nor snapshots memory.
In particular, \op{After}$(b, \op{Store}(d,v))$ returns $b$, not $v$, when $b$ and $d$ are disjoint.
Views may share storage despite having different UOps. Differentiation follows the returned value:
a matching unconditional full overwrite routes its gradient to the stored value; an unrelated write does not create a gradient path.
Partial or uncertain aliased mutation gradients may be rejected.
\smallskip
\textbf{Tensor scheduling contract.} Within a lazy Tensor schedule, reads retain their assignment dependencies.
A read must follow those dependencies and precede other writes that would destroy the required contents.
Lowering must preserve these requirements until accesses are ordered, even when arguments share storage.
Unsatisfiable requirements raise rather than read overwritten contents. This is a frontend requirement, not snapshot semantics for \op{After}.
An executed \texttt{clone()} preserves data in fresh storage; \texttt{contiguous()} need not allocate.
%% ============================================================
\subsection*{{\color{elwyellow}Elementwise Ops} \normalfont\small--- all inputs same shape, output same shape, applied per-element}
@@ -236,7 +248,7 @@ Ternary & $(P, A, B)$
\op{Custom} & (args\ldots) & fmt & Inject custom code string into generated source. \\
\op{AtomicAdd} & (idx, val) & --- & Atomic read-modify-write: \texttt{buf[idx] += val}. \\[4pt]
\op{CustomFunction} & (meta\ldots) & name & Opaque device function (e.g.\ HW decode). Via \op{Call}. \\
\op{Program} & (linear, source, binary) & --- & Compiled kernel: instructions, source, and machine code. \\
\op{Program} & (sink, \ldots) & metadata? & Kernel through compilation stages. \\
\op{Source} & () & str & Human-readable rendered source code. \\
\op{Binary} & () & bytes & Compiled machine code. \\
\bottomrule
@@ -244,6 +256,11 @@ Ternary & $(P, A, B)$
\smallskip
These ops are not part of the core specification and are subject to change.
\op{Program} contains a \op{Sink}, followed progressively by \op{Linear}, \op{Source}, and \op{Binary}.
Access analysis derives reads and writes from the memory operands of \op{Load}/\op{Store}, resolving \op{Param}s through \op{Call} arguments.
Compilation records these sets in \texttt{ProgramInfo.ins/outs} as zero-based argument slots; a read-modify-write belongs in both.
Listing a parameter, returning an \op{After}, or declaring a write does not establish full initialization.
Opaque code without computed access information is unsupported by assignment scheduling; its effects must not be guessed from its argument list.
%% ============================================================
\subsection*{Derived Properties}
+18 -23
View File
@@ -40,10 +40,10 @@ class TestAssign(unittest.TestCase):
def test_assign_copy(self):
a = Tensor([1.,2,3], device="PYTHON")
c = Tensor.empty(3).assign(a.to(None))
# it should copy into the empty buffer
# The creation copy has its own storage, independent of the assignment destination.
GlobalCounters.reset()
c.realize()
assert_kernel_count(2 if is_hcq2_device() else 1)
assert_kernel_count(3 if is_hcq2_device() else 2)
def test_assign_slice(self):
X = Tensor([1,2,3,4]).realize()
@@ -619,7 +619,7 @@ class TestAssign(unittest.TestCase):
contig.assign(Tensor([1, 4, 3], dtype=dtypes.int64))
GlobalCounters.reset()
base.assign(contig).realize()
assert_kernel_count(4 if is_hcq2_device() else 2) # TODO: first copy is dead, could be 1
assert_kernel_count(6 if is_hcq2_device() else 4) # TODO: first copy is dead
self.assertEqual(base.tolist(), [1,4,3])
def test_nested_after_contiguous_store_no_init(self):
@@ -629,7 +629,7 @@ class TestAssign(unittest.TestCase):
contig.assign(Tensor([1, 4, 3], dtype=dtypes.int64))
GlobalCounters.reset()
base.assign(contig).realize()
assert_kernel_count(2 if is_hcq2_device() else 1)
assert_kernel_count(3 if is_hcq2_device() else 2)
self.assertEqual(base.tolist(), [1,4,3])
def test_assign_temporary_copy_reshape(self):
@@ -637,7 +637,7 @@ class TestAssign(unittest.TestCase):
c = Tensor.empty(2, 2).assign(a.to(None))
GlobalCounters.reset()
c.realize()
assert_kernel_count(2 if is_hcq2_device() else 1)
assert_kernel_count(3 if is_hcq2_device() else 2)
self.assertEqual(c.tolist(), [[1., 2], [3, 4]])
class TestAssignOrdering(unittest.TestCase):
@@ -828,6 +828,13 @@ class TestAssignOrdering(unittest.TestCase):
b_np *= 0.9
np.testing.assert_allclose(param.item(), p_np, atol=1e-5)
def test_after_store_to_other_buffer(self):
x, state = Tensor([2.]).realize(), Tensor([0.]).realize()
ordered = Tensor(x.uop.after(state.uop.store(x.uop * 3)))
self.assertEqual((ordered + x).tolist(), [4.])
self.assertEqual(state.tolist(), [6.])
self.assertEqual(x.tolist(), [2.])
def test_war_reader_already_depends_on_write(self):
x = Tensor([1.0]).contiguous().realize()
y = Tensor([2.0]).contiguous().realize()
@@ -835,12 +842,8 @@ class TestAssignOrdering(unittest.TestCase):
x.assign(x * 2)
y.assign(y + x)
z = y + x_expr
Tensor.realize(x, y, z)
try:
np.testing.assert_allclose([x.item(), y.item(), z.item()], [2.0, 4.0, 15.0])
except AssertionError:
# TODO: broken now, x_expr reads x after the assign
np.testing.assert_allclose([x.item(), y.item(), z.item()], [2.0, 4.0, 16.0])
with self.assertRaisesRegex(RuntimeError, "cycle"):
Tensor.realize(x, y, z)
def test_war_multi_read_then_assign(self):
devices = ("CPU:0", "CPU:1")
@@ -875,12 +878,8 @@ class TestAssignOrdering(unittest.TestCase):
a.assign(b + 1) # a == 11
v1 = a * 3 # reads 11 -> 33
a.assign(b + 100) # a == 110
out = (a + v1).numpy()
try:
np.testing.assert_allclose(out, 143)
except AssertionError:
# TODO: broken now, v1 reads a after the second assign
np.testing.assert_allclose(out, 440)
with self.assertRaisesRegex(RuntimeError, "cycle"):
(a + v1).numpy()
def test_two_reads_between_three_assigns(self):
a = Tensor.zeros(4).realize()
@@ -995,12 +994,8 @@ class TestAssignOrdering(unittest.TestCase):
x.assign(x+1)
return y+x
a = Tensor([1.]).realize()
out = outer(a).item()
try:
self.assertEqual([out, a.item()], [7., 3.])
except AssertionError:
# TODO: broken now, the inner assign is run twice
self.assertEqual([out, a.item()], [6., 4.])
with self.assertRaisesRegex(RuntimeError, "cycle"):
outer(a).item()
class TestAssignToUnrealizedView(unittest.TestCase):
def test_copy(self):
+41
View File
@@ -105,6 +105,47 @@ def backward_gemm_custom(gradient:UOp, kernel:UOp) -> tuple[UOp, UOp]:
# **** tests ****
class TestCustomKernel(unittest.TestCase):
def test_readonly_after_args(self):
for chained in (False, True):
for corealize in (False, True):
with self.subTest(chained=chained, corealize=corealize):
x = Tensor([2.]).realize()
a, x1 = Tensor.empty(1).custom_kernel(x, fxn=custom_add_one_kernel)
b, x2 = Tensor.empty(1).custom_kernel(x1 if chained else x, fxn=custom_add_one_kernel)
if corealize:
y = x1 + b
Tensor.realize(y, x2)
self.assertEqual(y.tolist(), [5.])
else:
self.assertEqual((x1 + x2).tolist(), [4.])
self.assertEqual(a.tolist(), [3.])
self.assertEqual(b.tolist(), [3.])
self.assertEqual(x.tolist(), [2.])
def test_aliased_args_different_sizes(self):
def kernel(out:UOp, a:UOp, b:UOp):
i = UOp.range(4, 0)
return out[i].store(a[i] + b[0]).end(i).sink(arg=KernelInfo(name="aliased_sizes"))
x = Tensor([1., 2., 3., 4.]).realize()
out = Tensor.empty(4).custom_kernel(x, x[:1], fxn=kernel)[0]
self.assertEqual(out.tolist(), [2., 3., 4., 5.])
def test_unindexed_access_before_assign(self):
def kernel(out:UOp, x:UOp): return out.store(x + 1).sink(arg=KernelInfo(name="unindexed"))
x = Tensor([2.]).realize()
y = Tensor.empty(1).custom_kernel(x, fxn=kernel)[0]
x.assign(x * 2)
Tensor.realize(x, y)
self.assertEqual(x.tolist(), [4.])
self.assertEqual(y.tolist(), [3.])
def test_readonly_after_does_not_hide_write(self):
x = Tensor([2.]).realize()
_, before = Tensor.empty(1).custom_kernel(x, fxn=custom_add_one_kernel)
x.assign(x * 2)
with self.assertRaisesRegex(RuntimeError, "cycle"):
(before + x).realize()
def test_empty(self):
a = Tensor.empty(1)
a = Tensor.custom_kernel(a, fxn=lambda _: UOp.sink(arg=KernelInfo()))[0]
-89
View File
@@ -268,11 +268,6 @@ class TestOps(unittest.TestCase):
self.helper_test_exception([], lambda: torch.meshgrid(x, indexing="bad"), lambda: xt.meshgrid(indexing="bad"), expected=RuntimeError)
def test_meshgrid_scalar(self):
for indexing in ("ij", "xy"):
with self.subTest(indexing=indexing):
helper_test_op([()], lambda x: torch.meshgrid(x, indexing=indexing)[0], lambda x: x.meshgrid(indexing=indexing)[0])
def test_arange(self):
helper_test_op([], lambda: torch.arange(10, dtype=torch.int32), lambda: Tensor.arange(10), forward_only=True)
helper_test_op([], lambda: torch.arange(36, dtype=torch.int32), lambda: Tensor.arange(36), forward_only=True)
@@ -1188,20 +1183,6 @@ class TestOps(unittest.TestCase):
def test_small_cummax(self):
helper_test_op([(10)], lambda x: torch.cummax(x, dim=0).values, lambda x: Tensor.cummax(x, axis=0)[0])
helper_test_op([(10)], lambda x: torch.cummax(x, dim=0).indices.int(), lambda x: Tensor.cummax(x, axis=0)[1], forward_only=True)
def test_cumextrema_ties(self):
for op in ("cummax", "cummin"):
for axis in (0, 1, -1):
for values in ([[2, 2, 1, 3, 3, 0, 0]] * 2, [[0, 0, 0]] * 2):
with self.subTest(op=op, axis=axis, values=values):
helper_test_op(None, lambda x: getattr(torch, op)(x, dim=axis).indices.int(),
lambda x: getattr(x, op)(axis)[1], vals=[values], forward_only=True)
def test_cumextrema_ties_split(self):
for op in ("cummax", "cummin"):
helper_test_op(None, lambda x: getattr(torch, op)(x, dim=-1).indices.int(), lambda x: getattr(x, op)(-1)[1],
vals=[[[2.0, 2.0, 1.0, 3.0, 3.0, 0.0, 0.0] * 100] * 2], forward_only=True)
@slow_test
def test_simple_cummax(self):
helper_test_op([(512)], lambda x: torch.cummax(x, dim=0).values, lambda x: Tensor.cummax(x, axis=0)[0])
@@ -1668,10 +1649,6 @@ class TestOps(unittest.TestCase):
helper_test_op(None, lambda x: x.isclose(torch.tensor(1.0)), lambda x: x.isclose(1.0),
vals=[[1.0, 1.0 + 1e-7, 2.0, math.inf, -math.inf, math.nan]], forward_only=True)
def test_isclose_overflow(self):
helper_test_op(None, lambda x,y: x.isclose(y, rtol=3),
vals=[[3e38, -3e38, 3e38, 0.0], [-3e38, 3e38, 3e38, 1.0]], forward_only=True)
def test_mean(self):
helper_test_op([(3,4,5,6)], lambda x: x.mean())
helper_test_op([()], lambda x: x.mean())
@@ -1716,16 +1693,6 @@ class TestOps(unittest.TestCase):
helper_test_op([(15, 25, 35)], lambda x: x.var(keepdim=True))
helper_test_op([(15, 25, 35)], lambda x: x.var(0, keepdim=True, correction=0))
def test_var_std_integer(self):
for op in ("var", "std"):
for axis in (None, 0, 1):
for correction in (0, 1):
for keepdim in (False, True):
with self.subTest(op=op, axis=axis, correction=correction, keepdim=keepdim):
helper_test_op(None, lambda x: getattr(x.float(), op)(dim=axis, correction=correction, keepdim=keepdim),
lambda x: getattr(x, op)(axis=axis, correction=correction, keepdim=keepdim),
vals=[[[0, 1, 3], [1, 2, 4]]], forward_only=True)
@slow_test
def test_std(self):
helper_test_op([(15, 25, 35)], lambda x: x.std())
@@ -1842,21 +1809,6 @@ class TestOps(unittest.TestCase):
helper_test_op(None, lambda x: torch.logcumsumexp(x, dim=0), lambda x: x.logcumsumexp(), atol=1e-7, grad_atol=1e-7, vals=[[0.0, 100.0]])
helper_test_op(None, lambda x: torch.logcumsumexp(x, dim=0), lambda x: x.logcumsumexp(), vals=[[-math.inf, 0.0, 1.0]], forward_only=True)
def test_logcumsumexp_scalar_invalid_axis(self):
for axis in (-2, 1):
with self.subTest(axis=axis):
self.helper_test_exception([()], lambda x: torch.logcumsumexp(x, dim=axis), lambda x: x.logcumsumexp(axis), expected=IndexError)
def test_logcumsumexp_empty(self):
for shape, axis in (((0,), 0), ((2, 0, 3), 1), ((2, 0, 3), -1)):
with self.subTest(shape=shape, axis=axis):
helper_test_op([shape], lambda x: torch.logcumsumexp(x, dim=axis), lambda x: x.logcumsumexp(axis))
def test_logcumsumexp_nonfinite(self):
for values in ([-math.inf, -math.inf], [0., math.inf, -math.inf], [0., math.nan, 1.]):
with self.subTest(values=values):
helper_test_op(None, lambda x: torch.logcumsumexp(x, dim=0), lambda x: x.logcumsumexp(), vals=[values], forward_only=True)
def test_sinh(self):
helper_test_op([(45,65)], lambda x: x.sinh(), grad_atol=1e-6)
# TODO: backward nan instead of inf
@@ -2237,12 +2189,6 @@ class TestOps(unittest.TestCase):
helper_test_op([(3,5)], lambda x: x.diagonal(offset=2)) # offset on rectangular
self.helper_test_exception([(3,3)], lambda x: x.diagonal(dim1=0, dim2=0), expected=RuntimeError)
def test_diagonal_outside_matrix(self):
for shape, dims in (((2, 3), (0, 1)), ((2, 3, 4), (-2, -1)), ((2, 3, 4), (2, 0))):
for offset in (-10, -4, 4, 10):
with self.subTest(shape=shape, dims=dims, offset=offset):
helper_test_op([shape], lambda x: x.diagonal(offset=offset, dim1=dims[0], dim2=dims[1]))
def test_roll(self):
helper_test_op([(2, 4)], lambda x: x.roll(1))
helper_test_op([(2, 4)], lambda x: x.roll((1,)))
@@ -3380,16 +3326,6 @@ class TestOps(unittest.TestCase):
helper_test_op([(12,10)], lambda x: torch.nn.CrossEntropyLoss(label_smoothing=s)(x, torch.tensor(classes)),
lambda x: x.sparse_categorical_crossentropy(Tensor(classes), label_smoothing=s))
def test_sparse_categorical_crossentropy_default_ignore_index(self):
classes = [-1, 0, 2, -1]
for reduction in ("none", "sum", "mean"):
for smoothing in (0.0, 0.3, 1.0):
with self.subTest(reduction=reduction, smoothing=smoothing):
helper_test_op([(4, 3)],
lambda x: torch.nn.functional.cross_entropy(x, torch.tensor(classes), ignore_index=-1,
reduction=reduction, label_smoothing=smoothing),
lambda x: x.sparse_categorical_crossentropy(Tensor(classes), reduction=reduction, label_smoothing=smoothing))
def test_nll_loss(self):
target = np.random.randint(0, 10, (32,), dtype=np.int32).tolist()
helper_test_op([(32,10)],
@@ -3503,31 +3439,6 @@ class TestOps(unittest.TestCase):
if not COMPILE_ONLY: assert t == -1
class TestOpsUint8(unittest.TestCase):
def test_lerp_integer_end(self):
for dtype in dtypes.ints:
with self.subTest(dtype=dtype):
actual = Tensor([[10], [100]], dtype=dtypes.uint8).lerp(Tensor([20, 20, 100], dtype=dtype), Tensor([0., 0.5, 1.]))
self.assertEqual(actual.dtype, dtypes.uint8)
actual.realize()
if not COMPILE_ONLY: np.testing.assert_equal(actual.numpy(), [[10, 15, 100], [100, 60, 100]])
def test_lerp_float_end(self):
helper_test_op(None, lambda x,y,w: x.float().lerp(y, w), lambda x,y,w: x.cast(dtypes.uint8).lerp(y, w),
vals=[[[10], [100]], [20.5, 9.5, -5.5], [0., 0.5, 1.]], forward_only=True)
def test_interpolate_bilinear_full_range(self):
for values in ([[0, 255]], [[255, 0]], [[1, 200]], [[0, 255], [255, 0]]):
for size in ((1, 3), (5, 10)):
for align_corners in (False, True):
with self.subTest(values=values, size=size, align_corners=align_corners):
image = torch.tensor([[values]], dtype=torch.uint8)
expected = torch.nn.functional.interpolate(image, size=size, mode="bilinear", align_corners=align_corners)
actual = Tensor(image.numpy()).interpolate(size, align_corners=align_corners)
self.assertEqual(actual.dtype, dtypes.uint8)
# Midpoints are exact; other weights can differ by one with 7-bit fixed-point coefficients.
actual.realize()
if not COMPILE_ONLY: np.testing.assert_allclose(actual.numpy(), expected.numpy(), rtol=0, atol=0 if size == (1, 3) else 1)
def test_cast(self):
helper_test_op([(2,3,64,64)], lambda x: x.type(torch.uint8), lambda x: x.cast('uint8'), forward_only=True, low=0, high=255)
+19
View File
@@ -737,6 +737,25 @@ class TestZeroShapeTensor(unittest.TestCase):
np.testing.assert_allclose(a.numpy(), b.numpy())
self.assertIsNot(a.uop.base.buffer, b.uop.base.buffer)
def test_clone_unrealized_copy_does_not_alias(self):
for realize_clone in (False, True):
with self.subTest(realize_clone=realize_clone):
a = Tensor([2.])
b = a.clone()
if realize_clone: b.realize()
b.assign(7.).realize()
self.assertEqual(a.tolist(), [2.])
self.assertEqual(b.tolist(), [7.])
self.assertIsNot(a.uop.base.buffer, b.uop.base.buffer)
def test_clone_preserves_creation_copy(self):
source = Tensor([2.], device="PYTHON")
copied = source.to("CPU")
cloned = copied.clone().realize()
source.assign(7.).realize()
self.assertEqual(copied.tolist(), [2.])
self.assertEqual(cloned.tolist(), [2.])
def test_clone_deviceless_const(self):
t = Tensor(UOp.const(2.0).cast(dtypes.float)).clone()
np.testing.assert_equal(t.numpy(), 2.0)
+66
View File
@@ -0,0 +1,66 @@
import unittest
from tinygrad import Tensor, Context, dtypes
from tinygrad.uop.ops import UOp, Ops, KernelInfo, ProgramInfo
class TestCallAccess(unittest.TestCase):
def test_computed_reads_writes_and_unused_arguments(self):
out, x, unused = (UOp.param(i, dtypes.float, (1,), "CPU") for i in range(3))
body = out.store(x + 1).sink(arg=KernelInfo())
self.assertEqual(body.call(out, x, unused).call_access(), ((x,), (out,)))
def test_computed_read_modify_write(self):
out, x = (UOp.param(i, dtypes.float, (1,), "CPU") for i in range(2))
body = out.store(out + x).sink(arg=KernelInfo())
self.assertEqual(body.call(out, x).call_access(), ((out, x), (out,)))
def test_computed_empty_effects(self):
x = UOp.param(0, dtypes.float, (1,), "CPU")
self.assertEqual(UOp.sink(x, arg=KernelInfo()).call(x).call_access(), ((), ()))
def test_computed_program_accesses(self):
out, x = (UOp.param(i, dtypes.float, (1,), "CPU") for i in range(2))
sink = out.store(x.load()).sink(arg=KernelInfo())
program = UOp(Ops.PROGRAM, src=(sink,), arg=ProgramInfo.from_sink(sink))
self.assertEqual(program.call(out, x).call_access(), ((x,), (out,)))
def test_nested_linear_parameter_scopes(self):
a, b, c = (UOp.param(i, dtypes.float, (1,), "CPU") for i in range(3))
inner = a.store(b + 1).sink(arg=KernelInfo()).call(b, a)
body = UOp(Ops.LINEAR, src=(inner,))
self.assertEqual(body.call(a, b, c).call_access(), ((a,), (b,)))
def test_copy_accesses(self):
out, x = (UOp.param(i, dtypes.float, (1,), "CPU") for i in range(2))
self.assertEqual(UOp(Ops.COPY, src=(x,), arg=out.device).call(out, x).call_access(), ((x,), (out,)))
def test_unknown_opaque_accesses_reject(self):
x = UOp.param(0, dtypes.float, (1,), "CPU")
bodies = (UOp(Ops.PROGRAM, src=(UOp.sink(x),)),
UOp(Ops.CUSTOM, src=(x,), arg=("", dtypes.void)).sink(arg=KernelInfo()))
for body in bodies:
with self.assertRaisesRegex(RuntimeError, "cannot compute accesses"): body.call(x).call_access()
@Context(DEV="CPU")
def test_unknown_effects_do_not_replace_tensors_on_failure(self):
def kernel(x): return UOp(Ops.PROGRAM, src=(UOp.sink(x, arg=KernelInfo()),))
x = Tensor([2.]).realize().custom_kernel(fxn=kernel)[0]
before = x.uop
for _ in range(2):
with self.assertRaisesRegex(RuntimeError, "cannot compute accesses"): x.realize()
self.assertIs(x.uop, before)
def test_bad_access_slots(self):
arg = UOp.param(0, dtypes.float, (1,), "CPU")
for slot in (-1, 1):
p = UOp(Ops.PROGRAM, src=(UOp.sink(arg),), arg=ProgramInfo(globals=(0,), ins=(slot,), outs=()))
with self.assertRaisesRegex(RuntimeError, "invalid CALL access slot"): p.call(arg, arg).call_access()
def test_compiled_writable_alias_rejects(self):
a, b = (UOp.param(i, dtypes.float, (1,), "CPU") for i in range(2))
sink = a.store(b.load()).sink(arg=KernelInfo())
program = UOp(Ops.PROGRAM, src=(sink,), arg=ProgramInfo.from_sink(sink))
with self.assertRaisesRegex(RuntimeError, "aliased opaque"): program.call(a, a).call_access()
if __name__ == "__main__": unittest.main()
-18
View File
@@ -151,17 +151,6 @@ class TestTypeSpec(unittest.TestCase):
_assert_eq(Tensor.arange(5.0, 3.0), dtypes.default_float, np.arange(5.0, 3.0))
class TestAutoCastType(unittest.TestCase):
@unittest.skipUnless(dtypes.float64 in supported_dtypes, "need float64")
def test_linspace_float64_precision(self):
for start, stop in ((1., 1.+1e-8), (1.+1e-8, 1.), (1e10, 1e10+1)):
with self.subTest(start=start, stop=stop):
out = Tensor.linspace(start, stop, 3, dtype=dtypes.float64)
self.assertEqual(out.dtype, dtypes.float64)
np.testing.assert_allclose(out.numpy(), np.linspace(start, stop, 3), rtol=1e-15, atol=0)
with Context(DEFAULT_FLOAT=dtypes.float64):
out = Tensor.linspace(10**10, 10**10+2, 3, dtype=dtypes.int64)
np.testing.assert_array_equal(out.numpy(), [10**10, 10**10+1, 10**10+2])
def test_int_sqrt(self):
_assert_eq(Tensor([1, 4, 9, 16]).sqrt(), dtypes.default_float, [1, 2, 3, 4])
@@ -233,13 +222,6 @@ class TestAutoCastType(unittest.TestCase):
t.square().mean().backward()
np.testing.assert_allclose(t.grad.numpy().flatten(), [60000 * 2 / (N*N)] * N*N)
def test_var_integer_fractional(self):
for dtype in [*dtype_ints, dtypes.bool]:
with self.subTest(dtype=dtype):
out = Tensor([0, 1], dtype=dtype).var()
self.assertEqual(out.dtype, dtypes.float32)
np.testing.assert_allclose(out.numpy(), 0.5)
@unittest.skipUnless(dtypes.half in supported_dtypes, "need half")
def test_var_half_precision_large_n(self):
# the element count (70000) exceeds half max (65504): the denominator must not be materialized in half
+81
View File
@@ -88,6 +88,72 @@ class TestTensorGradient(unittest.TestCase):
np.testing.assert_allclose(x.grad.numpy(), [2.0, 2.0, 2.0, 2.0]) # gradient flows through clone
np.testing.assert_allclose(base.grad.numpy(), [0.0, 0.0, 0.0, 0.0]) # ...but detach blocks it from base
def test_gradient_through_single_assign(self):
x = Tensor([2., 3.]).realize()
y = x.clone()
y.assign(y.square())
self.assertEqual(y.sum().gradient(x)[0].tolist(), [4., 6.])
def test_gradient_through_assign_requires_old_versions(self):
for count in (2, 3):
with self.subTest(count=count):
x = Tensor([2., 3.]).realize()
y = x.clone()
for _ in range(count): y.assign(y.square())
g = y.sum().gradient(x)[0]
before = (x.uop, y.uop, g.uop)
# Reject incompatible versions, including on retry: failed scheduling must not replace them with buffers.
for _ in range(2):
with self.assertRaisesRegex(RuntimeError, "cycle"): g.realize()
self.assertEqual((x.uop, y.uop, g.uop), before)
def test_gradient_through_assign_with_snapshots(self):
x = Tensor([2., 3.]).realize()
y = x.clone()
for _ in range(2): y.assign(y.clone().square())
g = y.sum().gradient(x)[0]
gg = g.sum().gradient(x)[0]
Tensor.realize(g, gg)
self.assertEqual(g.tolist(), [32., 108.])
self.assertEqual(gg.tolist(), [48., 108.])
def test_gradient_after_unrelated_store(self):
x, v, dst = Tensor([2.]).realize(), Tensor([3.]).realize(), Tensor.empty(1)
y = Tensor(x.uop.after(dst.uop.store(v.uop)))
self.assertEqual([g.tolist() for g in y.sum().gradient(x, v)], [[1.], [0.]])
self.assertEqual(y.tolist(), [2.])
self.assertEqual(dst.tolist(), [3.])
def test_gradient_after_multiple_unrelated_stores(self):
x, a, b = Tensor([2.]).realize(), Tensor.empty(1), Tensor.empty(1)
y = Tensor(x.uop.after(a.uop.store(x.uop * 3), b.uop.store(x.uop * 4)))
self.assertEqual(y.sum().gradient(x)[0].tolist(), [1.])
def test_gradient_after_readonly_call(self):
x = Tensor([2.]).realize()
def kernel(dst, src): return dst.store(src * 3).sink(arg=KernelInfo())
for grad_fxn in (None, lambda g, k: (None, g * 3)):
_, unchanged = Tensor.empty(1).custom_kernel(x, fxn=kernel, grad_fxn=grad_fxn)
self.assertEqual(unchanged.sum().gradient(x)[0].tolist(), [1.])
def test_gradient_after_unrelated_call(self):
x, v, dst = Tensor([2.]).realize(), Tensor([3.]).realize(), Tensor.empty(1)
p, q = dst.uop.param_like(0), v.uop.param_like(1)
call = p.store(q * 3).sink(arg=KernelInfo()).call(dst.uop, v.uop, grad_fxn=lambda g, k: (None, g * 3))
y = Tensor(x.uop.after(call))
self.assertEqual([g.tolist() for g in y.sum().gradient(x, v)], [[1.], [0.]])
def test_gradient_after_aliased_store_view_rejects(self):
x = Tensor([2., 3.]).realize()
y = Tensor(x.uop.after(x.uop.shrink(((0, 1),)).store(4.)))
with self.assertRaisesRegex(RuntimeError, "aliased write"): y.sum().gradient(x)
def test_gradient_after_duplicate_call_output_rejects(self):
x = Tensor([2.]).realize()
def kernel(a, b): return a.store(b * 2).sink(arg=KernelInfo())
y = x.custom_kernel(x, fxn=kernel, grad_fxn=lambda g, k: (g, g))[0]
with self.assertRaisesRegex(RuntimeError, "ambiguous CALL"): y.sum().gradient(x)
def test_setitem_on_grad_used_tensor_raises(self):
x = Tensor([1.0, 2.0, 3.0, 4.0]).realize()
_ = (x * 2.0).sum()
@@ -136,6 +202,21 @@ class TestTensorGradient(unittest.TestCase):
self.assertIsNone(w.grad)
class TestMultiOutputGradient(unittest.TestCase):
def test_custom_kernel_inplace_gradient(self):
def double(x:UOp): return x[0].store(x[0]*2).sink(arg=KernelInfo(name="double_inplace"))
def backward(g:UOp, call:UOp): return (g*2,)
x = Tensor([2.]).realize()
y = x.custom_kernel(fxn=double, grad_fxn=backward)[0]
self.assertEqual(y.sum().gradient(x)[0].tolist(), [2.])
self.assertEqual(y.tolist(), [4.])
def test_custom_kernel_unchanged_output_gradient(self):
def noop(x:UOp): return x[0].store(x[0]).sink(arg=KernelInfo(name="identity"))
def backward(g:UOp, call:UOp): return (g,)
x = Tensor([2.]).realize()
y = x.custom_kernel(fxn=noop, grad_fxn=backward)[0]
self.assertEqual(y.sum().gradient(x)[0].tolist(), [1.])
@staticmethod
def addmul_kernel(C:UOp, D:UOp, A:UOp, B:UOp) -> UOp:
C, D, A, B = C.flatten(), D.flatten(), A.flatten(), B.flatten()
-82
View File
@@ -1,82 +0,0 @@
import unittest
from collections import OrderedDict, namedtuple
from types import SimpleNamespace
from tinygrad import Tensor
from tinygrad.nn.state import get_parameters, get_state_dict, load_state_dict
class TestStateDict(unittest.TestCase):
def test_container_subclasses(self):
class TensorDict(dict): pass
class TensorList(list): pass
class TensorTuple(tuple): pass
weight = Tensor([1., 2.])
for container, key in ((TensorDict(weight=weight), "weight"), (TensorList([weight]), "0"), (TensorTuple([weight]), "0")):
with self.subTest(container=type(container).__name__):
container.description = "model weights"
model = SimpleNamespace(layers=container)
state = get_state_dict(model)
self.assertEqual(list(state), [f"layers.{key}"])
self.assertIs(state[f"layers.{key}"], weight)
params = get_parameters(model)
self.assertEqual(len(params), 1)
self.assertIs(params[0], weight)
def test_namedtuple_and_ordered_dict(self):
first, second = Tensor([1.]), Tensor([2.])
pair = namedtuple("Pair", ["first", "second"])(first, second)
state = get_state_dict(OrderedDict(pair=pair))
self.assertEqual(list(state), ["pair.first", "pair.second"])
self.assertIs(state["pair.first"], first)
self.assertIs(state["pair.second"], second)
def test_load_container_subclass(self):
class TensorDict(dict): pass
weight = Tensor([1., 2.])
model = TensorDict(weight=weight)
loaded = load_state_dict(model, {"weight": Tensor([3., 4.])}, verbose=False)
self.assertEqual(len(loaded), 1)
self.assertIs(loaded[0], weight)
self.assertEqual(weight.tolist(), [3., 4.])
def test_container_tensor_attributes(self):
class TensorDict(dict): pass
class TensorList(list): pass
class TensorTuple(tuple): pass
for container_type in (TensorDict, TensorList, TensorTuple):
with self.subTest(container=container_type.__name__):
model = container_type()
model.weight = Tensor([1., 2.])
state = get_state_dict(model)
self.assertEqual(list(state), ["weight"])
self.assertIs(state["weight"], model.weight)
params = get_parameters(model)
self.assertEqual(len(params), 1)
self.assertIs(params[0], model.weight)
loaded = load_state_dict(model, {"weight": Tensor([3., 4.])}, verbose=False)
self.assertEqual(len(loaded), 1)
self.assertIs(loaded[0], model.weight)
self.assertEqual(model.weight.tolist(), [3., 4.])
def test_container_contents_and_attributes(self):
class TensorDict(dict): pass
class TensorList(list): pass
class TensorTuple(tuple): pass
item, weight = Tensor([1.]), Tensor([2.])
for model, key in ((TensorDict(item=item), "item"), (TensorList([item]), "0"), (TensorTuple([item]), "0")):
with self.subTest(container=type(model).__name__):
model.weight = weight
state = get_state_dict(model, prefix="model.")
self.assertEqual(list(state), [f"model.{key}", "model.weight"])
self.assertIs(state[f"model.{key}"], item)
self.assertIs(state["model.weight"], weight)
def test_container_attribute_precedence(self):
class TensorDict(dict): pass
model = TensorDict(weight=Tensor([1.]))
model.weight = Tensor([2.])
self.assertIs(get_state_dict(model)["weight"], model.weight)
if __name__ == '__main__':
unittest.main()
+7 -6
View File
@@ -647,9 +647,10 @@ class ElementwiseMixin(CreationMixin):
```
"""
other = self.ufix(other)
error = (self - other).abs()
is_finite_close = error.isfinite() & (error <= atol + rtol * other.abs())
return self.eq(other) | is_finite_close | (self.isnan() & other.isnan() & equal_nan)
is_finite_close = self.isfinite() & other.isfinite() & ((self - other).abs() <= atol + rtol * other.abs())
is_infinite_close = (self.isinf() | other.isinf()) & self.eq(other)
is_nan_close = (self.isnan() & other.isnan()) & equal_nan
return is_finite_close | is_infinite_close | is_nan_close
def ceil(self) -> Self:
"""
@@ -1086,7 +1087,7 @@ class ElementwiseMixin(CreationMixin):
print(Tensor([1., 2., 3.]).lerp(Tensor([4., 5., 6.]), 0.5).numpy())
```
"""
if self.dtype == dtypes.uint8 and not end.is_floating_point() and not isinstance(weight, ConstType):
weight_int = (weight * 128 + 0.5).cast(dtypes.int32) # 7 fractional bits
return ((self * (128 - weight_int) + end.cast(dtypes.int32) * weight_int + 64) >> 7).cast(dtypes.uint8)
if self.dtype == dtypes.uint8 and not isinstance(weight, ConstType):
w_i = (weight * (1<<(W_PREC:=7)) + 0.5).cast(dtypes.int16)
return (self+(((end - self).cast(dtypes.int8) * w_i + (1<<W_PREC-1)).cast(dtypes.uint16) >> W_PREC)).cast(dtypes.uint8)
return self + (end - self) * weight
+21 -4
View File
@@ -3,6 +3,7 @@ import math, dataclasses
from tinygrad.uop.ops import UOp, PatternMatcher, UPat, Ops, all_metadata, broadcast_axes
from tinygrad.helpers import argsort
from tinygrad.dtype import sum_acc_dtype
from tinygrad.device import Buffer
from tinygrad.function import renumber_invalid_outputs
def reduce_gradient(ctx:UOp, ret:UOp, op:Ops):
@@ -64,6 +65,25 @@ def call_gradient(ctx:UOp, k:UOp, needed:set[int]) -> tuple[UOp|None, ...]:
ret_set = set(ret_pos)
return (None,) + tuple(None if i in ret_set else (bwd_outs[gb_map[i]] if i in gb_map else None) for i in range(len(args)))
def after_gradient(ctx:UOp, ret:UOp):
value, *deps = ret.src
if len(deps) == 1:
dep = deps[0]
if dep.op is Ops.STORE and len(dep.src) == 2 and value is dep.src[0]: return (None, ctx)
if dep.op is Ops.CALL and (value.unsharded_base.is_unbound or value in dep.call_access()[1]):
if dep.src[1:].count(value) != 1: raise RuntimeError("ambiguous CALL output gradient")
return (None, UOp.sink(*(ctx if a is value else UOp(Ops.NOOP) for a in dep.src[1:])))
for dep in deps:
if dep.op is Ops.STORE: writes = dep.src[:1]
elif dep.op is Ops.CALL: _, writes = dep.call_access()
else: raise RuntimeError(f"gradient through {dep.op} ordering is unsupported")
for w in writes:
a, b = (u.storage_base.arg.buffer if u.storage_base.op is Ops.BUFFER else None for u in (value, w))
if not isinstance(a, Buffer) or not isinstance(b, Buffer) or a.base is b.base or \
any(buf.base.options is not None and buf.base.options.external_ptr is not None for buf in (a, b)):
raise RuntimeError("gradient through an aliased write is unsupported")
return (ctx,) + (None,)*len(deps)
# ctx is grad_output
pm_gradient = PatternMatcher([
(UPat(Ops.CAST, name="ret"), lambda ctx, ret: (ctx.cast(ret.src[0].dtype),)),
@@ -94,10 +114,7 @@ pm_gradient = PatternMatcher([
(UPat(Ops.COPY, name="ret"), lambda ctx, ret: (ctx.copy_to_device(ret.src[0].device),)),
(UPat(Ops.UNSHARD, name="ret"), lambda ctx, ret: ctx.shard(ret.device, ret.axis).src),
(UPat(Ops.SINK), lambda ctx: ctx.src),
(UPat(Ops.AFTER, src=(UPat.var("d"), UPat(Ops.CALL, name="k"))), lambda ctx, d, k:
(ctx, UOp.sink(*([ctx if i == k.src.index(d)-1 else UOp(Ops.NOOP) for i in range(len(k.src)-1)])))),
# clone/assign gradient passes through to val
(UPat(Ops.AFTER, src=(UPat(), UPat(Ops.STORE))), lambda ctx: (None, ctx)),
(UPat(Ops.AFTER, name="ret"), after_gradient),
(UPat(Ops.STORE, src=(UPat(), UPat())), lambda ctx: (None, ctx)),
# there's no gradient for bitcast
(UPat(Ops.BITCAST), lambda: (None,)),
+5 -4
View File
@@ -493,8 +493,9 @@ class MovementMixin:
```
"""
if indexing not in ("ij", "xy"): raise RuntimeError(f'indexing must be in ("ij", "xy"), got {indexing}')
basis = tuple(range(len(args)+1)) if indexing == "ij" or not args else (1, 0) + tuple(range(2, len(args)+1))
tensors = tuple(t.reshape((-1,) + (1,)*(len(args) - i)) for i,t in zip(basis, (self, *args)))
if len(tensors:=(self, *args)) == 1: return tensors
basis = tuple(range(len(tensors))) if indexing == "ij" else (1, 0) + tuple(range(2, len(tensors)))
tensors = tuple(t.reshape((-1,) + (1,)*(len(args) - i)) for i,t in zip(basis, tensors))
output_shape = _broadcast_shape(*(t.shape for t in tensors))
return tuple(t._broadcast_to(output_shape) for t in tensors)
@@ -527,8 +528,8 @@ class MovementMixin:
"""
if (dim1:=self._resolve_dim(dim1)) == (dim2:=self._resolve_dim(dim2)): raise RuntimeError("dim1 and dim2 cannot be the same dimension")
x = self.permute(*[i for i in range(self.ndim) if i != dim1 and i != dim2], dim1, dim2)
if offset >= 0: x = x.shrink((None,)*(x.ndim-1) + ((min(offset, x.shape[-1]), x.shape[-1]),))
else: x = x.shrink((None,)*(x.ndim-2) + ((min(-offset, x.shape[-2]), x.shape[-2]), None))
if offset >= 0: x = x.shrink(tuple(None for _ in x.shape[:-1]) + ((offset, x.shape[-1]),))
else: x = x.shrink(tuple(None for _ in x.shape[:-2]) + ((-offset, x.shape[-2]), None))
if (d := min(int(x.shape[-2]), int(x.shape[-1]))) <= 0: return x.reshape(*x.shape[:-2], 0)
nones, x = tuple(None for _ in x.shape[:-2]), x.shrink_to(tuple(None for _ in x.shape[:-2]) + (d, d))
return x.flatten(-2).pad_to(nones+(d*(d+1),)).unflatten(-1, (d, d+1)).shrink_to(nones+(None, 1)).squeeze(-1)
+19 -15
View File
@@ -206,7 +206,7 @@ class OpMixin(ElementwiseMixin, ReduceMixin):
if steps < 0: raise ValueError("number of steps must be non-negative")
if (dtype := to_dtype(dtype or dtypes.default_float)) == dtypes.bool: raise ValueError("linspace with bool dtype is not supported")
if steps == 1: return cls.full((1,), start, dtype=dtype, buffer=False)
return (start + cls.arange(steps, dtype=least_upper_dtype(dtype, dtypes.default_float)) * ((stop - start) / (steps - 1))).cast(dtype)
return (start + cls.arange(steps, dtype=dtypes.default_float) * ((stop - start) / (steps - 1))).cast(dtype)
@classmethod
def eye(cls, n:int, m:int|None=None, dtype:DTypeLike|None=None) -> Self:
@@ -542,10 +542,11 @@ class OpMixin(ElementwiseMixin, ReduceMixin):
print(t.var(axis=1).numpy())
```
"""
output_dtype = self.dtype if dtypes.is_float(self.dtype) else dtypes.float32
squares = (self - self.mean(axis=axis, keepdim=True)).square()
n = prod([si for si, so in zip(self.shape, squares.sum(axis=axis, keepdim=True).shape) if resolve(si != so)])
numerator = squares.sum(axis=axis, keepdim=keepdim, dtype=sum_acc_dtype(squares.commit_dtype()))
return numerator.div(smax(n - correction, 0)).cast(squares.dtype)
numerator = squares.cast(sum_acc_dtype(self.commit_dtype())).sum(axis=axis, keepdim=keepdim)
return numerator.div(smax(n - correction, 0)).cast(output_dtype)
def var_mean(self, axis:int|Sequence[int]|None=None, keepdim=False, correction=1) -> tuple[Self, Self]:
"""
@@ -806,10 +807,11 @@ class OpMixin(ElementwiseMixin, ReduceMixin):
```
"""
if self.ndim == 0: return self._split_cumalu(axis, Ops.MAX), type(self).zeros(self.shape, dtype=dtypes.int32, buffer=False)
values = self._split_cumalu(axis, Ops.MAX)
# Record the latest index matching the running maximum, then carry it forward.
idx = self.eq(values).transpose(axis, -1) * type(self).arange(self.shape[axis], dtype=dtypes.int32)
return values, idx._split_cumalu(-1, Ops.MAX).transpose(-1, axis)
values, n = self._split_cumalu(axis, Ops.MAX), int(self.shape[axis])
x, values_t = self.transpose(axis, -1), values.transpose(axis, -1)
match = x.unsqueeze(-1).eq(values_t.unsqueeze(-2)) * self._tri(n, n)
idx = (-(match * type(self).arange(n, 0, -1).reshape(n, 1)).max(-2) + n).cast(dtypes.int32)
return values, idx.transpose(-1, axis)
def cummin(self, axis:int=0) -> tuple[Self, Self]:
"""
@@ -849,12 +851,14 @@ class OpMixin(ElementwiseMixin, ReduceMixin):
print(t.logcumsumexp(axis=1).numpy())
```
"""
axis = self._resolve_dim(axis)
if self.ndim == 0: return self
x = self.transpose(axis, -1)
mask = self._tri(x.shape[-1], x.shape[-1], 1)
prefixes = mask.where(-math.inf, x.unsqueeze(-2))
return prefixes.logsumexp(-1).transpose(-1, axis)
last_dim_size = x.shape[-1]
x_unsqueezed = x.unsqueeze(-2)
x_cummax = (mx:=x.cummax(-1)[0].detach()).isfinite().where(mx, 0)
mask = self._tri(last_dim_size, last_dim_size, 1).logical_not()
ret = mask.where(x_unsqueezed - x_cummax.unsqueeze(-1), self.dtype.min).exp().sum(-1).log() + x_cummax
return ret.transpose(-1, axis)
def argmax(self, axis=None, keepdim=False) -> Self:
"""
@@ -1734,10 +1738,10 @@ class OpMixin(ElementwiseMixin, ReduceMixin):
if Y.device is not None and self.device is not None and Y.device != self.device:
raise RuntimeError(f"expected Y and self on the same device, {Y.device=}, {self.device=}")
log_probs = self.log_softmax()
loss_mask = Y.ne(ignore_index)
y = Y.unsqueeze(-1)._one_hot_along_dim(self.shape[-1], dim=-1)
smoothing = label_smoothing * log_probs.mean(-1)
unreduced = ((1 - label_smoothing) * (log_probs * y).sum(-1) + smoothing) * loss_mask
loss_mask = Y.ne(ignore_index) if ignore_index != -1 else Y.const_like(True, dtypes.bool)
y = Y.unsqueeze(-1)._one_hot_along_dim(self.shape[-1], dim=-1) * loss_mask.unsqueeze(-1)
smoothing = label_smoothing * (log_probs.mean(-1) * loss_mask)
unreduced = ((1 - label_smoothing) * (log_probs * y).sum(-1) + smoothing)
return -unreduced.sum() / loss_mask.sum() if reduction == "mean" else -unreduced._do_reduction(reduction)
def cross_entropy(self, Y:Self, reduction:ReductionStr="mean", label_smoothing:float=0.0) -> Self:
+3 -1
View File
@@ -1,4 +1,5 @@
import json, pathlib, struct, functools, io, zlib
from collections import OrderedDict
from typing import Any, Callable, BinaryIO, Iterable, cast
from tinygrad.tensor import Tensor
from tinygrad.dtype import dtypes
@@ -101,12 +102,13 @@ def get_state_dict(obj, prefix:str='', tensor_type=Tensor) -> dict[str, Tensor]:
"""
if isinstance(obj, tensor_type): return {prefix.strip('.'):obj}
if hasattr(obj, '_asdict'): return get_state_dict(obj._asdict(), prefix, tensor_type) # namedtuple
if isinstance(obj, OrderedDict): return get_state_dict(dict(obj), prefix, tensor_type)
if hasattr(obj, '__dict__'): return get_state_dict(obj.__dict__, prefix, tensor_type)
state_dict = {}
if isinstance(obj, (list, tuple)):
for i,x in enumerate(obj): state_dict.update(get_state_dict(x, f"{prefix}{str(i)}.", tensor_type))
elif isinstance(obj, dict):
for k,v in obj.items(): state_dict.update(get_state_dict(v, f"{prefix}{str(k)}.", tensor_type))
if hasattr(obj, '__dict__'): state_dict.update(get_state_dict(obj.__dict__, prefix, tensor_type))
return state_dict
def get_parameters(obj) -> list[Tensor]:
+43 -48
View File
@@ -1,8 +1,8 @@
import time, inspect
import time, inspect, dataclasses
from collections import deque
from tinygrad.uop.ops import UOp, Ops, UOpMetaClass, rewrite_group, graph_rewrite, gate_kernel_sink, KernelInfo
from tinygrad.uop.spec import type_verify, spec_tensor
from tinygrad.helpers import DEBUG, cpu_profile, TracingKey, SPEC, pluralize, SCACHE, BASEDIR, partition, dedup
from tinygrad.helpers import DEBUG, cpu_profile, TracingKey, SPEC, pluralize, SCACHE, BASEDIR, dedup
# **** schedule linearizer
@@ -11,67 +11,60 @@ def _unwrap_src(s: UOp) -> UOp:
while len(s.src) and s.op not in {Ops.AFTER, Ops.BUFFER, Ops.PARAM, Ops.MSELECT, Ops.MSTACK}: s = s.src[0]
return s
# a buffer state is AFTER | BUFFER | PARAM. MSELECT/MSTACK join per-device states
# unwrap per-device buffer arguments without dropping their ordering dependencies
def _states(s: UOp) -> list[UOp]:
s = _unwrap_src(s)
if s.op in {Ops.MSELECT, Ops.MSTACK}: return [st for ss in s.src for st in _states(ss)]
assert s.op in {Ops.AFTER, Ops.BUFFER, Ops.PARAM}, f"input to kernel must resolve to a buffer state, not {s.op}"
return [s]
def _split_after(after: UOp) -> tuple[tuple[UOp, ...], tuple[UOp, ...]]:
kernels, remaining = partition(after.src[1:], lambda s: s.op in {Ops.CALL, Ops.END})
deps, remaining = partition(remaining, lambda s: s.op is Ops.AFTER)
if invalid := [s for s in remaining if s.op is not Ops.STORE]:
raise AssertionError(f"AFTER source should be CALL, END, STORE, or AFTER, not {invalid[0].op}")
return tuple(kernels), tuple(deps)
def create_schedule(sched_sink:UOp) -> UOp:
with cpu_profile(TracingKey("toposort sched_sink")):
# build kernel dependency graph: edges from producer kernel to consumer kernels
afters = [u for u in sched_sink.toposort(gate_kernel_sink) if u.op is Ops.AFTER]
kernels = dict.fromkeys(k for u in afters for k in u.src[1:] if k.op in {Ops.CALL, Ops.END})
dependencies: dict[UOp, set[UOp]] = {}
writes: dict[UOp, set[UOp]] = {}
reads: list[tuple[UOp, UOp]] = []
ancestors: dict[UOp, set[UOp]] = {}
for k in kernels:
call = k.src[0] if k.op is Ops.END else k
states = [st for s in call.src[1:] for st in _states(s)]
for st in states:
if st not in ancestors: ancestors[st] = kernels.keys() & st.toposort(enter_calls=False).keys()
# AFTER supplies ordering dependencies, not evidence that its returned buffer was written.
dependencies[k] = set().union(*(ancestors[st] for st in states))
read_args, write_args = call.call_access()
reads += [(k, st) for s in read_args for st in _states(s)]
for s in write_args:
for st in _states(s): writes.setdefault(st.buf_uop, set()).add(k)
for u in afters:
for dep in (s for s in u.src[1:] if s.op is Ops.AFTER):
for k in (s for s in u.src[1:] if s in kernels):
dependencies[k].update(kernels.keys() & dep.toposort(enter_calls=False).keys() - {k})
# Tensor reads require the contents preceding writes absent from their argument ancestry (not an AFTER property).
for k, st in reads:
for writer in writes.get(st.buf_uop, set()):
if writer is not k and writer not in ancestors[st]: dependencies[writer].add(k)
children: dict[UOp, list[UOp]] = {}
in_degree: dict[UOp, int] = {}
writes: dict[UOp, list[tuple[UOp, tuple[UOp, ...]]]] = {} # superseded state -> (AFTER, new kernels)
reads: list[tuple[UOp, UOp, UOp]] = [] # (reader AFTER, reader kernel, buffer state read)
for u in sched_sink.toposort(gate_kernel_sink):
if u.op is not Ops.AFTER: continue
kernels, after_deps = _split_after(u)
prev_state = _unwrap_src(u.src[0])
prev_kernels = set(_split_after(prev_state)[0]) if prev_state.op is Ops.AFTER else set()
writes.setdefault(prev_state, []).append((u, tuple(k for k in kernels if k not in prev_kernels)))
for k in kernels:
in_degree.setdefault(k, 0)
if k.op is Ops.END: assert k.src[0].op is Ops.CALL, f"END src[0] should be KERNEL, not {k.src[0].op}"
kernel_deps = k.src[0].src[1:] if k.op is Ops.END else k.src[1:]
read_states = [st for s in kernel_deps for st in _states(s)]
reads += [(u, k, st) for st in read_states]
# RAW deps: a kernel runs after the kernels that produced the states it reads or joins
for st in read_states + [st for s in after_deps for st in _states(s)]:
if st.op is Ops.AFTER:
for t in _split_after(st)[0]:
children.setdefault(t, []).append(k)
in_degree[k] += 1
# WAR deps: a kernel reading buffer state S must run before another write that supersedes S. an AFTER only
# supersedes its immediate prior state; join members already present in that prior state are ordering deps, not writes
for u, k, s in reads:
for a, write_kernels in writes.get(s, []):
if a is u: continue
for t in write_kernels:
if t is not k and t not in k.backward_slice:
children.setdefault(k, []).append(t)
in_degree[t] += 1
in_degree = {k:len(deps) for k,deps in dependencies.items()}
for k, deps in dependencies.items():
for p in deps: children.setdefault(p, []).append(k)
with cpu_profile(TracingKey("linearize schedule")):
queue: deque[UOp] = deque(k for k,v in in_degree.items() if v == 0)
linearized: list[UOp] = []
while len(queue):
rk = queue.popleft()
if rk.op is Ops.LINEAR:
linearized.extend(rk.src)
else:
k = rk.src[0] if rk.op is Ops.END else rk
assert k.op is Ops.CALL, f"unexpected op in queue: {k.op}"
buf_uops = tuple(_unwrap_src(s).buf_uop for s in k.src[1:] if not s.is_bound_var)
linearized.append(k.src[0].call(*buf_uops))
k = rk.src[0] if rk.op is Ops.END else rk
assert k.op is Ops.CALL, f"unexpected op in queue: {k.op}"
buf_uops = tuple(_unwrap_src(s).buf_uop for s in k.src[1:] if not s.is_bound_var)
body = k.src[0]
# Storage aliases may share a parameter now that their dependencies are in the schedule.
if body.op is Ops.SINK and len(set(buf_uops)) != len(buf_uops):
params = {p for p in body.toposort(enter_calls=False) if p.op is Ops.PARAM and p.arg.slot >= 0}
body = body.substitute({p:q for p in params
if (q:=p.replace(arg=dataclasses.replace(p.arg, slot=buf_uops.index(buf_uops[p.arg.slot])))) in params})
linearized.append(body.call(*buf_uops))
for x in children.get(rk, []):
in_degree[x] -= 1
if in_degree[x] == 0: queue.append(x)
@@ -188,6 +181,8 @@ def create_linear_with_vars(big_sink:UOp) -> tuple[UOp, dict[str, int]]:
# this recursively resolves the linear_call and allocates buffers
linear = graph_rewrite(linear_call, pm_resolve_linear_call, name="resolve linear call")
for call in linear.src:
if call.src[0].op is Ops.PROGRAM: call.call_access()
# create copies
linear = graph_rewrite(linear, pm_copy_from_store, name="create COPY kernels for SDMA")
+1 -15
View File
@@ -301,27 +301,13 @@ def debuf(ctx:LocalAddBufferContext, buf:UOp):
ctx.dg += 1
return ret
def handle_after(ctx:LocalAddBufferContext, after:UOp):
if after.addrspace == AddrSpace.LOCAL: return None
buf = after.buf_uop
# NOTE: this is bottom up, so we only add it once
if buf not in ctx.map: ctx.map[buf] = after
return buf
def renumber_range(ctx:LocalAddBufferContext, r:UOp):
if r.tag != (): return None
ret = r.replace(arg=(ctx.range,)+r.arg[1:], tag=None)
ctx.range += 1
return ret
def find_bufs(x:UOp):
idxs = [s for s in x.toposort(gate=lambda x: x.op is not Ops.AFTER) if s.op is Ops.INDEX]
read_from: dict[UOp, Ops] = {}
if any((buf:=idx.buf_uop).op in {Ops.BUFFER, Ops.PARAM} and read_from.setdefault(buf, op:=idx.src[0].op) is not op for idx in idxs):
raise RuntimeError(f"cycle detected while indexing {buf}")
to_define_global = PatternMatcher([
(UPat(Ops.STORE, name="x"), find_bufs),
(UPat((Ops.BUFFER, Ops.MSTACK, Ops.MSELECT), name="buf"), debuf),
(UPat(Ops.PARAM, name="v"), lambda v:
v.replace(arg=replace(v.arg, slot=-1)) if v.arg.name is not None and v.arg.vmin_vmax is not None and v.arg.slot != -1 else None),
@@ -335,7 +321,7 @@ to_define_global = PatternMatcher([
# bound Variables are stores into Variable buffers: strip the store, the buffer becomes an ALU param via debuf
(UPat(Ops.AFTER, name="b"), lambda b: b.src[0] if b.is_bound_var else None),
(UPat(Ops.AFTER, name="after"), handle_after),
(UPat(Ops.AFTER, name="buf"), lambda ctx,buf: debuf(ctx, buf) if buf.addrspace != AddrSpace.LOCAL else None),
# remove device from local BUFFERIZE
(UPat(Ops.STAGE, name="b"), lambda b: b.replace(arg=replace(b.arg, device=None))),
+3 -6
View File
@@ -43,9 +43,6 @@ def creation_copy_is_realized(u:UOp):
# CONTIGUOUS and AFTER + parents are the only nodes that get updated
add_tags = PatternMatcher([
(UPat(Ops.COPY, name="u"), creation_copy_is_realized),
# no tag on copies that are assigned via STORE+AFTER — merge COPY tag into AFTER
(UPat(Ops.AFTER, src=(UPat(), UPat(Ops.STORE, src=(UPat(name="dest"), UPat(Ops.COPY, name="c")))), name="a"),
lambda a,c,dest: a.replace(src=(a.src[0], a.src[1].replace(src=(dest, c.rtag(())))), tag=a.tag+c.tag) if a.tag and c.tag else None),
(UPat((Ops.CONTIGUOUS, Ops.AFTER), name="x"), tag_uop),
(UPat(GroupOp.All, name="x"), lambda ctx,x: tag_uop(x) if x in ctx.bases else None),
])
@@ -62,8 +59,7 @@ def replace_contig_with_store_after(u:UOp):
def wrap_tagged_in_contig(x:UOp):
if x.tag is None: return None # untouched
# empty tag from rtag(()): a COPY already handled via buffer_map or merged into a parent AFTER.
# () is falsy but not None, so it isn't re-tagged like a bare (tag=None) node would be; just strip it here
# An empty tag suppresses retagging without requesting materialization.
if not x.tag: return x.rtag(None)
return x.rtag(None).contiguous(tag=x.tag) # the tag moves onto the wrapping CONTIGUOUS
@@ -398,8 +394,9 @@ class Tensor(RandMixin):
if any(t.dtype in dtypes.weaks and t.uop.device is not None for t in (self,)+lst):
raise RuntimeError("cannot realize a weak dtype; cast to a concrete dtype first")
big_sink, becomes_map = transform_to_call(UOp.sink(*[x.uop for x in (self,)+lst]))
linear, var_vals = create_linear_with_vars(big_sink)
_apply_map_to_tensors(becomes_map, name="buffers")
return create_linear_with_vars(big_sink)
return linear, var_vals
def schedule_linear(self, *lst:Tensor) -> UOp:
"""Creates the schedule needed to realize these Tensor(s)."""
+23 -3
View File
@@ -1250,6 +1250,27 @@ class UOp(RandMixin, metaclass=UOpMetaClass):
kernel = fxn(*placeholders).call(*srcs, grad_fxn=grad_fxn)
return [s.after(kernel) for s in srcs]
def call_access(self) -> tuple[tuple[UOp, ...], tuple[UOp, ...]]:
body = self.src[0]
if body.op is Ops.SINK and not body.op_in_backward_slice_with_self(Ops.CALL, Ops.CUSTOM, Ops.CUSTOMI, Ops.INS):
from tinygrad.codegen import pm_add_loads
info = ProgramInfo.from_sink(graph_rewrite(body, pm_add_loads))
ins, outs = info.ins, info.outs
elif body.op is Ops.PROGRAM and isinstance(body.arg, ProgramInfo): ins, outs = body.arg.ins, body.arg.outs
elif body.op is Ops.COPY: ins, outs = (1,), (0,)
elif body.op is Ops.LINEAR:
ins, outs = (tuple(sorted({p.arg.slot for args in group for a in args for p in a.buf_uop.toposort() if p.op is Ops.PARAM}))
for group in zip(*(c.call_access() for c in body.src))) if body.src else ((), ())
else: raise RuntimeError(f"cannot compute accesses for opaque {body.op}")
if any(i < 0 or i >= len(self.src)-1 or (body.op is Ops.PROGRAM and i not in body.arg.globals) for i in (*ins, *outs)):
raise RuntimeError("invalid CALL access slot")
if body.op is Ops.PROGRAM:
bufs = [s.buf_uop for s in self.src[1:]]
keys = [b.arg.buffer.base if b.op is Ops.BUFFER and isinstance(b.arg.buffer, Buffer) else b for b in bufs]
if any(i != j and keys[i] is keys[j] for i in outs for j in set(ins+outs)):
raise RuntimeError("aliased opaque kernel arguments are unsupported")
return tuple(self.src[i+1] for i in ins), tuple(self.src[i+1] for i in outs)
def to_elf(self) -> TinyELF:
assert self.op is Ops.PROGRAM and isinstance(self.arg, ProgramInfo), "to_elf should only be called on a PROGRAM ast"
params = tuple(u for u in self.src[1].src if u.op is Ops.PARAM and u.addrspace != AddrSpace.ALU)
@@ -1304,9 +1325,8 @@ class ProgramInfo:
for u in sink.toposort():
if u.op is Ops.PARAM and u.addrspace == AddrSpace.ALU: _vars.append(u)
if u.op is Ops.PARAM and u.addrspace != AddrSpace.ALU: _globals.append(u.arg.slot)
if u.op in (Ops.STORE, Ops.LOAD):
if (idx:=u.src[0]).op in (Ops.INDEX, Ops.SHRINK) or (u.src[0].op is Ops.CAST and (idx:=u.src[0].src[0]).op is Ops.INDEX):
if (buf:=idx.src[0].buf_uop).op is Ops.PARAM: (outs if u.op is Ops.STORE else ins).append(buf.arg.slot)
if u.op in (Ops.STORE, Ops.LOAD) and (buf:=u.src[0].buf_uop).op is Ops.PARAM and buf.addrspace is AddrSpace.GLOBAL:
(outs if u.op is Ops.STORE else ins).append(buf.arg.slot)
if u.op is Ops.SPECIAL: (local_size if u.arg[0] == 'l' else global_size)[int(u.arg[-1])] = cast(int, u.src[0].ssimplify())
return ProgramInfo(sink.arg.name if isinstance(sink.arg, KernelInfo) else "test", tuple(global_size), tuple(local_size),
tuple(sorted(dedup(_vars), key=lambda v: v.arg.slot)), tuple(sorted(dedup(_globals))), tuple(sorted(dedup(outs))),