mirror of
https://github.com/tinygrad/tinygrad.git
synced 2026-08-29 13:16:09 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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)))
|
||||
|
||||
Reference in New Issue
Block a user