ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

warp-transducer源码安装与warprnnt_pytorch编译实战

warp-transducer源码安装与warprnnt_pytorch编译实战 做语音识别的朋友对 RNN-Transducer 这个名字应该不陌生。我最近在一个流式识别项目里要把 RNNT loss 接进训练链路底层需要一套高效的累加和反向传播算子于是又回到了 warp-transducer 这个经典实现。说它经典是因为不少语音框架里的 RNNT loss 都直接或间接用了它而 warprnnt_pytorch 就是这套 C/CUDA 代码对 PyTorch 暴露出来的绑定层。源码安装这件事听起来简单但真正操作起来会碰到 CUDA 版本、编译 ABI、Ninja 并行度、shape 顺序等各种幺蛾子。这篇文章我把完整过程重新走了一遍重点说清楚 warp-transducer 源码安装怎么做、warprnnt_pytorch 是怎么生成的以及我顺手踩过的那些坑。1. 先把 warp-transducer 和 warprnnt_pytorch 的关系理清楚1.1 warp-transducer 解决的是哪一类问题RNN Transducer 是语音识别里一类主流训练目标它和 CTC 类似都需要在大量合法对齐路径上做求和。区别在于 RNNT 多了一个 prediction network每一帧既可以输出一个词表符号也可以输出 blank用来表示“当前帧没有新符号产生”。这样一来模型输出的对数概率张量通常是 T×U×V 的形状T 是声学帧数U 是目标符号序列长度加一V 是包含 blank 的词表大小。RNNT loss 的计算本质是一个二维动态规划。T、U 小的时候循环还能忍但训练语音动辄几百帧、标签序列几十个 token路径数量会呈指数增长直接循环求和完全不可行。warp-transducer 解决的正是这个问题它把动态规划里的前向、反向计算用 CUDA 并行化并且针对 GPU 的访存习惯做了很多优化。项目名里的 warp 指的是 GPU 线程调度的最小单位不是别的意思所以这套实现天然适合在 NVIDIA 卡上跑。1.2 warprnnt_pytorch 是绑定层不是独立算法很多人会混淆 warp-transducer 和 warprnnt_pytorch。简单说warp-transducer 是一个 C/CUDA 项目核心代码并不带 Python 接口warprnnt_pytorch 是在这个核心外面做的一层 PyTorch 包装。这层包装做的事情很直接通过 PyTorch 的 torch.utils.cpp_extension 机制把 C/CUDA 源文件编译成一个动态库再在 Python 侧封装成类似 RNNTLoss 的模块。你安装的时候看到的“生成 warprnnt_pytorch”指的不是生成什么数据文件而是把源码编译出来的.so动态库安装到当前 Python 环境的 site-packages 里。我自己早期在这个项目上犯过糊涂总是想直接 pip 一个 warprnnt_pytorch 完事结果要么版本和 torch 对不上要么 cuda 架构不支持。后来老老实实走源码编译反而一次就通了。所以源码安装这件事表面上是在跟编译工具打交道实际是在解决“环境匹配”这个真问题。1.3 源码安装更稳的适用场景如果你的训练环境是干净的新卡、新驱动预编译包也许能用但生产环境往往没有这么理想。比如你要跑 Kaldi 风格的特征管线、要接某个老训练脚本、要配合特定版本的 PyTorch这时候预编译的 wheel 不可能覆盖所有组合。源码安装的另一个好处是可控。你可以修改 CXXFLAGS可以指定 GPU 架构列表可以针对自己的编译器版本做规避。只要环境匹配成立生成的 warprnnt_pytorch 就能稳定用很久。后续如果要切换 PyTorch 大版本重新编译一次成本也不高所以把源码安装流程吃透比临时搜一段安装命令更有价值。2. 源码安装前先检查环境否则后面全是无效报错2.1 Python 与 PyTorch 版本怎么选我建议先确定 PyTorch 的版本再反推 Python。warp-transducer 本身是比较早期的项目虽然现代编译器也能编译但对 Python 没有特别苛刻的要求常用的 Python 3.8、3.9、3.10 我都试过。关键是 PyTorch 的版本要能够匹配你机器上的 CUDA 驱动。实际操作时我会先建一个独立的 Python 环境避免把系统环境弄乱conda create -n rnnt python3.8 conda activate rnnt conda install pytorch1.13.1 cudatoolkit11.6这里不要贪新选一个你已经在项目里验证过的组合就好。PyTorch 版本太新可能对老源码的扩展编译接口有变化版本太老又可能不支持你新显卡的架构。所谓“能用”的标准只有一个安装完 PyTorch 后torch.cuda.is_available()必须返回 True并且torch.version.cuda里的 CUDA 版本要和你相对齐。装完之后跑一下python -c import torch; print(torch.__version__, torch.version.cuda, torch.cuda.is_available())如果这一步就报错后面编译 warp-transducer 大概率也会失败。先排除 PyTorch 自身问题再谈源码安装。2.2 CUDA 和编译器的兼容性检查源码编译需要能调用 nvcc所以 CUDA Toolkit 必须安装完整。命令行里执行nvcc --version能看到版本信息就说明 nvcc 在 PATH 里。如果看不到需要找到 CUDA 安装目录并设置环境变量export CUDA_HOME/usr/local/cuda export PATH$CUDA_HOME/bin:$PATH还有一个很多人忽略的点nvcc 对 gcc/g 的版本有兼容范围。比如某些 CUDA 版本在遇到过新的 gcc 时会直接报 unsupported GNU version。如果你的系统 gcc 版本很新不要急着硬刚可以装一个较低版本的 gcc或者把 cxx 编译器指向旧版本。常见的做法是export CXX/usr/bin/g-9 export CC/usr/bin/gcc-9这个版本号要根据你机器上实际安装的来写。把编译器匹配好能省掉后面一大半编译错误。2.3 准备一个干净的环境我见过最多的问题是用了 sudo 去装装完 source code 和 site-packages 权限乱掉或者在同一台机器上 conda 环境、系统 Python、虚拟环境混合用结果 import 到不同版本。源码编译这种东西最怕环境不干净。我的建议是编译前先which python确认当前环境是你想装的那个 Python。再执行python -c import torch; print(torch.__file__)确认 torch 确实来自当前环境。如果之前编译过别的 CUDA 扩展最好新建一个环境避免site-packages里的.so文件互相干扰。还有一个实用技巧把编译时生成的build目录和dist目录保留下来。以后如果只改少量代码增量编译比全量重编快很多。我自己每次重装机器都会先把环境变量和版本号记在一个文件里不然下次再碰又是一轮折腾。3. 源码安装一步步把它编译出来3.1 拉取仓库并确认代码完整先把 warp-transducer 的源码拉下来。标准流程是git clone warp-transducer 仓库地址 cd warp-transducer git submodule update --init --recursive第二行不是摆设。有些仓库会把第三方依赖以 submodule 形式放进来不执行子模块更新后面编译时会突然找不到头文件。如果你拉下来后仓库里根本没有 submodule 配置也不会报错所以这步做了没坏处。进入仓库后可以看一下目录结构。通常核心的 C/CUDA 实现和 PyTorch binding 是分开放的。PyTorch binding 一般会在名字里带pytorch_binding或者类似标识里面有一个setup.py。我们要编译的 warprnnt_pytorch就是从这个setup.py构建出来的。3.2 设置编译环境变量在真正执行编译之前我会先把几个环境变量固定下来。第一个是 CUDA_HOME前面已经说过。第二个是 TORCH_CUDA_ARCH_LIST这个尤其重要export TORCH_CUDA_ARCH_LIST7.5 8.0这个变量的作用是告诉 nvcc 你希望为哪些 GPU 架构生成计算代码。比如 7.5 对应 Turing 架构的消费级卡8.0 对应部分 Ampere 架构的 compute capability。如果你的显卡架构不在列表里编译产物可能直接没法在当前 GPU 上运行。如果不清楚可以用 PyTorch 自带的方式看python -c import torch; print(torch.cuda.get_device_capability())它输出的元组就是当前 GPU 的 compute capability。比如返回(8, 0)就写在 TORCH_CUDA_ARCH_LIST 里写8.0如果返回(7, 5)就写7.5。多个卡可以写成空格分隔。第三个变量是 MAX_JOBS用来控制编译并行度。源码编译非常吃内存特别是大.cu文件会把显存和内存都拉满。机器内存不够的时候设置太小会慢设置太大会直接 OOM。我习惯先保守一点export MAX_JOBS43.3 真正执行编译观察生成产物进入 setup.py 所在目录cd pytorch_binding python setup.py install这里我特别推荐用python setup.py install而不是直接pip install .。因为这是一份老源码setup.py 里可能没有完整的 pyproject.toml 兼容逻辑pip 在构建时有时会触发额外的包管理逻辑反而更容易出问题。当然如果你的环境比较新、pip 构建也正常两者都可以。编译过程中会看到 nvcc 和 g 交替工作期间可能输出很多 warning。只要不是 error就不用管。编译时间取决于机器通常几分钟到十几分钟。成功之后终端会提示包安装完成site-packages 里多出一个 warprnnt_pytorch 包。这时候先别急着跑训练把目光放到构建产物上。在pytorch_binding目录下一般会生成build目录里面能看到类似_warprnnt_pytorch.cpython-xxx-x86_64-linux-gnu.so的动态库文件。这个.so就是绑定层的主体Python 侧import warprnnt_pytorch时实际加载的就是它。3.4 生成 warprnnt_pytorch 后怎么确认它存在安装完我习惯第一时间确认三个内容包路径、动态库、基本可导入性。python -c import warprnnt_pytorch; print(warprnnt_pytorch.__file__)如果输出的是一个 site-packages 里的路径说明模块已经安装到位。再检查目录内容python -c import warprnnt_pytorch, os; print([f for f in os.listdir(os.path.dirname(warprnnt_pytorch.__file__)) if f.endswith(.so)])能看到.so文件就算成功。这一步比单纯看安装提示更踏实因为有时候安装脚本返回成功但动态库根本没生成或者生成到了别的位置。4. 编译和运行阶段最容易踩的坑4.1 编译期CUDA 架构不对、gcc 版本过高我最常遇到的第一类错误长这样nvcc fatal : Unsupported gpu architecture compute_XX原因就是 TORCH_CUDA_ARCH_LIST 写了不支持的架构或者压根没设置自动探测出来的架构值不对。解决办法很简单先查卡的 capability再写环境变量重新编译。注意不要图省事写太老的值否则后面运行时还会报 “no kernel image is available”。第二类是 gcc 版本过高。老源码里用的是 C11 或 C14 风格新编译器有时会开启更严格的标准导致某些写法编译不过。如果在编译日志里看到大段模板错误不要慌先看开头几行判断是不是编译器版本问题。降低 gcc 版本或者设置export CXXFLAGS-stdc14很多时候能直接绕过去。4.2 ABI 不匹配导致的 undefined symbol编译成功不代表运行成功。最容易出的一种运行错误是ImportError: .../warprnnt_pytorch.so: undefined symbol: _Z...这种符号找不到绝大多数是 ABI 不匹配。PyTorch 预编译包在发布时使用了一种 C ABI 约定如果你用其他 C 编译器或者不同 GNU libstdc ABI 设置去编译扩展动态库里引用的标准库符号可能对不上。处理办法是保持编译器环境和 PyTorch 包本身一致。比如 PyTorch 如果是通过 conda 安装的尽量也用同一个 conda 环境里的 gcc 编译如果是 pip 安装的 PyTorch注意它编译时的 glibcxx 版本。实在搞不定可以给编译命令加上-D_GLIBCXX_USE_CXX11_ABI0或-D_GLIBCXX_USE_CXX11_ABI1换一个方向试一下。不同 PyTorch 版本的默认 ABI 不一样两个方向都试哪个能过用哪个。4.3 运行期 shape 和数据布局问题还有一种报错发生在你已经成功 import warprnnt_pytorch 之后RuntimeError: The size of tensor ... must match the size of tensor ...这种大多是输入 shape 顺序不对。底层 C/CUDA 核心为了访问连续内存对张量维度的约定不一定和 PyTorch 默认的 B、T、U、V 顺序一致。它可能要求的是 T、U、B、V 或者把程序里指定的维度做一次 permute。所以我的建议是不要直接调底层rnnt_loss函数尽量用封装好的RNNTLoss模块。封装层里通常已经处理了维度顺序和 contiguous 的问题。如果你自己写 call务必先看源码里 forward 的 permute 操作。还有一类很隐蔽的问题标签里出现了 blank 对应的 0。如果你的词表 id 从 1 开始0 是 blank那标签填充时绝不能把 padding 用 0 表示否则 loss 计算会把 padding 当成真正的 blank 参与路径。项目里一般用 -1 做 padding或者用单独的 mask使用时要注意。4.4 常见问题速查表现象可能原因处理方式nvcc 报不支持的 GPU 架构TORCH_CUDA_ARCH_LIST 设置错误或未设置查看 compute capability正确设置架构列表编译时提示 unsupported GNU versiongcc/g 版本太高切换到低版本 gcc或调整 CXXFLAGSninja 构建中断并行度太高、内存不足设置 MAX_JOBS2 或 4 后重新编译import 时报 undefined symbolABI 不匹配保持 PyTorch 和编译器环境一致尝试切换 _GLIBCXX_USE_CXX11_ABI运行时报 tensor size 不匹配维度顺序或长度不对使用 RNNTLoss 封装避免直接调底层函数loss 输出为 nan输入不是 log_softmax或标签含 blank对最后一维做 log_softmax标签从 1 开始且 padding 不等于 05. 用最小示例验证 warprnnt_pytorch 是否可用于训练5.1 一个可以直接抄的前向反向示例安装完之后我会用一个最小用例同时验证前向和反向。示例尽量把维度设得小一点跑得快出问题也好查。import torch from warprnnt_pytorch import RNNTLoss B, T, U, V 2, 8, 4, 6 # log_probs 的形状是 B, T, U, V最后一维是词表 blank log_probs torch.randn(B, T, U, V, dtypetorch.float32).log_softmax(dim-1) log_probs.requires_grad_(True) # 标签不含 blank所以取值从 1 到 V-1 targets torch.randint(1, V, (B, U - 1), dtypetorch.int) # 两个样本的声学长度和目标长度 input_lengths torch.tensor([6, 8], dtypetorch.int) target_lengths torch.tensor([3, 3], dtypetorch.int) criterion RNNTLoss(blank0, reductionmean) loss criterion(log_probs, targets, input_lengths, target_lengths) loss.backward() print(loss:, loss.item()) print(grad:, log_probs.grad.abs().sum().item())如果代码正常输出说明动态库参与了真实计算反向传播也通了。这里我特意把 reduction 写成了 mean这样 loss 数值不会太大方便判断。若 grad 是 0往往说明输入张量没有 requires_grad 或者某个中间节点被 detached 了。5.2 数据约定blank、log_softmax、长度对齐跑通之后再强调三个数据约定。第一输入 log_probs 必须是 log_softmax 的输出不是普通 softmax也不是模型 logits。RNNT loss 内部做的是在 log 域上的路径求和直接传 logits 会把路径概率算错。第二label 序列里不允许出现 blank idpadding 不能用 0。一般可用 -1 做 padding并在 target_lengths 里指定真实长度。第三U 这个维度是最大目标长度加一不是词表长度。如果你 batch 里最长的目标序列是 3那 U 至少是 4因为每个位置都还有一个 blank 的发射概率以及终止时需要一个额外位置。很多人在迁移现有 CTC loss 到 RNNT 时习惯性把标签、长度、blank 的约定照搬过来结果 loss 疯狂飘到 nan。我自己的经验是先打印出 log_probs 的 shape、label 的 range、lengths 的 max和源码里的注释对照一遍基本就能防住大部分雷。5.3 与训练框架集成的三个经验如果你的项目不是单独调 loss而是要在训练框架里集成有三个细节值得留意。第一个是精度。默认情况下torch.randn(...).log_softmax出来的 log_probs 是 float32训练里如果模型用了 float16 或 bfloat16需要在调用 RNNTLoss 之前转成 float32或者检查 warp-transducer 的 CUDA kernel 是否支持低精度输入。我在项目里一般让 model 输出 float32再交给 loss稳定很多。第二个是显存。RNNT loss 的动态规划表占显存不是很夸张但 T 和 U 都很长时中间状态仍然比普通分类 loss 大不少。如果训练时显存不够优先减小 U也就是限制预测网络的最大目标长度而不是盲目减小 batch。第三个是缓存。有些版本的 cpp_extension 会缓存编译结果切换 PyTorch 版本后可能还在用旧缓存。遇到奇怪现象时把 build 目录、~/.cache/torch_extensions下相关缓存删掉重新编译往往能解决。6. 我实际编译中沉淀的几个操作习惯我在这个项目上编译的次数不算少最后总结下来最有用的是把环境变量固定成脚本而不是每次手动敲。比如我会写一个build.sh里面把 Python 路径、CUDA_HOME、TORCH_CUDA_ARCH_LIST、MAX_JOBS 全部写好然后一行执行安装。这样换机器、换环境时不用重新想一遍要设什么。另一个习惯是安装后立刻跑一遍最小用例把输出结果记录到日志里。很多问题当时看不出过几周回来再用突然说np或者nan如果手里有当时的最小用例排查起来会快很多。最后再分享一个小技巧编译这种东西报错信息别只盯着最后一行要把前面几百行里的第一个 error 找出来。warprnnt_pytorch 的编译报错经常是一连串模板错误轰炸真正的 root cause 往往藏在第一次出现 error 的位置。找到它环境变量改对剩下的就是时间问题。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进