半年前第一次把 CUDA 训练的模型和 CANN 推理服务放进同一张共享调度表时,团队里争论最多的一个问题是:异构算力统一调度,到底是要做 API 翻译器,还是要做一套新运行时?我当时的答案是:都别选。真正要解决的是多芯片适配过程中的资源抽象、软件栈隔离和生命周期管理。最后我们做成了一套平台,上层用统一方式提交任务,底层按设备类型加载不同插件,让 CUDA 任务和 CANN 任务在同一张资源池里跑,互不干扰,还能在故障时迁移。
这篇复盘适合两类人看:一类是被厂商 SDK 割裂、每个模型都要改一套代码的算法工程师;另一类是维护多芯片算力池、总被排队不均和资源碎片折磨的平台同学。我会把设计思路、实操步骤和排障记录展开讲,也聊聊为什么我们放弃了“透明翻译”这条捷径。前半部分是背景和架构,后半部分是能直接落地的代码片段和排查步骤,希望对同行有参考价值。
1. CUDA 与 CANN 的差异到底在哪?先搞清适配的根因
1.1 编程模型和软件栈不是同一套语言
先看一张对比表。我刻意不写具体厂商名,因为关键不是哪家公司,而是生态差异本身。
| 对比维度 | CUDA 侧 | CANN 侧 |
|---|---|---|
| 运行时入口 | CUDA Runtime/Driver API | CANN 提供的宿主侧接口 |
| 设备端代码 | 手写 kernel,编译期确定执行粒度 | 偏图编译/子图下沉,支持自定义算子 |
| 显存分配 | cudaMalloc 风格接口 | 类似 aclrtMalloc 的宿主侧分配接口 |
| Kernel 提交 | 显式 launch,搭配 stream/event | 在流/队列中提交执行 |
| 框架适配 | PyTorch/TensorFlow 原生或插件 | 通过适配插件和图编译接入框架 |
| 集合通信 | 厂商自带通信库 | 另一套通信库和底层协议 |
| 编译与图优化 | 多数算子即时编译/预编译 | 更依赖图编译做算子融合和布局转换 |
很多人以为 CUDA 和 CANN 只是“两个 Python 接口”,但最大的差距在执行模型。CUDA 侧 kernel 是显式提交的,你可以精细控制线程块、共享内存、并发 stream;CANN 侧更倾向于把整个网络图交给运行时去编译、优化、执行,上层能干预的粒度反而小。你可以把 CUDA 理解成乐高积木,自己能拼出各种形状;CANN 更像一个出厂时已经帮你拼好大半的模块,直接在框架层面用图编译提供高性能。这两种思路没有绝对优劣,但如果想在不了解底层的前提下做统一适配,必然会在内存映射和计算粒度上丢语义。
1.2 换个编译目标不行的深层原因
我见过不少团队想走“API 翻译器”路线,把代码里的 cudaMalloc 替换成 CANN 的分配接口,把 kernel launch 换成对方对应的提交方式。从编译角度看,好像只是换了一个编译目标,但实际落地会碰到四个硬石头。
第一个是异步语义不对等。cudaMemcpyAsync 这类接口和 CANN 的异步接口虽然都叫异步,但它们返回的是不同的“完成事件”。如果只做函数名替换,原本代码里的同步点会丢失,内存被提前释放、kernel 还没跑完就开始拷贝,这些随机错误会让排障非常痛苦。
第二个是图融合策略不同。CANN 可能自动把 Conv+BN+ReLU 融合成一个算子,而 CUDA 侧可能是三个独立 kernel 依次执行。融合会改变浮点运算顺序,输出结果在小数点后第四位产生偏差,这种差异在训练场景会被梯度放大。第三个是内存模型不同。GPU 的显存线性地址空间和 NPU 的分级存储、多核共享内存模型并不是一回事,cudaMalloc 返回的句柄不能与 CANN 的句柄互通,物理内存分配粒度也不同。第四个是算子集交叠但不等价。CUDA 生态里的很多扩展算子,CANN 里不一定有同名实现,反过来也一样。没有算子的部分必须拆分、模拟或回退 CPU,这已经不是“翻译”能解决的问题。
1.3 统一调度的收益边界和影响范围
说清楚困难之后,还得说清楚收益,否则大家不知道折腾这件事的意义。统一调度后最直接的影响有三个。
第一,全局资源池抹平碎片。单独为 CUDA 和 CANN 建两个队列,很容易出现一种芯片空闲、另一种芯片排队的情况;统一池之后,训练任务可以利用推理服务不用了的空闲窗口,整体利用率能提升十几个百分点。第二,业务侧可以保持一套代码。通过平台的设备抽象,PyTorch 里 model.to(device) 的 device 可以是“平台虚拟设备”,底层自动映射到 CUDA 或 CANN,算法工程师不用天天写设备分支。第三,运维侧能统一监控和告警,设备健康、温度、故障率、任务状态在同一张 dashboard 上展示,不用维护两套割裂的监控系统。
但收益也有边界。跨厂商设备之间不可能做细粒度集合通信,也不能做无损失的 hot migration。统一调度解决的是资源生命周期管理,不是二进制兼容。把收益边界讲清楚,项目推进时反而不容易产生不切实际的预期。
2. 异构算力统一调度架构:资源层抽象和调度策略怎么定
2.1 核心设计:统一设备句柄 + 插件化 Runtime
第一版我们差点把调度器做成一个大仓库,把 CUDA 和 CANN 的细节全烩在一起。后来砍掉重做,核心就一句话:对上层暴露一套稳定的设备句柄,对下层用插件隔离厂商差异。你可以理解成打印机的驱动抽象:上层只发“打印一个文档”的任务,具体怎么抬打印头、怎么送纸,是不同驱动的事。这种类比我经常在分享里用,因为调度层本来就不是业务层,越稳定越好;上层逻辑一旦频繁感知两个生态的差异,代码里很快会铺满 if else,最后没人敢改。
我们内部定义了一组接口,分成三类:发现类、传输类、执行类。发现类包括 device_probe,返回设备数量、显存大小、能力位、拓扑位置;传输类包括 memory_alloc、memory_free、copy_h2d、copy_d2h、copy_d2d;执行类包括 kernel_launch、stream_sync、event_create、event_record、event_synchronize。这些接口不是给普通业务用的,而是给上层框架适配层用的。业务仍然用 PyTorch/TensorFlow,框架通过“平台虚拟设备”落到这些接口上。
每个厂商一个插件目录,大致长这样:
/opt/platform/runtime/ cuda/ libcuda_plugin.so device_config.yaml prebuild_ops/ cann/ libcann_plugin.so device_config.yaml prebuild_ops/device_config.yaml 里记录设备类型、驱动版本、显存池参数和拓扑位置。调度器拉起容器时,通过环境变量注入插件路径。之所以用插件化而非直接编译进调度器,是因为厂商驱动升级频率不一致。CUDA 运行时升级时,只需要替换 CUDA 插件目录,不影响 CANN 设备;反过来也一样。排障时也能按插件加载的 so 指纹精确定位是哪个版本引入的问题。
还要注意 so 文件的符号隔离。CUDA 运行库和 CANN 运行库内部很可能导出同名全局符号,如果两个插件直接塞进同一个进程,符号互相污染是必然的。我们踩过一次这个坑,表现为任务莫名其妙崩溃,后来把所有插件都改成独立动态加载、只导出统一接口符号,才彻底解决。
2.2 调度策略:单一大池、资源画像与标签打分
调度器最怕的是“看起来有空闲卡,但任务因为拓扑不满足而无法调度”。所以我们第一件事是生成设备拓扑图:每台节点上有哪些设备,设备挂在哪个 PCIe Switch,不同设备间的互连带宽,以及 NUMA 归属。这些信息不是一次性配置死的,而是每次启动时探测量一遍。
任务申请时会带一张资源画像:需要几张卡、每卡多少显存、是否需要集合通信、是否允许跨厂商部署、优先级多高。调度器按下面这个打分函数给候选节点打分:
score = 适配权重 * 设备类型匹配 + 显存剩余对齐 * 碎片惩罚系数 + 同拓扑亲和 * 互连距离加分 + 优先级权重 - 跨厂商集合通信惩罚得分最高的节点获得任务。这里有两条强制策略。第一,如果任务需要多卡并且要求集合通信,调度器尽量把卡分在同一个厂商、同一个 Switch 下;跨厂商的集合通信要么走参数服务器,要么直接禁止。第二,高优先级推理任务可以抢占低优先级训练任务的资源份额,但抢占前必须查能力位,不是所有设备都支持细粒度时间片切分。曾经有一回,我们抢占了一个训练任务后设备干脆不响应了,后来查下来才知道是底层不支持这种操作,所以要读能力位再决定。
任务资源画像也不是拍脑袋写的。第一次跑的任务可以按规格申请,跑完以后平台会自动记录实际显存峰值和 CPU 占用,下一次调度就用历史画像。这样整个池子的资源利用率会越调越准,不会出现申请 16G 但实际只用了 4G 的浪费。
2.3 跨厂商资源表与统一环境注入
统一抽象之后,任务提交的接口也要跟着变。我们在用户侧提供了一个客户端库,业务方提交任务时只需要写虚拟设备名,比如 plat-8c,而不是指定某家厂商的 8 张卡。平台根据调度结果,把具体设备注入成环境变量。
PLATFORM_DEVICE_TYPE=cuda PLATFORM_VISIBLE_DEVICES=0,1 PLATFORM_RUNTIME=/opt/platform/runtime/cuda/libcuda_plugin.so PLATFORM_OP_CACHE_DIR=/var/cache/ops/cuda框架适配层启动时读这些变量,选择运行时插件、设置可见设备、加载预编译算子缓存。之所以用环境变量而不是配置文件,是因为容器启动时的环境变量最好追踪,日志里能看到每一次任务跑在什么设备、用了哪个版本插件。这个设计救过我们很多次,特别是用户反馈“同一个模型上次能跑这次报错”时,先查环境变量里的 so 指纹,基本能定位问题。
还有一个隐含约束:我们要求业务代码不要直接调用厂商原始 API,统一走平台 SDK 的薄封装。这看起来多一层开销,但能保证本地没有厂商库污染,也方便后续加入新设备时只更新平台 SDK。薄封装的性能损耗很小,大约在微秒级,对训练任务几乎可以忽略。
3. 实操关键:算子映射、内存管理、跨卡通信与任务迁移
3.1 算子映射与精度对齐怎么落
算子映射不是查字典。我们维护了一张算子映射清单,从卷积、矩阵乘、归一化、激活、池化、Softmax 到 Attention,逐项检查 CUDA 侧和 CANN 侧各自有没有实现。结论通常分三类:有等价算子直接使用;没有等价算子但可以用两三个基础算子组合模拟;组合也实现不了的,回退 CPU 或标记不支持。
确定映射关系后要做算子级回归。工具很简单:用固定输入张量分别部署到两个设备,收集各算子的输出,计算绝对误差和相对误差。
def compare_tensor(a, b): diff = (a.float() - b.float()).abs() rel = diff / (b.float().abs() + 1e-8) return diff.max().item(), rel.mean().item()一开始我们把误差容忍度统一设成 1e-3,结果大量误报。后来改成按算子类型区分:矩阵乘和卷积要求高,Softmax 和 LogSumExp 允许更大误差。如果某算子差异大,先关闭该算子的融合,强制以非融合方式执行;仍不行就把中间量统一用 FP32 计算。举个例子,LayerNorm 在 CUDA 侧可能是一个融合 kernel 同时输出均值和方差,在 CANN 侧则可能被拆成 Reduce 和 Scale 两个步骤。这种拆分本身没问题,但混合精度下中间结果表达范围不一样,必须统一中间精度才对齐。
这份算子映射清单也不是一次性产物。现在它已经变成 CI 的一部分,每次厂商驱动升级或新算子发布,系统会自动跑一遍算子级回归,生成新的映射报告。报告里会明确标注哪些算子性能下降了、哪些算子误差超标了,平台团队再决定是更新插件还是回退驱动版本。
3.2 内存管理不是替换 cudaMalloc 就完事
内存管理是整套平台里最容易被低估的部分。很多人觉得把 cudaMalloc 换成对方接口就行,但真实环境里频繁分配释放会导致两个问题:分配粒度和异步释放。不同设备的内存分配粒度差异很大,统一内存池需要用“大块预留加小池切分”的策略。上层申请 128MB,底层不是立刻去向设备要 128MB,而是先从预留的大块里切一个 chunk 出来,用完再还回池子。
池子的参数设置也有经验。block_size_mb 设得太小,分配次数过多,单算子性能会下降;设得太大,碎片率会上升,大任务申请不到连续满足的块。我们一般先设 256MB 起步,跑几天以后看碎片率和分配耗时曲线再调。max_reserve_mb 也要跟上,否则池子无限膨胀,系统空闲时显存也收不回来。
更隐蔽的是异步释放问题。某些厂商的释放接口本身没有同步语义,你对一个还在被 kernel 使用的内存句柄调用 free,可能表面成功,实际内存直到 kernel 结束才被回收。如果不做额外同步,峰值显存很快被打满。我们的做法是封装一层带追踪的分配器:
Handle alloc = memory_alloc(device, 1024 * 1024); memory_alloc_trace_put(alloc, __FILE__, __LINE__); ... Handle release_handle = memory_free_async(alloc); event_synchronize(release_event);每个分配记录调用栈,开启 PLATFORM_MEMORY_TRACE=1 后,内存只增不减时能立刻看到是哪个模块没有释放。这个功能在排查推理服务长期运行问题时帮了大忙。
3.3 跨卡通信与拓扑感知的避坑项
异构算力统一调度的最大陷阱之一是集合通信。CUDA 生态的集合通信库和 CANN 生态的通信库不是同一套,直接跨厂商跑 AllReduce 基本不可行。我们的解决方案是把通信操作也做成可插拔的:
comm = comm_plugin_factory(device_type) if all_devices have same vendor: comm.use(vendor_native, mode="allreduce") else: comm.use(cpu_bridge, transfer_mode="shared_memory")但这不是鼓励跨厂商集合通信。调度器在设计上就应该尽量避免把同一任务分到不同厂商的卡上。如果真的遇到跨厂商部署,性能最好的方案是“参数服务器”而不是“AllReduce”,由 CPU 端做梯度汇总和同步。
拓扑感知也在这里起作用。我们读取不同卡之间的互连距离,生成一个距离矩阵,调度时给同 Switch 下的卡加分,给跨 NUMA 的卡减分。有一点容易被忽略:拓扑感知必须结合容器网络一起看,否则集合通信本来可以走机内互联,结果因为容器网络配置跨了机器,数据绕到以太网上走一遍,性能直接掉一个量级。还有 RDMA 注册接口差异,不同厂商对网卡内存注册的接口不一样,统一层需要抽象 buffer_reg 和 reg_dereg,否则数据搬移会掉到内核拷贝路径。
3.4 训练任务断点迁移与推理服务滚动切换
故障迁移是统一调度的加分项,但也是最容易产生误判的地方。先说结论:训练任务和推理任务要分别设计迁移方案。
训练任务我们采用“周期快照 + 断点续训”。调度器发现某张卡温度过高或驱动报错后,先把设备标记为不可用,然后在健康设备上重建容器,加载最近一次保存的 checkpoint、optimizer state 和 random state,继续训练。注意,最后一轮迭代大概率丢掉,这是可接受的。不要试图保存设备上的流状态和通信上下文,那些东西和硬件强绑定,迁移过去也没用。
推理任务不建议做热迁移,因为推理服务连接状态和模型状态很难原子迁移。我们用的是滚动切换:老副本继续服务,新副本在健康设备上拉起、加载模型、跑健康检查,确认 latency 达标后切流量,再缩容老副本。健康检查不能只发一个请求,至少要跑 10 个 warmup 请求,等预热完成再切流量,否则首个请求会因为图编译/算子缓存初始化而超时。
迁移步骤固定为五步:故障上报、标记设备、扣减配额、下发迁移指令、恢复后释放旧资源。顺序不能乱,否则会出现新任务已经被调度到故障卡上的情况。
4. 异构算力池排障实录:常见问题与快速定位技巧
4.1 同一个模型在两类卡上结果不一致
这是一个真实高频问题。现象是:PyTorch 模型在 CUDA 上训练 20 个 epoch 都正常,切到 CANN 后验证 loss 偶尔出现 NaN。我们当时的排查方向是先排除环境问题,固定随机种子、关闭混合精度,NaN 依然出现。接着,我们把 CANN 侧的图编译和算子融合全部关掉,NaN 消失了。这基本确认是某个融合算子在中间步骤里出现了表示范围溢出。
定位到具体算子后,做法是在配置里单独关闭这个算子的融合,或者把该算子的计算精度强制到 FP32。最后训练曲线恢复了。现在,我们所有模型上线前都要跑一遍“算子级对比 + 端到端对比”,端到端对比会包括 100 步左右的训练 loss 曲线。如果手里只有预算跑一个检查,建议直接跑端到端 loss 曲线对比,它能把大部分差异暴露出来。
不过也要提醒:端到端 loss 对齐不能完全依赖数值比较,有时需要把模型结构导出成图,对比一下融合后的子图到底长什么样。两边的图优化逻辑不一样,如果你只对比每一层的输出,可能会漏掉跨层融合带来的全局差异。
4.2 显存只增不减
推理平台运行一周后,每个容器的显存占用上涨 20%,重启后恢复。这是典型的资源泄漏。原因有两个:框架侧 host 缓存没释放,以及异步事件未同步导致 pinned memory 释放挂起。
排查步骤可以照抄:
- 开启内存追踪,打点分配和释放。
- 检查 D2H 拷贝后是否调用了流同步,没有同步的 pinned memory 不会真正释放。
- 检查框架缓存,PyTorch 的 caching allocator 有可扩展段配置,CANN 也有类似参数,把这层缓存开成可回收模式。
- 在调度层设置软性内存上限,触发回收或重启容器作为最终兜底。
设置内存上限要小心副作用。如果硬性 OOM,容器会直接被杀掉,服务中断时间取决于拉起速度;所以我们通常先设软上限,超过 80% 就触发告警和主动重启,尽量选业务低峰期。
4.3 CUDA 任务与 CANN 任务混部时互相拖慢
混部不是简单的“把任务放一起就行”。我们遇到过 8 卡训练任务和 4 卡推理服务混部后,推理 p99 延迟从 50ms 涨到 200ms。起初以为是设备算力不足,后来发现是 CPU 核争用和 NUMA 访问冲突。
解法:给训练和推理任务分别绑核,把两个任务放在不同 NUMA 节点;推理服务开启低延迟模式,训练任务限制最大并发流数量;调度器限制同一计算节点上可混部的任务数量,避免太多任务争抢同一个 PCIe Switch。这套组合拳之后,p99 稳定回到 55ms 左右,训练吞吐损失不到 5%。
绑核时还要避开处理网卡中断的 CPU 核心,否则网络包处理会和训练任务抢时间片。我们后来把节点上的核心分成三类:中断专用、推理专用、训练可用,调度器再根据任务类型去分配核心集合,效果比单纯绑核好很多。
4.4 日常排障速查表
| 症状 | 可能原因 | 快速检查 |
|---|---|---|
| 任务提交失败 | 驱动/Runtime 版本不匹配 | 查看插件 so 版本与设备驱动是否匹配 |
| Kernel 启动很慢 | 首次图编译耗时 | 开启预编译算子包,做 warmup |
| 任务排队饿死 | 调度打分为新任务加分过高 | 检查优先级权重和最长等待时间策略 |
| 断电重启后设备不可用 | 设备节点未重新枚举 | 执行设备探测脚本,重新加载拓扑表 |
| 跨厂商任务异常 | 触发了不支持的集合通信 | 检查调度器是否允许跨厂商部署 |
| 日志里出现未定义符号 | 多个运行库符号污染 | 确认插件 so 开启符号隔离,重新加载 |
这张表看着简单,但每条背后都有一次通宵排障。记录下来就是希望后来的人少走弯路。
踩过几次坑之后,我现在最想说的其实是:异构算力统一调度不是把 API 都变成同一套函数签名,而是把不确定性挡在统一层之外,把差异塞进插件之内。我们团队第一版跑通 PyTorch 只用了两周,但把精度对齐、内存泄漏、性能抖动这些问题解决干净,花了三个季度。如果你也在规划类似平台,我的建议是先别急着写万能调度器,先把现有模型在每类卡上的行为记录成表格,再动架构。最后分享一个小技巧:在调度器里给每个任务打上运行库的 so 指纹,版本不一致导致的诡异问题大概率能第一时间发现。