-
Notifications
You must be signed in to change notification settings - Fork 4.3k
Expand file tree
/
Copy pathops.py
More file actions
1847 lines (1687 loc) · 103 KB
/
Copy pathops.py
File metadata and controls
1847 lines (1687 loc) · 103 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
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, 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(); PLACEHOLDER = auto(); LOOP = auto() # noqa: E702
@dataclass(frozen=True, order=True)
class ParamArg:
slot: int
dtype: DType
# number of elements in the buffer. always a concrete int (never symbolic), None for scalars (shape ())
size: int|None = None
vmin_vmax: tuple[PyConst, PyConst]|None = None
multiple_of: int|None = None
name: str|None = None
addrspace: AddrSpace|None = AddrSpace.GLOBAL
device: str|tuple[str, ...]|None = None
volatile: bool = False
# (h, w) if this is an image2d buffer, then size == h*w*4
image: tuple[int, int]|None = None
# the device Buffer for a realized BUFFER. the UOp is the owner of the Buffer: they live and die together (1:1)
buffer: Buffer|MultiBuffer|None = None
def __repr__(self):
fields = (("vmin_vmax", None), ("multiple_of", None), ("name", None), ("addrspace", AddrSpace.GLOBAL), ("device", None),
("volatile", False), ("image", None))
args = [repr(self.slot), repr(self.dtype)] + ([repr(self.size)] if self.size is not None else []) + \
[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.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.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.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.END: 1, Ops.CALL: 1, Ops.LINEAR: 0}
# https://en.wikipedia.org/wiki/Identity_element
def identity_element(op:Ops, dt:DType) -> PyConst: return dt.const({Ops.ADD:0, Ops.MUL:1, Ops.MAX:dt.min}[op])
# 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:
src = tuple(x if isinstance(x, UOp) else UOp.const(x) for x in arg)
for x in src:
if not dtypes.is_int(x.dtype): raise RuntimeError(f"shape must be int, got {x.dtype} in {arg}")
return src[0] if len(src) == 1 else UOp(Ops.STACK, src=src)
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:
# here are the dtype production rules, total over all Ops
match op:
case Ops.STORE | Ops.LINEAR | Ops.SINK | Ops.PROGRAM | Ops.SOURCE | \
Ops.END | Ops.BARRIER | Ops.GROUP | Ops.IF | Ops.ENDIF | Ops.NOOP | \
Ops.CUSTOM_FUNCTION | Ops.REWRITE_ERROR | Ops.PYLITERAL:
# always void
return dtypes.void
case Ops.CALL:
# a call states its (possibly void) dtype in the CallInfo
return arg.dtype if isinstance(arg, CallInfo) else dtypes.void
case Ops.CUSTOM | Ops.CUSTOMI:
assert isinstance(arg, tuple) and len(arg) == 2 and isinstance(arg[1], DType), f"CUSTOM/CUSTOMI arg must be (str, DType), got {arg}"
return arg[1]
case Ops.INS:
# arg is (instruction, dtype), a queue command or an asm line is void
assert isinstance(arg, tuple) and len(arg) == 2 and isinstance(arg[1], DType), f"INS arg must be (instruction, DType), got {arg}"
return arg[1]
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 dtypes.bool if src[0].base.is_invalid else 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.WMMA:
# WMMA output dtype is the accumulator dtype (src[2])
return src[2].dtype
case Ops.GETADDR:
return dtypes.uint64
case Ops.THREEFRY:
return dtypes.uint64
case Ops.FDIV:
return least_upper_float(promo_dtype(src))
case Ops.SHL | Ops.SHR:
if not all(dtypes.is_int(x.dtype) or x.base.is_invalid for x in src):
raise RuntimeError(f"shift operands must be int, got {[x.dtype for x in src]}")
return src[0].dtype
case Ops.BUFFER | Ops.PARAM:
assert isinstance(arg, ParamArg), f"{op} must have ParamArg"
return arg.dtype
case Ops.BINARY:
return dtypes.uint8
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, src:tuple[UOp,...]=tuple(), arg:Any=None, tag:Any=None, metadata:tuple[Metadata,...]|None=None):
# NOTE: the key must separate nodes of different dtype: a CONST's dtype is the type of its arg, and True == 1 as dict keys
if (wret:=UOpMetaClass.ucache.get(key:=(op, src, arg, tag, type(arg)), None)) is not None and (ret:=wret()) is not None: return ret
UOpMetaClass.ucache[key] = weakref.ref(created:=super().__call__(op, src, arg, tag))
if metadata is not None: all_metadata[created] = metadata
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
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.rand import RandMixin
# NOTE: this should be frozen, but frozen is slower
@dataclass(eq=False, slots=True)
class UOp(RandMixin, metaclass=UOpMetaClass):
op:Ops
src:tuple[UOp, ...] = tuple()
arg:Any = None
tag:Any = None
@recursive_property
def dtype(self) -> DType: return dtype_from_uop(self.op, self.src, self.arg)
def __del__(self):
# NOTE: getattr because this object may be partially constructed (e.g. if __init__ raised, like the BEAM timeout SIGALRM)
try: del UOpMetaClass.ucache[(self.op, self.src, self.arg, self.tag, type(self.arg))]
except (AttributeError, KeyError): pass
def __reduce__(self): return UOp, (self.op, self.src, self.arg, self.tag, self.metadata)
def replace(self, **kwargs) -> UOp:
new_args = (kwargs.pop("op", self.op), 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.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):
if self.op is Ops.CONST: return self.arg
# a casted const CAST(dt, CONST(v)) is one const: .val reads the value through the CAST
assert self.op is Ops.CAST and self.src[0].op is Ops.CONST, f"val is only for consts, got {self.op}"
return self.src[0].val
@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 ""
@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 is not Ops.CALL else node.src[1:]):
stack.append((s, False)) # push srcs on the stack
else: cache[node] = None # second time i'm seeing this node, add it to returned toposort
return cache
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:
# arg goes through repr: args of different types (None, str, tuple) must stay mutually comparable for the sort
return (self.op.value, repr(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:
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.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.RANGE | Ops.SPECIAL: return ()
case Ops.BINARY: return (len(self.arg),)
case Ops.BUFFER | Ops.PARAM:
# these don't have a shape input, they have a size in the arg: int gives shape (size,), None gives ()
if (img:=self.arg.image) is not None: return (img[0], img[1], 4)
return () if self.arg.size is None else (self.arg.size,)
case Ops.CUSTOM | Ops.CUSTOMI:
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
# 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}")
# with symbolic views prod equality can be true at runtime but unprovable, only reject provably unequal products
if resolve(prod(ps) != prod(self.marg), False): raise ValueError(f"bad reshape: {ps} -> {self.marg}")
return self.marg
case Ops.EXPAND:
return tuple(self.marg) + ps
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 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 is Ops.CALL and self.src[0].op is Ops.CUSTOM_FUNCTION and self.src[0].src: return ()
if self.op is Ops.END: return tuple(r for r in self.src[1:] if r.op is Ops.RANGE)
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
with Context(TRACK_MATCH_STATS=0 if not tracked else TRACK_MATCH_STATS.value):
return graph_rewrite(self, symbolic, name="simplify")
def ssimplify(self) -> UOp|ConstType:
if (ret := self.simplify()).op is Ops.CAST and ret.src[0].op is Ops.CONST: return ret.dtype.const(ret.src[0].val)
return ret.val if ret.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)
# tags can contain UOps (callify tags nodes with their originals): store them as trace_nums, same as srcs
tag = tuple(t.trace_num if isinstance(t, UOp) else t for t in self.tag) if isinstance(self.tag, tuple) else self.tag
# the trace must not retain the device Buffer: store a placeholder instead (the real one would pin memory and fail pickling),
# keeping bound and unbound buffers distinguishable in viz
arg = replace(self.arg, buffer=cast("Buffer", object())) if isinstance(self.arg, ParamArg) and self.arg.buffer is not None else self.arg
uop_fields[num] = (self.op, tuple(s.trace_num for s in self.src), arg, 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 group(*srcs:UOp|None, **kwargs): # pylint: disable=no-self-argument
if len(srcs) == 1 and isinstance(srcs[0], UOp): return srcs[0]
return UOp(Ops.GROUP, src=tuple([x for x in srcs if x is not None]), **kwargs)
@property
def has_unbound_outputs(self) -> bool:
"""does this call still have unresolved outputs: unbound BUFFERs among its inputs (minted by call_with_outputs,
resolved when the call is inlined or the outputs are materialized). a lifecycle query, not a call type"""
return self.op is Ops.CALL and any(x.unsharded_base.is_unbound for x in self.src[1:])
@property
def unbound_outputs(self) -> tuple[UOp, ...]:
"""the unresolved outputs of this call: an AFTER on each unbound BUFFER input, usable like a normal buffer"""
return tuple(x.after(self) for x in self.src[1:] if x.unsharded_base.is_unbound)
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):
# for use after movement ops have been removed
return UOp.const(b, 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("src", self.src), (arg, kwargs.pop("dtype", self.dtype)), 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])
# .cast folds away at exactly the dtypes a CONST derives (bool/weakint/weakfloat): bare there, the pair everywhere else
return UOp(Ops.CONST, arg=dtype.const(b), src=()).cast(dtype)
# cast, except for CONST, in which case rebuild a new CONST at the dtype
def ccast(self, dtype:DType): return UOp.const(self.val, dtype) if self.op is Ops.CONST else self.cast(dtype)
# a forced CAST for bool: .cast(bool) folds, so UOp.const cannot state the width
@staticmethod
def cconst(b:ConstLike, dtype:DType): return UOp(Ops.CAST, src=(UOp.const(b),), arg=dtype)
@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): return UOp(Ops.SPECIAL, src=(sint_to_uop(end),), 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]
if self.op is Ops.PARAM: return None
# NOTE: they all have to share an axis, we always choose [-1]. src axes are right-aligned into the output shape
if self.op in GroupOp.ALU.union({Ops.STACK}):
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.pad_to(inp.max_shape),), arg=device).shrink_to(inp.shape)
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
# base with UNSHARD
@property
def unsharded_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
# TODO: why can't this be in normal base?
if self.op is Ops.UNSHARD: return self.src[0].base
return self
# the storage this uop ultimately targets: base with UNSHARD, BITCAST and AFTER stripped
@property
def storage_base(self) -> UOp:
b = self.unsharded_base
while b.op in {Ops.BITCAST, Ops.AFTER, Ops.UNSHARD}: b = b.src[0].unsharded_base
return b
# 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:
srcs = (self,)+tuple(arg)
dtype = dtype_from_uop(Ops.STACK, srcs, None)
return UOp(Ops.STACK, src=tuple(u if u.base.is_invalid else u.ccast(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.SHRINK, Ops.BITCAST, Ops.BINARY, Ops.MSTACK, Ops.MSELECT, Ops.PARAM, Ops.LINEAR}: 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}")
assert isinstance(size, int), f"new_buffer size must be a concrete int, got {size}"
slot = next(UOp.unique_num) if num is None else num
buf = MultiBuffer(device, size, dtype) if isinstance(device, tuple) else Buffer(device, size, dtype)
return UOp(Ops.BUFFER, arg=ParamArg(slot, dtype, size=size, device=device, buffer=buf))
@staticmethod
def from_buffer(opaque:Buffer, device:str|tuple[str, ...]|None=None):
# the opaque Buffer goes straight in the arg: the ucache dedups because the arg (and thus the Buffer) is part of the key
return UOp(Ops.BUFFER, arg=ParamArg(-id(opaque), opaque.dtype, size=opaque.size, device=device or opaque.device, buffer=opaque))
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=self.commit_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)])
if not data: ret.buffer.allocate(memoryview(bytearray()))
else: ret.buffer.ensure_allocated().host[:] = data
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, Ops.CONST}: 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(self) -> tuple[UOp, int]|None:
from tinygrad.schedule.prepare 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")
if out.op is not Ops.INDEX or not (b:=out.src[0]).tag or (c:=out.src[1]).op is not Ops.CONST or not isinstance(c.val, int): return None
return b.rtag(None), c.val
def contiguous_view_offset(self) -> int|None: return None if (view := self.contiguous_view()) is None else view[1]
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.PARAM} and not self.is_unbound
@property
def is_unbound(self) -> bool:
# an unbound GLOBAL BUFFER has no storage bound yet: it's a declaration of storage (call output, scheduler temp)
return self.op is Ops.BUFFER and isinstance(self.arg, ParamArg) and self.addrspace is AddrSpace.GLOBAL and self.arg.buffer is None
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.CONTIGUOUS_BACKWARD, Ops.RESHAPE, Ops.UNSHARD, Ops.DETACH, Ops.AFTER}: return self.src[0].buffer
# this buffer can process disk tensors and simple movement ops.
# NOTE: the view Buffer returned here is transient (short-lived), it only wraps an offset into the base BUFFER's storage
if self is not self.base or self.op is Ops.BITCAST:
if (cv := self.contiguous_view()) is None: raise RuntimeError(f"non-contiguous view is not supported for {self.device} buffer")
buf, offset = (b:=cv[0]).base.buffer, cv[1]
if isinstance(buf, MultiBuffer):
mbuf = MultiBuffer.__new__(MultiBuffer)
mbuf.bufs = [x.view(prod(self.max_shape), self.dtype, offset*b.dtype.itemsize) for x in buf.bufs]
return mbuf
return buf.view(prod(self.max_shape), self.dtype, offset*b.dtype.itemsize)
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 and self.arg.buffer is not None, f"must be a realized BUFFER {self}"
return self.arg.buffer
@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, and Variables (ALU) have no real storage
if self.op is Ops.BUFFER and self.addrspace in (AddrSpace.LOCAL, AddrSpace.REG, AddrSpace.ALU): return None
# an unbacked intermediate BUFFER (directly or as an MSTACK source) is not realized
if any(b.op is Ops.BUFFER and b.arg.buffer 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, param:bool=False) -> UOp:
# a Variable is a 0-d BUFFER in the ALU addrspace; binding it is storing a CONST into it
# param=True creates the kernel-side form directly: an ALU PARAM (what the BUFFER becomes inside kernels)
arg = ParamArg(-1, dtype, name=name, vmin_vmax=(min_val, max_val), multiple_of=multiple_of, addrspace=AddrSpace.ALU)
return UOp(Ops.PARAM if param else Ops.BUFFER, arg=arg)
@property
def is_variable(self) -> bool:
# a Variable is a 0-d BUFFER in the ALU addrspace that carries a value range (it becomes a PARAM inside kernels)
return self.op is Ops.BUFFER and isinstance(self.arg, ParamArg) and \
self.arg.vmin_vmax is not None and self.arg.addrspace is AddrSpace.ALU and self._shape == ()
@property
def is_bound_var(self) -> bool:
# a bound Variable is bind()'s AFTER(var, STORE(var, CONST))
return self.op is Ops.AFTER and self.src[0].is_variable and self.src[1].op is Ops.STORE and \
self.src[1].src[0] is self.src[0] and self.src[1].src[1].op is Ops.CONST and len(self.src) == 2
@property
def expr(self) -> str:
assert self.op in {Ops.PARAM, Ops.BUFFER}
return unwrap(self.arg.name)
def bind(self, val:int|UOp):
assert self.is_variable, f"op is {self.op}, need Variable"
# the Variable states the width, so the bound value stays bare: is_bound_var tests for a CONST there, unbind reads .val
uval = UOp.const(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 self.after(self.store(uval))
def unbind(self) -> tuple[Variable, int]:
assert self.is_bound_var, f"can't unbind {self}"
return self.src[0], self.src[1].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 in {Ops.PARAM, Ops.BUFFER} else UOp.variable("_device_num", 0, x.vmax, dtype=x.dtype, param=True)
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) or x.is_variable}, key=lambda v: v.expr)
# *** uop symbolic stuff ***