from __future__ import annotations from typing import cast, Iterator, Any, Sequence import decimal from dataclasses import dataclass, replace, field from tinygrad.helpers import CAPTURE_PROCESS_REPLAY, colored, DEBUG, GlobalCounters, ansipad, prod, flatten, Context, to_tuple, tqdm, unwrap from tinygrad.helpers import BEAM, size_to_str, time_to_str, VALIDATE_WITH_CPU, PROFILE, ProfilePointEvent, cpu_events, perf_counter_us, cpu_profile from tinygrad.uop.ops import get_process_replay_loc, Ops, PatternMatcher, UOp, UPat, AxisType, sym_infer, graph_rewrite, ProgramInfo, KernelInfo from tinygrad.device import Device, Buffer, MultiBuffer, ProfileGraphEntry, DEV, HCQ_RUNTIME_DEV from tinygrad.renderer import Estimates, Renderer from tinygrad.codegen import to_program, to_program_cache, to_program_key, to_program_context from tinygrad.engine.worker import get_worker_pool, terminate_worker_pool # **************** Helpers **************** def get_call_arg_uops(call:UOp) -> tuple[UOp, ...]: return tuple(s for s in call.src[1:] if not s.is_bound_var) def get_call_var_uops(call:UOp, prg:UOp) -> list[UOp]: # a bound value is a bare CONST: the Variable states the width bound = {s.expr: UOp.const(s.arg.val) for s in call.src[1:] if s.is_bound_var} return [bound.get(v.expr, v) for v in prg.arg.vars] def get_call_outs_ins(call:UOp) -> tuple[tuple[int, ...], tuple[int, ...]]: ast = call.body if isinstance(call.arg.aux, HCQInfo): return (), () if ast.op is Ops.PROGRAM: return tuple(ast.arg.outs), tuple(ast.arg.ins) if ast.op is Ops.STORE: return (0,), (1,) if ast.op is Ops.CUSTOM_FUNCTION and ast.arg.name == "encdec": return (0,), tuple(range(1, len(get_call_arg_uops(call)))) return (), () def get_call_kernels(call:UOp) -> list[tuple[str, UOp, tuple|None]]: if isinstance(call.arg.aux, HCQInfo): # the submitter itself, then every kernel it enqueues kernels:list[tuple[str, UOp, tuple|None]] = [(Device[call.arg.aux.device[0]].host, call, None)] return kernels + [(d, call, (name, estimates, key, bufs, io)) for devices,name,estimates,_,key,bufs,io in call.arg.aux.kernels for d in devices] ast = call.body if ast.op is Ops.CUSTOM_FUNCTION and ast.arg.name == "validate": return [] return [(d, call, None) for d in to_tuple(call.device)] def get_call_name(call:UOp, bufs:Sequence[Buffer|UOp], 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 {})) def _dev_str(buf:Buffer|UOp) -> str: return ', '.join(d[:7] for d in to_tuple(buf.device)) ast, arg_uops = call.body, get_call_arg_uops(call) if ast.op is Ops.PROGRAM: return ast.src[0].arg.name if ast.op is Ops.STORE: return colored(f"copy {_uop_sz_to_str(arg_uops[0]):>10}, {_dev_str(bufs[0]):>7s} <- {_dev_str(bufs[1]):7s}", "yellow") if ast.op is Ops.CUSTOM_FUNCTION and ast.arg.name == "encdec": return colored(f"enc/dec {_uop_sz_to_str(arg_uops[0])}", "yellow") raise NotImplementedError("get_call_name is not implemented") # **************** Stat **************** def estimate_uop(call:UOp) -> Estimates: call = call.without_after if isinstance(call.arg.aux, HCQInfo): return call.arg.aux.estimates if (ast:=call.body).op is Ops.PROGRAM: return ast.src[0].arg.estimates or Estimates() if ast.op is Ops.STORE or (ast.op is Ops.CUSTOM_FUNCTION and ast.arg.name == "encdec"): return Estimates(lds=(nbytes:=prod(call.src[1].shape) * call.src[1].dtype.itemsize), mem=nbytes) return Estimates() first_run_cache:set[bytes] = set() def track_stats(ctx:ExecContext, call:UOp, st:decimal.Decimal, ets:list[float|None]): if ctx.update_stats: is_hcq = isinstance(call.arg.aux, HCQInfo) estimates, n = estimate_uop(call), 1 if is_hcq else len(get_call_kernels(call)) GlobalCounters.kernel_count += len(call.arg.aux.kernels) if is_hcq else n GlobalCounters.global_ops += n*sym_infer(estimates.ops, ctx.var_vals) GlobalCounters.global_mem += n*sym_infer(estimates.mem, ctx.var_vals) GlobalCounters.time_sum_s += sum(et for et in ets if et is not None) if DEBUG < 2 and not PROFILE: return kernels = get_call_kernels(call) # everything below is the per kernel display: exec events for the profiler and DEBUG=2 lines args = [] if isinstance(call.arg.aux, HCQInfo) else resolve_params(call, ctx.input_uops) lanes = list(unwrap_multi(call, [args[g] for g in call.body.arg.globals] if call.body.op is Ops.PROGRAM else args)) if args else [] for i, (device, kcall, stats) in enumerate(kernels): et = ets[i] if i < len(ets) else None bufs = lanes[i][0] if i < len(lanes) else [cast(Buffer, ctx.input_uops[s].buffer) for s in (stats[3] if stats else ())] display_name = get_call_name(kcall, bufs, ctx.var_vals) if stats is None else stats[0] if PROFILE: # backdate the event to the start of the call, the viz matches a device range with the exec event before it outputs, inputs = get_call_outs_ins(kcall) if stats is None else stats[4] cpu_events.append(ProfilePointEvent(device, "exec", len(cpu_events), {"var_vals": ctx.var_vals, "bufs": [b.trace_num for b in bufs], "name": display_name, "outputs": outputs, "inputs": inputs}, ts=st)) if DEBUG < (3 if stats is None and isinstance(call.arg.aux, HCQInfo) else 2) or not ctx.update_stats: continue if et is None and not getattr(call.arg.aux, "skip_wait", False): Device[device].synchronize() et, st = float(perf_counter_us() - st)*1e-6, perf_counter_us() GlobalCounters.time_sum_s += et estimates = estimate_uop(kcall) if stats is None else stats[1] op_est, mem_est, lds_est = (sym_infer(x, ctx.var_vals) for x in (estimates.ops, estimates.mem, estimates.lds)) key = kcall.body.key if stats is None else stats[2] header_color = 'magenta' if ctx.jit else ('green' if key not in first_run_cache 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) 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" {ansipad(display_name, 46)} arg {len(bufs):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})")) first_run_cache.add(key) # **************** runtime cache **************** runtime_cache: dict[tuple[bytes, str], Any] = {} def get_runtime(device:str, ast:UOp, cache=True): if (runtime:=runtime_cache.get(key:=(ast.key, device))) is None: runtime = Device[device].runtime(ast.to_elf()) if cache: runtime_cache[key] = runtime return runtime # **************** run linear **************** capturing: list = [] # put classes with an add_linear method in here @dataclass class ExecContext: var_vals: dict[str, int] = field(default_factory=dict) input_uops: tuple[UOp, ...] = () update_stats: bool = True jit: bool = False wait: bool = False timeout: int|None = None cache: bool = True def _resolve(b:UOp, inputs:tuple[UOp, ...]) -> UOp: if b.op in (Ops.MSELECT, Ops.SHRINK, Ops.BITCAST, Ops.GETADDR): return b.replace(src=(_resolve(b.src[0], inputs), *b.src[1:])) if b.op is Ops.MSTACK: return b.replace(src=tuple(_resolve(x, inputs) for x in b.src)) return inputs[b.arg.slot] 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 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] if not any(isinstance(b, MultiBuffer) for b in bufs): yield cast(list[Buffer], bufs), {} else: # the DEVICE axis is bound per device at launch: it's a RANGE in the AST and the _device_num variable after codegen # on archs like x86, stack args (after the 6th) aren't PARAMs in the body, so also check the PROGRAM's vars has_dnum = ((isinstance(call.body.arg, ProgramInfo) and any(v.arg.name == '_device_num' for v in call.body.arg.vars)) or any((x.op is Ops.RANGE and x.axis_type is AxisType.DEVICE) or (x.op is Ops.PARAM and x.arg.name == '_device_num') for x in call.body.toposort())) lanes = max(len(b.bufs) for b in bufs if isinstance(b, MultiBuffer)) # a single buffer is shared by every lane per_lane = [b.bufs if isinstance(b, MultiBuffer) else (b,)*lanes for b in bufs] for j, per_dev in enumerate(zip(*per_lane)): yield list(per_dev), {"_device_num": j} if has_dnum else {} def exec_copy(ctx:ExecContext, call:UOp, ast:UOp) -> list[float|None]: for bufs, device_vars in unwrap_multi(call, resolve_params(call, ctx.input_uops)): dest, src = bufs[0].ensure_allocated(), bufs[1].ensure_allocated() if dest.get_storage().host is not None and src.get_storage().host is not None: for b in (dest, src): b.allocator.dev.synchronize() with cpu_profile(f"{src.device} -> {dest.device}", f"{src.device}:COPY"): dest.host[:] = src.host[:] elif dest._host_mv() is not None: src.allocator._copyout(dest.as_memoryview(allow_zero_copy=True), src._buf) else: dest.allocator._copyin(dest._buf, src.as_memoryview(allow_zero_copy=True)) return [] def exec_kernel(ctx:ExecContext, call:UOp, ast:UOp, devices=None) -> list[float|None]: ets:list[float|None] = [] resolved = resolve_params(call, ctx.input_uops) for device, (bufs, device_vars) in zip(devices or to_tuple(call.src[1].device), unwrap_multi(call, [resolved[i] for i in ast.arg.globals])): if devices is None and device.split(":")[0] in HOST_DEVS: Device[device].synchronize(ctx.timeout) var_vals = {**ctx.var_vals, **device_vars} prg_bufs = [b.ensure_allocated() for b in bufs] rt = get_runtime(device, ast, cache=ctx.cache) global_sz, local_sz = ast.arg.launch_dims(var_vals) try: vals = tuple(var_vals[v.expr] if v.is_variable else _resolve(call.src[1 + v.arg.slot], ctx.input_uops).val for v in ast.arg.vars) except KeyError as e: raise RuntimeError(f"unbound Variable {e}") from None ets.append(rt(*[b.get_buf(device) for b in prg_bufs], global_size=global_sz, local_size=local_sz, vals=vals, wait=ctx.wait, timeout=ctx.timeout)) return ets def exec_validate(ctx:ExecContext, call:UOp, ast:UOp) -> list[float|None]: import numpy as np from tinygrad.dtype import _to_np_dtype resolved = resolve_params(call, ctx.input_uops) Device["CPU"].synchronize(ctx.timeout) for bufs, device_vars in unwrap_multi(call, resolved): bufs, dev_bufs = bufs[:len(bufs)//2], bufs[len(bufs)//2:] var_vals = {**ctx.var_vals, **device_vars} cpu_rt = get_runtime("CPU", prg:=to_program(ast.src[0], Device["CPU"].renderer)) global_size, local_size = prg.arg.launch_dims(var_vals) cpu_rt(*[bufs[i].ensure_allocated()._buf for i in prg.arg.globals], global_size=global_size, local_size=local_size, vals=prg.arg.vals(var_vals)) for i in prg.arg.outs: dt = _to_np_dtype(resolved[i].dtype) assert dt is not None, f"no np dtype for {resolved[i].dtype}" np.testing.assert_allclose(np.frombuffer(dev_bufs[i].ensure_allocated().as_memoryview(), dt), np.frombuffer(bufs[i].as_memoryview(), dt), rtol=1e-3, atol=1e-3) return [] def exec_hcq(ctx:ExecContext, call:UOp, ast:UOp) -> list[float|None]: info = call.arg.aux ctx = replace(ctx, wait=ctx.wait and not info.skip_wait, var_vals={**ctx.var_vals, **{k: v for d in info.device for k, v in cast(Any, Device[d]).var_vals.items()}}) slots = {d: cast(Buffer, call.src[1 + i].buffer) for d, i in info.slots} for d in {d for devs, _, _, prof, *_ in info.kernels for d in devs if prof and (slots[d], prof[0]) in Device[d].prof_ents}: Device[d].synchronize() ets = exec_kernel(ctx, call, ast, devices=(Device[info.device[0]].host,)) if not (ctx.wait or PROFILE): return ets for devs, name, _, prof, pkey, *_ in info.kernels: for d in (devs if prof else ()): cast(Any, Device[d]).prof_ents[(slots[d], prof[0])] = ProfileGraphEntry(d, name, prof[0], prof[1], pkey) if ctx.wait: for device in info.device: cast(Any, Device[device]).synchronize(timeout=ctx.timeout) def _prof_tm(device:str, prof:tuple[int, ...]) -> float: st, en = (slots[device].host.view(fmt='Q')[x] for x in prof) return float(en-st) / cast(Any, Device[device]).timestamp_divider / 1e6 return ets + [_prof_tm(device, prof) if ctx.wait else None for devices, _, _, prof, *_ in info.kernels if prof for device in devices] # flatten LINEAR-in-LINEAR: any nested LINEAR child gets inlined into its parent's src pm_flatten_linear = PatternMatcher([ (UPat(Ops.LINEAR, custom_early_reject={Ops.LINEAR}, name="lin"), lambda lin: lin.replace(src=tuple(flatten(c.src if c.op is Ops.LINEAR else (c,) for c in lin.src)))), ]) def _validate(call:UOp, sink:UOp) -> UOp: 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) for p in params) copies = tuple(s.store_call(p) for s, p in zip(shadows, params)) return UOp(Ops.LINEAR, src=copies + (call, UOp.custom_function("validate", sink).call(*shadows, *params))) pm_validate = PatternMatcher([(UPat(Ops.CALL, src=(UPat(Ops.SINK, name="sink"),), name="call", allow_any_len=True), _validate)]) + pm_flatten_linear # ctx is beam value pm_beam = PatternMatcher([ (UPat(Ops.CALL, src=(UPat(Ops.SINK, name="sink"),), name="call", allow_any_len=True), lambda ctx,call,sink: call.replace(src=(sink.replace(arg=replace(sink.arg, beam=ctx)), *call.src[1:])) if sink.arg.beam == 0 else None), ]) # **************** parallel lowering + compilation **************** def _compile_kernel(x:tuple[int, tuple[UOp, Renderer], dict]) -> tuple[int, UOp]: with Context(**x[2]): return x[0], to_program(*x[1]) def _get_call_to_compile(c:UOp) -> tuple[UOp, Renderer]|None: ast = c.body # a PROGRAM with a ProgramInfo and a BINARY is already compiled if (ast.op is Ops.SINK and isinstance(ast.arg, KernelInfo)) or \ (ast.op is Ops.PROGRAM and not (isinstance(ast.arg, ProgramInfo) and ast.src[-1].op is Ops.BINARY)): return ast, Device[to_tuple(c.device)[0]]._select_renderer(HCQ_RUNTIME_DEV if isinstance(c.arg.aux, HCQInfo) else DEV) return None def lower_and_compile(linear:UOp, verbose=True) -> UOp: # collect the kernels to lower and compile, deduped by their compile cache key if not len(ar:={c: a for c in linear.toposort() if c.op is Ops.CALL and (a:=_get_call_to_compile(c)) is not None}): return linear # lower and compile what's not cached, in parallel if there's a worker pool keys = {c: to_program_key(*a) for c, a in ar.items()} todo = list({keys[c]: a for c, a in ar.items() if keys[c] not in to_program_cache}.items()) if len(todo): # kernels that beam search must compile in the parent, beam needs device access to time candidates pool = None if len(todo) == 1 or any(getattr(c.body.arg, "beam", 0) for c in ar) else get_worker_pool() ctx = {v.key: v.value for v in to_program_context} if CAPTURE_PROCESS_REPLAY: ctx["PROCESS_REPLAY_LOC"] = get_process_replay_loc() tasks = ((i, ast_ren, ctx) for i, (_, ast_ren) in enumerate(todo)) try: with tqdm(total=len(todo), desc="compiling", disable=DEBUG<1 or not verbose) as pbar: for i, prg in (map if pool is None else pool.imap_unordered)(_compile_kernel, tasks): pbar.set_description(f"compiling {ansipad(prg.src[0].arg.name, 40)}") to_program_cache[todo[i][0]] = prg pbar.update(1) except KeyboardInterrupt: if pool is not None: terminate_worker_pool() raise # swap the compiled PROGRAMs into the calls return linear.substitute({c: c.replace(src=(to_program_cache[keys[c]], *c.src[1:])) for c in ar}, name="precompile kernels") from tinygrad.runtime.support.hcq2 import hcq_compile, hcq_link, HCQInfo, HOST_DEVS # noqa: E402 # down here, hcq2 imports realize pm_exec = PatternMatcher([ (UPat(Ops.CALL, src=(UPat(Ops.STORE, name="ast"),), name="call", allow_any_len=True), exec_copy), (UPat(Ops.CALL, src=(UPat(Ops.PROGRAM, name="ast"),), name="call", allow_any_len=True), lambda ctx, call, ast: exec_hcq(ctx, call, ast) if isinstance(call.arg.aux, HCQInfo) else exec_kernel(ctx, call, ast)), (UPat(Ops.CALL, src=(UPat.custom_function("validate", name="ast"),), name="call", allow_any_len=True), exec_validate), ]) def compile_linear(linear:UOp, beam:int|None=None, validate=False, input_uops:list[UOp]|None=None, profile:bool|None=None, cache=False) -> UOp: if validate: linear = graph_rewrite(linear, pm_validate, name="validate", walk=True) if (beam_val:=BEAM.value if beam is None else beam) >= 1: linear = graph_rewrite(linear, pm_beam, ctx=beam_val, walk=True) linear = lower_and_compile(linear) linear = hcq_compile(linear, input_uops, bool(PROFILE or DEBUG >= 2) if profile is None else profile, cache=cache) return linear def link_linear(linear:UOp, input_uops:list[UOp]|None=None, allow_cache=True) -> UOp: return hcq_link(linear, input_uops=input_uops, allow_cache=allow_cache) def run_linear(linear:UOp, var_vals:dict[str, int]|None=None, input_uops:Sequence[UOp]=(), update_stats=True, jit=False, wait=False): assert linear.op is Ops.LINEAR, f"run_linear calls on {linear.op}" if len(linear.src) == 0: return inputs = list(input_uops) if not jit: linear = link_linear(compile_linear(linear, validate=VALIDATE_WITH_CPU, input_uops=inputs, cache=True), input_uops=inputs) ctx = ExecContext(var_vals or {}, tuple(inputs), update_stats, jit, wait or DEBUG>=2) for call in linear.src: track_stats(ctx, call.without_after, perf_counter_us(), pm_exec.rewrite(call.without_after, ctx)) def time_call(call:UOp, var_vals:dict[str, int]|None=None, timeout:int|None=None, clear_l2:bool=False) -> Iterator[float]: ctx = ExecContext(var_vals or {}, update_stats=False, wait=True, timeout=timeout, cache=False) linear = link_linear(compile_linear(UOp(Ops.LINEAR, src=(call,)), beam=0, profile=True, cache=False), allow_cache=ctx.cache) while True: if clear_l2: if hasattr(dev:=Device[call.device], 'invalidate_caches'): dev.invalidate_caches() else: from tinygrad.tensor import Tensor with Context(DEBUG=0, BEAM=0, CAPTURING=0, TRACK_MATCH_STATS=0): Tensor.ones(1024, 1024).contiguous().realize(do_update_stats=False) yield max(map(unwrap, pm_exec.rewrite(c:=linear.src[0].without_after, ctx)[isinstance(c.arg.aux, HCQInfo):]))