mirror of
https://gitlvb.teallvbs.xyz/IQ.Lvbs/IQ.Pilot.git
synced 2026-09-03 20:33:42 +08:00
1783 lines
96 KiB
Python
1783 lines
96 KiB
Python
from __future__ import annotations
|
|
from typing import Any, Callable, cast, TYPE_CHECKING, Type, Sequence, Iterable, Final, Iterator
|
|
import sys, time, functools, itertools, math, operator, hashlib, os, types, pickle, pathlib, inspect, weakref, collections, struct
|
|
from dataclasses import dataclass, replace
|
|
from enum import Enum, auto
|
|
from tinygrad.uop import Ops, GroupOp
|
|
from tinygrad.dtype import ConstType, dtypes, DType, DTypeLike, truncate, least_upper_dtype, least_upper_float, Invalid, AddrSpace, strong_dtype
|
|
from tinygrad.dtype import PyConst, InvalidType, weak_dtype, bitcast
|
|
from tinygrad.device import Buffer, MultiBuffer, canonicalize_device, TinyELF
|
|
from tinygrad.helpers import ContextVar, all_int, prod, getenv, all_same, Context, partition, temp, unwrap, T, argfix, Metadata, flatten, TRACEMETA
|
|
from tinygrad.helpers import PROFILE, dedup, cdiv, cmod, floordiv, floormod, diskcache_put, to_function_name, cpu_profile, TracingKey
|
|
from tinygrad.helpers import VIZ, SPEC, CAPTURE_PROCESS_REPLAY, DISALLOW_BROADCAST, get_shape, fully_flatten, to_tuple
|
|
from tinygrad.helpers import colored, ansilen, printable, Target, is_image_shape
|
|
if TYPE_CHECKING:
|
|
from tinygrad.renderer import Estimates
|
|
|
|
class AxisType(Enum):
|
|
def __repr__(self): return str(self)
|
|
DEVICE = auto(); GLOBAL = auto(); WARP = auto(); LOCAL = auto(); WEAK = auto(); GROUP_REDUCE = auto(); REDUCE = auto(); UPCAST = auto() # noqa: E702
|
|
UNROLL = auto(); THREAD = auto(); PLACEHOLDER = auto(); LOOP = auto() # noqa: E702
|
|
|
|
@dataclass(frozen=True, order=True)
|
|
class ParamArg:
|
|
slot: int
|
|
dtype: DType
|
|
vmin_vmax: tuple[PyConst, PyConst]|None = None
|
|
multiple_of: int|None = None
|
|
name: str|None = None
|
|
addrspace: AddrSpace|None = AddrSpace.GLOBAL
|
|
axis: int|None = None
|
|
device: str|tuple[str, ...]|None = None
|
|
volatile: bool = False
|
|
def __repr__(self):
|
|
fields = (("vmin_vmax", None), ("multiple_of", None), ("name", None), ("addrspace", AddrSpace.GLOBAL), ("axis", None), ("device", None),
|
|
("volatile", False))
|
|
args = [repr(self.slot), repr(self.dtype)] + [f"{k}={v!r}" for k,default in fields if (v:=getattr(self, k)) != default]
|
|
return f"ParamArg({', '.join(args)})"
|
|
axis_letters = {AxisType.DEVICE: "d", AxisType.GLOBAL: "g", AxisType.THREAD: "t", AxisType.LOCAL: "l", AxisType.WARP: "w", AxisType.WEAK: "L",
|
|
AxisType.LOOP: "L", AxisType.UPCAST: "u", AxisType.GROUP_REDUCE: "G", AxisType.REDUCE: "R", AxisType.UNROLL: "r"}
|
|
axis_colors = {AxisType.DEVICE: "green", AxisType.GLOBAL: "blue", AxisType.THREAD: "BLUE", AxisType.LOCAL: "cyan", AxisType.WARP: "CYAN",
|
|
AxisType.WEAK: "WHITE", AxisType.LOOP: "WHITE", AxisType.UPCAST: "yellow", AxisType.GROUP_REDUCE: "RED", AxisType.REDUCE: "red",
|
|
AxisType.UNROLL: "magenta"}
|
|
|
|
# NOTE: LOCAL and GROUP_REDUCE have the same priority. the order here matters
|
|
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.SLICE: 2, 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])
|
|
|
|
# With True as the default, this matches the old symbolic behavior
|
|
def resolve(x:UOp|bool, default:bool=True):
|
|
if isinstance(x, bool): return x
|
|
assert x.dtype == dtypes.bool, "UOp in resolve must be bool"
|
|
# NOTE: generating the text for the exception is expensive, so we do this
|
|
return bool(sx.vmin) if (sx:=x.simplify()).vmin == sx.vmax else default
|
|
|
|
# smax/smin are replacements for max/min that preserve symbolic
|
|
def _suop(lst, uop_fxn, python_fxn):
|
|
uops, nums = partition(lst, lambda x: isinstance(x, UOp))
|
|
return ssimplify(functools.reduce(uop_fxn, uops + ([python_fxn(nums)] if nums else [])))
|
|
def smax(*lst) -> sint: return _suop(argfix(*lst), UOp.maximum, max)
|
|
def smin(*lst) -> sint: return _suop(argfix(*lst), UOp.minimum, min)
|
|
def srender(x:sint) -> str: return x.render() if isinstance(x, UOp) else str(x)
|
|
def _align_left(*shapes:tuple[sint, ...]) -> tuple[tuple[sint, ...], ...]:
|
|
max_dim = max(len(s) for s in shapes)
|
|
return tuple((1,)*(max_dim-len(s))+s for s in shapes)
|
|
def _broadcast_shape(*shapes:tuple[sint, ...]) -> tuple[sint, ...]:
|
|
if all_same(shapes): return shapes[0]
|
|
# per right-aligned dim: sizes of 1 broadcast to the others, which must all agree
|
|
ret = []
|
|
for sizes in zip(*_align_left(*shapes)):
|
|
if len(rest:=dedup([s for s in sizes if isinstance(s, UOp) or s != 1])) > 1:
|
|
raise IndexError(f"shape mismatch: objects cannot be broadcast to a single shape {shapes}")
|
|
ret.append(rest[0] if rest else 1)
|
|
return tuple(ret)
|
|
def broadcast_axes(src_shape:tuple[sint, ...], out_shape:tuple[sint, ...]) -> tuple[int, ...]:
|
|
# out axes that are added or expanded
|
|
if (nleft:=len(out_shape)-len(src_shape)) < 0: raise RuntimeError(f"cannot broadcast {src_shape} into {out_shape}")
|
|
return tuple(range(nleft)) + tuple(nleft+i for i,s in enumerate(src_shape) if resolve(s == 1, default=False) and resolve(out_shape[nleft+i] != 1))
|
|
|
|
def ssimplify(uop:sint): return uop.ssimplify() if isinstance(uop, UOp) else uop
|
|
def sym_infer(uop: UOp|int, var_vals: dict[str, int]) -> int: return uop.sym_infer(var_vals) if isinstance(uop, UOp) else uop
|
|
|
|
def range_str(u:UOp, color=False) -> str:
|
|
ret = '_'.join([str(x) if x >= 0 else "m"+str(-x) for x in u.arg[0:-1]])
|
|
return colored(ret, axis_colors[u.arg[-1]]) if color else ret
|
|
|
|
def multirange_str(rngs:Iterable[UOp], color=False, pad=None) -> str:
|
|
ret = ','.join([range_str(x, color=color) for x in sorted(rngs, key=lambda x: x.arg)])
|
|
if pad is not None: ret += " " * (pad-ansilen(ret))
|
|
return ret
|
|
|
|
def shape_to_shape_arg(arg:tuple[sint, ...]) -> UOp:
|
|
for x in arg:
|
|
if isinstance(x, UOp) and not dtypes.is_int(x.dtype): raise RuntimeError(f"shape must be int, got {x.dtype} in {arg}")
|
|
if len(arg) == 0: return UOp(Ops.STACK)
|
|
elif len(arg) == 1: return UOp.const(arg[0], dtypes.weakint)
|
|
else: return UOp(Ops.STACK, src=tuple(UOp.const(x) if isinstance(x, int) else x for x in arg))
|
|
|
|
def consumer_map_from_toposort(lst:Iterable[UOp]):
|
|
ret: dict[UOp, dict[UOp, None]] = {}
|
|
for u in lst:
|
|
ret[u] = {}
|
|
for s in u.src:
|
|
if s in ret: ret[s][u] = None
|
|
return ret
|
|
|
|
def promo_dtype(src:tuple[UOp,...]) -> DType:
|
|
dts = [x.dtype for x in src]
|
|
return dts[0] if all_same(dts) else least_upper_dtype(*dts)
|
|
|
|
def dtype_from_uop(op:Ops, src:tuple[UOp,...], arg:Any) -> DType|None:
|
|
# here are the dtype production rules, eventually this will go in UOp as a recursive property
|
|
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:
|
|
# always void
|
|
return dtypes.void
|
|
case Ops.CALL:
|
|
# a CALL of an opaque body is void, a CALL of an address can return a value
|
|
return dtypes.void if src[0].dtype is dtypes.void else None
|
|
case Ops.CUSTOM | Ops.CUSTOMI | Ops.PYLITERAL:
|
|
return None
|
|
case Ops.INS:
|
|
return None
|
|
case Ops.NOOP:
|
|
# NOOP can be void or carry any dtype (e.g. x.f(Ops.NOOP) or substitute base with NOOP)
|
|
return None
|
|
case Ops.INDEX:
|
|
# an image access is always float, no matter the storage dtype
|
|
# TODO: should there be a CAST so src[0].dtype just work?
|
|
if (b:=src[0]).op is Ops.PARAM and is_image_shape(b.shape): return dtypes.float
|
|
return b.dtype
|
|
case Ops.LOAD | Ops.UNSHARD | Ops.REDUCE | Ops.AFTER | Ops.RANGE | \
|
|
Ops.CONTIGUOUS | Ops.CONTIGUOUS_BACKWARD | Ops.COPY | Ops.STAGE | Ops.DETACH | \
|
|
Ops.MSTACK | Ops.MSELECT | Ops.ALLREDUCE | Ops.SPECIAL:
|
|
# pass through first
|
|
return src[0].dtype
|
|
case Ops.CMPLT | Ops.CMPNE | Ops.CMPEQ:
|
|
return dtypes.bool
|
|
case Ops.SIN | Ops.LOG2 | Ops.EXP2 | Ops.SQRT | Ops.RECIPROCAL:
|
|
return least_upper_float(src[0].dtype)
|
|
case Ops.WHERE:
|
|
if src[0].dtype != dtypes.bool: raise RuntimeError(f"where cond must be bool, got {src[0].dtype}")
|
|
return promo_dtype(src[1:])
|
|
case Ops.STACK:
|
|
if len(src) == 0: return dtypes.void
|
|
return promo_dtype(src)
|
|
case Ops.BIND:
|
|
assert src[0].dtype == src[1].dtype, f"bind dtype mismatch {src[0].dtype} != {src[1].dtype}"
|
|
return src[0].dtype
|
|
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.SHL | Ops.SHR:
|
|
if not all(dtypes.is_int(x.dtype) 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"
|
|
return arg.dtype
|
|
case Ops.BINARY:
|
|
return dtypes.uint8
|
|
case Ops.SLICE:
|
|
# TODO: slice just shouldn't exist
|
|
return None
|
|
case Ops.CAST | Ops.BITCAST:
|
|
assert isinstance(arg, DType), f"CAST/BITCAST arg must be DType, got {arg}"
|
|
return arg
|
|
case Ops.CONST:
|
|
# derived from the value. order matters: bool is an int subclass, ConstFloat is a float subclass
|
|
if isinstance(arg, InvalidType): return dtypes.bool # Invalid is always bool, the promo lattice bottom
|
|
if isinstance(arg, bool): return dtypes.bool
|
|
if isinstance(arg, int): return dtypes.weakint
|
|
if isinstance(arg, float): return dtypes.weakfloat
|
|
raise TypeError(f"no dtype for CONST with arg {arg}")
|
|
if op in GroupOp.Unary: return src[0].dtype
|
|
# NOTE: CMPLT, CMPNE, CMPEQ, WHERE, SHL, SHR are handled above
|
|
if op in GroupOp.Broadcastable: return promo_dtype(src)
|
|
if op in GroupOp.Movement: return src[0].dtype
|
|
raise RuntimeError(f"no dtype for {op} with arg {arg}")
|
|
|
|
class UOpMetaClass(type):
|
|
ucache:dict[tuple, weakref.ReferenceType[UOp]] = {}
|
|
def __call__(cls, op:Ops, dtype:DType|None=None, src:tuple[UOp,...]=tuple(), arg:Any=None, tag:Any=None,
|
|
metadata:tuple[Metadata,...]|None=None, _buffer:Buffer|None=None):
|
|
if dtype is None: dtype = dtype_from_uop(op, src, arg) or dtypes.void
|
|
# CONST derives its dtype by value only when the constructor omits one
|
|
# TODO: delete this once the dtype field is removed, for now it just re-implements spec.py
|
|
# an INDEX presents its access dtype, which a still-weak source matches up to weakness
|
|
if SPEC == 2 and op is not Ops.CONST and \
|
|
not any(s.base.is_invalid for s in src) and (expected_dtype:=dtype_from_uop(op, src, arg)) is not None and expected_dtype != dtype and \
|
|
not (op is Ops.INDEX and weak_dtype(expected_dtype) == weak_dtype(dtype)):
|
|
raise RuntimeError(f"bad dtype {dtype}, expected {expected_dtype} on {op}")
|
|
if (wret:=UOpMetaClass.ucache.get(key:=(op, dtype, src, arg, tag), None)) is not None and (ret:=wret()) is not None: return ret
|
|
UOpMetaClass.ucache[key] = weakref.ref(created:=super().__call__(*key))
|
|
if metadata is not None: all_metadata[created] = metadata
|
|
# NOTE: this value is set by pickle when pickling a realized tensor
|
|
if _buffer is not None:
|
|
assert op is Ops.BUFFER, f"trying to set Buffer {_buffer} for {op}"
|
|
buffers[created] = _buffer
|
|
if SPEC > 1:
|
|
from tinygrad.uop.spec import spec_full, test_pyrender
|
|
if SPEC > 2:
|
|
# SPEC=3 checks the shape
|
|
_ = created._shape
|
|
if SPEC > 3:
|
|
test_pyrender(created)
|
|
with Context(CHECK_OOB=0): fret = cast(bool|None, spec_full.rewrite(created))
|
|
if fret is not True: raise RuntimeError(f"SPEC ISSUE {fret}: {created}")
|
|
return created
|
|
|
|
# some uops map to other stuff
|
|
buffers:weakref.WeakKeyDictionary[UOp, Buffer|MultiBuffer] = weakref.WeakKeyDictionary() # this maps BUFFER/SLICE uops to their device Buffers
|
|
all_metadata:weakref.WeakKeyDictionary[UOp, tuple[Metadata, ...]] = weakref.WeakKeyDictionary() # TODO: should this be here?
|
|
|
|
# recursive_property replaces functools.cached_property in recursive UOp functions to prevent RecursionError
|
|
class recursive_property(property):
|
|
def __init__(self, fxn):
|
|
self.fxn = fxn
|
|
self.nm = "_RECURSIVE_PROPERTY_"+fxn.__name__
|
|
self.__doc__ = fxn.__doc__
|
|
def __get__(self, x:UOp|None, owner=None):
|
|
if x is None: return self
|
|
if self.nm in x.__dict__: return x.__dict__[self.nm]
|
|
for node in x.toposort(gate=lambda node: self.nm not in node.__dict__): node.__dict__[self.nm] = self.fxn(node)
|
|
return x.__dict__[self.nm]
|
|
|
|
# we import this late so we can use resolve/smax in mixins
|
|
from tinygrad.mixin.op import OpMixin
|
|
from tinygrad.mixin.rand import RandMixin
|
|
|
|
# NOTE: this should be frozen, but frozen is slower
|
|
@dataclass(eq=False, slots=True)
|
|
class UOp(RandMixin, metaclass=UOpMetaClass):
|
|
op:Ops
|
|
dtype:DType = dtypes.void
|
|
src:tuple[UOp, ...] = tuple()
|
|
arg:Any = None
|
|
tag:Any = None
|
|
def __del__(self):
|
|
if Ops is not None and self.op is Ops.BUFFER and (buffer:=buffers.get(self)) is not None: buffer.ref(-1)
|
|
try: del UOpMetaClass.ucache[(self.op, self.dtype, self.src, self.arg, self.tag)]
|
|
except AttributeError: pass
|
|
def __reduce__(self):
|
|
args = [self.op, self.dtype, self.src, self.arg, self.tag, self.metadata]
|
|
if self.op is Ops.BUFFER and self.realized is not None: args.append(self.realized)
|
|
return UOp, tuple(args)
|
|
def replace(self, **kwargs) -> UOp:
|
|
new_args = (kwargs.pop("op", self.op), kwargs.pop("dtype", self.dtype), kwargs.pop("src", self.src),
|
|
kwargs.pop("arg", self.arg), kwargs.pop("tag", self.tag))
|
|
assert len(kwargs) == 0, f"unused kwargs in replace {list(kwargs)}"
|
|
if (self.op, self.dtype, self.src, self.arg, self.tag) == new_args: return self
|
|
return UOp(*new_args)
|
|
def rtag(self, tag=True): return self.replace(tag=tag)
|
|
@property
|
|
def val(self):
|
|
assert self.op is Ops.CONST, f"val is only for CONST, got {self.op}"
|
|
return self.arg
|
|
@property
|
|
def is_invalid(self) -> bool: return self.op is Ops.CONST and self.val is Invalid
|
|
@recursive_property
|
|
def key(self) -> bytes:
|
|
return hashlib.sha256(str((self.op, self.dtype, self.arg)).encode() + b"".join([s.key for s in self.src])).digest()
|
|
def __repr__(self):
|
|
from tinygrad.uop.render import pretty_print
|
|
return pretty_print(self)
|
|
def argstr(self):
|
|
if self.op is Ops.REDUCE: return f'({", ".join(map(str, self.arg))})'
|
|
return repr(self.arg)
|
|
def tagstr(self): return f", tag={self.tag}" if self.tag is not None else ""
|
|
|
|
def f(self, op, **kwargs): return UOp(op, dtype=kwargs.pop("dtype", self.dtype), src=(self,), **kwargs)
|
|
|
|
@functools.cached_property
|
|
def backward_slice(self:UOp) -> dict[UOp, None]:
|
|
res: dict[UOp, None] = self.toposort()
|
|
res.pop(self)
|
|
return res
|
|
|
|
@property
|
|
def backward_slice_with_self(self:UOp) -> dict[UOp, None]: return {self:None, **self.backward_slice}
|
|
def op_in_backward_slice_with_self(self, *ops:Ops) -> bool:
|
|
# Check self first, then iterate backward_slice (avoids creating intermediate dict)
|
|
return self.op in ops or any(x.op in ops for x in self.backward_slice)
|
|
|
|
@recursive_property
|
|
def _bool_slice(self) -> frozenset[UOp]: return frozenset().union(*[s.bool_slice for s in self.src])
|
|
# NOTE: self is added outside the cache, a cached self-reference is a cycle the refcounter can't free
|
|
@property
|
|
def bool_slice(self) -> frozenset[UOp]: return self._bool_slice | {self} if self.dtype is dtypes.bool else self._bool_slice
|
|
|
|
def toposort(self, gate:Callable|None=None, enter_calls=True) -> dict[UOp, None]:
|
|
cache: dict[UOp, None] = {}
|
|
stack: list[tuple[UOp, bool]] = [(self, False)] # each stack entry is (node, visited_flag)
|
|
while stack:
|
|
node, visited = stack.pop()
|
|
if node in cache: continue
|
|
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:]):
|
|
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
|
|
|
|
def topovisit(self, visitor:Callable[[UOp], T], cache:dict[UOp, T]) -> T:
|
|
# NOTE: this shares a lot of code with toposort
|
|
stack: list[tuple[UOp, bool]] = [(self, False)]
|
|
while stack:
|
|
node, visited = stack.pop()
|
|
if node in cache: continue
|
|
if not visited:
|
|
stack.append((node, True))
|
|
for s in reversed(node.src): stack.append((s, False))
|
|
else: cache[node] = visitor(node)
|
|
return cache[self]
|
|
|
|
@functools.cached_property
|
|
def tuplize(self:UOp) -> tuple:
|
|
return (self.op.value, self.arg, self.dtype,)+tuple([x.tuplize for x in self.src])
|
|
|
|
# *** uop shape stuff ***
|
|
|
|
@recursive_property
|
|
def _shape(self) -> tuple[sint, ...]|None:
|
|
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:
|
|
return None
|
|
|
|
# a void CALL has no shape, the return value of a CALL has the shape of its dtype
|
|
case Ops.CALL:
|
|
return None if self.dtype is dtypes.void else ()
|
|
|
|
# INS shape is always scalar, vector width is in the instruction encoding
|
|
case Ops.INS:
|
|
if self.dtype is dtypes.void: return None
|
|
return ()
|
|
|
|
# special (terrible) case for RESHAPE on NOOP
|
|
case Ops.RESHAPE:
|
|
if self.src[0].op is Ops.NOOP: return self.marg
|
|
|
|
# hacks for NOOP
|
|
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))
|
|
return tuple(shp) + self.src[0].shape[len(self.src[1:]):]
|
|
|
|
case Ops.STACK:
|
|
if len(self.src) == 0: return ()
|
|
return (len(self.src),) + self.src[0].shape
|
|
case Ops.CONST:
|
|
return ()
|
|
|
|
# some ops init the shape
|
|
case Ops.GETADDR: return ()
|
|
case Ops.BIND | Ops.RANGE | Ops.SPECIAL: return ()
|
|
case Ops.BINARY: return (len(self.arg),)
|
|
case Ops.BUFFER:
|
|
if len(self.src): return self.src[0].as_shape
|
|
return ()
|
|
case Ops.SLICE:
|
|
# HACK: SLICE is used inside kernels, so we set the shape to () if it's on an INDEX
|
|
if self.src[0].op is Ops.INDEX: return ()
|
|
return (self.arg,)
|
|
case Ops.CUSTOM | Ops.CUSTOMI:
|
|
if self.dtype is dtypes.void: return None
|
|
input_shapes = [x._shape for x in self.src if x._shape is not None]
|
|
return _broadcast_shape(*input_shapes) if input_shapes else None
|
|
case Ops.CUSTOM_FUNCTION: return None
|
|
case Ops.PYLITERAL: return None
|
|
case Ops.STAGE:
|
|
# STAGE adds the existing shape to the front, opposite of INDEX
|
|
return tuple([int(r.vmax+1) for r in self.src[1:]])+self.src[0].shape
|
|
|
|
# param has shape as the only arg
|
|
case Ops.PARAM:
|
|
return self.src[0].as_shape
|
|
|
|
# wmma output shape = accumulator shape (src[2])
|
|
case Ops.WMMA:
|
|
wmma_b = _broadcast_shape(self.src[0].shape[:-1], self.src[1].shape[:-1], self.src[2].shape[:-1])
|
|
return wmma_b + (self.src[2].shape[-1],)
|
|
|
|
# passthrough ops
|
|
case Ops.MSTACK | Ops.MSELECT | Ops.DETACH | Ops.CONTIGUOUS | Ops.CONTIGUOUS_BACKWARD | Ops.AFTER | Ops.LOAD | \
|
|
Ops.COPY | Ops.ALLREDUCE | Ops.STORE | Ops.END:
|
|
return self.src[0]._shape
|
|
|
|
case Ops.BITCAST:
|
|
ps = self.src[0]._shape
|
|
if ps is None: return None
|
|
if (output_sz:=self.dtype.itemsize) != (input_sz:=self.src[0].dtype.itemsize) and len(ps) > 0:
|
|
if isinstance(ps[-1], int) and (ps[-1]*input_sz) % output_sz: raise RuntimeError("unsupported size in bitcast")
|
|
return ps[:-1]+(ssimplify((ps[-1]*input_sz) // output_sz),)
|
|
return ps
|
|
|
|
# UNSHARD marker has no shape
|
|
case Ops.UNSHARD if len(self.src) == 0: return None
|
|
|
|
# movement ops change the shape
|
|
# NOTE: ssimplify is required because the shape needs to be canonical for broadcasting and same shape checking
|
|
if self.op in GroupOp.Movement.union({Ops.UNSHARD, Ops.REDUCE}):
|
|
ps = self.src[0]._shape
|
|
if ps is None: raise RuntimeError(f"movement op {self.op} requires shape, {self.src[0].op} doesn't have one")
|
|
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}")
|
|
return self.marg
|
|
case Ops.EXPAND:
|
|
return tuple(self.marg) + ps
|
|
case Ops.PERMUTE:
|
|
if sorted(self.marg) != list(range(len(ps))): raise ValueError(f"invalid permutation {self.marg} of len {len(ps)}")
|
|
return tuple(ps[i] for i in self.marg)
|
|
case Ops.PAD:
|
|
# TODO: why do i need resolve here?
|
|
if len(ps) != len(self.marg) or not all(resolve(sz>=0) and resolve(0<=o) and resolve(o+s<=sz) for s,(o,sz) in zip(ps, self.marg)):
|
|
raise ValueError(f"invalid pad {self.marg} for {ps}")
|
|
return tuple(ssimplify(sz) for _,sz in self.marg)
|
|
case Ops.SHRINK:
|
|
# TODO: why do i need resolve here?
|
|
if len(ps) != len(self.marg) or not all(resolve(0<=o) and resolve(sz>=0) and resolve(o+sz<=s) for s,(o,sz) in zip(ps, self.marg)):
|
|
raise ValueError(f"invalid shrink {self.marg} for {ps}")
|
|
return tuple(ssimplify(sz) for _,sz in self.marg)
|
|
case Ops.FLIP:
|
|
if len(ps) != len(self.marg) or not all(isinstance(x, bool) for x in self.marg): raise ValueError(f"bad flip on {ps}, {self.marg}")
|
|
return ps
|
|
case Ops.UNSHARD: return tuple(s*(int(self.src[1:][self.arg.index(a)].vmax)+1) if a in self.arg else s for a,s in enumerate(ps))
|
|
case Ops.REDUCE:
|
|
num_axes = self.arg[1]
|
|
if not isinstance(num_axes, int) or num_axes < 0 or num_axes > len(ps):
|
|
raise ValueError(f"invalid type for axis: {num_axes}")
|
|
return ps[num_axes:]
|
|
|
|
if self.op in GroupOp.Unary.union({Ops.CAST}):
|
|
assert len(self.src) == 1, "unary ops must have 1 src"
|
|
return self.src[0]._shape
|
|
|
|
# elementwise ops keep the shape the same. all inputs with shape must match
|
|
if self.op in GroupOp.Broadcastable:
|
|
input_shapes = [x._shape for x in self.src]
|
|
assert len(self.src) > 0 and all(x is not None for x in input_shapes), f"None input shape not supported for {self.op}"
|
|
if DISALLOW_BROADCAST and not all_same(input_shapes):
|
|
raise RuntimeError(f"shape mismatch at {self.op}: {input_shapes} {[x.op for x in self.src]}")
|
|
# broadcasting lives in _shape property now
|
|
return _broadcast_shape(*input_shapes)
|
|
|
|
# all Ops must be explicitly handled
|
|
raise NotImplementedError(f"no shape handling for {self.op} with {self.dtype}")
|
|
|
|
@property
|
|
def shape(self) -> tuple[sint, ...]:
|
|
if (ret:=self._shape) is None: raise RuntimeError(f"shape requested, but {self.op} doesn't have a shape")
|
|
return ret
|
|
|
|
@property
|
|
def max_shape(self) -> tuple[int, ...]: return to_max_shape(self.shape)
|
|
def max_numel(self) -> int: return prod(self.max_shape)
|
|
|
|
@property
|
|
def shard_shape(self) -> tuple[sint, ...]:
|
|
if not isinstance(self.device, tuple) or self.axis is None: return self.shape
|
|
dcount = int(self.src[1].vmax)+1 if self.op is Ops.UNSHARD else len(self.device)
|
|
return tuple(x//dcount if i == self.axis else x for i,x in enumerate(self.shape))
|
|
|
|
@property
|
|
def max_shard_shape(self) -> tuple[int, ...]: return to_max_shape(self.shard_shape)
|
|
|
|
@functools.cached_property
|
|
def ended_ranges(self) -> tuple[UOp, ...]:
|
|
if self.op in range_start: return self.src[range_start[self.op]:]
|
|
if self.op is Ops.AFTER: return tuple(flatten([x.ended_ranges for x in self.src[1:]]))
|
|
# UNSHARD ends the DEVICE range: its src is per-device index math, the device axis is carried by the axis metadata
|
|
if self.op is Ops.UNSHARD: return self.src[1:]
|
|
return ()
|
|
|
|
# determine what ranges this is in
|
|
@recursive_property
|
|
def _ranges(self) -> dict[UOp, None]:
|
|
ret: dict[UOp, None] = {}
|
|
for s in self.src: ret.update(s.ranges)
|
|
for er in self.ended_ranges:
|
|
if er.op is Ops.RANGE:
|
|
# if it's a single RANGE, we don't flow through it.
|
|
ret.pop(er, None)
|
|
else:
|
|
# if it's not a RANGE, we include all ranges in srcs.
|
|
# technically we shouldn't flow through these ranges either, but this is pre pm_add_control_flow so it's the same.
|
|
for s in er.ranges: ret.pop(s, None)
|
|
return ret
|
|
|
|
@property
|
|
def ranges(self) -> dict[UOp, None]:
|
|
if self.op is Ops.RANGE: return {self:None} | self._ranges
|
|
return self._ranges
|
|
|
|
# *** uop evaluation ***
|
|
|
|
def simplify(self, tracked=False):
|
|
if self.op is Ops.CONST: return self
|
|
if self.op is Ops.SINK and all(s.op is Ops.CONST or (s.op is Ops.STACK and len(s.src) == 0) for s in self.src): return self
|
|
# late import!
|
|
from tinygrad.uop.symbolic import symbolic, pm_fold_cast_const
|
|
with Context(TRACK_MATCH_STATS=0 if not tracked else TRACK_MATCH_STATS.value):
|
|
return graph_rewrite(self, symbolic+pm_fold_cast_const, name="simplify")
|
|
def ssimplify(self) -> UOp|ConstType: return ret.val if (ret:=self.simplify()).op is Ops.CONST else ret
|
|
def _eval(self, dtype, expected_type:Type[T]) -> T:
|
|
assert self.dtype in dtype, f"eval with wrong dtype {self}"
|
|
vmin, vmax = (simple_self:=self.simplify())._min_max
|
|
if vmin != vmax: raise ValueError(f"eval failed to be a single number, range is {vmin} to {vmax} in {simple_self.render()}")
|
|
assert isinstance(vmin, expected_type), f"vmin is wrong dtype {type(vmin)} != {expected_type}"
|
|
return vmin
|
|
def __bool__(self): return self._eval((dtypes.bool,), bool)
|
|
def __int__(self): return self._eval(dtypes.ints+(dtypes.weakint,), int)
|
|
def __float__(self): return float(self._eval(dtypes.floats+(dtypes.weakfloat,), float))
|
|
def substitute(self, dvars:dict[UOp, UOp], name:str|None=None, extra_pm:PatternMatcher|None=None, walk:bool=False, enter_calls:bool=False):
|
|
dvars = {k:v for k,v in dvars.items() if k is not v}
|
|
if len(dvars) == 0: return self
|
|
with Context(TRACK_MATCH_STATS=(0 if name is None else TRACK_MATCH_STATS.value)):
|
|
return graph_rewrite(self, (extra_pm+_substitute) if extra_pm is not None else _substitute, dvars,
|
|
bottom_up=True, walk=walk, enter_calls=enter_calls, name=name)
|
|
# NOTE: this is not called by Tensor slice (Tensor handles UOps directly), but satisfies SupportsIndex for type checking
|
|
def __index__(self): return self.__int__()
|
|
|
|
# *** uop tracing stuff ***
|
|
|
|
@recursive_property
|
|
def trace_num(self):
|
|
num = next(ucount)
|
|
uop_fields[num] = (self.op, self.dtype, tuple(s.trace_num for s in self.src), self.arg, self.tag)+((self.metadata,) if TRACEMETA>=2 else ())
|
|
return num
|
|
|
|
# *** uop syntactic sugar ***
|
|
|
|
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)
|
|
def group(*srcs:UOp|None): # 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]))
|
|
def index(self, *srcs:UOp|int|None, **kwargs):
|
|
new_srcs: list[UOp] = [UOp.const(x) if isinstance(x, int) else x for x in srcs if x is not None]
|
|
if len(new_srcs) == 1 and new_srcs[0].op is Ops.CONST and self.op is Ops.STACK: return self.src[new_srcs[0].val]
|
|
return UOp(Ops.INDEX, src=(self,)+tuple(new_srcs), **kwargs)
|
|
def __getitem__(self, idx):
|
|
# buffers index into INDEX UOps (scalar lookup); everything else uses the shared mixin view path
|
|
if self.addrspace in (None, AddrSpace.ALU) or self.device is not None: return super(UOp, self).__getitem__(idx)
|
|
idx = self._normalize_indices(list(argfix(idx)))
|
|
if len(slice_idx:=[i for i,x in enumerate(idx) if isinstance(x, slice)]):
|
|
# apply SHRINK for slices that aren't the full range
|
|
bounds = tuple((s.start or 0, s.stop if s.stop is not None else self.shape[i]) if isinstance(s, slice) else (0, self.shape[i])
|
|
for i, s in enumerate(idx))
|
|
src = self.shrink(bounds)
|
|
non_slice_args = [x for x in idx if not isinstance(x, slice)]
|
|
if not non_slice_args: return src # all dims are slices, no indexing needed
|
|
perm = src.permute(tuple([i for i in range(src.ndim) if i not in slice_idx] + slice_idx))
|
|
return perm.index(*non_slice_args)
|
|
return self.index(*idx)
|
|
@property
|
|
def _uop(self) -> UOp: return self
|
|
@classmethod
|
|
def _wrap_uop(cls, u:UOp) -> UOp: return u
|
|
def const_like(self, b:ConstLike, dtype:DType|None=None):
|
|
ret = UOp.const(b, dtype or self.dtype)
|
|
return ret._mop(Ops.EXPAND, arg=self._shape) if self._shape and ret._shape != self._shape else ret
|
|
def vconst_like(self, b:ConstLike, dtype:DType|None=None):
|
|
# for use after movement ops have been removed
|
|
return UOp.const(b, dtype or self.dtype).broadcast(self.max_numel())
|
|
def ufix(self, x):
|
|
if isinstance(x, UOp): return x
|
|
return UOp.const(x)
|
|
def broadcast(self, count:int):
|
|
if count == 1: return self
|
|
return UOp(Ops.STACK, src=(self,)*count)
|
|
def load(self, *src:UOp, **kwargs): return UOp(Ops.LOAD, src=(self,)+src, **kwargs)
|
|
def store(self, src:UOp|ConstType, gate:UOp|None=None, **kwargs):
|
|
srcs = (self, self.const_like(src) if not isinstance(src, UOp) else src) + ((gate,) if gate is not None else ())
|
|
return UOp(Ops.STORE, src=srcs, **kwargs)
|
|
def end(self, *src:UOp): return UOp(Ops.END, src=(self,)+src) if len(src) else self
|
|
def after(self, *src:UOp, **kwargs): return UOp(Ops.AFTER, src=(self,)+src, **kwargs) if len(src) else self
|
|
@property
|
|
def without_after(self) -> UOp: return self.src[0] if self.op is Ops.AFTER else self
|
|
def barrier(self, *src:UOp): return UOp(Ops.BARRIER, src=(self,)+src)
|
|
def ins(self, arg, **kwargs): return UOp(Ops.INS, kwargs.pop("dtype", self.dtype), kwargs.pop("src", self.src), arg, kwargs.pop("tag", self.tag))
|
|
def contract(self, *rngs:UOp):
|
|
assert all(x.arg[-1] == AxisType.UPCAST for x in rngs), "all contract ranges must be upcast"
|
|
return UOp.stack(*[self.substitute(dict(zip(rngs, [r.const_like(i) for r,i in zip(rngs, idx)])))
|
|
for idx in itertools.product(*[range(int(r.vmax)+1) for r in rngs])])
|
|
def alu(self, op, *src:UOp, **kwargs): return UOp(op, src=(self, *src), **kwargs)
|
|
@staticmethod
|
|
def const(b:ConstLike, dtype:DType|None=None):
|
|
if dtype is None or b is Invalid: dtype = dtypes.from_py(b)
|
|
if isinstance(b, UOp): return b.cast(dtype)
|
|
# NOTE: it always has to be STACK now, even if they are all the same
|
|
if isinstance(b, tuple): return UOp.stack(*[UOp.const(c, dtype) for c in b])
|
|
return UOp(Ops.CONST, dtype, arg=dtype.const(b), src=())
|
|
@staticmethod
|
|
def range(end:sint, axis_id, axis_type=AxisType.WEAK, *arg, dtype=dtypes.weakint, src=(), **kwargs):
|
|
return UOp(Ops.RANGE, src=(sint_to_uop(end, dtype),)+src, arg=(axis_id, axis_type)+arg, **kwargs)
|
|
@staticmethod
|
|
def loop(axis_id:int, *arg): return UOp(Ops.RANGE, src=(UOp(Ops.NOOP),), arg=(axis_id, AxisType.WEAK)+arg)
|
|
@staticmethod
|
|
def special(end:sint, name:str, dtype=dtypes.weakint): return UOp(Ops.SPECIAL, src=(sint_to_uop(end, dtype),), arg=name)
|
|
@staticmethod
|
|
def wmma(a:UOp, b:UOp, acc:UOp, dims:tuple[int, int, int], device:str, threads:int, tc_upcast_axes=None):
|
|
# dtype_in is stored in the arg (not derived from src[0].dtype) because bitcast rewrites change src dtypes
|
|
return UOp(Ops.WMMA, src=(a, b, acc), arg=(dims, a.dtype, device, threads, tc_upcast_axes))
|
|
def _rop(self, op:Ops, axis:tuple[int, ...]):
|
|
# NOTE: we don't allow reduce on 1s axis
|
|
axis = tuple(sorted(axis))
|
|
reduce_axis = tuple(x for x in axis if resolve(self.shape[x] != 1))
|
|
if not len(reduce_axis):
|
|
return self.reshape(tuple(s for i,s in enumerate(self.shape) if i not in axis))
|
|
# permute so reduced axes are at the front
|
|
perm = reduce_axis + tuple(i for i in range(len(self.shape)) if i not in reduce_axis)
|
|
ret = UOp(Ops.REDUCE, src=(self.permute(perm),), arg=(op, len(reduce_axis)))
|
|
return ret.reshape(tuple(s for i,s in enumerate(self.shape) if i not in axis)) if axis != reduce_axis else ret
|
|
@staticmethod
|
|
def invalid(): return UOp.const(Invalid)
|
|
def valid(self, cond):
|
|
return cond.where(self, self.const_like(Invalid))
|
|
def get_idx(self) -> UOp:
|
|
if self.op is Ops.STACK: return UOp.stack(*(x.get_idx() for x in self.src))
|
|
return self.src[1] if self.op is Ops.WHERE and self.src[2].is_invalid else self
|
|
def get_valid(self) -> UOp:
|
|
if self.op is Ops.STACK: return UOp.stack(*(x.get_valid() for x in self.src))
|
|
return self.src[0] if self.op is Ops.WHERE and self.src[2].is_invalid else UOp.const(not self.is_invalid)
|
|
def reduce(self, *src:UOp, **kwargs):
|
|
arg = kwargs.pop('arg', None)
|
|
if isinstance(arg, Ops): arg = (arg, 0)
|
|
return UOp(Ops.REDUCE, src=(self,)+src, arg=arg, **kwargs)
|
|
|
|
def bufferize(self, *args, **kwargs): return UOp(Ops.STAGE, src=(self,)+args, **kwargs)
|
|
def allreduce(self, op, device:str|tuple[str, ...]):
|
|
assert isinstance(self.device, tuple), f"allreduce must be on tuple {self.device} isn't"
|
|
return UOp(Ops.ALLREDUCE, src=(self,), arg=(op, device))
|
|
def overflows(self, dtype:DType) -> bool: return self.vmin < dtype.min or dtype.max < self.vmax
|
|
|
|
def split_uop(self:UOp, sep:Ops) -> Iterator[UOp]:
|
|
if self.op is sep:
|
|
for s in self.src: yield from s.split_uop(sep)
|
|
else: yield self
|
|
|
|
# *** multi-device helpers ***
|
|
|
|
def unshard(self, axis:int|tuple[int, ...]|None, device_range:UOp|tuple[UOp, ...]|None=None):
|
|
assert axis is not None, "multi None is no longer supported"
|
|
# an UNSHARD carries the value and one sharding range per sharded axis (arg is the tuple of sharded axes,
|
|
# sorted). the single-axis axis form defaults the range to a DEVICE range over the devices; a range need not
|
|
# be DEVICE, e.g. a LOCAL range shards a kernel tile into per-thread fragments
|
|
if isinstance(axis, int): axis = (axis,)
|
|
if device_range is None:
|
|
assert isinstance(self.device, tuple), f"multi device must be tuple, {self.device} isn't"
|
|
device_range = (UOp.range(len(self.device), -1, AxisType.DEVICE),)
|
|
if isinstance(device_range, UOp): device_range = (device_range,)
|
|
assert isinstance(device_range, tuple) and len(axis) == len(device_range) and len(set(axis)) == len(axis)
|
|
axis, device_range = map(tuple, zip(*sorted(zip(axis, device_range))))
|
|
return UOp(Ops.UNSHARD, src=(self, *device_range), arg=axis)
|
|
|
|
@property
|
|
def sharding(self) -> tuple[tuple[int, UOp], ...]:
|
|
"""(axis, RANGE) pairs this value is sharded over (the source of truth for shard bounds/counts)."""
|
|
return tuple(zip(self.arg, self.src[1:])) if self.op is Ops.UNSHARD else ()
|
|
|
|
@property
|
|
def bounds(self):
|
|
if self.axis is None: raise RuntimeError("bounds is not defined when axis is None")
|
|
dcount = int(self.src[1].vmax)+1 if self.op is Ops.UNSHARD else len(self.device)
|
|
return tuple(itertools.pairwise(itertools.accumulate([self.src[0].shape[self.axis] for _ in range(dcount)], initial=0)))
|
|
|
|
@functools.cached_property
|
|
def axis(self) -> int|None:
|
|
# COPY removes axis. TODO: add more tests for this, and consider MSELECT/MSTACK
|
|
if self.op is Ops.COPY: return None
|
|
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 self.arg.axis
|
|
# 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}):
|
|
return axes[-1] if (axes := dedup([x.axis+len(self.shape)-len(x.shape) for x in self.src if x.axis is not None])) else None
|
|
if len(self.src) == 0: return None
|
|
src_axis = self.src[0].axis
|
|
if self.op is Ops.SHRINK and src_axis is not None and self.marg[src_axis] != (0, self.src[0].shape[src_axis]):
|
|
return None # SHRINK will remove the sharding if it's on axis
|
|
if self.op is Ops.REDUCE:
|
|
if src_axis is None: return None
|
|
if src_axis < self.arg[1]: return None
|
|
return src_axis - self.arg[1]
|
|
if self.op is Ops.RESHAPE:
|
|
if src_axis is None: return None
|
|
arg_acc:list[sint] = [ssimplify(x) for x in itertools.accumulate(self.marg, operator.mul, initial=1)]
|
|
# new_axis is the last one that preserves prod(prior to new_axis) and must not move items between shards
|
|
target = ssimplify(prod(self.src[0].shape[:src_axis]))
|
|
if target not in arg_acc: raise RuntimeError(f"reshape {self.src[0].shape} -> {self.shape} moved items between shards")
|
|
new_axis = len(arg_acc) - arg_acc[::-1].index(target) - 1
|
|
dcount = len(self.device) if isinstance(self.device, tuple) else \
|
|
int(next(u.src[1] for u in self.src[0].toposort() if u.op is Ops.UNSHARD).vmax)+1
|
|
if self.shape[new_axis] % dcount != 0: raise RuntimeError(f"reshape {self.src[0].shape} -> {self.shape} moved items between shards")
|
|
return new_axis
|
|
if self.op is Ops.PERMUTE: return self.marg.index(src_axis) if src_axis is not None else None
|
|
if self.op is Ops.EXPAND: return src_axis + len(self.marg) if src_axis is not None else None
|
|
return src_axis
|
|
|
|
def _unshard(self, axis:int) -> UOp:
|
|
bsz, dcount = self.shape[axis], len(self.device)
|
|
dnum = UOp.range(dcount, -1, AxisType.DEVICE)
|
|
return self.pad(tuple((0,0) if a != axis else (bsz*dnum, bsz*(dcount-1) - bsz*dnum) for a in range(len(self.shape))))
|
|
|
|
def _shard(self, axis:int, rng:UOp) -> UOp:
|
|
if len(self.shape) == 0: return self # scalars broadcast, no sharding needed
|
|
dcount = int(rng.vmax)+1
|
|
if self.shape[axis] % dcount != 0: raise RuntimeError(f"multi axis uneven: {self.shape[axis]=} {axis=} {dcount=}")
|
|
sz = self.shape[axis] // dcount
|
|
return self.shrink(tuple((0,s) if i != axis else (rng*sz,rng*sz+sz) for i,s in enumerate(self.shape)))
|
|
def shard(self, devices:tuple[str, ...], axis:int|None=None) -> UOp:
|
|
copied = self.copy_to_device(devices)
|
|
return copied if axis is None else copied._shard(axis, UOp.range(len(devices), -1, AxisType.DEVICE)).unshard(axis)
|
|
|
|
def copy_to_device(self, device:str|tuple[str, ...], arg=None):
|
|
assert arg is None or isinstance(self.device, tuple)
|
|
inp = self if arg is None else UOp(Ops.MSELECT, src=(self,), arg=arg)
|
|
if inp.dtype in dtypes.weaks: raise RuntimeError(f"cannot create storage for weak dtype {inp.dtype}")
|
|
return UOp(Ops.COPY, src=(inp,), arg=device)
|
|
def mselect(self, arg:int) -> UOp: return UOp(Ops.MSELECT, src=(self,), arg=arg)
|
|
def mstack(self, *srcs: UOp) -> UOp: return UOp(Ops.MSTACK, src=(self,)+srcs) if len(srcs) else self
|
|
@property
|
|
def metadata(self) -> tuple[Metadata, ...]|None: return all_metadata.get(self, None)
|
|
|
|
# *** uop movement ops ***
|
|
|
|
@property
|
|
def base(self) -> UOp:
|
|
if self.op in GroupOp.Movement: return self.src[0].base
|
|
if self.op is Ops.DETACH: return self.src[0].base # DETACH can't change base
|
|
return self
|
|
|
|
# cached property here makes external_uop_gc fail, why?
|
|
@property
|
|
def as_shape(self) -> tuple[sint, ...]:
|
|
if self.op is Ops.CONST: return (self.val,)
|
|
if self.op is not Ops.STACK: return (ssimplify(self),)
|
|
return tuple(s.val if s.op is Ops.CONST else ssimplify(s) for s in self.src)
|
|
|
|
@functools.cached_property
|
|
def marg(self):
|
|
match self.op:
|
|
case Ops.RESHAPE | Ops.EXPAND: return self.src[1].as_shape
|
|
case Ops.PAD | Ops.SHRINK: return tuple(zip(self.src[1].as_shape, self.src[2].as_shape))
|
|
case Ops.PERMUTE | Ops.FLIP: return self.arg
|
|
case _: raise RuntimeError(f"{self.op} is not a MovementOp")
|
|
|
|
def _mop(self, op:Ops, arg) -> UOp:
|
|
# early NOOP
|
|
if op is Ops.EXPAND and len(arg) == 0: return self
|
|
if op in {Ops.SHRINK, Ops.PAD} and len(arg) == 0:
|
|
assert len(self.shape) == 0, "0 len arg only valid on zero length shape"
|
|
return self
|
|
match op:
|
|
case Ops.RESHAPE | Ops.EXPAND: src_args = [arg]
|
|
case Ops.PAD | Ops.SHRINK: src_args = list(zip(*arg))
|
|
case Ops.PERMUTE | Ops.FLIP: src_args = []
|
|
case Ops.STACK:
|
|
# arg is the other srcs; all are cast to the promoted dtype, spec requires STACK srcs to match its dtype
|
|
srcs = (self,)+tuple(arg)
|
|
dtype = cast(DType, dtype_from_uop(Ops.STACK, srcs, None))
|
|
# TODO: why cast here?
|
|
return UOp(Ops.STACK, dtype, tuple(u if u.base.is_invalid else u.cast(dtype) for u in srcs))
|
|
case _: raise RuntimeError(f"{op} is not a MovementOp")
|
|
usrcs = [shape_to_shape_arg(arg) for arg in src_args]
|
|
if len(usrcs) == 0: return UOp(op, src=(self,), arg=arg)
|
|
return UOp(op, src=(self,)+UOp.sink(*usrcs).simplify().src)
|
|
|
|
# *** uop Buffer stuff ***
|
|
|
|
unique_num = itertools.count(0)
|
|
|
|
def getaddr(self, device=None) -> UOp:
|
|
if self.without_after.op not in {Ops.BUFFER, Ops.SLICE, Ops.BINARY, Ops.MSTACK, Ops.MSELECT, Ops.PARAM}: return self
|
|
return UOp(Ops.GETADDR, src=(self,), arg=device or to_tuple(self.device)[0])
|
|
@staticmethod
|
|
def new_buffer(device:str|tuple[str, ...], size:int, dtype:DType, num=None):
|
|
if dtype in dtypes.weaks: raise RuntimeError(f"cannot create storage for weak dtype {dtype}")
|
|
slot = next(UOp.unique_num) if num is None else num
|
|
return UOp(Ops.BUFFER, src=(shape_to_shape_arg((size,)),), arg=ParamArg(slot, dtype, device=device))
|
|
@staticmethod
|
|
def from_buffer(opaque:Buffer, device:str|tuple[str, ...]|None=None):
|
|
if (uop:=UOp.new_buffer(device or opaque.device, opaque.size, opaque.dtype, num=-id(opaque))) not in buffers: buffers[uop] = opaque.ref(1)
|
|
else: assert buffers[uop] is opaque
|
|
return uop
|
|
def empty_like(self, dtype:DTypeLike|None=None, device:str|tuple[str, ...]|None=None) -> UOp:
|
|
device = canonicalize_device(self.device if device is None else device)
|
|
axis = self.axis if isinstance(device, tuple) else None
|
|
ret = UOp.empty(self.shard_shape if axis is not None else self.shape, dtype=strong_dtype(self.dtype) if dtype is None else dtype, device=device)
|
|
return ret.unshard(axis) if axis is not None else ret
|
|
@staticmethod
|
|
def _frompy(x:list|tuple|bytes, dtype:DType, device:str|tuple[str, ...]|None=None) -> UOp:
|
|
device = canonicalize_device(device)
|
|
if isinstance(x, bytes): ret, data = UOp.new_buffer("PYTHON", len(x)//dtype.itemsize, dtype), x
|
|
else:
|
|
# bfloat16 and fp8 have no struct format, so pack a float32 buffer and cast
|
|
bdtype = dtypes.float32 if dtype in [dtypes.bfloat16, *dtypes.fp8s] else dtype
|
|
assert bdtype.fmt is not None, f"{bdtype=} has None fmt"
|
|
ret = UOp.empty(shape:=get_shape(x), dtype=bdtype, device="PYTHON")
|
|
data = struct.pack(f"{prod(shape)}{bdtype.fmt}", *[truncate[bdtype](bdtype.const(xi)) for xi in fully_flatten(x)])
|
|
ret.buffer.allocate(memoryview(bytearray(data))) # fake realize. buffer storage must be writable, and bytes isn't
|
|
if ret.dtype != dtype: ret = ret.cast(dtype)
|
|
return ret if ret.device == device else ret.copy_to_device(device)
|
|
def clone(self, device=None) -> UOp:
|
|
device = device or self.device
|
|
ret = self.empty_like(device=device)
|
|
src = self if self.device is None or self.device == device else self.copy_to_device(device)
|
|
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 is Ops.STAGE: return self.arg.device
|
|
if self.op is Ops.AFTER: return self.src[0].device
|
|
if self.op is Ops.MSELECT:
|
|
assert isinstance(self.src[0].device, tuple), f"mselect must be on tuple device, getting {self.src[0].device}"
|
|
return self.src[0].device[self.arg]
|
|
if self.op is Ops.MSTACK: return tuple(cast(str, x.device) for x in self.src)
|
|
if self.op is Ops.BUFFER: return self.arg.device
|
|
if self.op is Ops.COPY: return self.arg
|
|
if self.op is Ops.ALLREDUCE: return self.arg[1]
|
|
for x in self.src:
|
|
if x.device is not None: return x.device
|
|
return None
|
|
@property
|
|
def is_virtual(self) -> bool:
|
|
# NOTE: no device means no place to store, weak means no width to store. neither can back a buffer as-is
|
|
# TODO: unify with has_buffer_identity
|
|
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 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
|
|
if self.op in {Ops.INDEX, Ops.CAST, Ops.AFTER, Ops.REDUCE, Ops.STORE, Ops.MSTACK, Ops.MSELECT, Ops.END, Ops.UNSHARD}:
|
|
return self.src[0].addrspace
|
|
if self.op in GroupOp.Movement: return self.src[0].addrspace
|
|
if self.op in {Ops.STACK, Ops.WMMA, Ops.GROUP} or self.op in GroupOp.Elementwise:
|
|
ad = [x.addrspace for x in self.src if x.addrspace is not None]
|
|
if not len(ad) or not all_same(ad): return None
|
|
return ad[0]
|
|
return None
|
|
@property
|
|
def buf_uop(self) -> UOp:
|
|
if self.op in {Ops.BUFFER, Ops.PARAM}: 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
|
|
s = self
|
|
while len(s.src) and s.op not in {Ops.BUFFER, Ops.PARAM, Ops.STAGE, Ops.MSTACK}: s = s.src[0]
|
|
return s
|
|
|
|
def contiguous_view_offset(self) -> int|None:
|
|
"""If movement ops on a BUFFER collapse to a contiguous range, return `offset` in elements. Otherwise None."""
|
|
from tinygrad.schedule.rangeify import pm_mops
|
|
from tinygrad.uop.symbolic import symbolic
|
|
|
|
# WEBGPU and CL do not support views.
|
|
# WEBGPU requires that minUniformBufferOffsetAlignment be at least 32 bytes: https://gpuweb.github.io/gpuweb/#adapter-capability-guarantees
|
|
# CL 1.1 provides the clCreateSubBuffer API, but at the time of writing, relevant CL runtimes (rusticl, adreno, nvidia, amd) do not provide
|
|
# reasonable values for CL_DEVICE_MEM_BASE_ADDR_ALIGN. cl_ext_buffer_device_address could potentially help, but this extension is not provided
|
|
# by relevant CL runtimes at time of writing.
|
|
if (dev:=self.device) is not None and any(d.startswith(("WEBGPU", "CL")) for d in ((dev,) if isinstance(dev, str) else dev)): return None
|
|
|
|
idx = self.flatten().index(UOp.range(self.numel(), 0))
|
|
out = graph_rewrite(idx, pm_mops+symbolic+pm_contiguous_view_offset, ctx=self, name="contiguous_view_offset")
|
|
return out.val if out.op is Ops.CONST and isinstance(out.val, int) else None
|
|
|
|
def has_buffer_identity(self, after_ok=False):
|
|
"""Check if this UOp has a concrete buffer identity in the graph (RESHAPE/UNSHARD -> BUFFER chain)."""
|
|
# TODO: this is confusing because UOp.variable('v', 0, 1, dtypes.weakfloat) is True for jit to work, but it doesn't have a buffer
|
|
if self.op in {Ops.RESHAPE, Ops.UNSHARD, Ops.MSELECT}: return self.src[0].has_buffer_identity(after_ok)
|
|
if after_ok and self.op == Ops.AFTER: return self.src[0].has_buffer_identity(after_ok)
|
|
return self.op in {Ops.BUFFER, Ops.SLICE, Ops.PARAM}
|
|
|
|
def _base_buffer_is_realized(self) -> bool:
|
|
"""Walk through AFTER chain to find if the underlying buffer is realized (has allocated memory)."""
|
|
u = self.base
|
|
while u.op is Ops.AFTER: u = u.src[0]
|
|
return u.is_realized
|
|
|
|
@property
|
|
def buffer(self) -> Buffer|MultiBuffer:
|
|
if self.op in {Ops.CONTIGUOUS, Ops.RESHAPE, Ops.UNSHARD, Ops.DETACH, Ops.AFTER}: return self.src[0].buffer
|
|
# this buffer can process disk tensors and simple movement ops
|
|
if self is not self.base:
|
|
buf = self.base.buffer
|
|
assert isinstance(buf, Buffer), "must be a Buffer for movement ops"
|
|
offset = self.contiguous_view_offset()
|
|
if offset is None: raise RuntimeError(f"non-contiguous view is not supported for {buf.device} buffer")
|
|
return buf.view(prod(self.max_shape), self.dtype, offset*self.dtype.itemsize)
|
|
if self.op is Ops.BITCAST:
|
|
buf = self.src[0].buffer
|
|
assert isinstance(buf, Buffer), "must be a Buffer for BITCAST"
|
|
return buf.view(prod(self.max_shape), self.dtype, 0)
|
|
if self.op is Ops.SLICE:
|
|
if (cret:=buffers.get(self)) is not None: return cret
|
|
buf = self.src[0].buffer
|
|
offset = self.src[1].val
|
|
if isinstance(buf, MultiBuffer):
|
|
mbuf = MultiBuffer.__new__(MultiBuffer)
|
|
mbuf.bufs = [b.view(self.arg, self.dtype, offset * self.src[0].dtype.itemsize) for b in buf.bufs]
|
|
buffers[self] = mbuf
|
|
return mbuf
|
|
assert isinstance(buf, Buffer), "must be a Buffer for SLICE"
|
|
buffers[self] = bv = buf.view(self.arg, self.dtype, offset * self.src[0].dtype.itemsize)
|
|
return bv
|
|
if self.op is Ops.MSELECT:
|
|
ret = self.src[0].buffer
|
|
assert isinstance(ret, MultiBuffer)
|
|
return ret.bufs[self.arg]
|
|
if self.op is Ops.MSTACK:
|
|
ret = MultiBuffer.__new__(MultiBuffer)
|
|
ret.bufs = [cast(Buffer, x.buffer) for x in self.src]
|
|
assert all_same([(x.size, x.dtype) for x in ret.bufs]), "multibuffers mismatch buffers"
|
|
return ret
|
|
assert self.op is Ops.BUFFER, f"must be BUFFER {self.op}"
|
|
if (cret:=buffers.get(self)) is not None: return cret
|
|
rdtype = self.dtype
|
|
if isinstance(self.device, tuple): ret = MultiBuffer(self.device, self.max_numel(), rdtype).ref(1)
|
|
else: ret = Buffer(self.device, self.max_numel(), rdtype).ref(1)
|
|
buffers[self] = ret
|
|
return ret
|
|
@property
|
|
def realized(self) -> Buffer|MultiBuffer|None:
|
|
if self.op is Ops.UNSHARD: return self.src[0].realized
|
|
# only these can be realized
|
|
if self.op not in (Ops.BUFFER, Ops.MSTACK): return None
|
|
# LOCAL/REG scratch buffers are never realized
|
|
if self.op is Ops.BUFFER and self.addrspace in (AddrSpace.LOCAL, AddrSpace.REG): return None
|
|
# an unbacked intermediate BUFFER (directly or as an MSTACK source) is not realized
|
|
if any(b.op is Ops.BUFFER and buffers.get(b) is None for b in self.backward_slice_with_self): return None
|
|
# NOTE: this is used by the JIT to determine which inputs we capture
|
|
return self.buffer if self.buffer.is_allocated() else None
|
|
@property
|
|
def is_realized(self) -> bool: return self.base.realized is not None
|
|
|
|
# *** uop Variable stuff ***
|
|
|
|
@staticmethod
|
|
def variable(name:str, min_val:PyConst, max_val:PyConst, dtype:DType=dtypes.weakint, multiple_of:int=1) -> UOp:
|
|
return UOp(Ops.PARAM, src=(shape_to_shape_arg(()),),
|
|
arg=ParamArg(-1, dtype, name=name, vmin_vmax=(min_val, max_val), multiple_of=multiple_of, addrspace=AddrSpace.ALU))
|
|
@property
|
|
def expr(self) -> str:
|
|
assert self.op is Ops.PARAM
|
|
return unwrap(self.arg.name)
|
|
def bind(self, val:int|UOp):
|
|
assert self.op is Ops.PARAM and self.addrspace is AddrSpace.ALU, f"op is {self.op}, need PARAM"
|
|
uval = self.const_like(val) if isinstance(val, int) else val
|
|
assert self.vmin <= uval.vmin and uval.vmax <= self.vmax, f"bind {val} not in range [{self.vmin}, {self.vmax}]"
|
|
assert uval.divides(self.arg.multiple_of) is not None, f"bind {val} not divisible by {self.arg.multiple_of}"
|
|
return UOp(Ops.BIND, src=(self, uval))
|
|
def unbind(self) -> tuple[Variable, int]:
|
|
assert self.op is Ops.BIND and self.src[0].op is Ops.PARAM and self.src[1].op is Ops.CONST, f"can't unbind {self}"
|
|
return self.src[0], self.src[1].val
|
|
def unbind_all(self) -> tuple[UOp, dict[Variable, int]]:
|
|
ret:dict[Variable, int] = {}
|
|
return graph_rewrite(self, pm_unbind, ctx=ret), ret
|
|
def variables(self) -> list[Variable]:
|
|
return sorted({x if x.op is Ops.PARAM else UOp.variable("_device_num", 0, x.vmax, dtype=x.dtype)
|
|
for x in self.backward_slice_with_self if (x.op is Ops.RANGE and x.arg[-1] is AxisType.DEVICE) or x.op is Ops.PARAM
|
|
and x.arg.addrspace is AddrSpace.ALU}, key=lambda v: v.expr)
|
|
|
|
# *** uop symbolic stuff ***
|
|
|
|
def const_factor(self) -> int:
|
|
"""largest known int that divides self"""
|
|
# TODO: for negatives it's not the largest
|
|
if self.op is Ops.CONST: return self.val
|
|
if self.op is Ops.STACK: return math.gcd(*[x.const_factor() for x in self.src])
|
|
if self.op is Ops.ADD: return math.gcd(self.src[0].const_factor(), self.src[1].const_factor())
|
|
if self.op is Ops.MUL: return self.src[0].val if self.src[0].op is Ops.CONST else self.src[1].val if self.src[1].op is Ops.CONST else 1
|
|
if self.op is Ops.PARAM and self.arg.multiple_of is not None: return self.arg.multiple_of
|
|
return 1
|
|
def divides(self, v:int) -> UOp|None:
|
|
if v==1: return self
|
|
if self.op is Ops.CONST: return self.const_like(self.val//v) if self.val%v == 0 else None
|
|
if self.op is Ops.STACK:
|
|
srcs = tuple(s.divides(v) for s in self.src)
|
|
return None if any(s is None for s in srcs) else UOp(Ops.STACK, src=cast(tuple[UOp, ...], srcs))
|
|
if self.op is Ops.ADD: return d0+d1 if (d0:=self.src[0].divides(v)) is not None and (d1:=self.src[1].divides(v)) is not None else None
|
|
if self.op is Ops.MUL:
|
|
if (d0:=self.src[0].divides(v)) is not None: return d0 * self.src[1]
|
|
if (d1:=self.src[1].divides(v)) is not None: return self.src[0] * d1
|
|
if self.op is Ops.PARAM and self.arg.multiple_of is not None: return self // v if self.arg.multiple_of%v == 0 else None
|
|
return None # generic None if we aren't sure
|
|
def pop_const(self, op=Ops.ADD) -> tuple[UOp, PyConst]: # NOTE: assume Invalid ALU is resolved
|
|
return (self.src[0], self.src[1].val) if self.op is op and self.src[1].op is Ops.CONST else (self, identity_element(op, self.dtype))
|
|
@staticmethod
|
|
def gcd(*uops: UOp) -> UOp:
|
|
terms, factors = zip(*[(u.divides(f:=u.const_factor()),f) for u in uops])
|
|
count = functools.reduce(operator.and_, [collections.Counter(term.split_uop(Ops.MUL)) for term in terms])
|
|
return math.prod([*count.elements(), terms[0].const_like(math.gcd(*factors))]) # put the const at the top
|
|
def divide_exact(self, v:UOp) -> UOp|None:
|
|
if self is v: return self.const_like(1)
|
|
if v.op is Ops.CONST: return self.divides(v.val)
|
|
if self.op is Ops.ADD: return None if (s0:=self.src[0].divide_exact(v)) is None or (s1:=self.src[1].divide_exact(v)) is None else s0+s1
|
|
if self.op is Ops.MUL:
|
|
(fac, const), (div_fac, div_const) = self.pop_const(Ops.MUL), v.pop_const(Ops.MUL)
|
|
new_count = collections.Counter(fac.split_uop(Ops.MUL))
|
|
new_count.subtract(div_fac.split_uop(Ops.MUL))
|
|
if const%div_const==0 and all(v>=0 for v in new_count.values()): return math.prod(new_count.elements(), start=self.const_like(const//div_const))
|
|
return None # generic None if we aren't sure
|
|
@property
|
|
def vmin(self) -> PyConst: return self._min_max[0]
|
|
@property
|
|
def vmax(self) -> PyConst: return self._min_max[1]
|
|
@functools.cached_property
|
|
def _min_max(self) -> tuple[PyConst, PyConst]:
|
|
if self.op in GroupOp.Binary and not dtypes.is_float(self.dtype):
|
|
(s0_vmin, s0_vmax), (s1_vmin, s1_vmax) = self.src[0]._min_max, self.src[1]._min_max
|
|
if self.op is Ops.ADD: return s0_vmin+s1_vmin, s0_vmax+s1_vmax
|
|
if self.op is Ops.SUB: return s0_vmin-s1_vmax, s0_vmax-s1_vmin
|
|
if self.op is Ops.AND and dtypes.is_int(self.dtype) and s1_vmin == s1_vmax >= 0:
|
|
return 0, s1_vmax if s0_vmin < 0 else min(s0_vmax, s1_vmax)
|
|
if self.op is Ops.MUL: return min(vals:=(s0_vmin*s1_vmin, s0_vmin*s1_vmax, s0_vmax*s1_vmin, s0_vmax*s1_vmax)), max(vals)
|
|
# SHL/SHR on consts only
|
|
if self.op is Ops.SHL and s1_vmin == s1_vmax and all_int(t:=(s0_vmin, s0_vmax, s1_vmin)): return t[0] << t[2], t[1] << t[2]
|
|
if self.op is Ops.SHR and s1_vmin == s1_vmax and all_int(t:=(s0_vmin, s0_vmax, s1_vmin)): return t[0] >> t[2], t[1] >> t[2]
|
|
if self.op is Ops.CMOD:
|
|
if (c:=s1_vmin) == s1_vmax > 0:
|
|
return (0 if s0_vmin > 0 else s0_vmin if 0 >= s0_vmin > -c else -(s1_vmax-1), 0 if s0_vmax < 0 else s0_vmax if 0 <= s0_vmax < c else c-1)
|
|
if s1_vmin > 0: return (0, s1_vmax-1) if s0_vmin >= 0 else (-(s1_vmax-1), 0) if s0_vmax <= 0 else (-(s1_vmax-1), s1_vmax-1)
|
|
if s1_vmax < 0: return (0, -s1_vmin-1) if s0_vmin >= 0 else (-(-s1_vmin-1), 0) if s0_vmax <= 0 else (-(-s1_vmin-1), -s1_vmin-1)
|
|
if self.op is Ops.CDIV:
|
|
assert isinstance(s0_vmin, int) and isinstance(s0_vmax, int) and isinstance(s1_vmin, int) and isinstance(s1_vmax, int)
|
|
if s1_vmin*s1_vmax>0:
|
|
return min(vals:=(cdiv(s0_vmin, s1_vmin), cdiv(s0_vmin, s1_vmax), cdiv(s0_vmax, s1_vmin), cdiv(s0_vmax, s1_vmax))), max(vals)
|
|
if self.op is Ops.FLOORDIV:
|
|
assert isinstance(s0_vmin, int) and isinstance(s0_vmax, int) and isinstance(s1_vmin, int) and isinstance(s1_vmax, int)
|
|
if s0_vmin > s0_vmax: return 0, 0 # numerator range is empty (e.g. RANGE with end=0)
|
|
if s1_vmin*s1_vmax>0: return min(vals:=(s0_vmin//s1_vmin, s0_vmin//s1_vmax, s0_vmax//s1_vmin, s0_vmax//s1_vmax)), max(vals)
|
|
if self.op is Ops.FLOORMOD:
|
|
assert isinstance(s0_vmin, int) and isinstance(s0_vmax, int) and isinstance(s1_vmin, int) and isinstance(s1_vmax, int)
|
|
if s0_vmin > s0_vmax: return 0, 0 # numerator range is empty (e.g. RANGE with end=0)
|
|
if (c:=s1_vmin) == s1_vmax > 0: return (s0_vmin%c, s0_vmax%c) if s0_vmin//c == s0_vmax//c else (0, c-1)
|
|
if (c:=s1_vmin) == s1_vmax < 0: return (s0_vmin%c, s0_vmax%c) if s0_vmin//c == s0_vmax//c else (c+1, 0)
|
|
if s1_vmin > 0: return (0, s1_vmax-1)
|
|
if s1_vmax < 0: return (s1_vmin+1, 0)
|
|
if self.op is Ops.XOR and s1_vmin == s1_vmax == -1 and isinstance(s0_vmin, int) and isinstance(s0_vmax, int):
|
|
return ~int(s0_vmax), ~int(s0_vmin)
|
|
if self.op is Ops.MAX: return max(s0_vmin, s1_vmin), max(s0_vmax, s1_vmax)
|
|
if self.op is Ops.CMPLT: return (s0_vmax<s1_vmin, s0_vmin<s1_vmax)
|
|
if self.op is Ops.CMPNE: return ((s0_vmax < s1_vmin) or (s1_vmax < s0_vmin), not (s0_vmin == s0_vmax == s1_vmin == s1_vmax))
|
|
if self.op is Ops.OR and self.dtype == dtypes.bool: return s0_vmin or s1_vmin, s0_vmax or s1_vmax
|
|
if self.op is Ops.AND and self.dtype == dtypes.bool: return s0_vmin and s1_vmin, s0_vmax and s1_vmax
|
|
# float has NAN issue and we use explicit NAN in transcendental
|
|
if self.op is Ops.WHERE and dtypes.is_int(self.dtype): return min(self.src[1].vmin, self.src[2].vmin), max(self.src[1].vmax, self.src[2].vmax)
|
|
# NOTE: returned UOp is assumed to be CONST
|
|
if self.op is Ops.PARAM and self.arg.vmin_vmax is not None: return self.arg.vmin_vmax
|
|
if self.op in (Ops.RANGE, Ops.SPECIAL) and self.dtype is not dtypes.void: return 0, (self.src[0]-1).vmax
|
|
if self.op is Ops.BIND: return self.src[0]._min_max # ignore the bound value
|
|
if self.op is Ops.STACK: return min(x.vmin for x in self.src), max(x.vmax for x in self.src)
|
|
if self.op is Ops.CONST and self.val is not Invalid: return self.val, self.val
|
|
if self.op is Ops.INDEX: return self.src[0]._min_max
|
|
if self.op is Ops.CAST:
|
|
# an int destination truncates a float source toward zero. trunc is monotone
|
|
smin, smax = self.src[0]._min_max
|
|
if dtypes.is_int(self.dtype) and dtypes.is_float(self.src[0].dtype) and all(math.isfinite(v) for v in (smin, smax)):
|
|
smin, smax = math.trunc(smin), math.trunc(smax)
|
|
# a cast to unsigned keeps exact bounds when the source fits
|
|
# TODO: can do more based on new dtype window
|
|
if dtypes.is_unsigned(self.dtype) and 0 <= smin and smax <= self.dtype.max: return smin, smax
|
|
if self.dtype in dtypes.floats+dtypes.sints+(dtypes.weakint,): return max(self.dtype.min, smin), min(smax, self.dtype.max)
|
|
return self.dtype.min, self.dtype.max
|
|
|
|
@functools.cached_property
|
|
def _sym_fxn(self):
|
|
from tinygrad.uop.render import _render_with_splits, renderer_infer
|
|
sself = self.simplify()
|
|
varnames = tuple(dedup(x.expr for x in sself.toposort() if x.op is Ops.PARAM and x.arg.addrspace == AddrSpace.ALU))
|
|
# TODO: sanitize varnames, or don't use naked eval while staying fast
|
|
ret = _render_with_splits(list(sself.toposort()), renderer_infer, {sself})
|
|
lines = [f" {k}={v}" for k,v in ret.items() if k != "ast"] + [f" return {ret['ast']}"]
|
|
ns: dict[str, Any] = {"max": max, "cdiv": cdiv, "cmod": cmod, "floordiv": floordiv, "floormod": floormod, "bitcast": bitcast, "dtypes": dtypes}
|
|
exec(f"def _f({','.join(varnames)}):\n"+'\n'.join(lines), ns) # pylint: disable=exec-used
|
|
return ns["_f"], varnames
|
|
|
|
def sym_infer(self, var_vals:dict[str, int]):
|
|
fxn, varnames = self._sym_fxn
|
|
return fxn(**{k:v for k,v in var_vals.items() if k in varnames})
|
|
|
|
def render(self, simplify=True, pm:PatternMatcher|None=None) -> str:
|
|
ctx: dict[UOp, str] = {}
|
|
from tinygrad.uop.render import renderer
|
|
pm = renderer if pm is None else pm
|
|
for u in (s:=self.simplify() if simplify else self).toposort():
|
|
ctx[u] = cast(str, pm.rewrite(u, ctx=ctx))
|
|
return ctx[s]
|
|
|
|
def pyrender(self):
|
|
from tinygrad.uop.render import pyrender
|
|
return pyrender(self)
|
|
|
|
# *** uop high level syntactic sugar ***
|
|
|
|
@staticmethod
|
|
def placeholder(shape:tuple[int, ...], dtype:DType, slot:int, addrspace=AddrSpace.GLOBAL, device=None, volatile=False):
|
|
dtype = strong_dtype(dtype) # storage is never weak: a placeholder commits the width of what's put in it
|
|
if addrspace is AddrSpace.GLOBAL:
|
|
ret = UOp(Ops.PARAM, src=(shape_to_shape_arg((prod(shape),)),), arg=ParamArg(slot, dtype, addrspace=addrspace, device=device,volatile=volatile))
|
|
else:
|
|
assert addrspace in (AddrSpace.LOCAL, AddrSpace.REG)
|
|
assert device is None, "LOCAL and REG placeholders cannot have a device"
|
|
ret = UOp(Ops.BUFFER, src=(shape_to_shape_arg((prod(shape),)),), arg=ParamArg(slot, dtype, addrspace=addrspace))
|
|
if len(shape) > 1: ret = ret.reshape(shape)
|
|
return ret
|
|
def placeholder_like(self, slot:int, addrspace=AddrSpace.GLOBAL):
|
|
assert all_int(self.shape), "no placeholder-like on symbolic shape"
|
|
return UOp.placeholder(self.max_shard_shape, self.dtype, slot, addrspace)
|
|
|
|
# set is store+end+after
|
|
def set(self:UOp, val:UOp|ConstType, end:UOp|tuple[UOp, ...]|list[UOp]=()) -> UOp:
|
|
return self.src[0].after(self.store(val).end(*argfix(end)))
|
|
|
|
# TODO: this should replace placeholder
|
|
@staticmethod
|
|
def param(slot:int, dtype:DType, shape:tuple[sint, ...]|None=None, device=None, vmin_vmax:tuple[PyConst, PyConst]|None=None,
|
|
multiple_of:int|None=None, name=None, addrspace=AddrSpace.GLOBAL, axis:int|None=None, volatile:bool=False):
|
|
if dtype in dtypes.weaks: raise RuntimeError(f"cannot create param for weak dtype {dtype}")
|
|
if shape is not None and axis is not None and isinstance(device, tuple):
|
|
shape = tuple(s*len(device) if i == axis else s for i,s in enumerate(shape))
|
|
src: tuple[UOp, ...] = (UOp(Ops.NOOP) if shape is None else shape_to_shape_arg(shape),)
|
|
return UOp(Ops.PARAM, src=src, arg=ParamArg(slot, dtype, vmin_vmax, multiple_of, name, addrspace, axis, device, volatile))
|
|
def param_like(self, slot:int):
|
|
if self.op is Ops.BIND: return self.src[0].replace(arg=replace(self.src[0].arg, slot=slot, name=f"p{slot}"))
|
|
addrspace = self.addrspace if self.addrspace is not None else AddrSpace.GLOBAL
|
|
return UOp.param(slot, self.dtype, self.shard_shape if self.axis is not None else self._shape, self.device, addrspace=addrspace, axis=self.axis)
|
|
|
|
@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_CALL_BODIES = {Ops.SINK, Ops.PROGRAM, Ops.LINEAR, Ops.COPY, Ops.SLICE, 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:
|
|
if ret_dtype is not None: return UOp(Ops.CALL, ret_dtype, src=(self,)+srcs)
|
|
# calls are launched per device, so an open DEVICE range is allowed to cross the call boundary
|
|
assert all(r.arg[-1] is AxisType.DEVICE for r in self.ranges), \
|
|
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))
|
|
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)
|
|
return [s.after(kernel) for s in srcs]
|
|
|
|
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"
|
|
sig = tuple((u.arg.name, u.arg.slot, u.dtype, u._shape)
|
|
for u in tuple(filter(lambda u: u.op is Ops.PARAM and u.addrspace != AddrSpace.ALU, self.src[1].src)) + self.arg.vars)
|
|
return TinyELF(self.src[3].arg, self.arg.function_name, self.arg.target, sig)
|
|
|
|
@dataclass(frozen=True)
|
|
class KernelInfo:
|
|
name: str = "test" # name of the kernel
|
|
axis_types: tuple[AxisType, ...] = tuple()
|
|
dont_use_locals: bool = False # don't use local indexing
|
|
applied_opts: tuple = tuple()
|
|
opts_to_apply: tuple|None = None
|
|
estimates: Estimates|None = None
|
|
beam: int = 0
|
|
@property
|
|
def function_name(self): return to_function_name(self.name)
|
|
|
|
@dataclass(frozen=True)
|
|
class ProgramInfo:
|
|
name: str = "test"
|
|
global_size: tuple[int|float, ...] = (1, 1, 1)
|
|
local_size: tuple[int, ...]|None = None
|
|
vars: tuple[UOp, ...] = ()
|
|
globals: tuple[int, ...] = ()
|
|
outs: tuple[int, ...] = ()
|
|
ins: tuple[int, ...] = ()
|
|
target: Target = Target()
|
|
|
|
@property
|
|
def function_name(self): return to_function_name(self.name)
|
|
|
|
@property
|
|
def runtimevars(self) -> dict[str, int]: return {v.expr: i for i, v in enumerate(self.vars) if v.expr == 'core_id'}
|
|
|
|
def launch_dims(self, var_vals:dict[str, int]) -> tuple[tuple[int, ...], tuple[int, ...]|None]:
|
|
global_size = tuple([sym_infer(sz, var_vals) for sz in self.global_size]) # type: ignore[arg-type]
|
|
local_size = tuple([sym_infer(sz, var_vals) for sz in self.local_size]) if self.local_size is not None else None
|
|
return global_size, local_size
|
|
|
|
def vals(self, var_vals:dict[str, int]) -> tuple[int|None, ...]:
|
|
try: return tuple(var_vals[k.expr] if k.expr not in self.runtimevars else None for k in self.vars)
|
|
except KeyError as e: raise RuntimeError(f"unbound Variable {e} used by {self.function_name}") from None
|
|
|
|
@staticmethod
|
|
def from_sink(sink:UOp, target:Target=Target()) -> ProgramInfo:
|
|
_vars: list[UOp] = []
|
|
_globals: list[int] = []
|
|
outs: list[int] = []
|
|
ins: list[int] = []
|
|
global_size: list[int] = [1, 1, 1]
|
|
local_size: list[int]|None = [1, 1, 1]
|
|
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 is Ops.SPECIAL:
|
|
if u.arg[0] == 'i': local_size = None
|
|
special_size = local_size if u.arg[0] == 'l' else global_size
|
|
if special_size is not None: special_size[int(u.arg[-1])] = cast(int, u.src[0].ssimplify())
|
|
if u.op is Ops.PARAM and u in _vars and u.expr == 'core_id': global_size[0] = int(u.vmax) + 1
|
|
return ProgramInfo(sink.arg.name if isinstance(sink.arg, KernelInfo) else "test", tuple(global_size),
|
|
tuple(local_size) if local_size is not None else None, tuple(sorted(dedup(_vars), key=lambda v: v.arg.slot)),
|
|
tuple(sorted(dedup(_globals))), tuple(sorted(dedup(outs))), tuple(sorted(dedup(ins))), target)
|
|
|
|
@dataclass(frozen=True)
|
|
class CallInfo:
|
|
grad_fxn: Callable|None = None
|
|
name: str|None = None
|
|
precompile: bool = False
|
|
precompile_backward: bool = False
|
|
aux: Any = None
|
|
# grad_fxn can't be pickled
|
|
def __reduce__(self): return (CallInfo, (None, self.name, self.precompile, self.precompile_backward, self.aux))
|
|
def __repr__(self):
|
|
gf = id(self.grad_fxn) if self.grad_fxn else None
|
|
return f"CallInfo({gf}, {repr(self.name)}, {self.precompile}, {self.precompile_backward})"
|
|
|
|
# ******** ops in python ********
|
|
|
|
def safe_exp2(x):
|
|
try: return 2 ** x
|
|
except OverflowError: return math.inf
|
|
|
|
def safe_pow(x, y):
|
|
try: return math.nan if isinstance(p:=pow(x, y), complex) else p
|
|
except ZeroDivisionError: return math.inf
|
|
except ValueError: return math.inf if x > 0 else -math.inf
|
|
|
|
python_alu: dict[Ops, Callable] = {
|
|
Ops.LOG2: lambda x: math.log2(x) if x > 0 else -math.inf if x == 0 else math.nan, Ops.EXP2: safe_exp2,
|
|
Ops.SQRT: lambda x: math.sqrt(x) if x >= 0 else math.nan, Ops.RECIPROCAL: lambda x: 1/x if x != 0 else math.copysign(math.inf, x),
|
|
Ops.SIN: lambda x: math.sin(x) if not math.isinf(x) else math.nan, Ops.POW: safe_pow,
|
|
Ops.TRUNC: lambda x: math.trunc(x) if math.isfinite(x) else x,
|
|
Ops.NEG: operator.neg, Ops.ADD: operator.add, Ops.SUB: operator.sub, Ops.MUL: operator.mul, Ops.CMPNE: operator.ne, Ops.CMPLT: operator.lt,
|
|
Ops.XOR: operator.xor, Ops.OR: operator.or_, Ops.AND: operator.and_, Ops.SHR: operator.rshift, Ops.SHL: operator.lshift, Ops.MAX: max,
|
|
Ops.CMOD: cmod, Ops.CDIV: cdiv, Ops.FLOORDIV: floordiv, Ops.FLOORMOD: floormod,
|
|
Ops.MULACC: lambda x,y,z: (x*y)+z, Ops.WHERE: lambda x,y,z: y if x else z, Ops.CMPEQ: operator.eq}
|
|
|
|
def exec_alu(op:Ops, dtype:DType, operands, truncate_output=True):
|
|
if any(isinstance(x, tuple) for x in operands):
|
|
count = max(len(x) for x in operands if isinstance(x, tuple))
|
|
return tuple([exec_alu(op, dtype, [x[i] if isinstance(x, tuple) else x for x in operands]) for i in range(count)])
|
|
if op in GroupOp.Binary and Invalid in operands: return Invalid
|
|
alu = python_alu[op](*operands)
|
|
if truncate_output and (truncate_fxn:=truncate.get(dtype)) is not None: return truncate_fxn(alu)
|
|
return alu
|
|
|
|
# ***** pattern matcher *****
|
|
|
|
def get_location() -> tuple[str, int]:
|
|
frm = sys._getframe(1)
|
|
# skip over ops.py and anything in mixin
|
|
while frm.f_back is not None and not frm.f_back.f_code.co_filename.startswith("<frozen"):
|
|
fn = frm.f_code.co_filename.replace("\\", "/")
|
|
if not (fn.endswith("/ops.py") or "/mixin/" in fn): break
|
|
frm = frm.f_back
|
|
return frm.f_code.co_filename, frm.f_lineno
|
|
|
|
class UPat(OpMixin):
|
|
__slots__ = ("op", "match_dtype", "match_tag", "arg", "name", "src", "is_any")
|
|
def __init__(self, op:Ops|tuple[Ops, ...]|set[Ops]|None=None, dtype:DType|tuple[DType, ...]|set[DType]|None=None,
|
|
src:tuple[UPat, ...]|list[UPat]|UPat|None=None, arg:Any=None,
|
|
name:str|None=None, allow_any_len:bool=False, custom_early_reject:set[Ops]|None=None, location=None, is_any:bool=False,
|
|
tag:Any=None):
|
|
assert op is None or isinstance(op, (Ops, tuple, set)), f"op must be Ops or tuple of Ops, not {op!r}"
|
|
self.op: tuple[Ops, ...]|None = (op,) if isinstance(op, Ops) else (tuple(op) if isinstance(op, set) else op)
|
|
self.match_dtype: tuple[DType, ...]|None = (dtype,) if isinstance(dtype, DType) else (tuple(dtype) if isinstance(dtype, set) else dtype)
|
|
self.match_tag: tuple[Any, ...]|None = (tag,) if isinstance(tag, str) else (tuple(tag) if isinstance(tag, set) else tag)
|
|
self.arg, self.name, self._in_src, self.custom_early_reject = arg, name, src, custom_early_reject
|
|
self.src: Any = None
|
|
self.is_any = is_any
|
|
assert self.name != "ctx", "UPat can't be named ctx"
|
|
assert dtype is None or isinstance(dtype, DType) or all(isinstance(x, DType) for x in dtype), f"invalid dtype {dtype}"
|
|
|
|
# try all permutations if it's a list
|
|
if isinstance(src, list): self.src = list(itertools.permutations(src)) if not all_same(src) else [tuple(src)]
|
|
# only one if it's a tuple
|
|
elif isinstance(src, tuple): self.src = [src]
|
|
# repeat if it's a UPat
|
|
elif isinstance(src, UPat): self.src = [itertools.repeat(src)]
|
|
|
|
self.strict_length = not (allow_any_len or isinstance(src, UPat) or src is None)
|
|
self.required_len: int = 0 if isinstance(src, UPat) or src is None else len(src)
|
|
self.location = location or get_location()
|
|
|
|
if custom_early_reject is not None: self.early_reject = custom_early_reject
|
|
else:
|
|
upat_match = [src] if isinstance(src, UPat) else ([] if src is None else self.src[0])
|
|
self.early_reject = {pp.op[0] for pp in upat_match if pp.op is not None and len(pp.op) == 1}
|
|
|
|
@property
|
|
def dtype(self) -> DType: return self.match_dtype[0] if self.match_dtype is not None else dtypes.void
|
|
|
|
def __reduce__(self):
|
|
return UPat, (self.op, self.match_dtype, self._in_src, self.arg, self.name, not self.strict_length, self.custom_early_reject, self.location,
|
|
self.is_any, self.match_tag)
|
|
def named(self, name:str):
|
|
return UPat(self.op, self.match_dtype, self._in_src, self.arg, name, not self.strict_length, self.custom_early_reject, tag=self.match_tag)
|
|
|
|
@staticmethod
|
|
def any(*src): return UPat(src=src, is_any=True)
|
|
def or_casted(self, name:str|None=None): return UPat.any(self if name is None else self.named(name), UPat(Ops.CAST, name=name, src=(self,)))
|
|
def or_after(self, name:str|None=None):
|
|
return UPat.any(self if name is None else self.named(name), UPat(Ops.AFTER, name=name, src=(self,), allow_any_len=True))
|
|
@staticmethod
|
|
@functools.cache
|
|
def var(name:str|None=None, dtype:DType|tuple[DType, ...]|None=None): return UPat(dtype=dtype, name=name)
|
|
@staticmethod
|
|
@functools.cache
|
|
def cvar(name:str|None=None, dtype:DType|tuple[DType, ...]|None=None, arg=None): return UPat(Ops.CONST, dtype, name=name, arg=arg)
|
|
@staticmethod
|
|
def const(b:ConstType, dtype:DType|tuple[DType, ...]|None=None): return UPat(Ops.CONST, dtype=dtype, arg=b)
|
|
|
|
# lil helper
|
|
def f(self, op, **kwargs): return UPat(op, src=(self,), **kwargs)
|
|
|
|
# copied from UOp
|
|
def sink(*srcs:UPat|None, **kwargs): # pylint: disable=no-self-argument
|
|
return UPat(Ops.SINK, src=tuple([x for x in srcs if x is not None]), **kwargs)
|
|
def index(self, *srcs:UPat|None, **kwargs):
|
|
return UPat(Ops.INDEX, src=(self,)+tuple(x for x in srcs if x is not None), **kwargs)
|
|
def cast(self, dtype=None, **kwargs):
|
|
if dtype is not None and self.match_dtype == (dtype,): return self
|
|
return UPat(Ops.CAST, dtype, (self,), **kwargs)
|
|
def bitcast(self, dtype=None): return UPat(Ops.BITCAST, dtype, (self,))
|
|
def load(self, *src:UPat, **kwargs): return UPat(Ops.LOAD, src=(self,)+src, **kwargs)
|
|
def store(self, *src:UPat, **kwargs): return UPat(Ops.STORE, src=(self,)+src, **kwargs)
|
|
def reduce(self, *src:UPat, **kwargs):
|
|
arg = kwargs.pop('arg', None)
|
|
if isinstance(arg, Ops): arg = (arg, 0)
|
|
return UPat(Ops.REDUCE, self.match_dtype, src=(self,)+src, arg=arg, **kwargs)
|
|
def broadcast(self, **kwargs): return UPat(Ops.STACK, self.match_dtype, src=self, **kwargs)
|
|
def after(self, *src:UPat, **kwargs): return UPat(Ops.AFTER, self.match_dtype, (self,)+src, **kwargs)
|
|
def end(self, *src:UPat, **kwargs): return UPat(Ops.END, src=(self,)+src, **kwargs)
|
|
|
|
def _broadcasted(self, y, reverse=False) -> tuple[UPat, UPat]:
|
|
y = self.ufix(y)
|
|
return (y, self) if reverse else (self, y)
|
|
def ufix(self, x): return UPat.cvar(arg=x) if not isinstance(x, UPat) else x
|
|
def __floordiv__(self, x): return self._binop(Ops.FLOORDIV, x, False)
|
|
def __rfloordiv__(self, x): return self._binop(Ops.FLOORDIV, x, True)
|
|
def mod(self, x, reverse=False): return self._binop(Ops.FLOORMOD, x, reverse)
|
|
def alu(self, op:Ops, *src:UPat):
|
|
asrc = (self,)+src
|
|
return UPat(op, dtypes.bool if op in {Ops.CMPLT, Ops.CMPNE} else asrc[-1].match_dtype, list(asrc) if op in GroupOp.Commutative else asrc)
|
|
|
|
def match(self:UPat, uop:UOp, store:dict[str, UOp]) -> list[dict[str, UOp]]:
|
|
if self.is_any: return flatten([x.match(uop, store.copy()) for x in self.src[0]])
|
|
if (self.op is not None and uop.op not in self.op) or \
|
|
(self.name is not None and store.setdefault(self.name, uop) is not uop) or \
|
|
(self.match_dtype is not None and uop.dtype not in self.match_dtype and uop.dtype.scalar() not in self.match_dtype) or \
|
|
(self.arg is not None and self.arg != uop.arg) or \
|
|
(self.match_tag is not None and uop.tag not in self.match_tag) or \
|
|
(len(uop.src) < self.required_len) or \
|
|
(self.strict_length and len(uop.src) != self.required_len): return []
|
|
if self.src is None: return [store]
|
|
res: list[dict[str, UOp]] = []
|
|
for vp in self.src:
|
|
stores, new_stores = [store.copy()], []
|
|
for uu, vv in zip(uop.src, vp):
|
|
for s in stores: new_stores.extend(vv.match(uu, s))
|
|
stores, new_stores = new_stores, []
|
|
res.extend(stores)
|
|
return res
|
|
|
|
def deconstruct_function(fxn:Callable) -> tuple:
|
|
new_globals = {k:v for k,v in fxn.__globals__.items() if k in fxn.__code__.co_names}
|
|
for co in fxn.__code__.co_consts:
|
|
if isinstance(co, types.CodeType): new_globals.update({k:v for k,v in fxn.__globals__.items() if k in co.co_names})
|
|
# NOTE: optional round trip through pickle!
|
|
assert fxn.__closure__ is None, "closures are not supported in pattern matchers"
|
|
ret = fxn.__code__, new_globals, fxn.__name__, fxn.__defaults__
|
|
return pickle.loads(pickle.dumps(ret)) if getenv("TEST_PICKLE") else ret
|
|
|
|
@functools.cache
|
|
def upat_interpret(p:UPat, fxn:Callable) -> Callable:
|
|
real_fxn = types.FunctionType(*deconstruct_function(fxn))
|
|
if 'ctx' in inspect.signature(real_fxn).parameters:
|
|
def universal_match(uop, ctx):
|
|
for match in p.match(uop, {}):
|
|
if (ret:=real_fxn(ctx=ctx, **match)) is not None: return ret # pylint: disable=not-callable
|
|
return None
|
|
else:
|
|
def universal_match(uop, _):
|
|
for match in p.match(uop, {}):
|
|
if (ret:=real_fxn(**match)) is not None: return ret # pylint: disable=not-callable
|
|
return None
|
|
return universal_match
|
|
|
|
def upat_deferred_compile(p:UPat, fxn:Callable, entry:list) -> Callable:
|
|
def lazy_compile(uop, ctx):
|
|
from tinygrad.uop.upat import upat_compile
|
|
entry[1] = upat_compile(p, fxn) or upat_interpret(p, fxn)
|
|
return entry[1](uop, ctx)
|
|
return lazy_compile
|
|
|
|
class PatternMatcher:
|
|
def __init__(self, patterns:Sequence[tuple[UPat, Callable|tuple]], compiled=bool(getenv("UPAT_COMPILE", 1))):
|
|
# if this comes from a pickle, we reconstruct the lambda functions here
|
|
self.patterns:list[tuple[UPat, Callable]] = [(p,types.FunctionType(*fxn) if isinstance(fxn, tuple) else fxn) for p,fxn in patterns]
|
|
# NOTE: use of DefaultDict here is very dangerous! all keys will live for the lifetime of the PatternMatcher!
|
|
self.pdict: dict[Ops, list[list]] = {}
|
|
# uop is required, arg is optional
|
|
for p,fxn in self.patterns:
|
|
assert p.op is not None
|
|
entry: list = [p, None, p.early_reject]
|
|
entry[1] = upat_deferred_compile(p, fxn, entry) if compiled else upat_interpret(p, fxn)
|
|
for uop in p.op: self.pdict.setdefault(uop, []).append(entry)
|
|
|
|
def __reduce__(self): return PatternMatcher, ([(x,deconstruct_function(fxn) if fxn.__name__ == "<lambda>" else fxn) for x,fxn in self.patterns],)
|
|
|
|
@functools.cache # pylint: disable=method-cache-max-size-none
|
|
def __add__(self, more:PatternMatcher) -> PatternMatcher: return PatternMatcher(self.patterns+more.patterns)
|
|
|
|
def rewrite(self, uop:UOp, ctx=None):
|
|
if len(pats:=self.pdict.get(uop.op, [])):
|
|
if (ler:=uop.__dict__.get('_src_ops')) is None: uop.__dict__['_src_ops'] = ler = {u.op for u in uop.src}
|
|
for _,match,early_reject in pats:
|
|
if not early_reject.issubset(ler): continue
|
|
if (ret:=match(uop, ctx)) is not None and ret is not uop: return ret
|
|
return None
|
|
|
|
# *** tracking pattern matcher ***
|
|
|
|
TRACK_MATCH_STATS = ContextVar("TRACK_MATCH_STATS", 2 if VIZ else 0)
|
|
REWRITE_STACK_LIMIT = ContextVar("REWRITE_STACK_LIMIT", 250000)
|
|
match_stats:dict[UPat, list[int|float]] = dict()
|
|
|
|
# TRACK_MATCH_STATS>=2 or VIZ=1 saves all matches
|
|
ucount = itertools.count()
|
|
uop_fields:dict[int, tuple] = {}
|
|
|
|
@dataclass(frozen=True)
|
|
class TrackedGraphRewrite:
|
|
loc:tuple[str, int] # location that called graph_rewrite
|
|
sink:int # the sink input to graph_rewrite
|
|
matches:list[tuple[int, int, tuple, float]] # before/after UOp, UPat location and time
|
|
name:str # name of the rewrite
|
|
depth:int # depth if it's a subrewrite
|
|
bottom_up:bool
|
|
walk:bool
|
|
enter_calls:bool
|
|
|
|
tracked_keys:list[TracingKey] = []
|
|
tracked_ctxs:list[list[TrackedGraphRewrite]] = []
|
|
_name_cnt:dict[str, itertools.count] = {}
|
|
|
|
if CAPTURE_PROCESS_REPLAY:
|
|
replay_capture: list[bytes] = []
|
|
import atexit, uuid
|
|
@atexit.register
|
|
def save_to_diskcache():
|
|
uid = uuid.uuid4() # one id per process
|
|
for i,v in enumerate(replay_capture): diskcache_put("process_replay", f"{uid}_{i}", v, prepickled=True)
|
|
|
|
def add_trace_group(kt:TracingKey) -> None:
|
|
tracked_keys.append(kt)
|
|
tracked_ctxs.append([])
|
|
|
|
active_group:list[int] = []
|
|
active_rewrites:list[TrackedGraphRewrite] = []
|
|
def rewrite_group(name:Callable[..., str|TracingKey]|bool=True, replay:bool=False, new_ctx:bool=True):
|
|
if not new_ctx: assert not callable(name) and not replay, "name fxn and replay are only supported for new_ctx groups"
|
|
def _decorator(func):
|
|
def __wrapper(*args, **kwargs):
|
|
# without tracking, we just call the function (unless top-level, which always profiles)
|
|
if TRACK_MATCH_STATS < 2 and not new_ctx: return func(*args, **kwargs)
|
|
fn = key = func.__name__
|
|
idx = -1
|
|
if TRACK_MATCH_STATS >= 2:
|
|
if new_ctx:
|
|
add_trace_group(key:=TracingKey(n:=f"{fn} n{next(_name_cnt.setdefault(fn, itertools.count(1)))}", (n,)))
|
|
active_group.append(idx:=len(tracked_keys)-1)
|
|
else:
|
|
rewrite_name = str(kwargs.get("name", None) or fn)
|
|
assert args and isinstance(args[0], UOp), f"invalid match tracing inputs for {rewrite_name} with {args}"
|
|
loc = ((frm:=sys._getframe(1)).f_code.co_filename, frm.f_lineno)
|
|
depth = len(active_rewrites)
|
|
if not tracked_ctxs: add_trace_group(TracingKey(f"default {fn}"))
|
|
dest_group = active_group[-1] if active_group else len(tracked_ctxs)-1
|
|
tracked_ctxs[dest_group].append(ctx:=TrackedGraphRewrite(loc, args[0].trace_num, [], rewrite_name, depth, kwargs.get("bottom_up", False),
|
|
kwargs.get("walk", False), kwargs.get("enter_calls", False)))
|
|
active_rewrites.append(ctx)
|
|
key = rewrite_name # profile spans are named after the rewrite step
|
|
with cpu_profile(key, "TINY") as e:
|
|
ret = func(*args, **kwargs)
|
|
if TRACK_MATCH_STATS >= 2:
|
|
if new_ctx: active_group.pop()
|
|
else: active_rewrites.pop()
|
|
if callable(name):
|
|
name_ret = name(*args, **kwargs, ret=ret)
|
|
assert isinstance(name_ret, (TracingKey, str)), f"name function returned {type(name_ret)}"
|
|
tracked_keys[idx] = k = TracingKey(n:=tracked_keys[idx].display_name.replace(fn, name_ret), (n,)) if isinstance(name_ret, str) else name_ret
|
|
e.name = TracingKey(k.display_name if isinstance(name_ret, str) else f"{fn} for {k.display_name}", k.keys)
|
|
if CAPTURE_PROCESS_REPLAY and replay:
|
|
# find the unittest frame we're capturing in
|
|
frm = sys._getframe(1)
|
|
while (f_back:=frm.f_back) is not None and "unittest" not in f_back.f_code.co_filename: frm = f_back
|
|
replay_loc = f"{frm.f_code.co_filename.split('/')[-1]}:{frm.f_lineno} {frm.f_code.co_name}"
|
|
# capture global context vars and all the args passed in
|
|
inputs = (fn, args, kwargs, ContextVar._cache)
|
|
replay_capture.append(pickle.dumps(inputs+(replay_loc, ret)))
|
|
return ret
|
|
return __wrapper
|
|
return _decorator
|
|
|
|
class TrackedPatternMatcher(PatternMatcher):
|
|
def rewrite(self, uop:UOp, ctx=None):
|
|
if len(pats:=self.pdict.get(uop.op, [])):
|
|
ret = None
|
|
ler = {u.op for u in uop.src}
|
|
for p,match,early_reject in pats:
|
|
if p not in match_stats: match_stats[p] = [0,0,0.0,0.0]
|
|
st = time.perf_counter()
|
|
if not early_reject.issubset(ler):
|
|
match_stats[p][2] += time.perf_counter()-st
|
|
continue
|
|
match_stats[p][1] += 1
|
|
try: ret = match(uop, ctx)
|
|
except Exception as e:
|
|
if TRACK_MATCH_STATS >= 2 and active_rewrites:
|
|
err_str = f"{type(e).__name__}\n{sys.exc_info()[1]}"
|
|
active_rewrites[-1].matches.append((uop.trace_num, UOp(Ops.REWRITE_ERROR, src=uop.src, arg=err_str).trace_num, p.location, 0))
|
|
raise
|
|
if ret is not None and ret is not uop:
|
|
match_stats[p][0] += 1
|
|
match_stats[p][3] += (et:=time.perf_counter()-st)
|
|
if TRACK_MATCH_STATS >= 3: print(f"{et*1e6:7.2f} us -- ", printable(p.location))
|
|
if TRACK_MATCH_STATS >= 2 and isinstance(ret, UOp) and active_rewrites:
|
|
active_rewrites[-1].matches.append((uop.trace_num, ret.trace_num, p.location, et))
|
|
return ret
|
|
match_stats[p][2] += time.perf_counter()-st
|
|
return None
|
|
|
|
@dataclass(frozen=True)
|
|
class RewriteTrace: keys:list[TracingKey]; rewrites:list[list[TrackedGraphRewrite]]; uop_fields:dict[int, tuple] # noqa: E702
|
|
|
|
if TRACK_MATCH_STATS or PROFILE:
|
|
PatternMatcher = TrackedPatternMatcher # type: ignore
|
|
import atexit
|
|
@atexit.register
|
|
def print_match_stats():
|
|
if TRACK_MATCH_STATS >= 2:
|
|
with open(fn:=temp("rewrites.pkl", append_user=True), "wb") as f:
|
|
print(f"rewrote {len(tracked_ctxs)} graphs and matched {sum(len(r.matches) for x in tracked_ctxs for r in x)} times, saved to {fn}")
|
|
pickle.dump(RewriteTrace(tracked_keys, tracked_ctxs, uop_fields), f)
|
|
TRACK_MATCH_STATS.value = 0
|
|
launch_viz("REWRITE_DATA", temp("rewrites.pkl", append_user=True))
|
|
if getenv("PRINT_MATCH_STATS", TRACK_MATCH_STATS.value and not VIZ):
|
|
ret = [0,0,0.0,0.0]
|
|
for k,v in sorted(list(match_stats.items()), key=lambda x: x[1][2]+x[1][3]):
|
|
loc_str = f"{k.location[0].split('/')[-1]}:{k.location[1]}"
|
|
if v[1] != 0: print(f"{v[0]:6d} / {v[1]:7d} -- {v[3]*1000.:9.2f} / {(v[2]+v[3])*1000.:9.2f} ms -- {loc_str:20s}", printable(k.location))
|
|
ret = [x+y for x,y in zip(ret, v)]
|
|
print(f"{ret[0]:6d} / {ret[1]:7d} -- {ret[3]*1000.:9.2f} / {(ret[2]+ret[3])*1000.:9.2f} ms -- TOTAL")
|
|
print(f"{len(match_stats)} rules, {sum(v[0] > 0 for v in match_stats.values())} matched once")
|
|
|
|
def launch_viz(env_str:str, data:str):
|
|
os.environ[f"{env_str}_DATA"] = data
|
|
if not TRACK_MATCH_STATS and not PROFILE:
|
|
os.environ["VIZ"], os.environ["PROFILE"], os.environ["TRACK_MATCH_STATS"] = "0", "0", "0"
|
|
args = ['--rewrites-path', os.getenv("REWRITE_DATA", "")] if os.getenv("REWRITE_DATA", "") else []
|
|
args += ['--profile-path', os.getenv("PROFILE_DATA", "")] if os.getenv("PROFILE_DATA", "") else []
|
|
viz_path = pathlib.Path(__file__).resolve().parent.parent / "viz" / "serve.py"
|
|
if VIZ > 0 and sys.stdout.isatty(): os.execv(sys.executable, [sys.executable, viz_path.as_posix()] + args)
|
|
if VIZ: print("saved viz files, view using: python -m tinygrad.viz.cli")
|
|
VIZ.value = 0
|
|
|
|
# *** simple graph rewrite engine ***
|
|
|
|
# A pure Python sentinel, but *typed* as UOp so it fits all the dict annotations
|
|
SENTINEL: Final[UOp] = cast(UOp, object())
|
|
class BottomUpGate(Exception): pass
|
|
class RewriteContext:
|
|
def __init__(self, pm, bpm, ctx=None, enter_calls=False):
|
|
self.pm: PatternMatcher|None = pm
|
|
self.bpm: PatternMatcher|None = bpm
|
|
self.bpm_cache: dict[UOp, UOp|None] = {}
|
|
self.ctx = ctx
|
|
self.replace: dict[UOp, UOp] = {}
|
|
self.enter_calls = enter_calls
|
|
|
|
# no cache needed: pm_rewrite is called at most once per UOp due to the replace dict check in unified_rewrite
|
|
def pm_rewrite(self, x:UOp) -> UOp|None: return unwrap(self.pm).rewrite(x, self.ctx)
|
|
|
|
def cached_bpm_rewrite(self, x:UOp) -> UOp|None:
|
|
if (ret:=self.bpm_cache.get(x,SENTINEL)) is not SENTINEL: return ret
|
|
ret = self.bpm_cache[x] = unwrap(self.bpm).rewrite(x, self.ctx)
|
|
return ret
|
|
|
|
def walk_rewrite(self, root:UOp) -> UOp:
|
|
"""MLIR-style Walk Pattern Rewrite Driver: single-pass, no re-traversal into rewritten subtrees."""
|
|
stack: list[tuple[UOp, bool]] = [(root, False)]
|
|
while stack:
|
|
n, processed = stack.pop()
|
|
if n in self.replace: continue
|
|
if not processed:
|
|
# bottom-up: try bpm on original node first, if it rewrites, use result as-is (no traversal into replacement)
|
|
if self.bpm is not None and (rewritten:=self.cached_bpm_rewrite(n)) is not None:
|
|
self.replace[n] = rewritten
|
|
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)):
|
|
self.replace[n.src[0]] = n.src[0]
|
|
for x in reversed(n.src):
|
|
if x not in self.replace: stack.append((x, False))
|
|
else:
|
|
# rebuild node with rewritten srcs
|
|
new_src = tuple(self.replace.get(x, x) for x in n.src)
|
|
new_n = UOp(n.op, _rebuild_dtype(n, new_src), new_src, n.arg, n.tag) if new_src != n.src else n
|
|
# top-down: try pm on rebuilt node, use result as-is (no re-traversal)
|
|
if self.pm is not None and (rewritten:=self.pm_rewrite(new_n)) is not None: new_n = rewritten
|
|
self.replace[n] = new_n
|
|
return self.replace.get(root, root)
|
|
|
|
def unified_rewrite(self, root:UOp) -> UOp:
|
|
stack: collections.deque[tuple[UOp, int, UOp]] = collections.deque([(root, 0, root)])
|
|
on_stack = {root} # all UOps either on the stack or in self.replace, i.e. dont have to be placed again
|
|
waitlist: dict[UOp, list[tuple[UOp, int, UOp]]] = {} # UOps waiting on a dependency to be in self.replace
|
|
while stack:
|
|
if len(stack) > REWRITE_STACK_LIMIT: raise RuntimeError("infinite loop in graph_rewrite (stack too big)")
|
|
n, stage, new_n = stack.pop()
|
|
if n in self.replace: continue # skip any nodes we have seen
|
|
if stage == 0:
|
|
# if bottom up, we rewrite this node early. in both cases, we add its srcs to the stack
|
|
if self.bpm is not None:
|
|
# apply rewrite rules until a fixed point is reached. may return `uop` itself if PatternMatcher doesn't match
|
|
test_n: UOp|None = n
|
|
seen = set()
|
|
try:
|
|
while test_n is not None:
|
|
if test_n in seen: raise RuntimeError("infinite loop in fixed_point_rewrite")
|
|
seen.add(test_n)
|
|
new_n, test_n = test_n, self.cached_bpm_rewrite(test_n)
|
|
except BottomUpGate:
|
|
# if the bpm matching raised a gate, we are done with this node and dont continue down the srcs
|
|
self.replace[n] = unwrap(test_n)
|
|
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)):
|
|
self.replace[new_n.src[0]] = new_n.src[0]
|
|
for x in reversed(new_n.src):
|
|
if x in on_stack: continue
|
|
stack.append((x, 0, x))
|
|
on_stack.add(x)
|
|
elif stage == 1:
|
|
tmp = []
|
|
for x in new_n.src:
|
|
if (rx:=self.replace.get(x, SENTINEL)) is SENTINEL:
|
|
# source not ready: register in waitlist instead of spinning
|
|
waitlist.setdefault(x, []).append((n, 1, new_n))
|
|
break
|
|
tmp.append(rx)
|
|
else:
|
|
# in stage 1, once all srcs are rewritten, rebuild (if changed) or run top-down rewrite
|
|
if (new_src:=tuple(tmp)) == new_n.src:
|
|
# if top down, do the rewrite. if no rewrite or bottom up, we are done rewriting this node so we add it to the dict
|
|
if self.pm is None or (new_src_n:=self.pm_rewrite(new_n)) is None:
|
|
self.replace[n] = new_n
|
|
if n in waitlist: stack.extend(waitlist.pop(n))
|
|
continue
|
|
else:
|
|
# if srcs changed from rewrites, construct a new UOp with the new srcs
|
|
new_src_n = UOp(new_n.op, _rebuild_dtype(new_n, new_src), new_src, new_n.arg, new_n.tag)
|
|
# trigger a rewrite of new_src_n, then after that rewrite is done, link it back to n
|
|
stack.append((n, 2, new_src_n))
|
|
stack.append((new_src_n, 0, new_src_n))
|
|
else:
|
|
# in stage 2, we link the result of new_n to the result of n
|
|
if (replaced_new_n:=self.replace.get(new_n, SENTINEL)) is SENTINEL:
|
|
# not ready: register in waitlist instead of spinning
|
|
waitlist.setdefault(new_n, []).append((n, 2, new_n))
|
|
else:
|
|
# otherwise we are done
|
|
self.replace[n] = replaced_new_n
|
|
if n in waitlist: stack.extend(waitlist.pop(n))
|
|
return self.replace[root]
|
|
|
|
@rewrite_group(new_ctx=False)
|
|
def graph_rewrite(sink:UOp, pm:PatternMatcher, ctx=None, bottom_up=False, name=None, bpm=None, walk=False, enter_calls=False) -> UOp:
|
|
rewrite_ctx = RewriteContext(pm if not bottom_up else None, pm if bottom_up else bpm, ctx, enter_calls)
|
|
return rewrite_ctx.walk_rewrite(sink) if walk else rewrite_ctx.unified_rewrite(sink)
|
|
|
|
def _rebuild_dtype(n:UOp, new_src:tuple[UOp,...]) -> DType:
|
|
# TODO: delete this once the dtype field is removed, every rebuild will re-derive
|
|
if all(a.dtype is b.dtype or b.base.is_invalid for a,b in zip(n.src, new_src)): return n.dtype
|
|
return dtype_from_uop(n.op, new_src, n.arg) or n.dtype
|
|
|
|
def sint_to_uop(x:sint, dtype=dtypes.weakint) -> UOp: return UOp.const(x, dtype)
|
|
def to_max_shape(shape:tuple[sint, ...]) -> tuple[int, ...]: return tuple(int(x.vmax) if isinstance(x, UOp) else x for x in shape)
|
|
|
|
_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])])
|
|
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:
|
|
if x.op is Ops.LINEAR: return False
|
|
if x.op is Ops.SINK and isinstance(x.arg, KernelInfo): return False
|
|
return True
|
|
|
|
def do_unbind(ctx:dict[Variable, int], x:UOp):
|
|
v,i = x.unbind()
|
|
ctx[v] = i
|
|
return v
|
|
pm_unbind = PatternMatcher([(UPat(Ops.BIND, name="x"), do_unbind)])
|
|
|
|
# ctx is source UOp for which we are finding a contiguous view for. used in contiguous_view_offset
|
|
pm_contiguous_view_offset = PatternMatcher([
|
|
(UPat(Ops.INDEX, src=(UPat(),)), lambda: UOp.const(0)),
|
|
(UPat(Ops.INDEX, src=(UPat(), UPat(Ops.RANGE))), lambda: UOp.const(0)),
|
|
(UPat(Ops.INDEX, src=(UPat(), UPat(Ops.RANGE)+UPat.cvar('c'))), lambda c: c),
|
|
(UPat(Ops.INDEX, src=(UPat(), UPat.cvar('c'))), lambda ctx, c: c if resolve(ctx.numel() == 1, False) else None),
|
|
])
|
|
|
|
# *** what was symbolic.py ***
|
|
|
|
sint = int|UOp
|
|
Variable = UOp
|
|
|
|
ConstLike = ConstType|Variable|tuple[ConstType, ...]
|