cleaner and faster run_linear (#15987)

* cleaner and faster run_linear

* x

* assert for now

* x

* x

* sym_infer

* remove sink
This commit is contained in:
nimlgen
2026-04-30 20:15:22 +03:00
committed by GitHub
parent 58b34e71bd
commit 11e1a2b89f
4 changed files with 71 additions and 62 deletions
+2 -2
View File
@@ -3,7 +3,7 @@ import functools
import numpy as np
from tinygrad import Tensor, Device, dtypes
from tinygrad.uop.ops import UOp, Ops, KernelInfo
from tinygrad.engine.realize import run_linear, estimate_uop
from tinygrad.engine.realize import run_linear, estimate_uop, compile_linear
from tinygrad.renderer import Estimates
from tinygrad.dtype import AddrSpace
from tinygrad.helpers import getenv
@@ -169,7 +169,7 @@ class TestCustomKernel(unittest.TestCase):
if self.arch != "rdna3": self.skipTest("only rdna3")
a = Tensor.full((16, 16), 1.).contiguous().realize()
a = Tensor.custom_kernel(a, fxn=custom_add_one)[0]
linear = a.schedule_linear()
linear = compile_linear(a.schedule_linear())
est = estimate_uop(linear.src[-1])
self.assertEqual(est.ops, a.numel())
self.assertEqual(est.mem, a.nbytes()*2)
+4 -4
View File
@@ -2,14 +2,14 @@ import unittest
import numpy as np
from tinygrad import Tensor, GlobalCounters, dtypes, nn, Device, Variable
from tinygrad.helpers import Context, getenv, DEV
from tinygrad.engine.realize import run_linear, estimate_uop
from tinygrad.engine.realize import run_linear, estimate_uop, compile_linear
from tinygrad.renderer.ptx import PTXRenderer
from test.helpers import needs_second_gpu
class TestArange(unittest.TestCase):
def _get_flops(self, tensor, desired):
GlobalCounters.reset()
linear = tensor.schedule_linear()
linear = compile_linear(tensor.schedule_linear())
self.assertEqual(len(linear.src), 1)
run_linear(linear)
np.testing.assert_equal(tensor.numpy(), desired)
@@ -36,7 +36,7 @@ class TestArange(unittest.TestCase):
def test_tri_complexity(self):
with Context(NOOPT=1):
t = Tensor.ones(256, 256).contiguous().realize()
linear = t.triu().schedule_linear()
linear = compile_linear(t.triu().schedule_linear())
self.assertLessEqual(estimate_uop(linear.src[-1]).ops, 4 * 256 * 256)
DSET, DDIM = 2048, 32
@@ -229,7 +229,7 @@ class TestIndexing(unittest.TestCase):
xq = xq.reshape(bs, seqlen, n_heads, head_dim)
xq_rope, _ = apply_rotary_emb(xq, xq, freqs_cis)
xq_rope.sum().backward()
linear = wq.grad.schedule_linear()
linear = compile_linear(wq.grad.schedule_linear())
assert len(linear.src) == 1, f"expected one kernel for backward, got: {len(linear.src)}"
bwd_ops = estimate_uop(linear.src[0]).ops
# bfloat16 on non CDNA4 has ~10x ops overhead because of the software emulation
+6 -14
View File
@@ -6,7 +6,7 @@ from tinygrad.device import Buffer, Compiled, Device, MultiBuffer
from tinygrad.dtype import DType, dtypes
from tinygrad.uop.ops import UOp, PatternMatcher, Variable, sym_infer, Ops, buffers, track_rewrites, graph_rewrite
from tinygrad.engine.realize import capturing, Estimates, compile_linear, run_linear, graph_cache, estimate_uop, get_runtime
from tinygrad.engine.realize import unwrap_multi, resolve_params
from tinygrad.engine.realize import unwrap_multi, resolve_params, get_call_arg_uops, get_call_outs_ins
from tinygrad.schedule.memory import memory_plan_rewrite, _collect_bufs
from tinygrad.nn.state import get_parameters
from tinygrad.schedule.rangeify import mop_cleanup
@@ -59,14 +59,6 @@ def graph_split_rewrite(linear:UOp, max_batch_size:int=0) -> UOp:
if current_batch: flush_batch()
return linear.replace(src=tuple(new_src))
def _call_outs_ins(call:UOp) -> tuple[set[int], set[int]]:
non_bind = [s for s in call.src[1:] if s.op is not Ops.BIND]
ast = call.src[0]
if ast.op is Ops.PROGRAM: return set(ast.arg.outs), set(ast.arg.ins)
if ast.op in (Ops.COPY, Ops.BUFFER_VIEW): return {0}, {1}
if ast.op is Ops.CUSTOM_FUNCTION and ast.arg == "encdec": return {0}, set(range(1, len(non_bind)))
return set(), set()
def _copy_input(u:UOp) -> UOp:
run_linear(UOp(Ops.LINEAR, src=(u.copy_to_device(u.device).call(new:=UOp.new_buffer(u.device, u.arg, u.dtype), u, metadata=()),)))
return new
@@ -102,7 +94,7 @@ class GraphRunner:
self.runtimes: list[Any|None] = []
self.uop_replace: list[list[tuple[int, int]]] = []
for call in self.linear.src:
replace = [(p, b.arg) for p, b in enumerate(b for b in call.src[1:] if b.op is not Ops.BIND) if b.op is Ops.PARAM]
replace = [(p, b.arg) for p, b in enumerate(get_call_arg_uops(call)) if b.op is Ops.PARAM]
for dev_idx, (bufs, device_vars) in enumerate(unwrap_multi(call, resolve_params(call, input_uops))):
self.calls.append((dev_idx, call.src[0], [b.ensure_allocated() for b in bufs], device_vars))
self.runtimes.append(get_runtime(bufs[0].device, call.src[0]) if call.src[0].op is Ops.PROGRAM else None)
@@ -170,7 +162,7 @@ class GraphRunner:
@staticmethod
def _all_devs(batch_devs:list[Compiled], new_call:UOp) -> list[Compiled]:
return dedup(batch_devs + [Device[x] for b in new_call.src[1:] if b.op is not Ops.BIND
return dedup(batch_devs + [Device[x] for b in get_call_arg_uops(new_call)
for x in (b.device if isinstance(b.device, tuple) else (b.device,))])
@staticmethod
@@ -199,9 +191,9 @@ class CapturedJit(Generic[ReturnType]):
out: set[UOp] = set()
for call in self.linear.toposort():
if call.op is not Ops.CALL: continue
non_bind = [s for s in call.src[1:] if s.op is not Ops.BIND]
outs, ins = _call_outs_ins(call)
out |= {non_bind[k] for k in outs - ins if non_bind[k].op in (Ops.BUFFER, Ops.BUFFER_VIEW)}
arg_uops = get_call_arg_uops(call)
outs, ins = get_call_outs_ins(call)
out |= {arg_uops[k] for k in set(outs) - set(ins) if arg_uops[k].op in (Ops.BUFFER, Ops.BUFFER_VIEW)}
return out
def __call__(self, input_uops:list[UOp], var_vals:dict[str, int]) -> ReturnType:
+59 -42
View File
@@ -1,7 +1,8 @@
from __future__ import annotations
from typing import cast, Iterator, Any
import time, random, itertools, math, contextlib, weakref
from dataclasses import dataclass, replace, field
from tinygrad.helpers import colored, DEBUG, GlobalCounters, ansilen, all_int, Metadata, TRACEMETA, prod, flatten
from tinygrad.helpers import colored, DEBUG, GlobalCounters, ansilen, all_int, TRACEMETA, prod, flatten
from tinygrad.helpers import BEAM, size_to_str, time_to_str, VALIDATE_WITH_CPU, PROFILE, ProfilePointEvent, cpu_events
from tinygrad.dtype import dtypes
from tinygrad.uop.ops import Ops, PatternMatcher, UOp, UPat, sym_infer, buffers, graph_rewrite, ProgramInfo
@@ -10,54 +11,74 @@ from tinygrad.renderer import Estimates
from tinygrad.codegen import to_program
from tinygrad.codegen.opt.postrange import bufs_from_ast
# **************** Helpers ****************
def get_call_arg_uops(call:UOp) -> tuple[UOp, ...]: return tuple(s for s in call.src[1:] if s.op is not Ops.BIND)
def get_call_outs_ins(call:UOp) -> tuple[tuple[int, ...], tuple[int, ...]]:
ast = call.src[0]
if ast.op is Ops.PROGRAM: return tuple(ast.arg.outs), tuple(ast.arg.ins)
if ast.op in (Ops.COPY, Ops.BUFFER_VIEW): return (0,), (1,)
if ast.op is Ops.CUSTOM_FUNCTION and ast.arg == "encdec": return (0,), tuple(range(1, len(get_call_arg_uops(call))))
return (), ()
def get_call_name(call:UOp, bufs:list[Buffer], var_vals:dict[str, int]|None=None) -> str:
def _uop_sz_to_str(uop:UOp) -> str: return size_to_str(sym_infer(prod(uop.shape) * uop.dtype.itemsize, var_vals or {}))
ast, arg_uops = call.src[0], get_call_arg_uops(call)
if ast.op is Ops.PROGRAM: return ast.arg.name
if ast.op is Ops.BUFFER_VIEW: return colored(f"view {_uop_sz_to_str(arg_uops[0]):>10} @ {ast.arg[1] * arg_uops[1].dtype.itemsize:<10d}", "yellow")
if ast.op is Ops.COPY: return colored(f"copy {_uop_sz_to_str(arg_uops[0]):>10}, {bufs[0].device[:7]:>7s} <- {bufs[1].device[:7]:7s}", "yellow")
if ast.op is Ops.CUSTOM_FUNCTION and ast.arg == "encdec": return colored(f"enc/dec {_uop_sz_to_str(arg_uops[0])}", "yellow")
if ast.op is Ops.CUSTOM_FUNCTION and ast.arg == "graph": return colored(f"batched {len(ast.src[0].src)}", "cyan")
raise NotImplementedError("get_call_name is not implemented")
# **************** Stat ****************
def estimate_uop(call:UOp) -> Estimates:
if call.src[0].op is Ops.SINK: call = pm_compile.rewrite(call)
ast = call.src[0]
if ast.op is Ops.PROGRAM: return ast.src[0].arg.estimates or Estimates()
if ast.op is Ops.COPY or (ast.op is Ops.CUSTOM_FUNCTION and ast.arg == "encdec"):
nbytes = prod(call.src[1].shape) * call.src[1].dtype.itemsize
return Estimates(lds=nbytes, mem=nbytes)
if ast.op is Ops.CUSTOM_FUNCTION and ast.arg == "graph":
return runner.estimates if (runner:=get_graph_runtime(ast)) is not None else Estimates()
if ast.op is Ops.CUSTOM_FUNCTION and ast.arg == "graph": return get_graph_runtime(ast).estimates
return Estimates()
def update_stats(display_name:str, device:str, estimates:Estimates, var_vals:dict[str, int], et:float|None, buf_count:int,
jit=False, metadata:tuple[Metadata, ...]=(), first_run=False):
first_run_cache:set[bytes] = set()
@contextlib.contextmanager
def track_stats(ctx:ExecContext, call:UOp, device:str, bufs:list[Buffer], var_vals:dict[str, int]):
if PROFILE:
outputs, inputs = get_call_outs_ins(call)
cpu_events.append(ProfilePointEvent(device, "exec", len(cpu_events), {"metadata": call.arg.metadata, "var_vals": var_vals,
"bufs": [b.trace_num for b in bufs], "name": get_call_name(call, bufs, var_vals), "outputs": outputs, "inputs": inputs}))
et: list[float|None] = [None]
if DEBUG >= 2: st = time.perf_counter()
yield et
if not ctx.do_update_stats: return
if DEBUG >= 2 and et[0] is None:
Device[device].synchronize()
et[0] = time.perf_counter() - st
estimates = estimate_uop(call)
GlobalCounters.kernel_count += 1
GlobalCounters.global_ops += (op_est:=sym_infer(estimates.ops, var_vals))
GlobalCounters.global_mem += (mem_est:=sym_infer(estimates.mem, var_vals))
if et is not None: GlobalCounters.time_sum_s += et
if et[0] is not None: GlobalCounters.time_sum_s += et[0]
if DEBUG >= 2:
display_name = get_call_name(call, bufs, var_vals)
lds_est = sym_infer(estimates.lds, var_vals)
header_color = 'magenta' if jit else ('green' if first_run else None)
ptm = colored(time_to_str(et, w=9), "yellow" if et > 0.01 else None) if et is not None else ""
flops, membw, ldsbw = op_est/(et or 1e-20), mem_est/(et or 1e-20), lds_est/(et or 1e-20)
header_color = 'magenta' if ctx.jit else ('green' if call.src[0].key not in first_run_cache else None)
ptm = colored(time_to_str(et[0], w=9), "yellow" if et[0] > 0.01 else None) if et[0] is not None else ""
flops, membw, ldsbw = op_est/(et[0] or 1e-20), mem_est/(et[0] or 1e-20), lds_est/(et[0] or 1e-20)
flops_str = f"{flops*1e-9:7.0f} GFLOPS" if flops < 1e14 else colored(f"{flops*1e-12:7.0f} TFLOPS", 'green')
mem_str = f"{membw*1e-9:4.0f}|{ldsbw*1e-9:<6.0f} GB/s" if membw < 1e13 and ldsbw < 1e15 else \
colored(f"{membw*1e-12:4.0f}|{ldsbw*1e-12:<6.0f} TB/s", 'green')
print(f"{colored(f'*** {device[:7]:7s} {GlobalCounters.kernel_count:4d}', header_color)}"+
f" {display_name+' '*(46-ansilen(display_name))} arg {buf_count:2d} mem {GlobalCounters.mem_used/1e9:6.2f} GB"+
("" if et is None else f" tm {ptm}/{GlobalCounters.time_sum_s*1e3:9.2f}ms ({flops_str} {mem_str})")+
f" {[repr(m) if TRACEMETA >= 2 else str(m) for m in metadata] if metadata else ''}")
first_run_cache:set[bytes] = set()
@contextlib.contextmanager
def track_stats(ctx:"ExecContext", call:UOp, device:str, display_name:str, bufs:list[Buffer], var_vals:dict[str, int], outputs=(0,), inputs=(1,)):
if PROFILE: cpu_events.append(ProfilePointEvent(device, "exec", len(cpu_events), {"metadata": call.arg.metadata, "var_vals": var_vals,
"bufs": [b.trace_num for b in bufs], "name": display_name, "outputs": outputs, "inputs": inputs}))
timing: list[float|None] = [None]
if DEBUG >= 2: st = time.perf_counter()
yield timing
if not ctx.do_update_stats: return
if DEBUG >= 2 and timing[0] is None:
Device[device].synchronize()
timing[0] = time.perf_counter() - st
update_stats(display_name, device, estimate_uop(call), var_vals, timing[0], len(bufs), jit=ctx.jit, metadata=call.arg.metadata,
first_run=call.src[0].key not in first_run_cache)
first_run_cache.add(call.src[0].key)
f" {display_name+' '*(46-ansilen(display_name))} arg {len(bufs):2d} mem {GlobalCounters.mem_used/1e9:6.2f} GB"+
("" if et[0] is None else f" tm {ptm}/{GlobalCounters.time_sum_s*1e3:9.2f}ms ({flops_str} {mem_str})")+
f" {[repr(m) if TRACEMETA >= 2 else str(m) for m in call.arg.metadata] if call.arg.metadata else ''}")
first_run_cache.add(call.src[0].key)
local_size_cache: dict[bytes, tuple[int, ...]] = {}
def optimize_local_size(call:UOp, prg:UOp) -> UOp|None:
@@ -114,7 +135,7 @@ class ExecContext:
def _resolve(b:UOp, inputs:tuple[UOp, ...]) -> UOp:
if b.op in (Ops.BUFFER_VIEW, Ops.MSELECT) and b.src[0].op is Ops.PARAM: return b.replace(src=(inputs[b.src[0].arg], *b.src[1:]))
return inputs[b.arg] if b.op is Ops.PARAM else b
def resolve_params(call:UOp, inputs:tuple[UOp, ...]) -> list[UOp]: return [_resolve(b, inputs) for b in call.src[1:] if b.op is not Ops.BIND]
def resolve_params(call:UOp, inputs:tuple[UOp, ...]) -> list[UOp]: return [_resolve(b, inputs) for b in get_call_arg_uops(call)]
def unwrap_multi(call:UOp, resolved:list[UOp]) -> Iterator[tuple[list[Buffer], dict[str, int]]]:
bufs = [b.buffer for b in resolved]
@@ -127,16 +148,13 @@ def exec_view(ctx:ExecContext, call, ast):
resolved = resolve_params(call, ctx.input_uops)
bufs = [cast(Buffer, b.buffer) for b in resolved]
bv = bufs[1].view(resolved[0].arg, ast.dtype, ast.arg[1]*bufs[1].dtype.itemsize)
with track_stats(ctx, call, bv.device, colored(f"view {bv.nbytes:8d} @ {bv.offset:<10d}", "yellow"), [bv, bufs[1]], ctx.var_vals):
buffers[resolved[0]] = bv
with track_stats(ctx, call, bv.device, [bv, bufs[1]], ctx.var_vals): buffers[resolved[0]] = bv
def exec_copy(ctx:ExecContext, call, ast):
for bufs, device_vars in unwrap_multi(call, resolve_params(call, ctx.input_uops)):
dest, src = bufs[0].ensure_allocated(), bufs[1].ensure_allocated()
xfer = hasattr(dest.allocator,'_transfer') and dest.allocator.supports_transfer and dest.device.split(":")[0] == src.device.split(":")[0]
name = colored(f"{'xfer' if xfer else 'copy'} {size_to_str(bufs[0].nbytes):>10}, {dest.device[:7]:>7s} <- {src.device[:7]:7s}", "yellow")
with track_stats(ctx, call, dest.device, name, [dest, src], ctx.var_vals):
if xfer:
with track_stats(ctx, call, dest.device, [dest, src], ctx.var_vals):
if hasattr(dest.allocator,'_transfer') and dest.allocator.supports_transfer and dest.device.split(":")[0] == src.device.split(":")[0]:
dest.allocator._transfer(dest._buf, src._buf, dest.nbytes, src_dev=src.allocator.dev, dest_dev=dest.allocator.dev) # type:ignore[attr-defined]
elif src.device.startswith("DISK") and getattr(src.allocator.dev, 'fd', None) is not None \
and hasattr(dest.allocator, 'copy_from_disk') and src.nbytes >= 4096 and dest.allocator.supports_copy_from_disk:
@@ -151,7 +169,7 @@ def exec_kernel(ctx:ExecContext, call, ast):
prg_bufs = [bufs[i].ensure_allocated() for i in ast.arg.globals]
rt = get_runtime(device:=bufs[0].device, ast)
global_size, local_size = ast.arg.launch_dims(var_vals)
with track_stats(ctx, call, device, ast.arg.name, prg_bufs, var_vals, outputs=ast.arg.outs, inputs=ast.arg.ins) as tm:
with track_stats(ctx, call, device, prg_bufs, var_vals) as tm:
tm[0] = rt(*[b._buf for b in prg_bufs], global_size=global_size, local_size=local_size, vals=ast.arg.vals(var_vals), wait=DEBUG>=2)
def exec_validate(ctx:ExecContext, call, ast):
@@ -167,13 +185,12 @@ def exec_validate(ctx:ExecContext, call, ast):
def exec_encdec(ctx:ExecContext, call, ast):
bufs = [cast(Buffer, b.buffer).ensure_allocated() for b in resolve_params(call, ctx.input_uops)]
shape, pos_var = tuple(s.arg for s in ast.src if s.op is Ops.CONST), ast.variables()[0].expr
with track_stats(ctx, call, bufs[0].device, colored(f"enc/dec {size_to_str(bufs[0].nbytes)}", "yellow"), bufs, ctx.var_vals):
with track_stats(ctx, call, bufs[0].device, bufs, ctx.var_vals):
bufs[0].allocator._encode_decode(bufs[0]._buf, bufs[1]._buf, bufs[2]._buf, [x._buf for x in bufs[3:]], shape, ctx.var_vals[pos_var])
def exec_graph(ctx:ExecContext, call, ast):
rt = get_graph_runtime(ast, ctx.input_uops)
with track_stats(ctx, call, rt.device, colored(f"batched {len(rt.calls)}", "cyan"), [], ctx.var_vals) as t:
t[0] = rt(ctx.input_uops, ctx.var_vals, wait=DEBUG>=2) # type: ignore[call-arg]
with track_stats(ctx, call, rt.device, [], ctx.var_vals) as t: t[0] = rt(ctx.input_uops, ctx.var_vals, wait=DEBUG>=2) # type: ignore[call-arg]
# flatten LINEAR-in-LINEAR: any nested LINEAR child gets inlined into its parent's src
pm_flatten_linear = PatternMatcher([
@@ -182,7 +199,7 @@ pm_flatten_linear = PatternMatcher([
])
def _validate(call:UOp, sink:UOp) -> UOp:
params = tuple(p for p in call.src[1:] if p.op is not Ops.BIND)
params = get_call_arg_uops(call)
shadows = tuple(UOp.new_buffer(("CPU",)*len(p.device) if isinstance(p.device, tuple) else "CPU", prod(p.max_shape), p.dtype.base) for p in params)
copies = tuple(p.copy_to_device(s.device).call(s, p) for s, p in zip(shadows, params))
return UOp(Ops.LINEAR, src=copies + (call, UOp(Ops.CUSTOM_FUNCTION, dtypes.void, src=(sink,), arg="validate").call(*shadows, *params)))