这些问题都落在同一个位置:Python 调用与 GPU kernel 之间的那层调度。 本篇只讲这一层——一次调用如何被识别成哪个算子、经过哪些处理、最终选中哪个实现。融合、torch.compile 的 guard、SDPA 后端选择等“kernel 层”话题见 kernel 与编译执行。

问题:一个算子要同时服务很多“执行者”

add 这个数学运算只有一个,但对它的调用可能来自完全不同的上下文:

朴素做法是在每个算子实现里写一串 if requires_grad ... if autocast ... if device == cuda ...。PyTorch 有两千多个算子,这样做既无法维护,也让每加一种机制都要改遍所有算子。需要的是一种办法:让这些横切关注点(cross-cutting concerns)与算子本身的计算解耦,各自独立地挂进去。

抽象:算子是一张按 dispatch key 索引的表

PyTorch 的解法分两步。

第一步,把算子从实现中剥离出来,变成一个有名字、有签名的对象。 每个 ATen 算子由 schema 描述,例如:

aten::add.Tensor(Tensor self, Tensor other, *, Scalar alpha=1) -> Tensor

aten 是命名空间,add 是算子名,.Tensor 是 overload 名——同名算子按参数类型区分,add.Scalar、add.out 都是别的 overload。schema 还用 Tensor(a!) 一类标注说明哪个参数被原地修改、输出是否与输入别名。Python 的 torch.add、Tensor.__add__、torch.ops.aten.add.Tensor 最终都落到同一个算子对象上。

第二步,每个算子不是一个函数,而是一张“dispatch key → kernel”的表。 autograd、autocast、CUDA、CPU、Meta 等各是一个 dispatch key,有固定的优先级。每次调用时,Dispatcher 计算本次的 key 集合:

key set = 所有输入张量自带的 key 的并集  ∪  线程局部 include  −  线程局部 exclude

张量自带的 key 来自它的 device 和属性(CUDA 张量带 CUDA、AutogradCUDA 等);线程局部状态(TLS)则由 autocast、inference_mode、FakeTensor mode 这类上下文管理器修改。Dispatcher 取集合中优先级最高的 key 去查表、调用对应 kernel。

关键在于后半句:被调用的那层 kernel 做完自己的事,通常会把自己的 key 排除掉,再 redispatch,于是调用落到下一个 key。整个过程像剥洋葱:

一次 torch.add 的调度路径

几个直接推论:

inference_mode 省掉的就是两层洋葱

有了 key 的视角,no_grad 与 inference_mode 的差别就很具体了(语义层面的区别见 推理模式与状态):

上下文 Autograd 层 ADInplaceOrView 层 产生的张量
默认 进入,可能记录 grad_fn 进入,in-place/view 时维护 version counter 与 view 关系 普通张量
no_grad 仍进入,检查 grad mode 后不记录 仍进入 普通张量
inference_mode 通过 TLS exclude 整体跳过 同样跳过 inference tensor,不带 version counter

所以 inference_mode 的收益来自每次调用少走两层 kernel、少维护一些元数据;它不改变 backend kernel,GPU 上执行的计算完全相同。在 batch 很小、算子很多的 decode 中,这类 CPU 侧开销才有可能显著;在 GPU 已经满载的 prefill 中通常不明显。

backend 层:一个 op 不等于一个 kernel

调度到 CUDA key 之后,事情还没有结束。backend kernel 内部又分几种情况:

结论是:Python 一行代码 → 一个或多个 ATen 算子 → 零个、一个或多个 GPU kernel。 估计 GPU 工作量要看 profiler,不能看代码行数。

在 profiler 里认出这条路径

torch.profiler 在 Dispatcher 处埋点,因此 CPU 一侧按算子名嵌套显示,CUDA 一侧是真正的 kernel 名,两者通过 launch 关联。读 trace 时可以把名字当作调度路径的“化石”:

看到的名字 说明
aten::linear 下嵌套 aten::t、aten::addmm 组合算子在分解;外层的 CPU 时间包含内层
aten::to / aten::_to_copy / aten::copy_ 发生了 dtype 或设备转换、或为满足布局要求的复制
aten::empty / aten::empty_strided 分配输出;频繁出现说明中间张量多
aten::item / aten::_local_scalar_dense 读取设备值到主机,通常意味着同步(见 CUDA 异步执行)
sgl_kernel::rmsnorm、sglang::... 自定义算子,和 aten:: 一样经过 Dispatcher
CPU 侧有算子、CUDA 侧没有 kernel 元数据算子,或 kernel 由下层其他算子发出

判断 dispatch 开销是否是瓶颈,看的是 GPU kernel 之间的空隙,而不是 aten:: 行的 CPU 耗时本身。CUDA Graph 在 replay 时完全不经过 Python 和 Dispatcher——整条洋葱路径只在 capture 时走一遍,这正是它能消除这部分开销的原因(见 Cuda Graph)。

自定义算子:把黑盒接入调度表

写了一个 CUDA/Triton kernel,用 pybind11 暴露成普通 Python 函数,eager 下当然能跑。问题出在其他“执行者”身上:torch.compile 追踪时会尝试钻进这个函数,遇到 Triton JIT、文件 I/O 或主机端逻辑就断图或失败;就算它不钻进去,也无法知道这个函数的输出 shape、改写了哪个参数,因而无法把它安全地放进图里。

注册成 torch 算子就是给这个黑盒补上一张“调度表条目”,由三部分组成:

自定义算子的三个组成部分
  1. schema:名字和签名。Tensor!(或 Python 侧的 mutates_args)声明哪个参数会被原地写——编译器做 functionalization、判断依赖关系都靠它,写错会导致结果被错误重排或复用。
  2. 真实实现:挂在 CUDA 等 backend key 上,eager 执行和 CUDA Graph 捕获时调用。
  3. fake 实现:挂在 Meta/Fake 路径上,只根据输入元数据构造输出(通常是 new_empty/empty_like),不读写真实数据。torch.compile 用 FakeTensor 追踪时调用它推导 shape,算子本身作为不透明节点留在图里。

接口有三档:C++ 的 TORCH_LIBRARY/TORCH_LIBRARY_FRAGMENT 加 m.def/m.impl;Python 的底层 torch.library.Library 加 define/impl/_register_fake;以及高层的 torch.library.custom_op 装饰器。高层接口会自动处理 autograd、functionalization 等更多 key,便利但每次调用的 Python 侧开销更大。见 Custom Operators。

在 SGLang 中:两套注册路径

SGLang 同时用了 C++ 和 Python 两种方式(基于当前 python/sglang/ 代码)。

C++ kernel:sgl_kernel 命名空间。 kernels/aot/csrc/common_extension.cc 中的 TORCH_LIBRARY_FRAGMENT(sgl_kernel, m) 逐个注册 kernel,例如:

m.def("rmsnorm(Tensor! output, Tensor input, Tensor weight, float eps, bool enable_pdl) -> ()");
m.impl("rmsnorm", torch::kCUDA, &rmsnorm);

schema 用 Tensor! output 和 -> () 声明这是写入调用方提供的输出缓冲区的算子,只注册了 kCUDA 实现,没有 autograd 公式——它只为推理服务。Python 包装 kernels/aot/python/sgl_kernel/elementwise.py 先 torch.empty_like(input) 分配输出,再调用 torch.ops.sgl_kernel.rmsnorm.default(...):直接取 .default overload,省去按参数匹配 overload 的步骤。这些 C++ 算子的 fake 实现在 Python 侧按需补上,例如 srt/layers/quantization/fp8_utils.py 用 register_fake_if_exists("sgl_kernel::fp8_scaled_mm") 注册一个只返回 mat_a.new_empty((M, N), dtype=out_dtype) 的函数;register_fake_if_exists(srt/utils/patch_torch.py)在算子不存在时跳过,以适配不同的 kernel 构建。

Python 函数:sglang 命名空间。 srt/utils/common.py 定义了 sglang_lib = Library("sglang", "FRAGMENT") 和 direct_register_custom_op():用 torch.library.infer_schema 从类型注解推出 schema,define 后把函数直接 impl 到 CUDA key(NPU、XPU、MUSA 上换成对应 key),有 fake 实现时再 _register_fake。源码注释说明了为什么不用 torch.library.custom_op——后者要处理复杂的调度逻辑,开销明显,而直接注册到 backend key 只多一次 Dispatcher 查表。日常使用的是 srt/utils/custom_op.py 里的 @register_custom_op 装饰器,它在内部调用 direct_register_custom_op,并可以根据 out_shape 自动生成 fake(对指定参数 empty_like),原地算子则不需要输出。装饰后返回的是 torch.ops.sglang.<op_name> 本身,所以之后的调用都经过 Dispatcher。

这条路径最重要的用户是 attention。srt/layers/radix_attention.py 中:

@register_custom_op(mutates_args=["output"])
@register_split_op()
def unified_attention_with_output(query, key, value, output, save_kv_cache, layer_id, ...) -> None:

attention 的实现依赖运行时的 ForwardBatch、KV cache 索引和后端选择,不适合被 Dynamo 追踪展开。注册成算子后,它在 FX 图里只是一个写入 output 的节点;register_split_op() 再把 sglang.unified_attention_with_output 加入 SPLIT_OPS,compilation/backend.py 的 split_graph() 在这些节点处把图切开,attention 之间的部分分别编译、捕获,形成 piecewise CUDA Graph。对 flashinfer 这类在内部做 JIT 编译或动态加载的外部函数,register_custom_op_from_extern()(同在 custom_op.py)做的是同样的事:把它包成不透明算子,用 fake 实现传播 shape/dtype。

小结

Dispatcher 把“一个算子”变成“一张按 dispatch key 索引的表”:autograd、autocast、追踪、backend 各占一层,每次调用按 key 优先级剥洋葱、逐层 redispatch,最后才落到真正的 kernel,而 backend kernel 本身又可能拆成多个 GPU kernel 或零个。理解了这张表,就能解释 inference_mode 省下了什么、profiler 里的 aten:: 名字意味着什么,以及为什么自定义 kernel 要以“schema + 真实实现 + fake 实现”的形式注册——只有进了这张表,它才能和 torch.compile、CUDA Graph 等其他执行者协作,SGLang 的 sgl_kernel 与 sglang 两个算子命名空间正是这样接入的。