16
数学库
海光 DCU 自定义算子完整实战:用 PyTorch + HIP 融合 bias 与 GELU
2026-08-25 21:54:46
94
![1784179332272.png](/api-forum/file/RedirectImage?path=20260825/1787666007059_1784179332272.png '1784179332272.png') > **海光 DCU 实战系列** > > 1. [从零跑通海光 DCU:完整配置与排错](https://blog.csdn.net/2403_86879992/article/details/162929518) > 2. [PyTorch + HIP 自定义融合算子](https://blog.csdn.net/2403_86879992/article/details/162934908) > 3. [用 hipprof 定位 PyTorch 性能瓶颈](https://blog.csdn.net/2403_86879992/article/details/162937545) > 本文围绕海光 DCU 的真实算子开发流程展开,面向已经能够运行 DCU 版 PyTorch、但还没有自己写过扩展算子的开发者。我们会从一个克制而实用的目标出发:把eager PyTorch 中分开的 `bias add` 和 `GELU` 合成一个 HIP kernel,并完成接口检查、源码编译、缓存隔离、数值回归、同步计时和显存高水位对比。全文代码可以直接复制,不依赖预编译二进制。 第一次写 PyTorch 自定义算子时,我最容易低估的不是 kernel 本身,而是 kernel周围那一圈工程问题:当前 stream 怎么拿、PyTorch 为什么把 `.cu` 变成 `.hip`、修改源码后为什么仍然加载旧结果、第一次运行几十秒到底是不是算子很慢,以及没有 `torch.cuda.synchronize()` 的计时究竟测到了什么。 真正能放进项目里的扩展,至少要同时回答六个问题: 1. 数学结果是否和 PyTorch 参考实现一致; 2. 输入设备、dtype、shape 和内存布局不对时,能否明确报错; 3. kernel 是否提交到 PyTorch 当前 stream,而不是擅自使用默认 stream; 4. 源码、PyTorch、HIP 和目标架构变化后,缓存是否会正确失效; 5. 冷构建、缓存加载和热态执行是否分别计时; 6. 性能提升来自真实的端到端减少,而不是漏同步或换了计算语义。 本文不会一上来写几千行模板,也不会把一个 add-one kernel 宣传成“生产级融合”。我们选择 `bias + GELU`,是因为它足够小,能看清整个扩展链路;又足够接近Transformer 的真实数据流,可以说明算子融合什么时候有用、什么时候没有用。 ## 一、先把 DCU 技术主线和算子边界说清楚 在这篇文章里,DCU、DTK、HIP 和 PyTorch 不是四个可以互换的名字,而是从硬件到框架的四层关系: | 层次 | 本文使用的对象 | 负责什么 | | --- | --- | --- | | 海光 DCU | BW / `gfx936` | 真正执行 kernel、访问显存 | | DTK | `/opt/dtk-25.04.2`、`hipcc` | 提供 DCU Runtime、编译器和开发工具链 | | HIP | `hipLaunchKernelGGL`、`hipStream_t` | 编写和发射设备 kernel | | PyTorch | `torch.utils.cpp_extension`、ATen | 管理张量、设备、stream 和 Python 接口 | 因此,本文不是把一篇通用 CUDA 扩展教程换几个名词。新增 kernel 的编译、加载、数值、接口边界和性能数据,均来自海光 DCU 的真实运行环境。PyTorch 和 HIP 是开发手段,DCU 才是最终执行与验证的平台。 下面这段 eager PyTorch 很常见: ```python output = torch.nn.functional.gelu(input_tensor + bias, approximate="tanh") ``` 从 Python 看只有一行,执行图里通常至少包含两个阶段: 1. `input_tensor + bias` 生成一个中间张量; 2. GELU 读取中间张量,再生成输出。 如果输入很小,真正的数学计算可能只需要几十微秒,Python 发射、kernel launch和临时张量管理反而占了不小比例。把两个逐元素阶段合并后,一个 kernel 直接读取输入和 bias,计算激活并写出最终结果,可以少一次中间张量写入、读取和一次 launch。 ![1784179332440.png](/api-forum/file/RedirectImage?path=20260825/1787666016868_1784179332440.png '1784179332440.png') 但这不意味着“看到两个算子就应该融合”。下面几种情况先不要急着写: - 算子只在初始化时运行一次; - 总耗时主要在 GEMM、通信或数据读取,逐元素部分占比很低; - 编译器已经稳定地把相同计算融合了; - 输入 shape 很小,扩展调用和 Python 开销大于节省的时间; - 数值语义、autograd、混合精度或动态 shape 还没有定义清楚。 更可靠的顺序是:先用 profiler 确认热点,再写最小候选,最后回到完整模型做 A/B。本文先讲清候选本身,后续的 hipprof 文章会继续讲如何从整模 trace 找到值得融合的数据流。 ## 二、海光 DCU 环境要求与最小自检 本文实测环境为 Ubuntu 22.04、DTK 25.04.2、PyTorch 2.5.1、HIP Runtime 6.3.25405,以及海光 BW / `gfx936` DCU。绝对时间只代表这一次实测,不应直接拿去和其他 DCU 型号、软件版本或节点负载比较。 开始前先确认当前 Python 是 HIP 版 PyTorch: ```bash python - <<'PY' import torch print("PyTorch:", torch.__version__) print("HIP runtime:", torch.version.hip) print("device available:", torch.cuda.is_available()) if torch.cuda.is_available(): prop = torch.cuda.get_device_properties(0) print("device:", prop.name) print("architecture:", getattr(prop, "gcnArchName", "unknown")) PY ``` 在海光 DCU 适配的 HIP 版 PyTorch 中,设备接口仍然沿用 `torch.cuda` 命名空间。因此 `torch.cuda.is_available()` 为真并不表示当前一定是 NVIDIA CUDA;判断是否真正进入 DCU/HIP 路径,要同时检查 `torch.version.hip`、设备名称和 gfx 架构。 还需要编译器、C++ 工具链和构建工具: ```bash command -v c++ command -v hipcc command -v ninja || true python -c "from torch.utils.cpp_extension import load; print('cpp_extension ready')" ``` 如果 DTK 环境尚未加载,可以临时执行: ```bash set +u source /opt/dtk-25.04.2/env.sh set -u ``` 这里先关闭 `nounset`,是因为部分版本的厂商 `env.sh` 会引用尚未定义的环境变量。不要为了补一个 Ninja 就整体升级 Torch、NumPy 或 HIP wheel;在定制 DCU 镜像中,框架版本和 Runtime 往往是成套适配的。 ## 三、我们要保持什么数学语义 融合前的参考实现是: ```python reference = torch.nn.functional.gelu( input_tensor + bias, approximate="tanh", ) ``` 输入约定: - `input_tensor`:二维连续张量,shape 为 `[rows, hidden_size]`; - `bias`:一维连续张量,shape 为 `[hidden_size]`; - 两者位于同一块 DCU; - 教程版本只支持 `float32`; - 输出 shape、dtype 和 device 与输入一致。 PyTorch 的 tanh 近似 GELU 为: ```text 0.5 * x * (1 + tanh(sqrt(2/pi) * (x + 0.044715 * x^3))) ``` 这里必须明确 `approximate="tanh"`。如果参考实现使用默认的精确 GELU,而 kernel实现的是 tanh 近似,两者差异不是“数值误差”,而是数学定义已经改变。 为了让教程聚焦在扩展链路,我们暂时不支持: - 反向传播; - BF16/FP16; - 非连续张量; - 任意维广播; - in-place 写回。 这些限制不是藏起来,而是在接口入口显式检查。一个只支持有限输入但边界清楚的算子,比“好像什么都能接、错误时直接 VMFault”的算子更适合成为项目起点。 ## 四、目录结构:三个文件足够跑通闭环 新建目录: ```text fused_bias_gelu/ ├── fused_bias_gelu.cpp ├── fused_bias_gelu_kernel.cu └── build_and_benchmark.py ``` 三个文件分别负责: | 文件 | 职责 | | --- | --- | | `fused_bias_gelu.cpp` | 声明 C++ 接口并通过 pybind11 暴露给 Python | | `fused_bias_gelu_kernel.cu` | 输入检查、输出分配、当前 stream 和 HIP kernel | | `build_and_benchmark.py` | 源码哈希、隔离构建、数值回归、同步计时和显存诊断 | 这里仍保留 `.cu` 后缀,是为了让 `torch.utils.cpp_extension` 识别设备源码并走HIPify/HIP 构建流程。目标机器最终编译的是转换后的 HIP 源码,不代表我们在调用CUDA Runtime。 ## 五、第一部分代码:最薄的 pybind11 接口 `fused_bias_gelu.cpp` 内容如下: ```cpp #include torch::Tensor fused_bias_gelu_dcu(torch::Tensor input, torch::Tensor bias); PYBIND11_MODULE(TORCH_EXTENSION_NAME, module) { module.def("forward", &fused_bias_gelu_dcu, "Fused bias + tanh GELU forward on HIP/DCU"); } ``` 这个文件故意保持很薄。它只做两件事:声明真正实现在另一个编译单元中的函数,然后把它注册成 Python 模块的 `forward` 方法。 `TORCH_EXTENSION_NAME` 不能手写成固定模块名。`load()` 会在编译时通过宏传入扩展名称;我们后面还会把源码和 Runtime 指纹写进名称,让不同版本不会共用同一个Python 模块。 ## 六、第二部分代码:HIP kernel 与接口检查 `fused_bias_gelu_kernel.cu`: ```cpp #include #include #include #include namespace { // Match torch.nn.functional.gelu(..., approximate="tanh"). One block covers // a contiguous slice of a row, so the kernel avoids an integer modulo for each // element and reads the broadcast bias with a regular access pattern. __global__ void fused_bias_gelu_kernel(const float* input, const float* bias, float* output, int64_t rows, int64_t hidden_size) { const int64_t row = static_cast(blockIdx.y); const int64_t column = static_cast(blockIdx.x) * blockDim.x + threadIdx.x; if (row >= rows || column >= hidden_size) { return; } const int64_t index = row * hidden_size + column; const float value = input[index] + bias[column]; constexpr float kSqrtTwoOverPi = 0.7978845608028654f; constexpr float kGeluCoefficient = 0.044715f; const float cubic = value * value * value; output[index] = 0.5f * value * (1.0f + tanhf(kSqrtTwoOverPi * (value + kGeluCoefficient * cubic))); } } // namespace torch::Tensor fused_bias_gelu_dcu(torch::Tensor input, torch::Tensor bias) { TORCH_CHECK(input.is_cuda(), "input must be on a HIP/DCU device"); TORCH_CHECK(bias.is_cuda(), "bias must be on a HIP/DCU device"); TORCH_CHECK(input.device() == bias.device(), "input and bias must be on the same device"); TORCH_CHECK(input.scalar_type() == at::kFloat, "input must use float32 in this tutorial implementation"); TORCH_CHECK(bias.scalar_type() == at::kFloat, "bias must use float32 in this tutorial implementation"); TORCH_CHECK(input.is_contiguous(), "input must be contiguous"); TORCH_CHECK(bias.is_contiguous(), "bias must be contiguous"); TORCH_CHECK(input.dim() == 2, "input must have shape [rows, hidden_size]"); TORCH_CHECK(bias.dim() == 1, "bias must have shape [hidden_size]"); TORCH_CHECK(input.size(1) == bias.numel(), "input hidden_size must match bias length"); auto output = torch::empty_like(input); const int64_t rows = input.size(0); const int64_t hidden_size = input.size(1); if (rows == 0 || hidden_size == 0) { return output; } constexpr int threads = 256; const dim3 block(threads); const dim3 grid(static_cast((hidden_size + threads - 1) / threads), static_cast(rows)); hipStream_t stream = at::cuda::getCurrentCUDAStream().stream(); hipLaunchKernelGGL(fused_bias_gelu_kernel, grid, block, 0, stream, input.data_ptr(), bias.data_ptr(), output.data_ptr(), rows, hidden_size); const hipError_t status = hipGetLastError(); TORCH_CHECK(status == hipSuccess, "HIP launch failed: ", hipGetErrorString(status)); return output; } ``` 这段代码里有几个比 GELU 公式更值得注意的细节。 ### 1. 为什么使用二维 grid 一种更短的写法是把所有元素展平成一维,再通过: ```cpp column = index % hidden_size; ``` 找到 bias 下标。但这会让每个元素多一次整数取模。本文让 `blockIdx.y` 对应行,`blockIdx.x * blockDim.x + threadIdx.x` 对应列,bias 下标就是 column,访问模式也更规整。 这不代表二维 grid 永远更快。真正项目仍应使用业务 shape 做微基准;这里至少避免了一个显而易见、且没有必要的逐元素整数除法。 ### 2. 为什么必须拿 PyTorch 当前 stream ```cpp hipStream_t stream = at::cuda::getCurrentCUDAStream().stream(); ``` PyTorch 的 stream 可能由上层上下文、异步流水或其他算子切换。如果扩展擅自把kernel 发到默认 stream,简单脚本可能看不出问题,复杂流水中却可能产生错误依赖、额外同步甚至读取尚未完成的数据。 `ATen/cuda/CUDAContext.h` 中名字仍带 CUDA,是因为 DCU/HIP 版 PyTorch 为兼容上层代码复用了这套设备抽象接口。真正 launch 使用的是 `hipLaunchKernelGGL` 和`hipStream_t`。 ![1784179332509.png](/api-forum/file/RedirectImage?path=20260825/1787666041082_1784179332509.png '1784179332509.png') ### 3. 为什么 launch 后检查 `hipGetLastError` kernel launch 是异步的,很多运行时错误不会在函数返回前完成。但 launch 配置、 非法设备函数等即时错误可以通过 `hipGetLastError()` 尽早暴露,并转换成带上下文的`TORCH_CHECK` 异常。 它不能替代测试脚本里的同步。真正的越界访问可能在后续同步时才报告,因此数值测试和 benchmark 仍然必须显式 `torch.cuda.synchronize()`。 ### 4. 为什么先返回空张量 当 `rows == 0` 或 `hidden_size == 0` 时,没有必要发射 grid 中某一维为 0 的 kernel。直接返回 `empty_like` 得到的空输出,可以让接口对合法空输入保持定义明确。 ## 七、第三部分代码:构建、缓存与 benchmark 这部分代码最长,但它决定了实验是否可信。`build_and_benchmark.py` 完整内容如下: ```python """Build and benchmark a source-only fused bias + GELU HIP extension.""" from __future__ import annotations import gc import hashlib import os from pathlib import Path import time from typing import Callable import torch import torch.nn.functional as F from torch.utils.cpp_extension import load WARMUP = 30 REPEATS = 100 SHAPES = ((1024, 768), (4096, 1024), (8192, 2048)) def build_extension() -> tuple[object, float, str, Path]: """Build from a content-addressed staged source directory.""" if not torch.cuda.is_available() or torch.version.hip is None: raise SystemExit("A HIP-enabled PyTorch runtime is required") here = Path(__file__).resolve().parent source_paths = [here / "fused_bias_gelu.cpp", here / "fused_bias_gelu_kernel.cu"] source_payloads = [(path.name, path.read_bytes()) for path in source_paths] prop = torch.cuda.get_device_properties(0) arch_name = getattr(prop, "gcnArchName", "") if not arch_name: raise SystemExit("The PyTorch runtime did not report a gfx architecture") arch = arch_name.split(":", 1)[0] digest = hashlib.sha256() for name, payload in source_payloads: digest.update(name.encode()) digest.update(payload) for value in (torch.__version__, str(torch.version.hip), arch, "-O3"): digest.update(value.encode()) extension_name = f"dcu_fused_bias_gelu_{digest.hexdigest()[:12]}" os.environ.setdefault("PYTORCH_ROCM_ARCH", arch) os.environ.setdefault("TORCH_EXTENSIONS_DIR", f"/tmp/torch_extensions_{os.getuid()}") stage_root = Path( os.environ.get( "DCU_EXTENSION_STAGE_ROOT", f"/tmp/dcu_extension_sources_{os.getuid()}" ) ) stage_dir = stage_root / extension_name stage_dir.mkdir(parents=True, exist_ok=True) staged_sources: list[str] = [] for name, payload in source_payloads: staged = stage_dir / name if not staged.exists() or staged.read_bytes() != payload: staged.write_bytes(payload) staged_sources.append(str(staged)) start = time.perf_counter() extension = load( name=extension_name, sources=staged_sources, extra_cflags=["-O3"], extra_cuda_cflags=["-O3"], verbose=True, ) load_seconds = time.perf_counter() - start return extension, load_seconds, arch, stage_dir def timed_ms(function: Callable[[], torch.Tensor]) -> tuple[float, torch.Tensor]: """Measure end-to-end eager execution with an explicit final synchronize.""" output = function() for _ in range(WARMUP - 1): output = function() torch.cuda.synchronize() start = time.perf_counter() for _ in range(REPEATS): output = function() torch.cuda.synchronize() return (time.perf_counter() - start) * 1000.0 / REPEATS, output def incremental_peak_mib(function: Callable[[], torch.Tensor]) -> float: """Measure the additional allocated-memory high-water mark of one call.""" gc.collect() torch.cuda.empty_cache() torch.cuda.synchronize() baseline = torch.cuda.memory_allocated() torch.cuda.reset_peak_memory_stats() output = function() torch.cuda.synchronize() peak = torch.cuda.max_memory_allocated() del output return (peak - baseline) / 2**20 def expect_runtime_error(label: str, function: Callable[[], torch.Tensor]) -> None: """Require an invalid-input case to fail with a PyTorch RuntimeError.""" try: function() except RuntimeError as error: first_line = str(error).splitlines()[0] print(f"contract[{label}]=ok: {first_line}") else: raise RuntimeError(f"contract case {label!r} unexpectedly succeeded") def validate_contract(extension: object) -> None: """Exercise empty and invalid inputs before the performance benchmark.""" bias = torch.zeros(8, device="cuda", dtype=torch.float32) empty = torch.empty(0, 8, device="cuda", dtype=torch.float32) empty_output = extension.forward(empty, bias) if tuple(empty_output.shape) != (0, 8): raise RuntimeError(f"empty-output shape is wrong: {tuple(empty_output.shape)}") print("contract[empty]=ok: shape=(0, 8)") expect_runtime_error( "cpu_input", lambda: extension.forward(torch.zeros(2, 8), bias), ) expect_runtime_error( "bias_length", lambda: extension.forward( torch.zeros(2, 8, device="cuda"), torch.zeros(7, device="cuda"), ), ) expect_runtime_error( "non_contiguous", lambda: extension.forward( torch.zeros(8, 2, device="cuda").transpose(0, 1), bias, ), ) def main() -> None: torch.manual_seed(20260716) extension, load_seconds, arch, stage_dir = build_extension() validate_contract(extension) print(f"architecture={arch}") print(f"load_or_build_seconds={load_seconds:.3f}") print(f"build_root={os.environ['TORCH_EXTENSIONS_DIR']}") print(f"staged_sources={stage_dir}") print( "rows hidden max_abs mean_abs eager_ms fused_ms speedup " "eager_peak_mib fused_peak_mib" ) for rows, hidden_size in SHAPES: input_tensor = torch.randn( rows, hidden_size, device="cuda", dtype=torch.float32 ) bias = torch.randn(hidden_size, device="cuda", dtype=torch.float32) eager = lambda: F.gelu(input_tensor + bias, approximate="tanh") fused = lambda: extension.forward(input_tensor, bias) reference = eager() actual = fused() torch.cuda.synchronize() difference = (actual - reference).abs() max_abs = float(difference.max()) mean_abs = float(difference.mean()) if not torch.allclose(actual, reference, rtol=2e-5, atol=2e-5): raise RuntimeError( f"validation failed for {(rows, hidden_size)}: max_abs={max_abs}" ) eager_ms, eager_output = timed_ms(eager) fused_ms, fused_output = timed_ms(fused) eager_peak = incremental_peak_mib(eager) fused_peak = incremental_peak_mib(fused) speedup = eager_ms / fused_ms print( f"{rows} {hidden_size} {max_abs:.8g} {mean_abs:.8g} " f"{eager_ms:.5f} {fused_ms:.5f} {speedup:.3f} " f"{eager_peak:.2f} {fused_peak:.2f}" ) del reference, actual, difference, eager_output, fused_output del input_tensor, bias if __name__ == "__main__": main() ``` 代码里出现的 `extra_cuda_cflags` 也是 PyTorch 扩展 API 沿用的兼容命名。在这套DCU 环境中,它传给的是 `hipcc`,不会启动 `nvcc`。同理,构建日志中的“Detected CUDA files”表示框架识别到了设备源码并准备执行 HIPify,不能只根据这句日志判断实际后端。 下面逐块解释为什么脚本要写得比“调用一次然后 print”更严格。 ### 1. 扩展名称为什么包含内容哈希 哈希输入包括: ```text 源码文件名和内容 + PyTorch 版本 + HIP Runtime 版本 + gfx 目标架构 + 编译选项 ``` 只用固定名称 `fused_bias_gelu` 会有一个很隐蔽的问题:修改源码后,当前 Python进程、扩展构建目录或已有 `.so` 可能仍然对应旧版本。A/B 候选反复共用一个名称,还可能出现“日志说编译了 B,实际加载的像 A”的缓存串包。 内容变化后扩展名也变化,可以让旧模块和新模块在文件系统和 Python import 层面都分开。当然,生产项目还应把 ABI、额外宏、编译器版本等真正影响二进制的因素加入指纹。 ### 2. 为什么把源码副本放到 `/tmp` DCU 镜像中的 HIP 版 PyTorch 可能在传入的 `.cu` 源文件旁生成 `.hip` 文件。如果直接把正式工程目录传给 `load()`,运行一次教程后,源码旁可能多出生成文件;做多个候选实验时,这些文件还容易被误提交、误打包或参与下一次扫描。 脚本先把不可变源码副本写到: ```text /tmp/dcu_extension_sources_// ``` 然后从暂存目录编译。构建产物进入: ```text /tmp/torch_extensions_/ ``` 这样正式源码目录保持干净,缓存又能在同一容器内复用。需要注意:`/tmp` 通常是节点本地临时目录,容器或节点回收后可能消失;它适合构建缓存,不适合保存唯一源码。 ![1784179332642.png](/api-forum/file/RedirectImage?path=20260825/1787666055585_1784179332642.png '1784179332642.png') ### 3. 为什么要区分 warmup 和正式重复 第一次调用可能包含: - 模块加载; - Runtime 初始化; - 缓存分配; - 页面映射; - 库或设备上下文的延迟初始化。 这些成本和稳定期每次调用的 kernel 时间不是同一个问题。脚本先 warmup 30 次,同步后再重复 100 次,最后同步并计算均值。 如果你的业务关注冷启动,就应该另外保留冷启动指标,而不是把 warmup 之后的数字冒充首次响应。本文同时记录构建/加载时间和热态时间,避免把两个阶段混在一起。 ### 4. 为什么 `time.perf_counter()` 前后都要同步 HIP kernel 默认异步提交。下面这种写法主要测到 Python 发射: ```python start = time.perf_counter() output = extension.forward(input_tensor, bias) elapsed = time.perf_counter() - start ``` 正确做法是在正式计时前等待此前提交的工作完成,并在结束前等待本轮工作完成: ```python torch.cuda.synchronize() start = time.perf_counter() for _ in range(repeats): output = function() torch.cuda.synchronize() elapsed = time.perf_counter() - start ``` 这里测到的是包含 Python 调用和 launch 的端到端 eager 路径,正适合比较“两次eager launch”与“一次扩展 launch”。如果要分析单 kernel 的设备时间,可以再用HIP event 或 profiler,但不能把不同口径的数字放在同一列直接比较。 ### 5. 为什么要先验证数值,再测速度 错误 kernel 也可以很快,少算一部分元素通常更快。脚本先比较: ```python difference = (actual - reference).abs() max_abs = float(difference.max()) mean_abs = float(difference.mean()) torch.allclose(actual, reference, rtol=2e-5, atol=2e-5) ``` 只有数值门通过,才继续 benchmark。教程使用固定随机种子,便于同一环境复查;正式测试还应继续补齐极值、错误 dtype、更多 shape 和 NaN/Inf 等用例。 ## 八、在海光 DCU 上编译与运行 进入目录后直接运行: ```bash cd fused_bias_gelu python build_and_benchmark.py ``` 第一次运行会经过 HIPify、C++/HIP 编译和链接,时间明显长于后续缓存加载。不要在看到终端停留几十秒时立刻判断“程序死了”,可以从另一个终端检查: ```bash ps -ef | grep '[n]inja' find /tmp/torch_extensions_$(id -u) -maxdepth 2 -type f | head ``` 本次冷构建日志中的三条关键命令如下。第二步明确调用 DTK 的 `hipcc`,目标架构为`gfx936`;第三步链接 HIP Runtime: ```text [1/3] c++ ... fused_bias_gelu.cpp [2/3] /opt/dtk-25.04.2/bin/hipcc ... --offload-arch=gfx936 ... fused_bias_gelu_kernel.hip [3/3] c++ ... -lamdhip64 ... dcu_fused_bias_gelu_.so ``` 本文在 BW / `gfx936`、PyTorch 2.5.1、HIP 6.3.25405 环境的实测结果如下。 ```text architecture=gfx936 cold build/load=50.654 s cached second load=0.085 s rows hidden max_abs eager_ms fused_ms speedup eager_peak fused_peak 1024 768 0 0.02200 0.01535 1.433x 6.00 MiB 3.00 MiB 4096 1024 0 0.09012 0.06017 1.498x 32.00 MiB 16.00 MiB 8192 2048 0 0.33779 0.22444 1.505x 128.00 MiB 64.00 MiB ``` 三组 shape 的最大绝对误差和平均绝对误差均为 0。第三次独立热进程复测得到`1.481x / 1.488x / 1.502x`,说明这次收益不是一次明显的计时毛刺;缓存加载仍会受文件系统和进程初始化波动影响,因此 `0.085 s` 不应被解释为所有机器上的固定值。 ![1784179332757.png](/api-forum/file/RedirectImage?path=20260825/1787666064446_1784179332757.png '1784179332757.png') 脚本还在 benchmark 前执行了四个接口契约用例:空输入正常返回 `[0, 8]`,CPU输入、错误 bias 长度和非连续输入都在 C++ 入口给出预期的 `RuntimeError`,没有进入不受控的 kernel 访问。 ![1784179332944.png](/api-forum/file/RedirectImage?path=20260825/1787666071188_1784179332944.png '1784179332944.png') 这里的增量显存是 `torch.cuda.max_memory_allocated()` 得到的单次调用诊断值,只用于同进程 A/B。它不是设备总占用,也不等同于外部采样器、进程峰值或业务稳定期平均显存。 ## 九、怎么解读结果,而不是只盯着“加速倍数” ### 1. 融合减少的是数据流,不是 GELU 数学量 融合前: ```text input + bias -> temporary -> GELU -> output ``` 融合后: ```text input + bias -> GELU -> output ``` GELU 的公式并没有变少。收益主要来自: - 少生成一个与输入同尺寸的中间张量; - 少一次中间张量写回与再次读取; - 少一次 kernel launch; - 减轻缓存分配器和张量生命周期压力。 因此,输入越大、路径越偏内存带宽或 launch 开销,融合越可能有效。如果算子本身计算密集,或者输入太小,收益可能很弱。 ### 2. 微基准成功不等于模型必然同倍加速 假设这个小算子微基准快了 30%,它只占整模 5%,理论上整模提升上限也只有约1.5%,还没有计入调用位置、shape 差异和上层同步。 把扩展放回真实模型后,至少要重新测: - 完整 batch 和真实 hidden size; - 稳态端到端推理时间; - 峰值或采样显存; - 完整输出数值; - profiler 中 launch 数量和热点占比是否真的下降。 单 kernel 图很好看,但整模没有变化,常见原因不是“硬件有问题”,而是优化对象原本就不是端到端瓶颈。 ### 3. 显存减少不一定等于设备占用立刻下降 PyTorch caching allocator 会保留已经申请的显存块。因此中间张量消失后,`memory_allocated()` 或峰值可能下降,但 `memory_reserved()`、`hy-smi` 看到的进程占用不一定同步下降。 报告显存结果时要说明口径: - 张量活动内存:`torch.cuda.memory_allocated()`; - 缓存池保留:`torch.cuda.memory_reserved()`; - 单进程设备占用:设备监控工具; - 稳定期平均值:外部定频采样; - 进程生命周期峰值:峰值监控或 profiler。 不要用一种口径的下降去承诺另一种口径一定下降。 ## 十、最容易踩的八个坑 ### 坑 1:扩展能 import,但运行的仍像旧源码 优先检查: - 扩展名称是否随源码变化; - `TORCH_EXTENSIONS_DIR` 是否被多个候选共用; - 当前 Python 进程是否已经 import 旧模块; - A/B 是否从不同绝对路径轮流改写同一个 `build.ninja`; - 暂存源码是否真的更新。 调试时可以打印扩展名、构建根目录和暂存目录,不要只看最后的 `.so` 时间戳。 ### 坑 2:原始目录突然出现 `.hip` 这是 HIPify 的生成文件,不一定是人工写入。把输入源码复制到稳定的临时目录后再调用 `load()`,并把正式源码目录视为只读,可以避免项目树被构建过程污染。 ### 坑 3:没有指定目标架构,第一次编译特别慢 如果环境变量包含多个 gfx 目标,编译器可能为多个架构生成代码。脚本从 `torch.cuda.get_device_properties(0).gcnArchName` 获取实机架构,再设置: ```python os.environ.setdefault("PYTORCH_ROCM_ARCH", arch) ``` 不要直接照抄别人的 `gfx936`。目标架构必须来自当前设备,指定错误可能编译失败或运行时报 `invalid device function`、illegal instruction 等错误。 ### 坑 4:计时快得不真实 先检查有没有同步。然后确认: - 输出是否真正被计算和使用; - eager 与 fused 是否使用相同 shape、dtype 和数学定义; - 是否把编译时间放在一边、热态时间放在另一边; - 是否只测了一次; - profiler 是否正在插桩。 ### 坑 5:简单脚本正确,放进模型后偶尔出错 重点检查 stream。扩展应使用 PyTorch 当前 stream,不能默认假设所有算子都在stream 0。还要检查上游张量是否连续、生命周期是否覆盖异步执行,以及调用方是否在其他 stream 上创建了输入。 ### 坑 6:VRAM 看着还有很多,却报 HSA out of resources 这类错误不一定是普通显存 OOM,也可能是设备上下文、队列或 HSA Runtime 已经进入异常状态。建议用全新 Python 进程依次检查: ```bash rocminfo >/dev/null python - <<'PY' import torch x = torch.ones(1, device="cuda") print(x) PY ``` 如果 `rocminfo` 和新进程的单张量都失败,不要继续反复改 kernel;保存日志并重置容器或节点。若只有自定义算子失败,再缩小 shape,检查网格、指针和索引。 ### 坑 7:直接支持所有 dtype,结果没有一种可靠 教程先限制 float32,是为了把语义和验证链路走通。增加 BF16/FP16 时需要重新考虑: - 输入和累加用什么精度; - `tanh` 近似如何实现; - 是否转 float 计算再转回; - 与 PyTorch autocast 的交互; - 容差如何按 dtype 设置; - 不同 gfx 架构的向量化与指令支持。 不要只把指针类型改成半精度就宣布支持混合精度。 ### 坑 8:只写 forward,却拿去训练 本文扩展只适合推理或不需要梯度的路径。训练需要实现 backward,或者用`torch.autograd.Function` 明确前后向关系,并分别做 `gradcheck`、混合精度和数值稳定性验证。 如果前向偷偷绕过 autograd,训练脚本可能不立即报错,却会让相关参数得不到正确梯度,这比显式失败更危险。 ## 十一、推荐的调试顺序 遇到失败时,不要同时改编译参数、kernel、缓存目录和输入 shape。按下面顺序缩小: ![1784179333323.png](/api-forum/file/RedirectImage?path=20260825/1787666081238_1784179333323.png '1784179333323.png') ### 第一步:只验证环境 ```bash python -c "import torch; print(torch.__version__, torch.version.hip, torch.cuda.is_available())" hipcc --version ``` ### 第二步:只编译,不跑大输入 保留 `verbose=True`,确认 Ninja 的三步通常都出现: ```text C++ binding compile HIP source compile shared library link ``` ### 第三步:用很小的确定性输入 例如: ```python input_tensor = torch.tensor( [[-1.0, 0.0, 1.0], [2.0, -2.0, 0.5]], device="cuda", ) bias = torch.tensor([0.1, -0.2, 0.3], device="cuda") ``` 同步后打印 reference 和 actual。小输入能直接看出 bias 广播、行列索引或 shape理解是否错误。 ### 第四步:随机数值回归 固定随机种子,覆盖几组 rows 和 hidden size,记录最大误差、平均误差和 allclose结果。数值门通过后再开 benchmark。 ### 第五步:真实 shape 微基准 使用模型 profiler 中真实出现的 dtype、hidden size 和 batch/token 展平后的 rows。不要拿一个方便的方阵替代业务形状。 ### 第六步:整模 A/B 保持模型权重、输入、warmup、重复次数、同步位置和显存采样方式一致。除了替换的算子,其他条件不要变化。 ## 十二、从教程版本走向工程版本 如果这个算子要长期维护,建议继续补齐下面几层。 ### 1. 测试矩阵 至少覆盖: ```text rows: 0 / 1 / 小值 / 典型值 / 大值 hidden: 1 / 非 256 倍数 / 典型 hidden / 大 hidden dtype: FP32 / BF16 / FP16(如果声称支持) layout: contiguous / sliced / transposed device: 正确设备 / CPU / 不同设备 bias shape: 正确 / 过短 / 过长 / 二维 value: 0 / 正负随机 / 大绝对值 / NaN / Inf ``` 错误输入要验证错误信息,而不仅是“确实失败”。 ### 2. 多 dtype dispatch 可以使用 ATen dispatch 宏或分别实现 kernel,但要把计算精度和输出精度写清楚。对 GELU 这类非线性函数,BF16 输入通常不意味着所有中间量都必须 BF16。 ### 3. backward 与 autograd 训练场景需要 GELU 导数和 bias 的归约梯度。bias gradient 涉及跨 rows 的 reduction,已经不是简单逐元素 kernel;这时应重新评估是否自己实现、分阶段实现,或者让成熟编译器/库处理。 ### 4. shape 与 launch 调优 固定 `threads=256` 是可靠起点,不是所有设备和 hidden size 的最优答案。可以针对真实 shape 比较 128、256、512 threads,并用 profiler 查看 wave、VGPR、occupancy、LDS 和内存访问,而不是只看某次墙钟时间。 ### 5. 构建与发布 source-only 方式适合可审计、跨目标机器现场编译的项目。若要发布 wheel,还需要明确支持的 PyTorch/HIP/编译器 ABI 组合,建立 CI,并避免把某台机器生成的二进制假定为所有 DCU 环境通用。 ## 十三、最终验收清单 在把这个 DCU/HIP 自定义算子合入项目前,我会逐项确认: - [ ] 参考数学定义和近似模式完全一致; - [ ] CPU、错误 dtype、错误 shape 和非连续输入有明确错误; - [ ] 空输入不会发射非法 grid; - [ ] 使用 PyTorch 当前 stream; - [ ] launch 后检查 HIP 状态; - [ ] 随机测试和边界测试均通过; - [ ] benchmark 前后都有同步; - [ ] 冷构建、缓存加载、热态执行分别记录; - [ ] 扩展缓存包含源码和 Runtime 指纹; - [ ] HIPify 和构建产物不污染正式源码目录; - [ ] 性能使用真实 shape 验证; - [ ] 回到完整模型后重新测时间、显存和输出; - [ ] 训练场景已经实现并验证 backward,或明确禁止用于训练。 ## 结语 在海光 DCU 上写出一个能编译的 HIP kernel 并不难。真正费时间的是把它变成一个 不会因为 stream、缓存、异步计时或输入边界而给出错误结论的 PyTorch 扩展。 本文的 bias + GELU 只是一个起点,但整个方法可以复用到更多逐元素和轻量融合路径: ```text 先定义数学语义 -> 写最小 HIP kernel -> 接入当前 stream -> 建立内容寻址缓存 -> 数值回归 -> 同步微基准 -> 真实模型 A/B ``` 如果微基准没有收益,就应尽早停止;如果微基准有效而整模无收益,就回 profiler 重新判断热点占比。自定义算子的目标不是让项目里多一个 `.so`,而是用可验证、 可复现的方式减少真实数据流成本。 ## 参考资料 - PyTorch C++ Extension: - PyTorch HIP semantics: - 光合开发者社区工具下载: - DTK 25.04.2《HIP C++ 编程指南》 - DTK 25.04.2《开发环境使用手册》 - DTK 25.04.2《hipprof 使用手册》
全部评论(0)
只看楼主
暂无数据
--
楼主
Level.6
0 帖子
楼主热帖
暂无数据