跳转至

接入新的硬件 backend

TileLang 是支持多种 backend 的 DSL,每种硬件各有一套独立的 kernel,由各自的 Python 包发行。TileOPs 因此定义了一套协议:仓库之外的 Python 包可以接管某个 op 的 kernel,取代 in-tree 实现,且不必修改 TileOPs 的任何代码。

本页说明如何接入一类新硬件,使这类设备上的 op 由该硬件自己的 kernel 执行。

backend 只提供一件事:一个能执行这次调用的可调用对象。 其余工作都由 op 层负责。

本页讲的是 target,即接管整个 op 的那种接入方式。前半部分按实现顺序说明 backend 作者要做的事:

  1. 要写的四样东西;
  2. 协议中的四个函数;
  3. 一次调用如何走到这些函数;
  4. 一个可直接安装运行的 backend;
  5. 如何把模板改造为面向真实硬件的 backend;
  6. 编写 kernel 的四条规则;
  7. 各阶段允许做什么;
  8. 安装之后每个 op 所处的状态,以及各条错误信息对应的原因。

后半部分说明协议为何如此设计,包括:

  • 两层选择;
  • op 层的契约;
  • kernel 的重建条件;
  • 调用方可用的接口;
  • 刻意不支持的情形。

两种接入方式

一个仓库之外的包按自己接管的范围,从两种方式中选一种:

  1. register_kernel_type:为一个 kernel 接口新增一个实现,与 in-tree 实现一起参与选择;
  2. target:接管 op 的全部调用。

第一种方式写出的 kernel 类遵守 kernel 接口,与 in-tree 实现遵守同一份契约,写法见 backend 如何接入。target 遵守的是 op 在 manifest 中的签名,backend 为它写一个 build_kernel。本页以下各节只讲 target。

写一个 backend 要做的四件事

# 做什么
1 在 pyproject.toml 中声明一条 entry point,指向 backend 模块
2 起一个 target 名,编写 detect,声明这套 kernel 面向哪一类设备
3 选定第一个要接管的 op,按它的 manifest 签名编写 build_kernel
4 在模块顶层调用 register_detector 与 register_kernel_builder

四件事完成后,pip install 即生效。下面几节依次给出:

  1. 这四个函数的签名;
  2. 一次调用如何走到它们;
  3. 一个按这四步写成、可直接安装运行的完整 backend。

之后逐个 op 增加 build_kernel。目标模型用到的、自己构造 kernel 的 op 必须全部覆盖:缺少任何一个都会报错,不会改用 in-tree 实现,因为 in-tree kernel 在该 target 的设备上无法启动。只调用子 op 的复合 op 不需要 builder。

协议中的四个函数

tileops.backend 只定义对外接口,用 Python 的结构化类型(typing.Protocol)表达:backend 不继承基类,也不实现抽象方法,只需写出签名相符的普通函数并注册。op 层检查返回值时同样只看结构,即 callable()。

backend 实现 detect 与 build_kernel,再调用 register_detector 与 register_kernel_builder 登记它们。此外还有协议定义的 TensorSpec,以及 pyproject.toml 中的一条 entry point:

# 名字 谁写 谁调用,何时调用
1 detect backend 实现 op 层为一次调用选定 target 时,对每个 target 各调用一次
2 build_kernel backend 实现 op 层在记忆表未命中时调用
3 register_detector backend 调用 backend 模块被 import 时执行一次
4 register_kernel_builder backend 调用 backend 模块被 import 时执行,每个要接管的 op 调用一次
—— register_device_facts backend 调用,可选 backend 模块被 import 时执行,每种设备类型一次,见设备事实
—— TensorSpec 协议定义 op 层构造,作为 build_kernel 的实参传入
—— entry point backend 在 pyproject.toml 中声明 TileOPs 在构造第一个 op 时枚举

下面按这个顺序逐个给出签名与含义,最后给出协议定义的 TensorSpec。

1. detect

def detect(device: torch.device) -> bool: ...

由 backend 实现。它回答这类设备是否由自己这套 kernel 服务,只看设备,不看 dtype 与形状。设备不属于自己时返回 False,不抛异常。

# 认领一整类设备
def detect(device: torch.device) -> bool:
    return device.type == "acme"

# 需要读取环境变量或查询厂商 runtime 的判断也在这里完成
def detect(device: torch.device) -> bool:
    if device.type != "privateuseone":
        return False
    return acme_runtime.is_present(device.index)

2. build_kernel

def build_kernel(*inputs: "TensorSpec | None", **params) -> Callable[..., KernelResult]: ...

由 backend 实现,每组 (op, target) 一个。它的签名就是该 op 的 manifest 签名:inputs 按声明顺序与 signature.inputs 的条目一一对应,params 按 signature.params 命名。声明为 optional 的输入在本次调用中没有传入时,对应的实参是 None。

# GroupNormFwdOp 的 spec:weight、bias 是可选输入,没传时实参是 None
def build_group_norm(x, weight, bias, *, num_groups, eps):
    if weight is None:                                   # 由该位置的实参判断是否传入
        return AcmeGroupNorm(num_groups, eps, x.dtype)
    return AcmeGroupNormAffine(num_groups, eps, x.dtype)

op 层调用 build_kernel 时,经 torch.accelerator 把调用设备设为当前设备;调用设备不是进程 accelerator 的类型时不切换。

build_kernel 返回的 kernel 可以另外定义两个可选成员,含义与 in-tree Kernel 的同名成员相同:

成员 op 层如何使用
request_tune() op 处于调优模式时调用。op.request_tune() 对已构造的 kernel 调用它,之后构造的 kernel 在首次调用之前调用它。每次请求在 kernel 所属的每个设备上各调用一次,调用时该设备是当前设备
config 一个非空 dict。op 自己没有非空配置时,op.kernel_config() 返回第一个非空的 kernel config

request_tune() 遵守四条约定:

  • 按设备幂等:之后的每次请求都会再次调用它;同一个对象被两个设备共享时,在每个设备上各调优一次。
  • 可以当场调优,也可以推迟到下一次 launch;推迟时,完成调优的那次调用必须在 CUDA graph 捕获之前(见各阶段允许做什么)。
  • 它抛出的异常就是触发它的那次调用或 op.request_tune() 的异常;构造时调优失败的 kernel 不进记忆表,下一次调用重新构造。
  • kernel 没有定义它时不调优,op 实例只警告一次。

3. register_detector

def register_detector(target: str, detect: Callable[[torch.device], bool]) -> None: ...

由 backend 调用,每个 target 一次,在 backend 模块被 import 时执行。它登记该 target 的设备识别函数。

4. register_kernel_builder

def register_kernel_builder(op: str, target: str, build_kernel: BuildKernel) -> None: ...

由 backend 调用,每个要接管的 op 一次。它登记 (op, target) 的 kernel 构造函数;同一组重复登记会报错。

非 CUDA 设备的设备事实:register_device_facts

def register_device_facts(device_type: str, read: Callable[[torch.device], DeviceFacts]) -> None: ...

由 backend 调用,每种设备类型一次。kernel 选择读取调用设备的四项设备事实:arch、calibration、sm_count、smem_budget。

设备类型 设备事实的来源
cuda TileOPs 自己读取
已注册的其他类型 注册的 read:收到调用的 torch.device,返回 DeviceFacts
未注册的类型,如 cpu 没有设备事实:-1 / None / 0 / 0
  • 需要注册的是用 register_kernel_type 加入、按架构、SM 数或共享内存选择的实现。声明了 supported_archs 的实现在未注册的设备类型上永远不可用。
  • 只服务整个 op 的 target 不需要注册:target 的 kernel 自己决定怎么选。
  • 按设备类型注册,与 target 无关:同一种设备上有多个 target 时,只由一个包注册。重复注册、注册 cuda 都报 BackendError。
  • arch 只在同一种设备类型内可比,所以声明 supported_archs 的实现,其 devices 只能有一种设备类型。
  • read 收到的设备:设备是进程 accelerator 的类型时,op 层先补上设备序号;cpu 这类不编号的设备原样传入。
  • read 的调用时机:每个 call spec 第一次读设备事实时调用一次,结果只存在这个 call spec 上;声明了 supported_archs 的 kernel 构造时也调用一次。开销大时由 backend 在 read 内缓存。
  • read 抛异常或返回值不合规(不是 DeviceFacts、字段类型不对、sm_count 或 smem_budget 为负)时,op 层报 BackendError,指出是哪个 read。
import torch
from tileops.backend import DeviceFacts, register_device_facts
import acme_runtime                                      # 厂商 runtime,这里是假想的

def read_acme(device: torch.device) -> DeviceFacts:
    props = acme_runtime.device_properties(device.index)
    return DeviceFacts(arch=props.arch, calibration=None,
                       sm_count=props.compute_units,
                       smem_budget=props.shared_memory_per_block)

register_device_facts("acme", read_acme)                 # "acme" 是 torch 的设备类型名

TensorSpec

class TensorSpec(NamedTuple):
    device: torch.device
    dtype: torch.dtype
    shape: tuple[int, ...]

协议定义的类型,由 op 层构造后传入 build_kernel。它描述一个张量的属性,不包含张量本身。

# build_kernel 收到的实参
TensorSpec(device=torch.device("acme:0"), dtype=torch.float16, shape=(4096, 4096))

# 可读取的只有这三项
def build_gemm(a: TensorSpec, b: TensorSpec, *, trans_a, trans_b):
    m, k = a.shape                    # 形状是编译期常量,用于选择实现与 tile
    if a.dtype is not torch.float16:  # 不支持的 dtype 在这里报错
        raise ValueError(f"acme gemm needs fp16, got {a.dtype}")
    ...

返回值只需满足一条结构约定:它必须可调用,能以 (*tensors) 的形式调用,返回一个张量、一个张量元组,或在纯原地写入时返回 None。op 层对它的检查就是 callable()。

协议只传入张量的描述。 因此协议不需要另写一条「构造时不得读取张量内容、不得保存对张量的引用」的规则,op 层也无法校验这样的规则。这条规则要防止两件事:

  • 读取数据会使构造结果依赖数据,而记忆表只按设备与形状记录。
  • 保存引用会使张量与被缓存的 kernel 存活得一样久。

TensorSpec 上既没有数据也没有张量,这两件事因此无从发生。

下一节说明这四个函数在一次真实调用中分别在何时被调用。

一次调用如何走到 build_kernel

下面列出一次调用从用户代码到 backend 的 build_kernel 所经过的每一步:

# ── 调用方 ───────────────────────────────────────────────────────────
op = GemmFwdOp()                 # 构造时未给出 target=,由输入张量的设备决定
                                 #   给出 target="acme" 时跳过设备探测,直接使用它;
                                 #   给出 target=BUILTIN 时强制使用 in-tree kernel
a = torch.randn(4096, 4096, dtype=torch.float16, device="acme:0")
b = torch.randn(4096, 4096, dtype=torch.float16, device="acme:0")
d = op(a, b)                     # 所有输入必须在同一设备上:a.device == b.device

# ── op 层:定 target ────────────────────────────────────────────────
# 每个已安装的 backend 在 import 时向注册表登记一个 detect。op 层把 a.device
# 原样传给每个 detect,由它回答这块设备是否属于自己这套 kernel:
#   acme 的 detect(device) → True      其他 backend 的 → False
#   恰好一个返回 True    → target = "acme",这个 op 实例此后固定使用它
#   没有一个返回 True    → 使用 in-tree kernel
#   两个以上返回 True    → 抛出 AmbiguousTargetError,要求显式给出 target=

# ── op 层:先执行由 manifest 签名生成的检查,再由 target 服务整个 op ──
#   GemmFwdOp 自己的 forward 与 kernel_for 只服务 in-tree 实现,这次不执行
#   传给 target 的张量按 signature.inputs 排列,不写入的输入先转为连续张量

# ── op 层:按设备与输入签名查外部记忆表 ─────────────────────────────
#   ("acme:0", (float16, (4096, 4096)), (float16, (4096, 4096)))
#   第一项是设备,其余每项对应一个输入的 (dtype, shape)
#   这是该 op 实例的第一次调用,表为空 → 未命中,进入构造
#   设备、dtype 与形状都相同的下一次调用命中,直接进入最后一步

# ── backend:op 层调用 build_gemm,张量已转为 TensorSpec ───────────────
#   build_gemm(TensorSpec("acme:0", float16, (4096, 4096)),
#              TensorSpec("acme:0", float16, (4096, 4096)),
#              trans_a=False, trans_b=True)      # params 按 manifest 中的名字传入
#   → 返回一个可调用对象

# ── op 层:存入记忆表,然后 launch ─────────────────────────────────
#   kernel(a, b)                 # d = a @ b.T,由 acme 的 kernel 算出

backend 只需编写其中一步,即 build_gemm,并把它注册进来:

def build_gemm(a: TensorSpec, b: TensorSpec, *, trans_a, trans_b):
    m = a.shape[1] if trans_a else a.shape[0]
    if m == 1:                                  # backend 从 TensorSpec 自行判断属于哪种情形
        return AcmeGemv(a, b, trans_a, trans_b)
    return AcmeGemm(a, b, trans_a, trans_b)


register_kernel_builder(op="GemmFwdOp", target="acme", build_kernel=build_gemm)

build_gemm 由 op 层调用,backend 自己不调用它。import backend 模块时只把它登记进注册表;一次调用由这个 target 服务且外部记忆表未命中时,op 层才调用它,每个「设备 + 输入签名」一次。它返回的可调用对象由 op 层 launch 并存入记忆表。

这条调用路径对应以下四点:

  • kernel_for 与各实现的 entry_for 只服务 in-tree 实现。 它们决定取哪个 in-tree kernel、按什么查表以及如何构造。op 选中某个 target 之后,整个 op 由这个 target 服务,这几处都不会执行。
  • 张量按位置传入,参数按名字传入。 在 build_kernel(*inputs, **params) 中,位置实参是 TensorSpec(没有传入的可选输入是 None),关键字实参是 manifest 中 params 的名字与本次调用的确定值。
  • 一组 (op, target) 只注册一个 builder。 in-tree 路径会选用哪个实现(例如 GEMM 在 kernel_types 中声明的多个实现之一)不会告诉 backend,build_kernel 从 TensorSpec 自行判断应返回哪个 kernel。
  • backend 不必自己缓存构建结果。 设备与输入签名相同时,op 层不会再次调用 build_kernel;需要更细的区分或更少的重建时,backend 在 build_kernel 内部另加一层缓存。专为外部 backend 编写的 op 既不声明 kernel_types 也不声明 interfaces,没有 target 认领设备时,调用直接抛出 OpNotAvailableError。

实现一个可运行的 backend

tileops-backend-example 是按这四步写成的完整 backend,可以复制后改造。它用纯 PyTorch 实现 kernel 并认领 CPU,因此在任何机器上都能安装、运行和测试。除了 kernel 本身不涉及专用硬件,它的其余部分与面向专用硬件的 backend 完全一致,包括 entry point、注册方式、build_kernel 签名、记忆规则与错误信息。

安装这个包前后的差别如下:

$ python -c "import torch; from tileops.norm import RMSNormFwdOp; \
             RMSNormFwdOp(normalized_shape=(64,))(torch.randn(4,64,dtype=torch.float16), \
                                                  torch.randn(64,dtype=torch.float16))"
OpNotAvailableError: RMSNormFwdOp's in-tree kernels do not run on cpu; known targets for this op: []

$ pip install -e .

$ python -c "...同一段代码..."
# 正常返回,结果与 torch.nn.functional.rms_norm 逐位相同

下面按这四步逐段说明它的内容。

第一步,pyproject.toml 中的三行。 entry point 组的名字固定为 tileops.backends,值是 backend 模块名:

[project.entry-points."tileops.backends"]
torch_cpu = "tileops_cpu"

pip install 之后不需要任何初始化。TileOPs 在构造第一个 op 时枚举这个组并 import 其中声明的模块,模块顶层的注册调用随之填好注册表。backend 既不需要继承基类,也不需要实现接口。

第二步,起 target 名,编写 detect。 两者都在 target.py 中。detect 只有一行,认领所有 CPU 设备:

TARGET = "torch_cpu"


def detect(device: torch.device) -> bool:
    return device.type == "cpu"

两个名字含义不同:TARGET = "torch_cpu" 是这套 kernel 的名字,由 backend 作者决定;device.type == "cpu" 是它认领的设备类型,由 torch 定义。

第三步,按 manifest 签名编写 build_kernel。 RMSNormFwdOp 的 spec 声明了两个输入 x 与 weight(可选),以及两个参数 normalized_shape 与 eps,函数的形参与这份声明一一对应。它与 kernel 类 CpuRMSNorm 一起放在 ops/rms_norm.py 中:

def build_rms_norm(x: TensorSpec, weight: TensorSpec | None, *, normalized_shape, eps):
    if eps is None:                     # manifest 默认为 null:取参考 API 的含义
        eps = torch.finfo(torch.float32).eps
    return CpuRMSNorm(normalized_shape, eps, x.dtype)

第四步,在模块顶层注册。 ops/__init__.py 中的 BUILDERS 列出这个 target 接管的全部 op,每个键都按 manifest 中的拼写书写。包的 __init__.py 登记一个 detector,再为表中每个 op 登记一次 builder:

BUILDERS = {
    "RMSNormFwdOp": build_rms_norm,
    "GemmFwdOp": build_gemm,
}
register_detector(target=TARGET, detect=detect)

for _op, _build_kernel in BUILDERS.items():
    register_kernel_builder(op=_op, target=TARGET, build_kernel=_build_kernel)

模板项目:结构、测试与改造

仓库结构

示例仓库中每个文件对应接入工作的一个环节:

文件 内容
pyproject.toml entry point 声明,即全部的安装机制
src/tileops_cpu/__init__.py 全部注册代码
src/tileops_cpu/target.py target 名与 detect
src/tileops_cpu/ops/__init__.py BUILDERS,即这个 target 接管的全部 op。键按 manifest 中的拼写书写,键写错的 builder 永远不会被调用
src/tileops_cpu/ops/rms_norm.py、ops/gemm.py 每个 op 一个模块,存放 kernel 实现和构造它的 builder。真实 backend 在 kernel 的构造函数中编译
tests/test_takeover.py 数值、校验、归一与输出
tests/test_discovery.py entry point 与注册
tests/test_errors.py 三条错误路径:未登记的 op 报错而不回退,未知的 target 报错,调用失败后 op 不固定到任何 target
tests/test_memoization.py build_kernel 在什么条件下被重新调用
tests/test_tuning.py 调优请求到达请求前后构造的 kernel,op.kernel_config() 报告 kernel 的 config

CpuRMSNorm 在构造时得不到行数,这体现了「构造函数只接收编译期参数」这条规则。

运行测试

运行示例仓库的测试需要一个已经安装 tileops 的环境:

pip install -e .          # tileops 已安装时加 --no-deps
python -m pytest -q       # 有 GPU 与没有 GPU 时都全部通过,没有跳过项

在 TileOPs 的 dev 镜像 中运行同样不需要修改 TileOPs,<tag> 取一个已发布的 tag,格式见该页:

docker run --rm --gpus all -v "$PWD/..":/work -w /work \
  ghcr.io/tile-ai/tileops-dev:<tag> \
  bash -lc 'pip install -e /work/TileOPs --no-deps -q &&
            pip install -e /work/tileops-backend-example --no-deps -q &&
            cd /work/tileops-backend-example && python -m pytest -q'

tileops 有意不写在示例的依赖列表中。这个包扩展的是一个已经存在的安装;在依赖中写上版本下限会解析到早于 tileops.backend 的发行版。由此产生的 ImportError 会被收入 load_failures(),表现为「这个 backend 不可用」,而真正的原因是 TileOPs 版本过旧。

改造成面向真实硬件的 backend

改造分为六步:

  1. 复制仓库:tileops_cpu 改为 tileops_<硬件名>,target 名随之改写。
  2. target.py 中的 detect 改为认领对应的设备类型。
  3. ops/ 下各模块中的 kernel 换成真实 kernel:构造时编译,在 __call__ 中启动。
  4. 第一个要接管的 op 按它的 manifest 签名编写 build_kernel,并在 BUILDERS 中登记。
  5. tests/ 中的测试沿用,改动三处:包名、target 名、op 名与 kernel 类名;构造张量的设备与断言中的设备类型(示例为 CPU);针对具体 op 或 kernel 的断言。
  6. 其余 op 逐个增加 build_kernel,直到覆盖目标模型用到的全部 op。

编写 kernel

签名来自 manifest

编写 kernel 只需读 manifest,不必读 TileOPs 的源码。 builder 的签名就是该 op 的 manifest 签名。以 src/tileops/manifest/spec/norm.yaml 中的 RMSNormFwdOp 为例:

signature:
  forall: {B: Shape, T: "DType[float16 | bfloat16]"}
  params:                       # 按这些名字作为关键字参数传入
    normalized_shape: {type: "list[int] | tuple[int, ...]"}
    eps: {type: "float | None", default: null}
  inputs:                       # 声明顺序即传入顺序
    x: {dtype: T, shape: "[*B, *normalized_shape]"}
    weight: {dtype: T, shape: "[*normalized_shape]", optional: true}

对应的 builder 签名:

def build_rms_norm(x: TensorSpec, weight: TensorSpec | None, *, normalized_shape, eps):

这个签名有两点约定:

  • 参数按 op 实例保存的值传入。 构造时没有给出 eps 时,builder 收到的是 manifest 的默认值 None,其含义与参考 API 相同,由 builder 按该语义处理;没有传入的可选输入 weight 收到 None。
  • 返回值按 signature.outputs 的声明给出。 单输出返回张量,多输出按声明顺序返回 tuple,纯原地写入的 op 返回 None。

构造函数只接收编译期参数

会被编译进生成代码的值(tile 尺寸、当作常量的维度、dtype)传给构造函数,其余的值留给 __call__。

这条规则对 decode 路径是硬性要求:seq_len 逐步递增,batch 随 running set 变化,把它们放进构造函数会导致每一步都重新编译。

形状由 manifest 规定

op 层不改变形状:kernel 收到的就是 manifest 声明的形状,kernel 需要的 layout 由它在自己的调用包装中处理。

代码与 manifest 不一致时以 manifest 为准。输出 dtype、形状规则与参数类型都由 manifest 规定,kernel 不得改写。

kernel 无法服务这次调用时如何报错

kernel 无法服务这次调用时直接报错,不做降级处理。报错信息须给出两项内容:

  • 未满足的是哪一项:dtype、形状、arch、无可用实现,还是编译失败。
  • 实际收到的值。

只写「不支持」不构成有效的诊断信息。

各阶段允许做什么

decode 路径会被 CUDA graph 捕获,因此各阶段允许执行的操作规定如下:

阶段 允许 不允许
查记忆表(键与重建条件见 kernel 的重建条件) 一次字典查找 其他任何操作
detect 一次谓词判断 任何 import,任何加锁
构造 kernel 选择实现、编译、分配显存、重新 import、建立 handle 依赖真实张量的调优
kernel 的 request_tune() 编译、分配显存、计时 依赖调用方真实张量的调优
调用 kernel 启动已编译的 kernel,经 torch allocator 分配输出 编译、惰性初始化、建立 handle、host 端同步

模块顶层的 import 不得触发编译。 TileOPs 在构造第一个 op 时 import backend 模块,编译应当发生在 build_kernel 被调用时。

调用 kernel 还须满足两条与流有关的规则:

  • 必须在当前流上启动。 在 CUDA 上,当前流即 torch.cuda.current_stream(device),不得改用默认流。自带 launcher 的 backend 尤其容易违反这一条。
  • 内部分配的生命周期必须覆盖异步执行。 如果只把裸指针传给 launch,对应对象必须存活到该流执行完成。协议不提供 workspace,这部分安全由 backend 自己保证。

因为构造 kernel 时允许编译,调用方需要在捕获之前完成预热,即至少执行一次同形状的非捕获调用。op.request_tune() 之后同样如此:每种形状在捕获之前至少再执行一次非捕获调用,推迟到 launch 的调优在这次调用中完成。捕获期间只允许「查表命中后直接调用」这一条路径。

安装之后:三种状态

detect 认领某一类设备之后,该设备上的所有 op 都由这个 target 服务,缺少其中任何一个都会报错,不会改用 in-tree 实现。唯一的例外是不自己构造 kernel 的复合 op。

不回退的理由是:选中一个 target 意味着这块设备属于另一套硬件,in-tree kernel 在它上面无法启动。回退会把一条明确的「该 target 未实现此 op」错误变成一次难以诊断的启动失败。

因此安装之后,每个 op 处于以下三种状态之一:

状态 结果
该 target 为这个 op 注册了 build_kernel 正常执行,整个 op 由 target 服务
没有注册,且 op 自己构造 kernel 报错,指出这个 target 没有为该 op 注册 builder,且不会改用 in-tree 实现
没有注册,且 op 是复合 op op 照常执行它的组合,每个子 op 各自选定 target

因此,覆盖目标模型用到的每一个 op 是 backend 一侧的工作。op 一侧的前提由设计保证:op 层按本次调用的输入为外部路径计算记忆键(见一次调用如何走到 build_kernel)。

平台无关的前提

target 确定之前,op 层不查询与特定硬件绑定的信息,例如 CUDA 的 SM 版本。如果查询,在没有该驱动的机器上,调用会在到达 build_kernel 之前失败,而失败原因与这个 backend 无关。

在 backend 的硬件上出现这种失败时,调用栈停在 TileOPs 内部,而不是 backend 的 build_kernel 中。这属于 TileOPs 一侧的回归,应提交 issue 并附上调用栈。

在看不到 GPU 的环境中,示例仓库的全部测试照常通过,没有跳过项;这次运行本身检查了这条前提。

错误信息与处理

以下三条均为实测输出,每条对应一种成因和一种处理方式。

未为该 op 注册 builder:

OpNotAvailableError: target 'torch_cpu' registers no kernel builder for SoftmaxFwdOp;
targets that do: []. There is no fall back to the in-tree implementation: those kernels
do not run on this target's devices.

处理方式是为该 op 编写并注册一个 builder。

指定了未注册的 target:

UnknownTargetError: no backend registered target 'nope'; known targets: ['torch_cpu']

这说明包没有安装成功,或者 target 名拼写有误。tileops.backend.registered_targets() 可以列出实际注册的内容。

以 target=BUILTIN 强制使用 in-tree 实现:

OpNotAvailableError: RMSNormFwdOp's in-tree kernels do not run on cpu; known targets for this op: ['torch_cpu']

BUILTIN 显式绕过所有 backend。in-tree 实现无法在 CPU 张量上运行,这条错误展示的正是「不改用 in-tree 实现」这条规则所要避免的后果。

backend 包 import 失败时,TileOPs 跳过它并发出一条警告,同时把原因收入 load_failures()。单个损坏的插件不会导致 TileOPs 无法导入。如果注册过程中途抛出异常,该 backend 本次注册的内容会全部回滚,注册表中不会留下只完成一半注册的 target。

from tileops.backend import load_failures
print(load_failures())

为什么选择分两层

名字 定义 在 dispatch 中的位置
target 一套 kernel 的名字。一个 backend 发行版带来一套 kernel,并为它起一个名字,例如 "acme" 第一层:选中一个 target 后,本次调用的 kernel 从它这一套中选出
detect backend 编写的一个函数,每个 target 一个 第一层的选择依据:接收一个 torch.device,回答这类设备是否是自己这套 kernel 的目标设备;不是则返回 False
build_kernel backend 为某个 op 编写的一个函数,每组 (op, target) 一个 第二层:接收本次调用的描述,即各输入张量的 device、dtype、shape 与 op 参数,从自己这套 kernel 中选定一个,构造好并返回

选择分两层:TileOPs 选 target,target 在自己那套 kernel 中选一个。 第二层发生在 build_kernel 内部,协议不参与,这条路径上没有 kernel 一级的概念、能力协商与候选筛选。TileOPs 实际执行的候选筛选(可用性、适用范围、优先关系)属于 in-tree 实现与范围较小的 register_kernel_type(见两种接入方式),target 绕过这些筛选。

detect 只回答设备的归属,不回答更细的问题。本次调用是否受支持(涉及 dtype、形状与参数组合)由 build_kernel 回答,因为只有它看得到完整的输入描述与参数,不支持时也在那里报错。detect 只拿到一个 torch.device,无法作出这些判断。

TileOPs 把 torch.device 原样传给 detect,自己不解析它。原因是设备类型与 target 并不一一对应,以下三种情形都会使解析出来的字符串失去意义:

  • 同一个 device type 可能对应多套 kernel,分属不同厂商。
  • 部分硬件经 privateuseone 接入,字符串中不含任何厂商信息。
  • 还有一些 backend 要读取环境变量或调用厂商 runtime 才能作出判断。

op 层的契约

以下七项是 op 层对所有 target 的契约。这些功能由 op 层实现,backend 直接重用,无需自行实现。表中各项按 backend 作者接触到它们的先后排列:

# op 层提供 对 backend 意味着什么
1 torch 侧的公开 API 与参数语义 这个 op 如何被调用、参数名与各参数的含义均已确定,backend 既不定义也不能改动
2 manifest 校验 不符合 manifest 签名的调用由 op 层拒绝,不会到达 backend;签名之内的检查分工见下文
3 参数按名字传入 backend 收到的参数名是 manifest params 的名字,值是 op 实例保存的值。manifest 中默认为 null 的参数,传入的是 op 选定的值,可能是 None,也可能是一个具体数值
4 输入的连续性归一 本次调用不写入的输入都转成连续张量;被写入的输入按调用方传入的原样传给 backend,除非 manifest 声明它 contiguous: true
5 kernel 的记忆与重用 构造函数对每种特化调用一次:设备与输入签名相同的后续调用直接使用上一次的返回值。因此构造函数内部可以编译,op 层保证它不会被重复调用
6 torch.compile 与 CUDA graph 的边界 对有编译边界的 op(spec 有调用期张量输入、没有 composition),op 层把一次调用包装成不透明 op,并另配一个 fake,使编译器不执行也能推出输出的形状与 dtype。backend 的 kernel 不为编译做任何事,细节见接入 torch.compile
7 roofline、profile 与数值测试 op 层已有的测试可以用 backend 的 kernel 运行,见backend 的测试结论;性能报告见性能报告

七项由 op 层统一实现,每个 target 在它所运行的设备上得到的完全相同。接入第三方 backend 时不得绕过其中任何一项,也不得另行实现。

第 2 项的检查分三层:

检查 由谁做 不满足时
dtype 与形状是否符合 manifest 签名 op 层 调用不到达 backend
target 是否服务签名之内的这组 dtype、形状与参数 build_kernel build_kernel 报错
依赖张量取值的条件,如 RoPE 的位置越界 target 的 kernel,在调用时;op 层不替它检查 kernel 须与 in-tree 实现一样抛 ValueError,op 层把异常原样传给调用方

性能报告

benchmark 的运行设备由 --tileops-device 指定,必须是 CUDA 或进程的 accelerator;cpu 上不做 benchmark。

运行设备 计时方式 timing 一栏
CUDA CUPTI cupti
CUDA,CUPTI 失败且运行带 --tileops-allow-events-fallback CUDA events cuda-events
其他 accelerator 设备事件(torch.Event) events

设备事件计时不区分执行与 kernel 之间的间隙,device_busy_ms 与 latency_ms 相等。

baseline 库在运行设备上不能运行(没有安装,或只能在 CUDA 上运行)时:

baseline 的角色 CUDA 上 其他设备上
必选对照 这个 case 失败 这个 case 跳过并写明原因
可选对照 去掉这一列 去掉这一列

backend 的测试结论

backend 在自己的设备上运行 op 层已有的测试:

pytest tests/ops --tileops-target=<t> --tileops-device=<d> --junitxml=<report>

<t> 是服务设备 <d> 的 backend target;cpu 加 builtin 不是 tests/ops 支持的运行配置。三类标记决定哪些测试运行:

标记 含义 在 backend 的运行中
in_tree_kernels 测试依赖 in-tree 实现 --tileops-target 不是 builtin 时去掉
cuda_only 测试本身需要 CUDA --tileops-device 不是 cuda 时去掉
sm<NN>,如 sm90 测试需要该 CUDA 架构 设备不符时跳过,计入 skip 数

JUnit 中每个调用了 check() 的测试带 op 与 checked_outputs 两个属性。

backend 对一个 op 的结论是「通过了这台设备上实际运行的测试」,不代表覆盖了这个 op 全部的 dtype、形状与输出。结论成立需要三个条件:

条件 内容
有数值证据 该 op 名下至少一个测试通过且 checked_outputs > 0
没有失败 该 op 名下没有失败的测试
行为测试 不带 op 属性的测试,整次运行没有失败

给出结论时随附三组数字:

  • 该 op 名下的通过数与 skip 数,checked_outputs == 0 的测试记为 skip;
  • 各测试的 checked_outputs;
  • 按测试文件统计的被去掉的测试数。被去掉的测试不进 JUnit,这个数由 backend 自行计算,即两次 pytest tests/ops --collect-only -q 的 node id 之差:一次用默认参数,一次加上 --tileops-target 与 --tileops-device。

TileOPs 的 in-tree kernel(src/tileops/kernels/)是默认实现:它没有 target 名,也不进入注册表。

默认使用 in-tree 实现。 没有安装 backend、没有通过 target= 指名、也没有设置进程默认值时,调用由 in-tree 实现服务。安装一个 backend 之后,只有当它的 detect 认领了这块设备,或者它被 target= 或 set_default_target 指名时,该 op 的 kernel 才换成这个 backend 的那一套。

kernel 的重建条件

TileOPs 按设备加输入签名记住 build_kernel 的返回值。这个键的构成是:

调用设备(见调用设备),加上按 signature.inputs 顺序逐项取出的 (dtype, shape);声明为 optional 而本次没有传入的输入,这一项记 None。

因此,设备与输入签名都相同的两次调用,TileOPs 使用同一个 kernel,不再调用 build_kernel。同一个 target 的第二块卡会重新构造一次,因为为一块卡编译出的产物不一定能在另一块卡上启动。参数不进入这个键,因为它们对一个 op 实例而言是固定的。

op 层如何查表、未命中时如何调用 build_kernel,见一次调用如何走到 build_kernel。

由此得出两点:

  • 条目不一定一直保留。 一次调用失败时,op 会撤销它的 target 判定并清空记忆表。backend 不得假设自己返回的可调用对象一直存活,它所依赖的资源应由它自己持有引用。
  • 需要更细或更粗的粒度时,都在 backend 一侧解决。 更细的区分在 backend 内部处理;需要减少重建次数时,在 build_kernel 内部另加一层缓存。

调用方可用的接口

以下接口面向使用者,backend 作者不需要调用,但调试时会用到:

from tileops.backend import (
    BUILTIN, registered_targets, set_default_target, default_target, load_failures,
)

registered_targets()                 # ['torch_cpu']
registered_targets("RMSNormFwdOp")   # ['torch_cpu']
set_default_target("torch_cpu")      # 进程默认,优先于设备探测
set_default_target(BUILTIN)          # 全局关闭替换

target 按以下顺序选取:

  1. 构造参数 target=;
  2. 进程默认值;
  3. 设备探测。

BUILTIN 强制使用 in-tree 实现。指定的 target 没有注册或没有实现该 op 时直接报错,不会改用其他 target。

协议不支持的情形

以下情形不在这套协议的范围内,理由见表:

不支持 理由
同一个 target 上存在多个 backend 一个 target 对应一套 kernel 与一个提供者。重复注册同一组 (op, target) 会直接报错,因为这说明安装了两个都声明服务该 target 的包
跨 target 回退 指定的 target 没有实现时直接报错,不会改用其他 target 执行
backend 改变输入形状,或代替调用方还原输出 这是 op 层对所有 target 统一提供的服务,需要改动时对所有 target 一起改动
一次调用跨越多个设备 所有输入位于同一设备上,manifest 声明 device: cpu 的张量除外
调用方提供 workspace 或显式 stream backend 需要的只是当前流,而 torch 的流本身就是隐式的当前值
与 autograd 联动 这条调用链服务推理,fwd 与 bwd 各自是独立的 op