This commit is contained in:
2023-03-12 22:31:46 -07:00
parent 8d16ebaea7
commit 153cce0f7e
3 changed files with 258 additions and 54 deletions
+247 -43
View File
@@ -1,3 +1,17 @@
"""
Welcome to the tinygrad documentation
=================
this file will take you on a whirlwind journey from a Tensor to a Byte
tinygrad has been aggressively refactored in the 2.5 years it's been worked on.
what you see here is a refined library (with more refining to go still!)
the whole tinygrad is ~2300 lines, so while it's readable in an evening or two,
this documentation will help with entry points and understanding the abstraction stack
"""
# %%
# == Boilerplate imports (typing mostly) ==
from __future__ import annotations
from typing import Optional, Tuple, Union, Any, Dict, Callable, Type, List
from enum import Enum, auto
@@ -5,40 +19,65 @@ from abc import ABC
import numpy as np
import torch
# tinygrad has grown a lot since any docs were last written.
# It's now a terribly large 2300 lines!!
# Let's trace an addition down through the layers of abstraction:
# (note: this is a Python file for syntax highlighting)
# %%
# == Example: Tensor 2+3 ==
# Let's trace an addition down through the layers of abstraction.
# We will be using the clang backend
# Some of this is documentation from the future, meaning this is how things will be refactored to be
# Though this is pretty close, and is cleaner than what's actually there.
from tinygrad.lazy import Device
Device.DEFAULT = "CLANG"
# first, 2+3 as a Tensor
from tinygrad.tensor import Tensor
a = Tensor([2])
b = Tensor([3])
result = a + b
print(f"{a.numpy()} + {b.numpy()} = {result.numpy()}")
assert result.numpy()[0] == 5.
# %%
# == Tensor (in tinygrad/tensor.py, code 8/10) ==
# it's worth just reading tinygrad/tensor.py. it's pretty beautiful
import tinygrad.mlops as mlops
# Tensor (in tinygrad/tensor.py, code 8/10)
# this is the good old familiar Tensor class
class Function: pass
class Tensor:
# these two are pretty striaghtforward
# these two are pretty straightforward
grad: Optional[Tensor]
requires_grad: Optional[bool]
# this is the graph for the autograd engine
_ctx: Optional[Function]
# this is where the data (and other tensor properties) actually live
lazydata: LazyBuffer
# all the definitions of the derivatives are superclasses of Function
# (in tinygrad/mlops.py, code 9/10)
# they have forward and backward, and operate on LazyBuffers
# Function.apply is responsible for lowering the Tensors to LazyBuffers and running forward
# high level ops (hlops) are defined here. example: relu
def relu(self): return self.maximum(0)
# LazyBuffer (in tinygrad/lazy.py, code 5/10)
# this is where the properties live that you thought were a part of Tensor
# log is an mlop, this is the wrapper function in Tensor
def log(self): return mlops.Log.apply(self)
# all the definitions of the derivatives are subclasses of Function (like mlops.Log)
# there's only 18 mlops for derivatives for everything (in tinygrad/mlops.py, code 9/10)
# if you read one file, read mlops.py. if you read two files, also read tinygrad/tensor.py
# you can differentiate the world using the chain rule
class Function:
# example types of forward and backward
def forward(self, x:LazyBuffer) -> LazyBuffer: pass
def backward(self, x:LazyBuffer) -> LazyBuffer: pass
# %%
# == LazyBuffer (in tinygrad/lazy.py, code 5/10) ==
from tinygrad.helpers import DType
# this is where the properties live that you thought were a part of Tensor
# LazyBuffer is like a Tensor without derivatives, at the mlop layer
class LazyBuffer:
# these three define the "type" of the buffer, and they are proxied through Tensor
device:str
shape:Tuple[int, ...]
dtype:DType
# these three define the "type" of the buffer, and they are returned as Tensor properties
device: str
shape: Tuple[int, ...]
dtype: DType
# if the lazybuffer is unrealized, it has a LazyOp
# this LazyOp describes the computation needed to realize this LazyBuffer
@@ -49,12 +88,12 @@ class LazyBuffer:
realized: Optional[DeviceBuffer]
# LazyOp (in tinygrad/ops.py, code 4/10)
# it's an AST node, that defines the type of the compute, the sources, and an optional static argument
# they form an Abstract Syntax Tree
# they form an Abstract Syntax Tree for a single GPU kernel
# LazyOp is an AST node that defines:
class LazyOp:
op: Op
src: Tuple[Union[LazyOp, LazyBuffer], ...]
arg: Optional[Any] = None
op: Op # the type of the compute
src: Tuple[Union[LazyOp, LazyBuffer], ...] # the sources
arg: Optional[Any] = None # and an optional static argument
# there's currently 20 Ops you have to implement for an accelerator.
class UnaryOps(Enum): NOOP = auto(); EXP = auto(); LOG = auto(); NEG = auto(); NOT = auto()
@@ -67,10 +106,49 @@ class LoadOps(Enum): FROMCPU = auto()
# as they are handled by the ShapeTracker(in tinygrad/shape/shapetracker.py, code 7/10)
Op = Union[UnaryOps, BinaryOps, ReduceOps, MovementOps, LoadOps]
# which reminds me, we should get back to DeviceBuffer
# it's an abstract class to be implemented for each backend
# most of tinygrad/lazy.py is concerned with fusing Ops into LazyOps ASTs that map to GPUKernels
# it's beyond the scope of this tutorial, but you can read the file if interested
# %%
# == Example: LazyBuffer for 2+3 ==
from tinygrad.tensor import Tensor
from tinygrad.ops import LazyOp, BinaryOps, LoadOps
# the 2+3 from before
result = Tensor([2]) + Tensor([3])
print(type(result.lazydata), result.lazydata) # let's look at the lazydata of result
# you'll see it has a LazyOp
# the op type is BinaryOps.ADD
# and it has two sources, the 2 and the 3
lazyop: LazyOp = result.lazydata.op
assert lazyop.op == BinaryOps.ADD
assert len(lazyop.src) == 2
# the first source is the 2, it comes from the CPU
# the source is a LazyBuffer, since FROMCPU cannot be folded into LazyOp ASTs
# again, a LazyOp AST is like a GPU kernel. you have to copy the data on the device first
print(lazyop.src[0].op)
assert lazyop.src[0].op.op == LoadOps.FROMCPU
assert lazyop.src[0].op.arg[0] == [2], "the arg of the FROMCPU LazyOP is the [2.]"
assert result.lazydata.realized is None, "the LazyBuffer is not realized yet"
# now we realize the LazyBuffer
result.lazydata.realize()
assert result.lazydata.realized is not None, "the LazyBuffer is realized!"
# this brings us nicely to DeviceBuffer, of which the realized ClangBuffer is a subclass
assert 'ClangBuffer' in str(type(result.lazydata.realized))
# getting ahead of ourselves, but we can copy the DeviceBuffer toCPU
assert result.lazydata.realized.toCPU()[0] == 5, "when put in numpy with toCPU, it's 5"
# %%
# == DeviceBuffer (in tinygrad/ops.py, code 4/10) ==
# DeviceBuffer is an abstract class to be implemented for each Device backend
class DeviceBuffer(ABC):
# these two are straightforward. no need for device, since that's contained in the concrete type
# these two are straightforward.
# unlike LazyBuffer, there's no need for device, since that's contained in the concrete type
shape: Tuple[int, ...]
dtype: DType
@@ -83,7 +161,9 @@ class DeviceBuffer(ABC):
def toCPU(self) -> np.ndarray: raise NotImplementedError("must be implemented")
# DeviceBuffers come in two flavors, InterpretedBuffer and CompiledBuffer
# InterpretedBuffers are a lot simpler than CompiledBuffers, and are used to implement the CPU(numpy) and TORCH backends
# InterpretedBuffers are a lot simpler than CompiledBuffers
# they are used to implement the CPU(numpy) and TORCH(torch) backends
# it's worth reading CPUBuffer (in tinygrad/runtime/ops_cpu.py, code 8/10)
class InterpretedBuffer(DeviceBuffer):
# this is where the data actually lives
# finally some classes you recognize!
@@ -94,10 +174,14 @@ class InterpretedBuffer(DeviceBuffer):
fxn_for_op: Dict[Op, Callable] = {UnaryOps.EXP: lambda x: np.exp(x), BinaryOps.ADD: lambda x,y: x+y}
# NOTE: exec_ast should not need to be overridden!
# The actual method lives in tinygrad/ops.py, and it walks the LazyOp tree and calls fxn_for_op as appropriate
# The actual method lives in tinygrad/ops.py
# it walks the LazyOp tree and calls fxn_for_op as appropriate
# ********** NOTE: for the CPU and TORCH backends, we are done and you can stop reading here **********
# %%
# == CompiledBuffer (in tinygrad/ops.py, code 4/10) ==
# however, all the magic of tinygrad will come from CompiledBuffer
# this is used for the GPU(opencl), CUDA, METAL, CLANG, and LLVM backends
class CompiledBuffer(DeviceBuffer):
@@ -128,10 +212,6 @@ class RawBuffer(ABC):
# toCPU converts the RawBuffer to a numpy array with shape (size,). many backends are 0 copy here
def toCPU(self) -> np.ndarray: raise NotImplementedError("must be implemented")
# RawMallocBuffer is the simplest concrete version of this (in tinygrad/ops.py). it's used for the CLANG and LLVM backends
# it's just malloc(size * dtype.itemsize)
from tinygrad.ops import RawMallocBuffer
# Runtime is what actually runs the kernels
class Runtime(ABC):
# `name` is the name of the function, and `prg` is the code
@@ -140,31 +220,44 @@ class Runtime(ABC):
# call runs the code on the bufs. NOTE: the output is always bufs[0], but this is just a convention
def __call__(self, global_size:Optional[List[int]], local_size:Optional[List[int]], *bufs:List[RawBuffer]): pass
# ClangProgram is the simplest version (in tinygrad/runtime/ops_clang.py)
# %%
# == Example: 2+3 in raw clang ==
# RawMallocBuffer is the simplest concrete version of RawBuffer (in tinygrad/ops.py)
# it's used for the CLANG and LLVM backends
# it's just malloc(size * dtype.itemsize)
from tinygrad.ops import RawMallocBuffer
# ClangProgram is the simplest runtime (in tinygrad/runtime/ops_clang.py, code 7/10)
# __init__ calls clang, and __call__ calls the function in the *.so outputted by clang
# in CLANG, global_size and local_size are ignored
from tinygrad.runtime.ops_clang import ClangProgram
# a concrete example looks like this, that adds two size 1 RawBuffer
from tinygrad.helpers import dtypes
# a concrete example looks like this, this adds two size 1 RawBuffer
# first we create two numpy buffers containing 2 and 3
# then we copy the numpy in to RawMallocBuffers
# last, we create an empty output buffer
from tinygrad.helpers import dtypes
numpy_a, numpy_b = np.array([2], dtype=np.float32), np.array([3], dtype=np.float32)
input_a, input_b = RawMallocBuffer.fromCPU(numpy_a), RawMallocBuffer.fromCPU(numpy_b)
output = RawMallocBuffer(1, dtypes.float32)
# compile the program, run it, and 2+3 does indeed equal 5
program = ClangProgram("add", "void add(float *a, float *b, float *c) { *a = *b + *c; }")
program(None, None, output, input_a, input_b) # NOTE: the None are for global_size and local_size. clean this up?
program(None, None, output, input_a, input_b) # NOTE: the None are for global_size and local_size
print(output.toCPU())
assert output.toCPU()[0] == 5, "it's still 5"
np.testing.assert_allclose(output.toCPU(), numpy_a+numpy_b)
# %%
# == ASTKernel (in tinygrad/codegen/ast.py, code 2/10) ==
# but we are nowhere near done!
# we need the LazyOp ASTs to actually be turned into code
# we wrote the code above by hand
# we need the LazyOp ASTs to be automatically turned into code
# the current class looks roughly like this, but this will change and we will update the docs
# this stuff is in the terrible 528 lines of (tinygrad/codegen/*, code 2/10 aka turd quality)
class ASTKernel: # (from tinygrad/codegen/ast.py)
class ASTKernel:
# create the kernel with the AST
# NOTE: the AST contains the CompiledBuffers themselves as the root nodes. this will change
def __init__(self, ast:LazyOp): pass
@@ -178,7 +271,118 @@ class ASTRunner: # (from tinygrad/ops.py)
# that hides a lot of complexity that will be refactored, but that's the basic idea of code generation
# last, but not least (in fact one of the nicest things in tinygrad). the ShapeTracker
class ShapeTracker: pass
# %%
# == Example: 2+3 autogenerated clang code ==
# TODO: finish this, the coffee shop is closing
from tinygrad.tensor import Tensor
result = Tensor([2]) + Tensor([3])
# we have a global cache used by the JIT
# from there, we can see the generated clang code
from tinygrad.ops import GlobalCounters
GlobalCounters.cache = [] # enables the cache
result.realize() # create the program and runs it
cache_saved = GlobalCounters.cache
GlobalCounters.cache = None # disable the cache
# there's one ASTRunner in the cache
assert len(cache_saved) == 1
prg, bufs = cache_saved[0]
# print the C Program :)
print(prg.prg)
# after some formatting (the compiler doesn't care)
# NOTE: the 2 and 3 are constant folded
"""
void E_1(float* data0) {
for (int idx0 = 0; idx0 < 1; idx0++) {
data0[0] = (2.0f) + (3.0f);
}
}
"""
# %%
# == Example: ShapeTracker (in tinygrad/shape/shapetracker.py, code 7/10) ==
# remember how I said you don't have to write the MovementOps for CompiledBuffers?
# that's all thanks to ShapeTracker!
# ShapeTracker tracks the indices into the RawBuffer
from tinygrad.shape.shapetracker import ShapeTracker
# create a virtual (10, 10) Tensor. this is just a shape, there's no actual tensor
a = ShapeTracker((10, 10))
# you'll see it has one view. the (10, 1 are the strides)
print(a) # ShapeTracker(shape=(10, 10), views=[View((10, 10), (10, 1), 0)])
# we can permute it, and the strides change
a.permute((1,0))
print(a) # ShapeTracker(shape=(10, 10), views=[View((10, 10), (1, 10), 0)])
# we can then reshape it, and the strides change again
# note how the permute stays applied
a.reshape((5,2,5,2))
print(a) # ShapeTracker(shape=(5, 2, 5, 2), views=[View((5, 2, 5, 2), (2, 1, 20, 10), 0)])
# now, if we were to reshape it to a (100,) shape tensor, we have to create a second view
a.reshape((100,))
print(a) # ShapeTracker(shape=(100,), views=[
# View((5, 2, 5, 2), (2, 1, 20, 10), 0),
# View((100,), (1,), 0)])
# Views stack on top of each other, to allow zero copy for any number of MovementOps
# we can render a Python expression for the index at any time
idx, _ = a.expr_idxs()
print(idx.render()) # (((idx0%10)*10)+(idx0//10))
# of course, if we reshape it back, the indexes get simple again
a.reshape((10,10))
idx, _ = a.expr_idxs()
print(idx.render()) # ((idx1*10)+idx0)
# the ShapeTracker still has two views though...
print(a) # ShapeTracker(shape=(10, 10), views=[
# View((5, 2, 5, 2), (2, 1, 20, 10), 0),
# View((10, 10), (10, 1), 0)])
# ...until we simplify it!
a.simplify()
print(a) # ShapeTracker(shape=(10, 10), views=[View((10, 10), (1, 10), 0)])
# and now we permute it back
a.permute((1,0))
print(a) # ShapeTracker(shape=(10, 10), views=[View((10, 10), (10, 1), 0)])
# and it's even contiguous
assert a.contiguous == True
# %%
# == Example: Variable (in tinygrad/shape/symbolic.py, code 7/10) ==
# Under the hood, ShapeTracker is powered by a small symbolic algebra library
from tinygrad.shape.symbolic import Variable
# Variable is the basic class from symbolic
# it's created with a name and a min and max (inclusive)
a = Variable("a", 0, 10)
b = Variable("b", 0, 10)
# some math examples
print((a*10).min, (a*10).max) # you'll see a*10 has a min of 0 and max of 100
print((a+b).min, (a+b).max) # 0 20, you get the idea
# but complex expressions are where it gets fun
expr = (a + b*10) % 10
print(expr.render()) # (a%10)
# as you can see, b is gone!
# one more
expr = (a*40 + b) // 20
print(expr.render()) # (a*2)
print(expr.min, expr.max) # 0 20
# this is just "(a*2)"
# since b only has a range from 0-10, it can't affect the output
# %%
+3 -3
View File
@@ -170,7 +170,7 @@ class ASTKernel:
if mergeable: rets[j][-1] = (rets[j][-1][0] * shapes[j][i], strides[j][i])
else: rets[j].append((shapes[j][i], strides[j][i]))
for i,x in enumerate(rets): self.sts[i]._reshape(tuple(y[0] for y in x))
for i,x in enumerate(rets): self.sts[i].reshape(tuple(y[0] for y in x))
self.first_reduce = get_first_reduce([x.shape for x in self.sts])
# this should be aware of the three parts to the shape
@@ -179,8 +179,8 @@ class ASTKernel:
# * the size outputted by each kernel
def reshape_and_permute(self, new_shape_fxn, axis):
for st in self.sts:
if new_shape_fxn is not None: st._reshape(tuple(new_shape_fxn(st.shape)))
if axis is not None: st._permute(tuple(axis))
if new_shape_fxn is not None: st.reshape(tuple(new_shape_fxn(st.shape)))
if axis is not None: st.permute(tuple(axis))
# axis : the axis to pull from
# amount : the amount to take
+8 -8
View File
@@ -149,7 +149,7 @@ class ShapeTracker:
offset = sum([self.strides[i]*x for i,(x,_) in enumerate(arg)])
self.views[-1] = View(tuple(y-x for x,y in arg), self.strides, self.offset+offset)
def _pad(self, arg: Tuple[Tuple[int, int], ...]):
def pad(self, arg: Tuple[Tuple[int, int], ...]):
assert all((b>=0 and e>=0) for b,e in arg) and len(arg) == len(self.shape)
if all(b==0 and e==0 for b,e in arg): return self # ZeroView is expensive if we don't need it
zvarg = tuple((-b,s+e) for s,(b,e) in zip(self.shape, arg))
@@ -158,15 +158,15 @@ class ShapeTracker:
# if we add a ZeroView, we add another (stock) view also for modding
self.views += [zeroview, View(self.shape, strides_for_shape(self.shape))]
def _shrink(self, arg: Tuple[Tuple[int, int], ...]):
def shrink(self, arg: Tuple[Tuple[int, int], ...]):
assert all((b>=0 and e<=s) for s,(b,e) in zip(self.shape,arg)) and len(arg) == len(self.shape)
self.__unsafe_resize(arg)
def _expand(self, new_shape: Tuple[int, ...]):
def expand(self, new_shape: Tuple[int, ...]):
assert all(isinstance(x, int) and (s == x or (s == 1 and st == 0)) for s,x,st in zip(self.shape, new_shape, self.strides)), f"can't expand {self.shape} into {new_shape}"
self.views[-1] = View(new_shape, self.strides, self.offset)
def _reshape(self, new_shape: Tuple[int, ...]):
def reshape(self, new_shape: Tuple[int, ...]):
if self.shape == new_shape: return self
assert all(isinstance(x, int) and x != 0 for x in new_shape), f"shape must be ints and can't contain 0 {new_shape}"
assert prod(self.shape) == prod(new_shape), f"can't reshape {self.shape} -> {new_shape}"
@@ -186,13 +186,13 @@ class ShapeTracker:
if (merged_view := merge_views(cast(View, self.views[-1]), view)) is not None: self.views[-1] = merged_view
else: self.views.append(view)
def _permute(self, axis: Tuple[int, ...]):
def permute(self, axis: Tuple[int, ...]):
assert all(isinstance(x, int) and x >= 0 and x < len(self.shape) for x in axis), f"invalid permute {axis} for {self.shape}"
assert len(set(axis)) == len(axis) and len(axis) == len(self.shape), f"can't permute {self.shape} with {axis}"
self.views[-1] = View(tuple(self.shape[a] for a in axis), tuple(self.strides[a] for a in axis), self.offset)
# except for the negative case, you can build this from the others. invertible in the negative case
def _stride(self, mul: Tuple[int, ...]):
def stride(self, mul: Tuple[int, ...]):
assert all(isinstance(x, int) and x != 0 for x in mul), f"invalid stride {mul} for {self.shape}"
strides = tuple(z*m for z,m in zip(self.strides, mul))
new_shape = tuple((s+(abs(m)-1))//abs(m) for s,m in zip(self.shape, mul))
@@ -206,8 +206,8 @@ class ShapeTracker:
dispatch[op](self, arg)
return self
dispatch: Dict[MovementOps, Callable] = {MovementOps.RESHAPE: ShapeTracker._reshape, MovementOps.EXPAND: ShapeTracker._expand, MovementOps.PAD: ShapeTracker._pad,
MovementOps.SHRINK: ShapeTracker._shrink, MovementOps.PERMUTE: ShapeTracker._permute, MovementOps.STRIDE: ShapeTracker._stride}
dispatch: Dict[MovementOps, Callable] = {MovementOps.RESHAPE: ShapeTracker.reshape, MovementOps.EXPAND: ShapeTracker.expand, MovementOps.PAD: ShapeTracker.pad,
MovementOps.SHRINK: ShapeTracker.shrink, MovementOps.PERMUTE: ShapeTracker.permute, MovementOps.STRIDE: ShapeTracker.stride}
# returns the axes to create new_shape if new_shape can be created by combining axis from old_shape
def get_contraction(old_shape:Tuple[int, ...], new_shape:Tuple[int, ...]) -> Optional[List[List[int]]]: