delete Ops.FUNCTION/GETTUPLE/TUPLE: call outputs are AFTER on RETURNED placeholders

value-producing calls: the body is a plain parametric program that stores outputs
into output PARAMs (slots after the input PARAMs). the RETURNED placeholders are
inputs to the call, bound to the output PARAMs positionally wherever the call is
resolved, and callers AFTER on them like normal buffers. gradient flows through
the generic AFTER rule; everything is just Ops.CALL.
This commit is contained in:
2026-08-28 09:59:20 -07:00
parent 287679a88a
commit 53a11f59ab
16 changed files with 174 additions and 153 deletions
+1 -1
View File
@@ -185,7 +185,7 @@ class TestMultiScalarALU(unittest.TestCase):
return (inner.sum(),)
param = x.as_param(0)
fxn = _fxn(param.uop, x.device)
per_dev_scalar = Tensor(fxn[0].uop.call(x.uop).gettuple(0))
per_dev_scalar = Tensor(fxn[0].uop.call(x.uop).returned_outputs[0])
result = x * per_dev_scalar
self.assertEqual(result.shape, (4, 4))
self.assertEqual(result.uop.axis, 0)
+1 -2
View File
@@ -228,8 +228,7 @@ class TestViz(unittest.TestCase):
with save_viz() as viz:
inner = UOp.const(3)
call = UOp(Ops.CALL, src=(UOp(Ops.SINK, src=(inner,)),))
func = UOp(Ops.FUNCTION, src=(UOp(Ops.TUPLE, src=(call,)),))
graph_rewrite(func, TrackedPatternMatcher(pm.patterns), enter_calls=True)
graph_rewrite(call, TrackedPatternMatcher(pm.patterns), enter_calls=True)
details = list(viz.get_details(0, 0))
self.assertTrue(details[-1]["change"], "viz replay should detect change inside CALL")
+5 -5
View File
@@ -223,8 +223,8 @@ class TestCallSchedule(unittest.TestCase):
a = Tensor.ones(3)
x = f(a, UOp.variable("scale_a", 1, 100).bind(2))
y = f(a, UOp.variable("scale_b", 1, 100).bind(3))
fx = next(u for u in x.uop.toposort() if u.op is Ops.FUNCTION)
fy = next(u for u in y.uop.toposort() if u.op is Ops.FUNCTION)
fx = next(u for u in x.uop.toposort() if u.op is Ops.CALL and u.num_returned)
fy = next(u for u in y.uop.toposort() if u.op is Ops.CALL and u.num_returned)
self.assertEqual(fx.src[0].key, fy.src[0].key)
np.testing.assert_equal(x.numpy(), [2, 2, 2])
np.testing.assert_equal(y.numpy(), [3, 3, 3])
@@ -251,9 +251,9 @@ class TestCallSchedule(unittest.TestCase):
a = Tensor.empty(4, 8)
b = Tensor.empty(4, 8)
r0, r1 = f(a), f(b)
# find the FUNCTION nodes
c0 = next(u for u in r0.uop.toposort() if u.op is Ops.FUNCTION)
c1 = next(u for u in r1.uop.toposort() if u.op is Ops.FUNCTION)
# find the call nodes
c0 = next(u for u in r0.uop.toposort() if u.op is Ops.CALL and u.num_returned)
c1 = next(u for u in r1.uop.toposort() if u.op is Ops.CALL and u.num_returned)
# the function bodies (src[0]) should have identical keys
self.assertEqual(c0.src[0].key, c1.src[0].key)
+3 -3
View File
@@ -174,13 +174,13 @@ class TestFunction(unittest.TestCase):
def test_name(self):
@function
def f(a:Tensor) -> Tensor: return a + 1
assert f(Tensor([1])).uop.src[0].arg.name.endswith("f")
assert f(Tensor([1])).uop.src[1].arg.name.endswith("f")
def test_method_name(self):
class Foo:
@function
def __call__(self, x:Tensor) -> Tensor: return x + 1
assert Foo()(Tensor([1])).uop.src[0].arg.name.endswith("Foo.__call__")
assert Foo()(Tensor([1])).uop.src[1].arg.name.endswith("Foo.__call__")
def test_callable_instance(self):
class Foo:
@@ -189,7 +189,7 @@ class TestFunction(unittest.TestCase):
foo = Foo()
f = function(foo, allow_implicit=True)
np.testing.assert_equal(f(Tensor([1,2,3])).numpy(), [11,22,33])
assert f(Tensor([1,2,3])).uop.src[0].arg.name.endswith("Foo")
assert f(Tensor([1,2,3])).uop.src[1].arg.name.endswith("Foo")
def test_iadd(self):
@function
+8 -5
View File
@@ -59,7 +59,7 @@ class _function(Generic[ReturnType]):
if isinstance(ret, Tensor):
uret = ret.uop
elif isinstance(ret, tuple) and all(isinstance(x, Tensor) for x in ret):
uret = UOp.maketuple(*[x.uop for x in ret])
uret = UOp.sink(*[x.uop for x in ret])
else:
raise RuntimeError(f"function return type {type(ret)} not supported")
@@ -78,16 +78,19 @@ class _function(Generic[ReturnType]):
buf_strs = '\n '.join(f"{i}: dtype={b.dtype}, size={b.max_numel()}, device={b.device}" for i,b in enumerate(implicit_buffers))
raise RuntimeError(f"function {name} has {len(implicit_buffers)} implicit buffer(s), but allow_implicit=False\n {buf_strs}")
fret = uret.call(*call_uops, grad_fxn=self.grad_fxn, name=name, precompile=self.precompile,
precompile_backward=self.precompile_backward)
fret = (UOp.call_outputs(uret.src, *call_uops, grad_fxn=self.grad_fxn, name=name, precompile=self.precompile,
precompile_backward=self.precompile_backward) if isinstance(ret, tuple)
else uret.call(*call_uops, grad_fxn=self.grad_fxn, name=name, precompile=self.precompile,
precompile_backward=self.precompile_backward))
if DEBUG >= 2:
print(" "*_function.depth+f"function {uret.key.hex()[:8]} in {(time.perf_counter()-st)*1000:8.2f} ms: {name}")
outs = fret.returned_outputs
if isinstance(ret, tuple):
return cast(ReturnType, tuple(Tensor(fret.gettuple(i)) for i in range(len(ret))))
return cast(ReturnType, tuple(Tensor(o) for o in outs))
else:
return cast(ReturnType, Tensor(fret.gettuple(0)))
return cast(ReturnType, Tensor(outs[0]))
# overload signatures support both @function and @function(precompile=True) syntax
@overload
+32 -31
View File
@@ -16,7 +16,8 @@ def reduce_gradient(ctx:UOp, ret:UOp, op:Ops):
def _compact_params(body:UOp, all_args:tuple[UOp, ...]) -> tuple[UOp, tuple[UOp, ...]]:
"""Remove unused PARAMs from body and return compacted (body, args)."""
used = sorted({p.arg.slot: p for p in body.toposort() if p.op is Ops.PARAM}.items())
# NOTE: don't enter nested calls, their PARAMs are lexical params of the subprogram
used = sorted({p.arg.slot: p for p in body.toposort(enter_calls=False) if p.op is Ops.PARAM}.items())
body = body.substitute({p: p.replace(arg=dataclasses.replace(p.arg, slot=j)) for j,(_, p) in enumerate(used)}, walk=True)
return body, tuple(all_args[i] for i,_ in used)
@@ -24,32 +25,41 @@ def call_gradient(ctx:UOp, k:UOp, needed:set[int]) -> tuple[UOp|None, ...]:
fxn, args = k.src[0], k.src[1:]
if k.arg.grad_fxn is not None:
# put const on a device, also TODO why do we still have NOOP...
def on_dev(g, i): return g.clone(device=args[i].device if k.op is Ops.CALL else k.device) if g.device is None else g
if ctx.op is Ops.TUPLE:
def on_dev(g, i): return g.clone(device=args[i].device) if g.device is None else g
def arg_grads(g): return (None,) + g + (None,)*k.num_returned
if ctx.op is Ops.GROUP:
real = [on_dev(g, i) for i,g in enumerate(ctx.src) if g.op is not Ops.NOOP]
return (None,) + (k.arg.grad_fxn(*real, call=k) if len(real) > 1 else k.arg.grad_fxn(real[0], k))
return (None,) + k.arg.grad_fxn(on_dev(ctx, 0), k)
assert fxn.op is Ops.TUPLE, f"expected TUPLE body for gradient, got {fxn.op}"
return arg_grads(k.arg.grad_fxn(*real, call=k) if len(real) > 1 else k.arg.grad_fxn(real[0], k))
return arg_grads(k.arg.grad_fxn(on_dev(ctx, 0), k))
# the RETURNED inputs are the call outputs: their positions in the args get the output gradients from the AFTER rule
assert fxn.op is Ops.SINK and k.num_returned, f"expected a CALL with RETURNED inputs or a grad_fxn, got {fxn.op}"
ret_pos = [i for i, a in enumerate(args) if a.unsharded_base.op is Ops.RETURNED]
n_args = len(args) - len(ret_pos)
# the body stores the outputs into output PARAMs (slots after the args): the values are the stored values in slot order
out_stores = sorted((st for st in fxn.src if st.op is Ops.STORE), key=lambda st: st.src[0].unsharded_base.arg.slot)
values = UOp(Ops.GROUP, src=tuple(st.src[1] for st in out_stores))
params = {x.arg.slot:x for x in fxn.toposort(enter_calls=False) if x.op == Ops.PARAM}
# grads are collected at the flat param storage: reshape to each arg's view (max view shrunk to symbolic)
def shaped_grad(grad:UOp, i:int) -> UOp:
a = args[i]
return grad.view_as(a.shard_shape, a.axis) if a.axis is not None and isinstance(a.device, tuple) else grad.view_as(a._shape)
grad_args = ctx.src
root_grad = UOp(Ops.TUPLE, src=tuple(UOp(Ops.NOOP) if g.op is Ops.NOOP else
grad_args = tuple(ctx.src[i] for i in ret_pos)
root_grad = UOp(Ops.GROUP, src=tuple(UOp(Ops.NOOP) if g.op is Ops.NOOP else
g if g.device is None else g.param_like(len(args)+i) for i,g in enumerate(grad_args)))
grads = compute_gradient(fxn, root_grad, set(params.values()))
grads = compute_gradient(values, root_grad, set(params.values()))
# for precompiled calls, substitute forward outputs with params so intermediates aren't recomputed
fwd_subs = {src: src.param_like(len(args)+len(grad_args)+i) for i, src in enumerate(fxn.src)} if k.arg.precompile else {}
fwd_outs = tuple(k.gettuple(i) for i in range(len(fxn.src))) if k.arg.precompile else ()
fwd_subs = {src: src.param_like(len(args)+len(grad_args)+i) for i, src in enumerate(values.src)} if k.arg.precompile else {}
fwd_outs = k.returned_outputs if k.arg.precompile else ()
# collect needed gradient bodies, compact unused params, create a single backward CALL
grad_bodies = [(i, shaped_grad(grads[p], i)) for i in needed if (p:=params.get(i)) is not None and p in grads]
bwd_body = UOp.maketuple(*(gb for _, gb in grad_bodies)).substitute(fwd_subs, walk=True)
bwd_body = UOp(Ops.GROUP, src=tuple(gb for _, gb in grad_bodies)).substitute(fwd_subs, walk=True)
bwd_body = renumber_invalid_outputs(bwd_body)
# NOTE: args includes the RETURNED inputs so the param slots above line up; they are unused and compacted away
bwd_body, compact_args = _compact_params(bwd_body, (*args, *grad_args, *fwd_outs))
bwd_call = bwd_body.call(*compact_args, name=(k.arg.name or "")+"_backward", precompile=k.arg.precompile_backward)
bwd_outs = UOp.call_outputs(bwd_body.src, *compact_args, name=(k.arg.name or "")+"_backward",
precompile=k.arg.precompile_backward).returned_outputs
gb_map = {i: idx for idx, (i, _) in enumerate(grad_bodies)}
return (None,) + tuple(bwd_call.gettuple(gb_map[i]) if i in gb_map else None for i in range(len(args)))
return (None,) + tuple(bwd_outs[gb_map[i]] if i in gb_map else None for i in range(n_args)) + (None,)*len(ret_pos)
# ctx is grad_output
pm_gradient = PatternMatcher([
@@ -80,9 +90,9 @@ pm_gradient = PatternMatcher([
(UPat(Ops.STACK, name="ret"), lambda ctx, ret: tuple(ctx[i] for i in range(len(ret.src)))),
(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.TUPLE), lambda ctx: ctx.src),
(UPat(Ops.GROUP), lambda ctx: ctx.src),
(UPat(Ops.AFTER, src=(UPat.var("d"), UPat(Ops.CALL, name="k"))), lambda ctx, d, k:
(ctx, UOp.maketuple(*(ctx if i == k.src.index(d)-1 else UOp(Ops.NOOP) for i in range(len(k.src)-1))))),
(ctx, UOp(Ops.GROUP, src=tuple(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.STORE, src=(UPat(), UPat())), lambda ctx: (None, ctx)),
@@ -102,18 +112,9 @@ def compute_gradient(root:UOp, root_grad:UOp, targets:set[UOp]) -> dict[UOp, UOp
grads: dict[UOp, UOp] = {root: root_grad}
for t0 in reversed(walk):
if t0 not in grads or grads[t0].op is Ops.NOOP: continue
# GETTUPLE: accumulate gradient into a TUPLE UOp on the FUNCTION, process when we hit the FUNCTION
if t0.op is Ops.GETTUPLE:
k = t0.src[0] # the FUNCTION
assert k.op is Ops.FUNCTION and k.src[0].op is Ops.TUPLE
n_outputs = len(k.src[0].src)
prev = grads[k].src if k in grads else tuple(UOp(Ops.NOOP) for _ in range(n_outputs))
grads[k] = UOp.maketuple(*(prev[i] + grads[t0] if i == t0.arg and prev[i].op is not Ops.NOOP else
grads[t0] if i == t0.arg else prev[i] for i in range(n_outputs)))
continue
# FUNCTION/CALL: pass needed param set so backward only computes required gradients
# (FUNCTION uses implicit TUPLE gradient or grad_fxn; CALL requires an explicit grad_fxn)
if t0.op in {Ops.FUNCTION, Ops.CALL}:
# CALL: pass needed param set so backward only computes required gradients
# (calls with RETURNED inputs use the implicit body gradient or grad_fxn; opaque CALLs require an explicit grad_fxn)
if t0.op is Ops.CALL:
needed = {i for i, arg in enumerate(t0.src[1:]) if arg in targets or in_target_path.get(arg, False)}
lgrads:tuple[UOp|None, ...]|None = call_gradient(grads[t0], t0, needed)
else:
@@ -126,9 +127,9 @@ def compute_gradient(root:UOp, root_grad:UOp, targets:set[UOp]) -> dict[UOp, UOp
if k._shape is not None and v._shape is not None and k._shape != v._shape:
v = v.cast(sum_acc_dtype(v.dtype))._rop(Ops.ADD, broadcast_axes(k.shape, v.shape)).reshape(k.shape).cast(v.dtype)
if k in grads and grads[k].op is not Ops.NOOP:
if v.op is Ops.TUPLE and grads[k].op is Ops.TUPLE:
grads[k] = UOp.maketuple(*(p + n if (p.op is not Ops.NOOP and n.op is not Ops.NOOP) else
n if p.op is Ops.NOOP else p for p, n in zip(grads[k].src, v.src)))
if v.op is Ops.GROUP and grads[k].op is Ops.GROUP:
grads[k] = UOp(Ops.GROUP, src=tuple(p + n if (p.op is not Ops.NOOP and n.op is not Ops.NOOP) else
n if p.op is Ops.NOOP else p for p, n in zip(grads[k].src, v.src)))
else: grads[k] = grads[k] + v
else: grads[k] = v
if len(forward_metadata:=all_metadata.get(t0, ())):
+2 -2
View File
@@ -23,7 +23,7 @@ class IndexingContext:
ALWAYS_CONTIGUOUS: set[Ops] = {Ops.CONTIGUOUS, Ops.AFTER, Ops.BUFFER,
Ops.CONST, Ops.MSELECT, Ops.MSTACK, Ops.PARAM,
Ops.LOAD, Ops.CALL, Ops.FUNCTION}
Ops.LOAD, Ops.CALL}
def realize(ctx:IndexingContext, tr:UOp) -> None: ctx.realize_map[tr] = None
@@ -204,7 +204,7 @@ def run_rangeify(tsink:UOp, debug:bool=False) -> UOp:
ending_ranges: dict[UOp, list[UOp]] = {}
for x in reversed(tsink_toposort):
# no ranges on kernels, they are internal
if x.op in {Ops.CALL, Ops.FUNCTION, Ops.LINEAR}: continue
if x.op in {Ops.CALL, Ops.LINEAR}: continue
# AFTER doesn't have range
if x.op is Ops.AFTER: continue
+10 -16
View File
@@ -269,16 +269,12 @@ def passthrough_multi(root:UOp, multi:UOp):
return UOp(root.op, root.dtype, src=new_src, arg=root.arg).unshard(multi.arg, multi.src[1:])
def rewrite_into_function(call:UOp):
if call.arg.precompile: return None
if call.arg is None or call.arg.precompile: return None
# the call body is a plain parametric program: multi rewrites it like anything else (the output PARAM dests sub-view per
# shard through the normal store rules), and all srcs (args and RETURNEDs) become their per-shard views
new_body = graph_rewrite(call.src[0], multi_pm, name="subcall")
new_args = tuple(a.src[0] if a.op is Ops.UNSHARD else a for a in call.src[1:])
# after multi resolution, TUPLE elements may be UNSHARD — strip UNSHARD from body, create per-shard FUNCTION, wrap each GETTUPLE in its own UNSHARD
assert new_body.op is Ops.TUPLE
if any(s.op is Ops.UNSHARD for s in new_body.src):
shard_call = call.replace(src=(UOp.maketuple(*[s.src[0] if s.op is Ops.UNSHARD else s for s in new_body.src]),)+new_args)
return UOp.maketuple(*[shard_call.gettuple(i).unshard(s.arg, s.src[1:]) if s.op is Ops.UNSHARD else shard_call.gettuple(i)
for i, s in enumerate(new_body.src)])
return call.replace(src=(new_body,)+new_args)
assert new_body.op is Ops.SINK
return call.replace(src=(new_body,) + tuple(a.src[0] if a.op is Ops.UNSHARD else a for a in call.src[1:]))
# NOTE: this is the same pattern as unrolled ranges
multi_pm = PatternMatcher([
@@ -297,14 +293,12 @@ multi_pm = PatternMatcher([
(UPat(Ops.ALLREDUCE, src=(UPat(Ops.UNSHARD, name="multi"),), name="red"),
lambda multi,red: multi.src[0].allreduce(*red.arg).unshard(multi.arg, multi.src[1:])),
# resolve TUPLE+GETTUPLE (needed in multi)
(UPat(Ops.GETTUPLE, src=(UPat(Ops.TUPLE, name="t"),), name="g"), lambda g,t: t.src[g.arg]),
# rewrite into FUNCTION calls explicitly for UNSHARD (value-producing)
(UPat(Ops.FUNCTION, name="call"), rewrite_into_function),
(UPat((Ops.CALL, Ops.FUNCTION, Ops.AFTER), src=(UPat(Ops.UNSHARD, name="multi"), ), name="root", allow_any_len=True), passthrough_multi),
# just strip the UNSHARD from non-value-producing CALLs (custom kernels, etc.) — FUNCTION is handled by rewrite_into_function
# rewrite value-producing calls explicitly for UNSHARD
(UPat(Ops.CALL, name="call"), lambda call: rewrite_into_function(call) if call.num_returned else None),
(UPat((Ops.CALL, Ops.AFTER), src=(UPat(Ops.UNSHARD, name="multi"), ), name="root", allow_any_len=True), passthrough_multi),
# just strip the UNSHARD from non-value-producing CALLs (custom kernels, etc.) — value-producing CALLs are handled by rewrite_into_function
(UPat(Ops.CALL, dtype=dtypes.void, name="root", custom_early_reject=set([Ops.UNSHARD])), lambda root:
UOp(root.op, src=tuple(x.src[0] if x.op is Ops.UNSHARD else x for x in root.src), arg=root.arg)),
UOp(root.op, src=tuple(x.src[0] if x.op is Ops.UNSHARD else x for x in root.src), arg=root.arg) if root.num_returned == 0 else None),
(UPat((Ops.CAST, Ops.BITCAST, Ops.CONTIGUOUS, Ops.DETACH, Ops.CONTIGUOUS_BACKWARD),
src=(UPat(Ops.UNSHARD, name="multi"), ), name="root"), passthrough_multi),
# STORE of a sharded value into an unsharded dest (e.g. a fragment into a full output tile)
+6 -5
View File
@@ -1,7 +1,7 @@
import itertools
from tinygrad.dtype import dtypes, to_dtype
from tinygrad.uop.ops import PatternMatcher, UPat, Ops, UOp, resolve, GroupOp
from tinygrad.uop.ops import graph_rewrite, rewrite_group, ParamArg, identity_element
from tinygrad.uop.ops import graph_rewrite, rewrite_group, ParamArg, identity_element, resolve_returned_after
from tinygrad.uop.movement import mop_cleanup
from tinygrad.helpers import prod, getenv, all_int, DEBUG, SPLIT_REDUCEOP, OPENPILOT_HACKS, FLOAT16, argsort
from tinygrad.schedule.indexing import apply_movement_op
@@ -93,6 +93,7 @@ def resolve_function(c:UOp, allow_param_mismatch=True) -> UOp|None:
params: list[UOp] = []
graph_rewrite(c.src[0], pm_gather_params, bottom_up=True, ctx=params, name="gather params")
params = sorted(params, key=lambda x: x.arg.slot)
# the RETURNED inputs bind to the output PARAMs (slots after the input PARAM slots), just like the args bind to input PARAMs
args = c.src[1:]
# NOTE: this isn't really needed. it's okay if there's unused args in the function
@@ -129,11 +130,11 @@ def expand_bitcast(bc:UOp) -> UOp|None:
return parts[0].stack(*parts[1:], dim=-1).flatten(-2).cast(new_uint).bitcast(bc.dtype)
earliest_rewrites = mop_cleanup+PatternMatcher([
# resolve FUNCTION calls (inline the body)
(UPat(Ops.FUNCTION, name="c"), resolve_function),
# resolve calls with RETURNED inputs (inline the body)
(UPat(Ops.CALL, name="c"), lambda c: resolve_function(c) if c.num_returned else None),
# resolve TUPLE+GETTUPLE
(UPat(Ops.GETTUPLE, src=(UPat(Ops.TUPLE, name="t"),), name="g"), lambda g,t: t.src[g.arg]),
# resolve AFTER on RETURNED (call outputs)
(UPat(Ops.AFTER, src=(UPat(name="r"), UPat(Ops.SINK, name="t")), allow_any_len=True), resolve_returned_after),
# resolve allreduce (must be bottom up)
(UPat(Ops.ALLREDUCE, src=(UPat.var("buf"),), name="red"), create_allreduce_function),
+27 -16
View File
@@ -9,6 +9,7 @@ from tinygrad.dtype import DType, DTypeLike, dtypes, ConstType, least_upper_dtyp
from tinygrad.helpers import all_int, getenv, fetch, Metadata, TRACEMETA, TracingKey
from tinygrad.helpers import cpu_profile, suppress_finalizing, disable_gc, VIZ, pluralize
from tinygrad.uop.ops import UOp, Ops, sint, all_metadata, Variable, ConstLike, UPat, PatternMatcher, GroupOp, ParamArg, graph_rewrite, rewrite_group
from tinygrad.uop.ops import resolve_returned_after
from tinygrad.mixin.rand import RandMixin
from tinygrad.schedule import create_linear_with_vars
from tinygrad.device import Buffer, canonicalize_device
@@ -107,15 +108,19 @@ def _precompiled_output_redirect(s:UOp, t:UOp) -> UOp|None:
return None
def transform_precompiled_call(c:UOp) -> UOp|None:
if not c.arg.precompile: return None
assert c.src[0].op is Ops.TUPLE, f"expected TUPLE body for precompiled FUNCTION, got {c.src[0].op}"
input_buffers = tuple(x.contiguous() if x.op is not Ops.AFTER else x for x in c.src[1:])
if c.arg is None or not c.arg.precompile or c.num_returned == 0: return None
assert c.src[0].op is Ops.SINK, f"expected SINK body for precompiled call, got {c.src[0].op}"
# the RETURNED inputs are the call outputs (they are always the last srcs), the outputs are the stores into the
# output PARAMs (slots after the args) in slot order
n_ret = c.num_returned
call_args, returned = c.src[1:len(c.src)-n_ret], c.src[len(c.src)-n_ret:]
input_buffers = tuple(x.contiguous() if x.op is not Ops.AFTER else x for x in call_args)
out_stores = sorted((st for st in c.src[0].src if st.op is Ops.STORE), key=lambda st: st.src[0].unsharded_base.arg.slot)
srcs = tuple(st.src[1] for st in out_stores)
# add the outputs to the call
srcs = c.src[0].src
resolved = [c.gettuple(i) for i in range(len(srcs))]
outs = tuple(r.empty_like() for r in resolved)
targets = [o.param_like(len(c.src)-1+i).shrink_to(s.shape) for i,(o,s) in enumerate(zip(outs, srcs))]
outs = tuple(r.empty_like() for r in returned)
targets = [o.param_like(len(input_buffers)+i).shrink_to(s.shape) for i,(o,s) in enumerate(zip(outs, srcs))]
subs:dict[UOp, UOp] = {}
items:list[UOp] = []
@@ -131,23 +136,24 @@ def transform_precompiled_call(c:UOp) -> UOp|None:
items.append(t.after(t.store(s.after(*after_deps))))
fxn = UOp.sink(*(x.substitute(subs) for x in items))
# body switches from TUPLE to SINK, so the node becomes an opaque CALL (not FUNCTION)
# all bodies are SINKs now, the node just becomes an opaque CALL
new_call = UOp(Ops.CALL, src=(fxn, *input_buffers, *outs), arg=c.arg)
rets = tuple(o.after(new_call) for o in outs)
# if the CALL has symbolic shapes, shrink the max-sized output to the actual symbolic shape
# NOTE: must use resolved shapes from the FUNCTION (which substitutes PARAMs with external args), not raw body shapes
rets = tuple(r.shrink_to(rs.shape) for r,rs in zip(rets, resolved))
# NOTE: must use the resolved shapes of the RETURNED placeholders (which substitute PARAMs with external args), not raw body shapes
rets = tuple(r.shrink_to(rs.shape) for r,rs in zip(rets, returned))
return UOp.maketuple(*rets)
# the AFTER outputs resolve against this: stores of each real output into its RETURNED placeholder
return UOp.sink(*[r.store(v) for r, v in zip(returned, rets)])
# NOTE: adding rules to here is bad. these all need to run before the schedule cache
pm_early_transform_tensor_graph = PatternMatcher([
# transform precompiled FUNCTIONs into CALLs (body becomes SINK with stores)
(UPat(Ops.FUNCTION, name="c"), transform_precompiled_call),
# transform precompiled value-producing calls into opaque CALLs (outputs become real buffers)
(UPat(Ops.CALL, name="c"), transform_precompiled_call),
# resolve TUPLE+GETTUPLE (for precompiled calls)
(UPat(Ops.GETTUPLE, src=(UPat(Ops.TUPLE, name="t"),), name="g"), lambda g,t: t.src[g.arg]),
# resolve AFTER on RETURNED placeholders (for precompiled calls)
(UPat(Ops.AFTER, src=(UPat(name="r"), UPat(Ops.SINK, name="t")), allow_any_len=True), resolve_returned_after),
# fold MOPS+BITCAST over BUFFER into SHRINK when movement ops collapse to contiguous range
(UPat((Ops.COPY, Ops.CONTIGUOUS), src=(UPat(GroupOp.Movement|{Ops.BITCAST}, name="src"),), name="c"), contiguous_mops_to_view),
@@ -164,6 +170,9 @@ pm_early_transform_tensor_graph = PatternMatcher([
# add CONTIGUOUS to tagged UOps
(UPat(GroupOp.All-{Ops.CONTIGUOUS, Ops.AFTER, Ops.STORE}, name="x"),
lambda x: None if x.tag is None else x.rtag(None).contiguous(tag=x.tag) if x.tag else x.replace(tag=None)),
# an AFTER on a RETURNED placeholder is a call output (a computed value), not an assignment: allocate fresh storage for it
(UPat(Ops.AFTER, name="x"),
lambda x: None if x.tag is None or x.src[0].unsharded_base.op is not Ops.RETURNED else x.rtag(None).contiguous(tag=x.tag)),
# remove extra CONTIGUOUS on AFTER (only when target is contiguous)
(UPat(Ops.CONTIGUOUS, src=(UPat(Ops.AFTER, name="a"),), name="c"),
lambda a,c: a.replace(tag=(a.tag or ())+(c.tag or ())) if a.src[0].has_buffer_identity() else None),
@@ -178,6 +187,8 @@ pm_early_transform_tensor_graph = PatternMatcher([
def finalize_after(ctx:AllocCtx, x:UOp):
# bound Variables are call inputs, not assigns: they stay in the graph and pm_replace_buf turns them into call args
if x.is_bound_var: return None
# AFTER on a RETURNED placeholder is a call output, not an assign: it's inlined when the call is resolved
if x.src[0].unsharded_base.op is Ops.RETURNED: return None
# untagged: record as an assign for the call body
if x.tag is None:
ctx.assigns.append(x)
@@ -383,7 +394,7 @@ class Tensor(RandMixin):
def call(self, *lst:Tensor, fxn:Tensor|UOp, grad_fxn:Callable|None=None) -> Tensor:
fret = fxn._uop.call(*[t.uop for t in (self,)+lst], grad_fxn=grad_fxn)
return Tensor(fret.gettuple(0))
return Tensor(fret.returned_outputs[0])
def custom_kernel(self, *lst:Tensor, fxn:Callable, grad_fxn:Callable|None=None) -> list[Tensor]:
"""
+4 -4
View File
@@ -23,8 +23,8 @@ class Ops(FastEnum):
# uops that aren't rendered
NOOP = auto(); REWRITE_ERROR = auto()
# FUNCTION has a TUPLE body and is gradient-able; CALL is an opaque kernel invocation
PARAM = auto(); FUNCTION = auto(); CALL = auto()
# CALL is a kernel invocation; calls with RETURNED inputs are value-producing (and gradient-able), the rest are opaque
PARAM = auto(); CALL = auto()
# renderer
# LINEAR is a list of UOps, SOURCE has a str arg that's human readable, BINARY has bytes arg that's compiled
@@ -37,8 +37,8 @@ class Ops(FastEnum):
# vector creation / item selection
STACK = auto()
# tuple/gettuple for function with multiple returns
TUPLE = auto(); GETTUPLE = auto()
# RETURNED is a placeholder for a buffer a call writes and returns: it's an input to the call and you AFTER on it
RETURNED = auto()
# hcq specific
GETADDR = auto()
+64 -47
View File
@@ -49,7 +49,7 @@ axis_colors = {AxisType.DEVICE: "green", AxisType.GLOBAL: "blue", AxisType.THREA
axis_to_pos = {AxisType.DEVICE: -2, AxisType.WEAK: -1, AxisType.LOOP: -1, AxisType.THREAD: 0, AxisType.GLOBAL: 0, AxisType.WARP: 1,
AxisType.LOCAL: 2, AxisType.UPCAST: 3, AxisType.GROUP_REDUCE: 2, AxisType.REDUCE: 4, AxisType.UNROLL: 5}
range_start = {Ops.STAGE: 1, Ops.REDUCE: 1, Ops.WMMA: 3, Ops.END: 1, Ops.CALL: 1, Ops.FUNCTION: 1, Ops.LINEAR: 0}
range_start = {Ops.STAGE: 1, Ops.REDUCE: 1, Ops.WMMA: 3, Ops.END: 1, Ops.CALL: 1, Ops.LINEAR: 0}
# https://en.wikipedia.org/wiki/Identity_element
def identity_element(op:Ops, dt:DType) -> PyConst: return dt.const({Ops.ADD:0, Ops.MUL:1, Ops.MAX:dt.min}[op])
@@ -120,7 +120,7 @@ def dtype_from_uop(op:Ops, src:tuple[UOp,...], arg:Any) -> DType|None:
match op:
case Ops.STORE | Ops.LINEAR | Ops.SINK | Ops.PROGRAM | Ops.SOURCE | \
Ops.END | Ops.BARRIER | Ops.GROUP | Ops.IF | Ops.ENDIF | \
Ops.TUPLE | Ops.FUNCTION | Ops.CUSTOM_FUNCTION | Ops.REWRITE_ERROR | Ops.PYLITERAL:
Ops.CUSTOM_FUNCTION | Ops.REWRITE_ERROR | Ops.PYLITERAL:
# always void
return dtypes.void
case Ops.CALL:
@@ -157,10 +157,6 @@ def dtype_from_uop(op:Ops, src:tuple[UOp,...], arg:Any) -> DType|None:
case Ops.WMMA:
# WMMA output dtype is the accumulator dtype (src[2])
return src[2].dtype
case Ops.GETTUPLE:
# GETTUPLE extracts from a TUPLE (possibly through a FUNCTION)
in_tuple = src[0].src[0] if src[0].op is Ops.FUNCTION else src[0]
return in_tuple.src[arg].dtype
case Ops.GETADDR:
return dtypes.uint64
case Ops.THREEFRY:
@@ -171,8 +167,8 @@ def dtype_from_uop(op:Ops, src:tuple[UOp,...], arg:Any) -> DType|None:
if not all(dtypes.is_int(x.dtype) or x.base.is_invalid for x in src):
raise RuntimeError(f"shift operands must be int, got {[x.dtype for x in src]}")
return src[0].dtype
case Ops.BUFFER | Ops.PARAM:
assert isinstance(arg, ParamArg), "BUFFER/PARAM must have ParamArg"
case Ops.BUFFER | Ops.PARAM | Ops.RETURNED:
assert isinstance(arg, ParamArg), f"{op} must have ParamArg"
return arg.dtype
case Ops.BINARY:
return dtypes.uint8
@@ -310,7 +306,7 @@ class UOp(RandMixin, metaclass=UOpMetaClass):
if not visited:
if gate is None or gate(node):
stack.append((node, True)) # push node back on stack to process after its srcs
for s in reversed(node.src if enter_calls or node.op not in {Ops.CALL, Ops.FUNCTION} else node.src[1:]):
for s in reversed(node.src if enter_calls or node.op is not Ops.CALL else node.src[1:]):
stack.append((s, False)) # push srcs on the stack
else: cache[node] = None # second time i'm seeing this node, add it to returned toposort
return cache
@@ -339,7 +335,7 @@ class UOp(RandMixin, metaclass=UOpMetaClass):
match self.op:
# late ops don't have shape
case Ops.IF | Ops.BARRIER | Ops.SINK | Ops.REWRITE_ERROR | Ops.ENDIF | Ops.GROUP | \
Ops.LINEAR | Ops.PROGRAM | Ops.SOURCE | Ops.TUPLE | Ops.FUNCTION:
Ops.LINEAR | Ops.PROGRAM | Ops.SOURCE:
return None
# a void CALL has no shape, the return value of a CALL has the shape of its dtype
@@ -359,17 +355,6 @@ class UOp(RandMixin, metaclass=UOpMetaClass):
case Ops.NOOP:
return self.src[0]._shape if len(self.src) >= 1 else None
case Ops.GETTUPLE:
# GETTUPLE extracts from a TUPLE (possibly through a FUNCTION)
in_tuple = self.src[0].src[0] if self.src[0].op is Ops.FUNCTION else self.src[0]
assert in_tuple.op is Ops.TUPLE
inner_shape = in_tuple.src[self.arg]._shape
if inner_shape is None: return None
# if through a FUNCTION, substitute internal PARAMs in the shape with corresponding args
if self.src[0].op is Ops.FUNCTION:
return tuple(graph_rewrite(s, _pm_resolve_params, self.src[0].src[1:], walk=True) if isinstance(s, UOp) else s for s in inner_shape)
return inner_shape
case Ops.INDEX:
shp:list[sint] = []
for s in self.src[1:]: shp.extend(list(s.shape))
@@ -385,8 +370,8 @@ class UOp(RandMixin, metaclass=UOpMetaClass):
case Ops.GETADDR: return ()
case Ops.RANGE | Ops.SPECIAL: return ()
case Ops.BINARY: return (len(self.arg),)
case Ops.BUFFER | Ops.PARAM:
# PARAM/BUFFER don't have a shape input, they have a size in the arg: int gives shape (size,), None gives ()
case Ops.BUFFER | Ops.PARAM | Ops.RETURNED:
# these don't have a shape input, they have a size in the arg: int gives shape (size,), None gives ()
if (img:=self.arg.image) is not None: return (img[0], img[1], 4)
return () if self.arg.size is None else (self.arg.size,)
case Ops.CUSTOM | Ops.CUSTOMI:
@@ -428,7 +413,8 @@ class UOp(RandMixin, metaclass=UOpMetaClass):
match self.op:
case Ops.RESHAPE:
if not all(x >= 0 for x in self.marg): raise ValueError(f"shape can't contain negative numbers {self.marg}")
if prod(ps) != prod(self.marg): raise ValueError(f"bad reshape: {ps} -> {self.marg}")
# with symbolic views prod equality can be true at runtime but unprovable, only reject provably unequal products
if resolve(prod(ps) != prod(self.marg), False): raise ValueError(f"bad reshape: {ps} -> {self.marg}")
return self.marg
case Ops.EXPAND:
return tuple(self.marg) + ps
@@ -555,12 +541,23 @@ class UOp(RandMixin, metaclass=UOpMetaClass):
def sink(*srcs:UOp|None, **kwargs): # pylint: disable=no-self-argument
return UOp(Ops.SINK, src=tuple([x for x in srcs if x is not None]), **kwargs)
def maketuple(*srcs:UOp): # pylint: disable=no-self-argument
return UOp(Ops.TUPLE, src=srcs)
def gettuple(self, idx:int) -> UOp:
in_tuple = self.src[0] if self.op is Ops.FUNCTION else self
assert in_tuple.op is Ops.TUPLE, f"gettuple requires FUNCTION or TUPLE source, got {self.op}"
return UOp(Ops.GETTUPLE, src=(self,), arg=idx)
@staticmethod
def returned(slot:int, dtype:DType, shape:tuple[sint, ...]|sint|None=None, device=None, axis:int|None=None) -> UOp:
"""create a RETURNED placeholder: like PARAM it has a flat storage size in the arg with a view on top,
but it marks a buffer the enclosing call writes and returns: it's an input to the call and you AFTER on it"""
if isinstance(shape, (int, UOp)): shape = (shape,)
if shape is not None and axis is not None and isinstance(device, tuple):
# multi-device values have a per-shard sized storage wrapped in UNSHARD: the sharding lives in the graph, not the arg
shape = tuple(s // len(device) if i == axis else s for i, s in enumerate(shape))
if shape is None or len(shape) == 0: return UOp(Ops.RETURNED, arg=ParamArg(slot, dtype, None, device=device))
ret = UOp(Ops.RETURNED, arg=ParamArg(slot, dtype, prod(to_max_shape(shape)), device=device))
return ret.view_as(shape, axis)
@property
def num_returned(self) -> int: return sum(x.unsharded_base.op is Ops.RETURNED for x in self.src[1:])
@property
def returned_outputs(self) -> tuple[UOp, ...]:
"""the outputs of a value-producing call: an AFTER on each RETURNED input, usable like a normal buffer"""
return tuple(x.after(self) for x in self.src[1:] if x.unsharded_base.op is Ops.RETURNED)
def group(*srcs:UOp|None, **kwargs): # pylint: disable=no-self-argument
if len(srcs) == 1 and isinstance(srcs[0], UOp): return srcs[0]
return UOp(Ops.GROUP, src=tuple([x for x in srcs if x is not None]), **kwargs)
@@ -705,10 +702,6 @@ class UOp(RandMixin, metaclass=UOpMetaClass):
if self.op is Ops.UNSHARD:
if len(self.arg) != 1: raise RuntimeError(f"UOp is sharded on multiple axes {self.arg}, use .sharding")
return self.arg[0]
# GETTUPLE: axis comes from the specific TUPLE element, not src[0]
if self.op is Ops.GETTUPLE:
in_tuple = self.src[0].src[0] if self.src[0].op is Ops.FUNCTION else self.src[0]
return in_tuple.src[self.arg].axis if in_tuple.op is Ops.TUPLE else None
if self.op is Ops.PARAM: return None
# NOTE: they all have to share an axis, we always choose [-1]. src axes are right-aligned into the output shape
if self.op in GroupOp.ALU.union({Ops.STACK}):
@@ -855,7 +848,7 @@ class UOp(RandMixin, metaclass=UOpMetaClass):
return ret.after(ret.store(src.cast(ret.dtype)))
@recursive_property
def device(self) -> str|tuple[str, ...]|None:
if self.op is Ops.PARAM: return self.arg.device
if self.op in (Ops.PARAM, Ops.RETURNED): return self.arg.device
if self.op is Ops.STAGE: return self.arg.device
if self.op is Ops.AFTER: return self.src[0].device
if self.op is Ops.MSELECT:
@@ -875,7 +868,7 @@ class UOp(RandMixin, metaclass=UOpMetaClass):
return self.device is None or self.dtype in dtypes.weaks
@recursive_property
def addrspace(self) -> AddrSpace|None:
if self.op is Ops.PARAM: return self.arg.addrspace
if self.op in (Ops.PARAM, Ops.RETURNED): return self.arg.addrspace
if self.op is Ops.BUFFER: return self.arg.addrspace
if self.op in {Ops.SPECIAL, Ops.RANGE}: return AddrSpace.ALU
if self.op is Ops.LOAD: return AddrSpace.ALU # LOAD brings things into the ALU
@@ -889,7 +882,7 @@ class UOp(RandMixin, metaclass=UOpMetaClass):
return None
@property
def buf_uop(self) -> UOp:
if self.op in {Ops.BUFFER, Ops.PARAM}: return self
if self.op in {Ops.BUFFER, Ops.PARAM, Ops.RETURNED}: return self
if self.op is Ops.MSELECT: return self.src[0].buf_uop.mselect(self.arg)
if self.op is Ops.MSTACK: return UOp(Ops.MSTACK, src=tuple(x.buf_uop for x in self.src))
if self.base.op is Ops.AFTER: return self.base.src[0].buf_uop.base
@@ -1201,7 +1194,7 @@ class UOp(RandMixin, metaclass=UOpMetaClass):
@staticmethod
def custom_function(name:str, *src:UOp) -> UOp: return UOp(Ops.CUSTOM_FUNCTION, src=src, arg=name)
# opaque bodies stay as Ops.CALL; value-producing bodies become Ops.FUNCTION (wrapped in TUPLE)
# opaque bodies are just CALLs; value-producing bodies become CALLs with RETURNED placeholders as extra inputs
_OPAQUE_CALL_BODIES = {Ops.SINK, Ops.PROGRAM, Ops.LINEAR, Ops.COPY, Ops.CUSTOM_FUNCTION}
def call(self, *srcs:UOp, ret_dtype:DType|None=None, grad_fxn:Callable|None=None,
name:str|None=None, precompile:bool=False, precompile_backward:bool=False, aux:Any=None) -> UOp:
@@ -1211,9 +1204,27 @@ class UOp(RandMixin, metaclass=UOpMetaClass):
f"ranges {self.ranges} are leaking out of the call in {self.pyrender()}"
if self.op in UOp._OPAQUE_CALL_BODIES:
return UOp(Ops.CALL, src=(self,)+srcs, arg=CallInfo(grad_fxn, name, precompile, precompile_backward, aux))
# value-producing bodies are always wrapped in TUPLE so FUNCTION dtype is always void
body = self if self.op is Ops.TUPLE else UOp.maketuple(self)
return UOp(Ops.FUNCTION, src=(body,)+srcs, arg=CallInfo(grad_fxn, name, precompile, precompile_backward, aux))
# value-producing bodies delegate to call_outputs with a single output
return UOp.call_outputs((self,), *srcs, grad_fxn=grad_fxn, name=name, precompile=precompile,
precompile_backward=precompile_backward, aux=aux)
@staticmethod
def call_outputs(values:tuple[UOp, ...], *srcs:UOp, grad_fxn:Callable|None=None,
name:str|None=None, precompile:bool=False, precompile_backward:bool=False, aux:Any=None) -> UOp:
"""call a body producing the given values: the body stores into output PARAMs, and the outputs are RETURNED
placeholders that are inputs to the call (you AFTER on them like normal buffers). the RETURNEDs are bound to the
output PARAMs positionally wherever the call is resolved, just like the args are bound to the input PARAMs"""
# the device defaults to the first device in the values or args, like srcs-based device resolution
default_dev = next((x.device for x in itertools.chain(values, srcs) if x.device is not None), None)
# the RETURNED storage has the resolved shape: substitute internal PARAMs in the shapes with corresponding args
def returned(o:UOp, i:int) -> UOp:
return UOp.returned(len(srcs)+i, o.dtype, None if (shp:=o._shape) is None else
tuple(graph_rewrite(s, _pm_resolve_params, srcs, walk=True) if isinstance(s, UOp) else s for s in shp),
dev if (dev:=o.device) is not None else default_dev, o.axis if isinstance(o.device, tuple) else None)
rets = tuple(returned(o, i) for i, o in enumerate(values))
# the body only knows PARAMs: the output PARAMs get the slots right after the input PARAM slots
body = UOp.sink(*[v.param_like(len(srcs)+i).store(v) for i, v in enumerate(values)])
return UOp(Ops.CALL, src=(body,)+srcs+rets, arg=CallInfo(grad_fxn, name, precompile, precompile_backward, aux))
def custom_kernel(*srcs:UOp, fxn:Callable, grad_fxn:Callable|None=None) -> list[UOp]:
placeholders = [UOp.placeholder_like(s, slot=i) for i,s in enumerate(srcs)]
kernel = fxn(*placeholders).call(*srcs, grad_fxn=grad_fxn)
@@ -1685,7 +1696,8 @@ class RewriteContext:
continue
# no rewrite, process children then come back to rebuild
stack.append((n, True))
if not self.enter_calls and (n.op is Ops.FUNCTION or (n.op is Ops.CALL and n.src[0].op in UOp._OPAQUE_CALL_BODIES)):
# calls with RETURNED inputs are always inlined into the enclosing graph, their bodies are never rewritten separately
if n.op is Ops.CALL and (n.num_returned or (not self.enter_calls and n.src[0].op in UOp._OPAQUE_CALL_BODIES)):
self.replace[n.src[0]] = n.src[0]
for x in reversed(n.src):
if x not in self.replace: stack.append((x, False))
@@ -1723,11 +1735,10 @@ class RewriteContext:
if n in waitlist: stack.extend(waitlist.pop(n))
continue
stack.append((n, 1, new_n))
# NOTE: CALL/FUNCTION are handled as a special case.
# The function that is called is not included in the graph_rewrite.
# If you want to graph_rewrite a call, you can
# A CALL of an address is not a body, its srcs are regular dataflow
if not self.enter_calls and (new_n.op is Ops.FUNCTION or (new_n.op is Ops.CALL and new_n.src[0].op in UOp._OPAQUE_CALL_BODIES)):
# NOTE: CALLs are handled as a special case: the call body is not included in the graph_rewrite (a CALL of an
# address is not a body, its srcs are regular dataflow). calls with RETURNED inputs are always inlined into the
# enclosing graph, their bodies are never rewritten separately
if new_n.op is Ops.CALL and (new_n.num_returned or (not self.enter_calls and new_n.src[0].op in UOp._OPAQUE_CALL_BODIES)):
self.replace[new_n.src[0]] = new_n.src[0]
for x in reversed(new_n.src):
if x in on_stack: continue
@@ -1781,6 +1792,12 @@ def to_max_shape(shape:tuple[sint, ...]) -> tuple[int, ...]: return tuple(int(x.
_substitute = PatternMatcher([(UPat(tuple(Ops), name="x"), lambda ctx,x: ctx.get(x,None))])
_pm_resolve_params = PatternMatcher([(UPat(Ops.PARAM, name="p"), lambda ctx,p: ctx[p.arg.slot])])
def resolve_returned_after(r:UOp, t:UOp) -> UOp|None:
"""AFTER on a RETURNED placeholder extracts the call output value: the value of the matching store in a SINK body"""
if (rb:=r.unsharded_base).op is not Ops.RETURNED or t.op is not Ops.SINK: return None
vals = [st.src[1] for st in t.src if st.op is Ops.STORE and st.src[0].unsharded_base is rb]
return vals[0] if len(vals) == 1 else None
remove_all_tags = PatternMatcher([(UPat(GroupOp.All, name="x"), lambda x: x.replace(tag=None) if x.tag is not None else None)])
def gate_kernel_sink(x:UOp) -> bool:
+2 -2
View File
@@ -144,7 +144,7 @@ def pyrender(ast:UOp) -> str:
cmap = consumer_map_from_toposort(lst)
not_rendered = {Ops.CONST}
always_rendered = {Ops.PARAM, Ops.LOAD, Ops.SPECIAL, Ops.RANGE, Ops.CONTIGUOUS, Ops.STACK,
Ops.BUFFER, Ops.COPY, Ops.CALL, Ops.FUNCTION, Ops.WHERE, Ops.END}
Ops.BUFFER, Ops.COPY, Ops.CALL, Ops.WHERE, Ops.END}
to_render: set[UOp] = {ast}
for u in lst:
@@ -152,7 +152,7 @@ def pyrender(ast:UOp) -> str:
for s in u.src: to_render.add(s)
if u.op is Ops.STORE: to_render.add(u.src[1])
if u.op is Ops.REDUCE: to_render.add(u.src[0])
if u.op is Ops.FUNCTION or (u.op is Ops.CALL and u.src[0].dtype is dtypes.void): raise NotImplementedError("call can't be pyrendered")
if u.op is Ops.CALL and u.src[0].dtype is dtypes.void: raise NotImplementedError("call can't be pyrendered")
if u.op in not_rendered: continue
# checking the consumers is not enough, you have to make sure it's not used twice by the one consumer
if len(cmap[u]) == 1 and len([x for x in list(cmap[u].keys())[0].src if x is u]) == 1 and u.op not in always_rendered: continue
+4 -9
View File
@@ -94,9 +94,9 @@ spec_shared = PatternMatcher([
# GROUP of stores (or groups, or NOOPs)
(UPat(Ops.GROUP, dtypes.void, src=UPat((Ops.GROUP, Ops.STORE, Ops.NOOP, Ops.INS, Ops.END))), lambda: True),
# AFTER on Movement Op, PARAM, BUFFER, CONTIGUOUS, or another AFTER
# AFTER on Movement Op, PARAM, BUFFER, CONTIGUOUS, RETURNED, or another AFTER
(UPat(Ops.AFTER, src=(UPat(GroupOp.Movement.union({Ops.PARAM, Ops.BUFFER, Ops.CONTIGUOUS, Ops.INDEX,
Ops.AFTER, Ops.UNSHARD, Ops.BITCAST, Ops.INS})),),
Ops.AFTER, Ops.UNSHARD, Ops.BITCAST, Ops.INS, Ops.RETURNED})),),
allow_any_len=True, name="x"), lambda x: matches_dtype(x.src[0], x.dtype)),
# CUSTOM (inline and non inline): the arg is the source string and the dtype it produces, void for a bare statement
@@ -132,8 +132,6 @@ spec_shared = PatternMatcher([
def is_device(d): return isinstance(d, str) or (isinstance(d, tuple) and all(isinstance(s, str) for s in d))
def valid_gettuple(g:UOp, t:UOp): return isinstance(g.arg, int) and 0 <= g.arg < len(t.src) and matches_dtype(t.src[g.arg], g.dtype)
# these ops can exist in tensor but not programs. example: movement
spec_tensor = PatternMatcher([
(UPat((Ops.SIN, Ops.LOG2, Ops.EXP2, Ops.SQRT, Ops.RECIPROCAL), src=(UPat(),), name="u"),
@@ -153,11 +151,8 @@ spec_tensor = PatternMatcher([
# CALL
(UPat(Ops.CALL, dtypes.void, src=(UPat((Ops.SINK, Ops.LINEAR, Ops.PROGRAM, Ops.COPY, Ops.CUSTOM_FUNCTION)),), allow_any_len=True), lambda: True),
# FUNCTION + TUPLE must have void dtype, GETTUPLE can only appear on FUNCTION or TUPLE
(UPat(Ops.FUNCTION, dtypes.void, src=(UPat(Ops.TUPLE),), allow_any_len=True), lambda: True),
(UPat(Ops.TUPLE, dtypes.void), lambda: True),
(UPat(Ops.GETTUPLE, src=(UPat(Ops.FUNCTION, src=(UPat(Ops.TUPLE, name="t"),), allow_any_len=True),), name="g"), valid_gettuple),
(UPat(Ops.GETTUPLE, src=(UPat(Ops.TUPLE, name="t"),), name="g"), valid_gettuple),
# RETURNED is a placeholder for a buffer a call writes and returns: it has a size in the arg, no shape input
(UPat(Ops.RETURNED, src=(), name="x"), lambda x: isinstance(x.arg, ParamArg)),
# SPECIAL is index before index lowering. custom_kernel currently has this
(UPat(Ops.SPECIAL, src=(UPat.var("x", dtypes.weakint),), name="s"), lambda s,x: matches_dtype(x, s.dtype) and isinstance(s.arg, str)),
+1 -1
View File
@@ -309,7 +309,7 @@ symbolic = symbolic_simple+commutative+PatternMatcher([
((UPat.var("x", dtypes.weakint) + UPat.cvar("c")).cast(dtypes.sints, name="cast"), lambda x,c,cast:x.cast(cast.dtype)+cast.const_like(c.val)),
# only RANGE/IF/STORE/KERNEL have side effects
(UPat(Ops.AFTER, name="x"), lambda x: x.replace(src=(x.src[0],)+
tuple(dedup(flatten([(y,) if y.op in {Ops.RANGE, Ops.STORE, Ops.CALL, Ops.FUNCTION, Ops.BARRIER, Ops.END, Ops.LINEAR, Ops.STAGE}
tuple(dedup(flatten([(y,) if y.op in {Ops.RANGE, Ops.STORE, Ops.CALL, Ops.BARRIER, Ops.END, Ops.LINEAR, Ops.STAGE}
else y.src for y in x.src[1:]]))))),
# after/end with 1 src is just src[0]
(UPat((Ops.AFTER, Ops.END), src=(UPat.var("s"),)), lambda s: s),
+4 -4
View File
@@ -51,7 +51,7 @@ uops_colors = {Ops.LOAD: "#ffc0c0", Ops.STORE: "#87CEEB", Ops.CONST: "#e0e0e0",
Ops.WMMA: "#efefc0", Ops.UNSHARD: "#f6ccff", Ops.INS: "#eec4ff",
**{x:"#D8F9E4" for x in GroupOp.Movement}, **{x:"#ffffc0" for x in GroupOp.ALU}, Ops.THREEFRY:"#ffff80",
Ops.BUFFER: "#B0BDFF", Ops.GETADDR: "#9DB1F0", Ops.COPY: "#a040a0", Ops.CUSTOM_FUNCTION: "#bf71b6",
Ops.CALL: "#00B7C8", Ops.FUNCTION: "#C07788", Ops.PARAM: "#14686F", Ops.SOURCE: "#c0c0c0", Ops.BINARY: "#404040",
Ops.CALL: "#00B7C8", Ops.PARAM: "#14686F", Ops.RETURNED: "#C07788", Ops.SOURCE: "#c0c0c0", Ops.BINARY: "#404040",
Ops.LINEAR: "#7DF4FF",
Ops.ALLREDUCE: "#ff40a0", Ops.MSELECT: "#d040a0", Ops.MSTACK: "#d040a0", Ops.CONTIGUOUS: "#FFC14D",
Ops.STAGE: "#AC640D", Ops.REWRITE_ERROR: "#1a1b26", Ops.AFTER: "#8A7866", Ops.END: "#524C46"}
@@ -145,7 +145,7 @@ def uop_to_json(data:VizData, x:UOp) -> dict[int, dict]:
label += f"\n({multirange_str(rngs, color=True)})"
if u._shape is not None:
label += f"\n{shape_to_str(u.shape)}"
if u.op in {Ops.CALL, Ops.FUNCTION}:
if u.op is Ops.CALL and u.src[0].dtype is dtypes.void:
label += f"\n{u.src[0].key.hex()[:8]}\n{u.src[0].op}"
if u.op in {Ops.INDEX, Ops.STAGE}:
label += f"\n{u.render()}" if sum(len(s.toposort()) for s in u.src[1:]) < 30 else "\nINDEX TOO LARGE"
@@ -156,10 +156,10 @@ def uop_to_json(data:VizData, x:UOp) -> dict[int, dict]:
label += "\n"+' '.join([f"{range_str(s, color=True)}({s.vmax+1})" for s in trngs])
except Exception:
label += "\n<ISSUE GETTING LABEL>"
ref = data.ref_map.get(canonicalize_ast(u.src[0])) if u.op in {Ops.CALL, Ops.FUNCTION} else None
ref = data.ref_map.get(canonicalize_ast(u.src[0])) if u.op is Ops.CALL and u.src[0].dtype is dtypes.void else None
if ref is not None: label += f"\ncodegen@{fmt_colored(data.ctxs[ref]['name'])}"
# NOTE: kernel already has metadata in arg
if TRACEMETA >= 2 and u.metadata is not None and u.op not in {Ops.CALL, Ops.FUNCTION}: label += "\n"+str(u.metadata)
if TRACEMETA >= 2 and u.metadata is not None and u.op is not Ops.CALL: label += "\n"+str(u.metadata)
# limit SOURCE labels line count
if u.op is Ops.SOURCE and len(lines:=label.split("\n")) > 40:
label = "\n".join(lines[:30]) + "\n..."