Files
StarPilot/tinygrad_repo/tinygrad/schedule/prepare.py
T
2026-11-02 13:38:46 -06:00

261 lines
15 KiB
Python

from dataclasses import replace
from tinygrad.dtype import dtypes, to_dtype
from tinygrad.uop.ops import PatternMatcher, UPat, Ops, UOp, GroupOp
from tinygrad.uop.ops import graph_rewrite, MapCtx, rewrite_group, identity_element, resolve_returned_after
from tinygrad.uop.movement import mop_cleanup
from tinygrad.helpers import prod, getenv, all_int, DEBUG, SPLIT_REDUCEOP, OPENPILOT_HACKS, FLOAT16, argsort
from tinygrad.schedule.indexing import apply_movement_op
from tinygrad.schedule.allreduce import create_allreduce_function
from tinygrad.schedule.multi import multi_pm
def forward_call_outputs(sink:UOp) -> UOp:
placed:dict[UOp, UOp] = {}
items:list[UOp] = []
for item in sink.src:
st = item.src[1] if item.op is Ops.AFTER and len(item.src) == 2 and item.src[1].op is Ops.STORE else item
if st.op is not Ops.STORE or (item is not st and item.src[0] is not st.src[0]):
items.append(item)
continue
target, src = st.src
while src.op is Ops.AFTER: src = src.src[0]
base = src.storage_base
if item is not st and base.op is not Ops.ALLOC:
items.append(item)
continue
# Forward the allocation, not just one view of it, so saved values and other aliases follow the same placement.
key = base if base.op is Ops.ALLOC else src
if key not in placed and (src.op is Ops.STAGE or target.has_buffer_identity()) and \
target.storage_base not in st.src[1].toposort():
if base.op is Ops.ALLOC and src.has_buffer_identity() and base.max_numel() == target.storage_base.max_numel():
placed[key] = target.storage_base
elif src.op is Ops.STAGE: placed[key] = target.after(target.store(src.src[0]))
elif src.op in {Ops.BUFFER, Ops.UNSHARD} and src.has_buffer_identity(): placed[key] = target
if key in placed:
if item is not st: placed[item] = st.src[1]
items.append(st.src[1])
continue
items.append(target.after(st))
return UOp.sink(*items).substitute(placed, walk=True)
def walk_mop(u:UOp):
if u.op in GroupOp.Movement or u.op in {Ops.INDEX, Ops.UNSHARD, Ops.BITCAST}: return walk_mop(u.src[0])
if u.op is Ops.AFTER and (b:=walk_mop(u.src[0])) is not u.src[0]: return b.after(*u.src[1:])
return u
def found_after(ctx:dict[UOp, UOp], after:UOp, src:UOp):
if (x:=src).op is Ops.CAST and x.dtype == dtypes.half and FLOAT16: x, after = x.src[0], after.cast(dtypes.float)
while True:
if x.op is Ops.PERMUTE: x, after = x.src[0], after.permute(argsort(x.marg))
elif x.op is Ops.RESHAPE: x, after = x.src[0], after.reshape(x.src[0].shape)
elif x.op is Ops.WHERE and x.src[2].base.is_invalid and x.src[1].op is Ops.PAD:
x, after = x.src[1].src[0], after.shrink(tuple((o, s+o) for (o,_),s in zip(x.src[1].marg, x.src[1].src[0].shape)))
else: break
ctx[x] = after
# *** fold moved AFTERs (hack for openpilot) ***
pm_fold_moved_after = PatternMatcher([
(UPat(Ops.AFTER, src=(UPat(), UPat(Ops.STORE, src=(UPat(), UPat((*GroupOp.Movement,Ops.CAST,Ops.WHERE), name="src")))), name="after"), found_after),
# contiguous is also a materialization point (it bufferizes in the scheduler)
(UPat(Ops.STAGE, src=(UPat((*GroupOp.Movement,Ops.CAST,Ops.WHERE), name="src"),), name="after"), found_after),
# replace ALU sources with AFTER versions found above
(UPat(GroupOp.ALU, name="alu"), lambda ctx,alu: alu.replace(src=new_src) if (new_src:=tuple(ctx.get(s, s) for s in alu.src)) != alu.src else None),
])
# movement op on INDEX as a PatternMatcher
def _mop_index(r:UOp, idx:UOp):
idxs = idx.src[1:]
if len(idxs) == len(r.shape):
return r.src[0].index(*apply_movement_op(r.op, r.src[0].shape, r.marg, idxs), arg=idx.arg)
if r.op is Ops.RESHAPE:
src_prefix = len(r.src[0].shape) - len(r.shape[len(idxs):])
if src_prefix >= 0 and r.src[0].shape[src_prefix:] == r.shape[len(idxs):]:
ret = r.src[0].index(*apply_movement_op(r.op, r.src[0].shape[:src_prefix], r.shape[:len(idxs)], idxs), arg=idx.arg)
return ret if ret.shape == idx.shape else None
pm_mops = PatternMatcher([
# handle movement ops on INDEX
(UPat(GroupOp.Movement, name="r").f(Ops.INDEX, allow_any_len=True, name="idx"), _mop_index),
# A store through a padded view gates the destination index, not its loaded value.
(UPat.var("a").where(UPat.var("b").where(UPat.var("dst"), 0), UPat.var("pad")).store(UPat.var("val")),
lambda a,b,dst,pad,val: (a&b).where(dst, 0).store(val) if pad.is_invalid else None),
(UPat.var("cond").where(UPat.var("buf").index(UPat.var("idx")), 0).store(UPat.var("val")),
lambda cond,buf,idx,val: buf.index(idx.valid(cond)).store(val)),
# move movement ops and INDEX after AFTER
(UPat(GroupOp.Movement|{Ops.INDEX}, name="r").after(name="a", allow_any_len=True),
lambda r,a: UOp(r.op, src=(a.replace(src=(r.src[0],)+a.src[1:]),)+r.src[1:], arg=r.arg)),
(UPat(GroupOp.Movement, name="r").end(name="a", allow_any_len=True), lambda r,a: a.replace(src=(r.src[0],)+a.src[1:])),
])
# *****************
# 0. do some cleanup rewrites, mostly copied from the old stuff
def split_reduceop(reduce:UOp, x:UOp):
if prod(reduce.shape) == 0: return None
if not SPLIT_REDUCEOP or not all_int(x.shape) or prod(x.shape[:reduce.arg[1]])<getenv("REDUCEOP_SPLIT_THRESHOLD", 32768): return None
# if there are few globals, make some reduces into globals by splitting into two kernels
# cap output buffer to 2**22: heuristic number of global outputs to achieve max occupancy with enough locals+upcasts for gemm
# ~2**10 should be enough if GROUP is used
# 256 split maximum should be "negligible reduce" for low prod(reduce.shape), 8 split minimum.
# split is moved to the end to provide maximum locality for the second phase reduce.
# an axis is expanded if its range does not reach the index into the base
rngs = tuple(UOp.range(s, i) for i,s in enumerate(x.shape))
idxs, u = rngs, x
while u.op in GroupOp.Movement: idxs, u = apply_movement_op(u.op, u.src[0].shape, u.marg, idxs), u.src[0]
is_expanded = [r not in UOp.sink(*idxs).ranges for r in rngs]
if not (split_candidates:=[(i,d) for i in range(reduce.arg[1])
for d in range(min(256,2**getenv("REDUCEOP_SPLIT_SIZE",22)//prod(reduce.shape)),8-1,-1)
if x.shape[i]%d==0 and not is_expanded[i]]): return None
dim_to_split, divisor = split_candidates[0]
splitted = x.unflatten(dim_to_split, (divisor, -1)).permute(tuple([d for d in range(x.ndim+1) if d!=dim_to_split]+[dim_to_split]))
if DEBUG >= 3: print(f"split {divisor}: {x.shape} -> {splitted.shape} -> {reduce.shape}")
# reduce original axes, then split
return splitted._rop(reduce.arg[0], tuple(range(reduce.arg[1]))).contiguous()._rop(reduce.arg[0], (len(reduce.shape),))
def resolve_function(c:UOp) -> UOp|None:
if not c.is_inline_call: return None
nodes = c.body.toposort()
# Input and output PARAMs both bind to explicit arguments by slot; unused arguments are allowed.
args = c.src[1:]
# params have a flat storage size in the arg, the logical shape is a view (RESHAPE/SHRINK/UNSHARD) on top of it.
# substitute args by their flat max-shaped storage view so the movement views on the params stay valid
def flat_storage(a:UOp) -> tuple[int, UOp]: # returns (size, view of a as flat max-shaped storage)
shp = a.max_shard_shape if a.axis is not None and isinstance(a.device, tuple) else a.max_shape
if a.op is Ops.SHRINK and a.src[0].shape == shp and all(s == 0 for s,_ in a.marg): a = a.src[0]
return (n:=prod(shp)), a if a.shape == (n,) else a.pad_to(shp).reshape((n,))
dict_map = {p:args[p.arg.slot] for p in nodes if p.op is Ops.PARAM and p.arg.slot >= 0}
for p, a in dict_map.items():
if p.shape:
n, flat = flat_storage(a)
if p.max_numel() != n: raise TypeError(f"arg {p.arg.slot} shape mismatch: expected size {p.max_numel()}, got {a.shape}")
dict_map[p] = flat
elif a.shape != ():
raise TypeError(f"arg {p.arg.slot} shape mismatch: expected scalar, got {a.shape}")
if p.dtype != a.dtype: raise TypeError(f"arg {p.arg.slot} dtype mismatch: expected {p.dtype}, got {a.dtype}")
# Inlining removes the call scope, so its local allocations need fresh identities.
dict_map.update({b:b.replace(arg=replace(b.arg, slot=next(UOp.unique_num))) for b in nodes if b.op is Ops.ALLOC})
return c.body.substitute(dict_map, walk=True)
# shape-changing bitcast
def expand_bitcast(bc:UOp) -> UOp|None:
x = bc.src[0]
if (ns:=bc.dtype.itemsize) == (os:=x.dtype.itemsize) or x.on_disk(): return None
new_uint, tmp = to_dtype(f"uint{8*ns}"), x.bitcast(to_dtype(f"uint{8*os}"))
if ns > os:
tmp = tmp.reshape(x.shape[:-1] + (x.shape[-1]//(rate := ns//os), rate))
parts = [tmp.shrink((None,)*(len(tmp.shape)-1) + ((i, i+1),)).cast(new_uint)<<8*i*os for i in range(rate)]
return parts[0].usum(*parts[1:]).squeeze(-1).bitcast(bc.dtype)
parts = [tmp>>8*i*ns for i in range(os//ns)]
return parts[0].stack(*parts[1:], dim=-1).flatten(-2).cast(new_uint).bitcast(bc.dtype)
def copy_to_anon_store(x:UOp, copy:UOp):
# copies are always cross device: pad to the max shape so the copy reads a whole buffer (SDMA can't do offset copies)
x = x.pad_to(x.max_shape)
# the buffer takes the DEVICE range from the copy (no-op for single device copies)
buf = UOp.alloc((x.max_numel(),), copy.dtype, device=copy.device)
buf = buf.replace(src=buf.src[:1]+copy.src[1:]).reshape(x.max_shape)
return buf.after(buf.store(x)).shrink_to(copy.shape)
def stage_to_anon_store(x:UOp, stg:UOp):
# the buffer created here is inside the call and is not persisted, like the buffers created for copies
buf = UOp.alloc(x.max_shape, stg.dtype, device=x.device)
view = buf.shrink_to(stg.shape)
return view.after(view.store(x))
def materialize_cross_device_src(dest:UOp, src:UOp):
# cross-device copies must read a whole buffer (SDMA can't do offset copies)
if src.device is None or dest.device == src.device or src.has_buffer_identity(after_ok=True): return None
return dest.store(src.contiguous())
pm_inline_calls = PatternMatcher([
(UPat(Ops.CALL, name="c"), resolve_function),
(UPat(Ops.AFTER, src=(UPat(name="r"), UPat(Ops.SINK, name="t")), allow_any_len=True), resolve_returned_after),
])
pm_disk_copy = PatternMatcher([
# remove contiguous on movement ops before a copy on disk
(UPat(GroupOp.Movement, name="x").f(Ops.STAGE).f(Ops.COPY, name="copy"), lambda x,copy:
copy.replace(src=(x,)) if x.on_disk() else None),
# push all movement ops to the destination: views exposed here are no longer normalized into input PARAMs,
# so leaving SHRINK/RESHAPE behind can cause materialize_cross_device_src to allocate a temporary on disk
(UPat(GroupOp.Movement, name="x").f(Ops.COPY, name="copy"), lambda x,copy:
x.replace(src=(copy.replace(src=(x.src[0],)),)+x.src[1:]) if x.on_disk() else None),
])
earliest_rewrites = mop_cleanup+PatternMatcher([
# resolve allreduce (must be bottom up)
(UPat(Ops.ALLREDUCE, src=(UPat.var("buf"),), name="red"), create_allreduce_function),
# split_reduceop
(UPat(Ops.REDUCE, name="reduce", src=(UPat.var("x"),)), split_reduceop),
# remove DETACH/CONTIGUOUS_BACKWARD (TODO: this is copied in allocations)
(UPat((Ops.DETACH, Ops.CONTIGUOUS_BACKWARD), name="x"), lambda x: x.src[0]),
# ** copy rules **
# a copy to the same device as the source is not allowed: it is a no-op, STAGE materializes on the same device
(UPat(Ops.COPY, src=(UPat.var("x"),), allow_any_len=True, name="copy"), lambda x,copy: x if x.device == copy.device else None),
# a COPY in src[1] of a plain STORE can just be removed: a STORE to a buffer on a different device is a COPY
(UPat(Ops.STORE, src=(UPat.var("dst"), UPat(Ops.COPY, src=(UPat.var("x"),), allow_any_len=True, name="cpy"))),
lambda dst,x,cpy: dst.store(x) if dst.device == cpy.device and dst.has_buffer_identity(after_ok=True) else None),
# a bare COPY is an anonymous store: realize it as a STORE into a fresh call-local buffer on the copy device
(UPat(Ops.COPY, src=(UPat.var("x"),), allow_any_len=True, name="copy"), copy_to_anon_store),
# ** stage rules **
# a STAGE of an already materialized value (or of a COPY, which materializes itself) is a no-op
(UPat(Ops.STAGE, src=(UPat.var("x"),)),
lambda x: x if x.has_buffer_identity(after_ok=True) or x.op is Ops.COPY else None),
# a bare STAGE is an anonymous same-device materialization: realize it as a STORE into a fresh call-local buffer
(UPat(Ops.STAGE, src=(UPat.var("x"),), name="stg"), stage_to_anon_store),
# reshaping on STORE can be a NOOP
(UPat(Ops.STORE, src=(UPat(Ops.RESHAPE, src=(UPat.var("dst",),), allow_any_len=True),
UPat(Ops.RESHAPE, src=(UPat.var("src",),), allow_any_len=True))),
lambda dst,src: dst.store(src) if dst.shape == src.shape else None),
# ** store rules **
# materialize the src of a cross device STORE on its own device first: the STORE itself is the copy
(UPat(Ops.STORE, src=(UPat(name="dest"), UPat(name="src"))), materialize_cross_device_src),
# remove two STOREs that store the same thing to the same place: TestSchedule.test_dedup_Assign
(UPat.var("buf").after(UPat.var("buf").store(UPat.var("src")), name="a1").after(UPat.var("a1").store(UPat.var("src"))), lambda buf,src,a1:a1),
# store a buffer's own current contents back into itself: TestAssign.test_assign_from_alias
(UPat.var("buf").after(UPat.var("buf").store(UPat.var("buf").after(UPat.var("buf").store(UPat()), name="a1"))), lambda buf,a1:a1),
# move bitcast from store dest to source: TestAssign.test_assign_bitcast
(UPat(Ops.STORE, src=(UPat(Ops.BITCAST, src=(UPat(name="target"),)), UPat(name="src"))),
lambda target, src: target.store(src.bitcast(target.dtype))),
(UPat(Ops.BITCAST, name="bc"), expand_bitcast),
# ** size 0 **
# reduce of size 0 is the identity element
(UPat(Ops.REDUCE, name="reduce", src=(UPat.var("x"),)),
lambda reduce,x: reduce.const_like(identity_element(reduce.arg[0], reduce.dtype)) if 0 in x.shape and 0 not in reduce.shape else None),
# handle size 0
(UPat(GroupOp.All-{Ops.SINK}, name="x"), lambda x: x.const_like(0) if x._shape is not None and 0 in x.shape else None),
# remove movement ops from SINK/AFTER. TODO: should be generic
(UPat(Ops.SINK, name="s"), lambda s: s.replace(src=tuple(walk_mop(u) for u in s.src if u.op is not Ops.NOOP))),
(UPat(Ops.AFTER, name="s"), lambda s: s.replace(src=(s.src[0],)+tuple(walk_mop(u) for u in s.src[1:] if u.op is not Ops.NOOP))),
])
@rewrite_group(new_ctx=False)
def prepare_rangeify(sink:UOp) -> UOp:
# prepare for rangeify
tsink = graph_rewrite(forward_call_outputs(sink), multi_pm, name="multi_pm")
tsink = graph_rewrite(tsink, pm_mops+pm_inline_calls+pm_disk_copy, name="inline calls")
if OPENPILOT_HACKS: tsink = graph_rewrite(tsink, pm_fold_moved_after, init_ctx=MapCtx, name="fold moved afters")
tsink = graph_rewrite(tsink, pm_mops+earliest_rewrites, bottom_up=True, name="earliest rewrites")
return tsink