夜雨聆风学习资料网

ARTICLE · 1243684

《AI基础设施系列》之GPU 软件栈与“软硬件血统”

《AI基础设施系列》之GPU 软件栈与“软硬件血统”

卷 C · 软件栈与“软硬件血统”:为什么必须懂

阅读定位:卷 A 讲硬件“有什么”,卷 B 讲硬件“怎么执行”。本卷讲软件与硬件之间那条血统(pedigree)链。每一代硬件的能力,都会在软件栈里留下接口、习惯与约束。不懂这条链,读文档像查字典,每句话都认识,却不知道它为何存在。懂这条链,就能解释这版 cuDNN 为什么要求这个架构、这个算子为什么在新卡上反而慢、代码迁到国产卡要改什么。本卷适合想把这部分做扎实的读者,尤其是平台、推理与性能方向的工程师。

C.1 什么是“软硬件血统”,为什么它是必修课

定义:软硬件血统指这样一条家谱:GPU 架构世代 → 该代引入的硬件能力 → 软件栈据此新增的接口/库/优化路径 → 上层框架与代码习惯。Ampere 引入 Tensor Core 的 TF32 支持后,cuBLAS/cuDNN 出现 TF32 路径,PyTorch 的自动混合精度把“允许 TF32”变成一行配置,于是“fp32 训练但允许 TF32”成了一代人的默认习惯。Hopper 引入 FP8 与 Transformer Engine 后,同样的链条再次发生,这一次受益者是 FP8 训练与量化推理。

为什么必须懂,有四个理由。

  1. 定位问题不靠猜。报错和性能问题常源于代码假设的架构能力与实际运行的硬件、库版本不匹配,比如某内核要求算力 9.0 的 FP8 指令,跑在 8.0 卡上会报错或回退。懂血统的人先查谁在哪个世代引入这项能力,再查运行环境,范围立刻缩小。
  2. 读文档能抓住要点。官方发布稿和 Release Notes 里大量出现“某库新版本 is optimized for Hopper/Blackwell”这类句式。它的意思是这条代码路径用了该代专属的硬件特性,比如 TMA、FP8 或新指令,并非说别的卡不能用。
  3. 性能预期才成立。同一算子在不同代 GPU 上的快慢差异,往往来自有没有为该代写过专门的内核路径,以及手上的库版本是否包含这条路径。不懂血统,就会把库优化差异误当成硬件算力差异。
  4. 迁移与选型才安全。把代码迁到 AMD、国产加速卡或者换新一代卡时,真正要评估的是你用了血统链上的哪几环:是纯 PyTorch 高层 API,是自定义 CUDA 内核,还是依赖某版本 NCCL/cuDNN 的特性。异硬件之间的可移植性讨论如果没有血统视角,都会变成口号。

取“低精度推理”这条线把定义走一遍,比背定义更直观。Blackwell 的 Tensor Core 支持 FP4 档位,这是硬件能力;CUDA 与推理库(TensorRT 一类)随之新增 FP4 的 kernel 与量化工具链,这是接口与库路径;上层框架开始提供“一键 FP4 量化”的开关,这是软件习惯;项目里“默认 FP4 推理”成为可选项,这是工作流变化。每一环都建立在上一环的硬件前提上,缺了任何一环,整条线都不成立。反过来说,你在 A100 上复现不出 Blackwell 的 FP4 收益,因为旧卡没有同档的 FP4 路径,这条链在源头就不存在,跟你的代码无关。

血统还有一层时间方向:它是逐代累积的遗传,不只是一次性对应。新硬件通常会保留旧能力,也就是前向兼容,见 C.4,所以今天一个正常项目的代码里,往往同时依赖三代以前的老指令、老库路径和最新的新档位、新特性。“理解一段代码为什么这么写”常常要回溯它诞生时的硬件代际。资深工程师读老代码能说出“这行是为 Kepler 时代写的优化”,新手只看到奇怪的写法。血统意识本质上是一种带时间轴的硬件知识。

链条的六个环节:从算力代号到模型代码

血统链条有个可以指认的形状,见图 C-1。六环从上往下是:硬件算力代号、驱动、CUDA 工具链与运行时、算子库、框架、模型代码。硬件算力代号的官方名是 compute capability,编译参数里写成 sm_80、sm_90 这类标记;工具链与运行时指 nvcc、ptxas、NVRTC 与 CUDA runtime;算子库指 cuBLAS、cuDNN、cuFFT、NCCL、CUB;框架指 PyTorch、TensorRT。上一环的“能做什么”是下一环“能做什么”的上限,反方向不成立:驱动支持某条路径,不代表你的训练脚本用上了它。

每一环都有一个可查的“版本载体”,这是血统这套说法能落地的前提。硬件看算力代号;驱动看 nvidia-smi 上半部分给出的驱动版本与它支持的 CUDA 版本上限;工具链与运行时看 nvcc --version 与 Python 侧的 torch.version.cuda 这类字段;算子库看各自的版本查询接口,cuDNN 那份用 torch.backends.cudnn.version() 就能看到;框架看包版本与 wheel 的 CUDA 后缀;模型代码看编译参数、环境变量与配置文件里写死的那几项。六样凑齐就是一条完整的血统记录,C.7 的版本矩阵是这个记录的表格形态。

链条上真正容易断的位置只有三个,图 C-1 把它们画在主干旁边。断在驱动:宿主机驱动只支持到某个 CUDA 大版本,镜像里的 runtime 比它新,容器内应用启动即失败。断在库:硬件与驱动都到位,可这一版 cuDNN/cuBLAS 没给这代卡写内核路径,于是能跑、跑得慢,profiler 里看到的内核名还是上一代的。断在代码:前面三层都满足,可配置里那个精度开关没打开,或者编译时把 arch 写成了上一代,新能力一路被截在最后一步。三个断点的排查成本差得很远:断在驱动看一眼就清楚,断在代码要读自己的配置,断在库最费时间,因为“能跑”会把排查方向引到别处。

表 C-1 六环各自的“版本载体”与断链后的第一症状(本书整理;组件名均为官方名称;症状一列是常见形态,实际措辞随版本变化,须以现场日志为准)

环
这一环的“版本载体”(能查的东西)
断掉之后的第一症状
① 硬件算力代号
算力代号(torch.cuda.get_device_capability() 可读)+ 编译时写进产物的 arch
加载或启动时报“找不到可执行内核”一类错误;或编译期就拒绝该 arch
② 驱动
nvidia-smi
 给的驱动版本,以及它支持的 CUDA 版本上限
容器内应用启动即失败,日志指向 CUDA 初始化;同一镜像换宿主机才复现
③ CUDA 工具链与运行时
nvcc --version
、torch.version.cuda、镜像 tag 里的 CUDA 段
编译期报不认识的 arch 或指令;运行期报动态库加载失败
④ 算子库
各库自身版本(cuDNN 看 torch.backends.cudnn.version(),其余看发行说明与包元数据)
算子初始化失败、版本不匹配报错;或安静地回退到通用实现,性能对不上
⑤ 框架
框架包版本 + wheel 的 CUDA 后缀(+cuXXX 这类)
算子不存在、设备不支持、精度开关写了却不生效
⑥ 模型代码
编译参数、环境变量(如 TORCH_CUDA_ARCH_LIST)、配置文件里的精度字段
能跑但没有收益;数值与预期不符;换了机器结果变

不懂血统的三个具体后果

前面四个理由讲的是判断层面。换成三个能在项目里指认的后果更实在,每一个都对应一类真实工单。

后果一,同一份代码跨代行为不同。TF32 的开关在 Ampere 起的卡上有实际效果,在更早的卡上写了也没用;FP8 路径从 Hopper 起才存在;同一个混合精度脚本跑在 A100 与 H100 上,数值能对上,速度差异却可能远小于档位差,也可能远大于档位差,取决于库有没有为这代卡写 kernel。这类差异最容易被写成“训练不稳定”或“库有 bug”,它本质是血统链在新一代上重新分配了默认路径。查法是把 torch.cuda.get_device_capability() 与 profiler 里的内核名一起看,确认跑的是不是这代该走的那条路。

后果二,镜像没换,换台宿主机性能就变。容器镜像能冻结用户态的一切,包括 CUDA runtime、算子库与框架,但冻不住宿主机驱动。同一个镜像 tag 落到驱动较新和驱动较旧的机器上,能力上限不同,某些后端初始化会静默降级,某些会直接报错。查法是把镜像 tag、宿主机驱动版本、卡型三联一起记,性能对比只在三联一致的机器上做,见 C.7。

后果三,迁移时报错链是多层的。报错的落点常常和根因所在的层不是同一层。日志在框架层抛出“算子不可用”,根因在镜像层的 CUDA 版本;日志在库层报初始化失败,根因在宿主机驱动。缺少血统视角,人会顺着报错字面在框架里搜,搜到一堆无关 issue;有血统视角,第一动作是先确认这条链上哪几环换过,再往那一环查。查法是先写清迁移前后各环的版本对照,再读报错,C.5 的报错链一节给出逐层排除的顺序。

判据:判断一个能力能不能用,要问到哪几层

把血统折算成一条可执行的判据。任何一个“我能不能用 X”的问题,都沿链问四个层,四问答完才有结论,见表 C-2。

  1. 硬件这一环有没有。这块卡属于哪一代,X 是哪一代引入的能力。
  2. 驱动这一环开不开。宿主机驱动支持的 CUDA 版本上限,够不够跑用到 X 的那版 runtime。
  3. 库这一环有没有路径。你手上这版 cuDNN/cuBLAS/NCCL 与框架里,有没有面向这代卡的 X 实现。
  4. 代码这一环走不走。精度开关、编译 arch、后端选择有没有把这条路打开。

表 C-2 能力可用性判据:四个问题、查什么、答“否”时的结论

要问的问题
查什么(命令或字段)
答“否”时的结论与动作
1 这块卡的算力代号够不够?
torch.cuda.get_device_capability()
;nvidia-smi --query-gpu=name 对照官方算力表;编译产物里的 arch
硬件这一环不支持。调参与调库都不会有收益,只能换卡或改路线(C.3)
2 驱动支不支持要用的 CUDA 版本?
nvidia-smi
 上半部分的驱动版本与 CUDA 版本上限;容器内的 runtime 版本
换宿主机,或把镜像降到驱动支持的大版本内;同大版本内的小版本差异另有规则(C.4)
3 手上的库版本有没有该能力的内核路径?
库版本 + 官方支持矩阵;torch.backends.cudnn.version() 一类字段
升级库,或确认该能力在当前栈里不成立:纸面支持不等于可用
4 我的代码真的走这条路了吗?
精度开关(如 TF32/FP8 相关开关)、torch.cuda.get_arch_list()、profiler 里的内核名
条件齐备但没被用上:改配置或改代码,这是最便宜也最常见的一类
收口:把结论变成一条记录
版本四元组(驱动 / CUDA / 框架 / 算力代号)写进 issue 或实验记录
缺这四样,同一个问题会被重复排查;补齐后它变成可复现的工单

C.2 软件栈逐层:每一层在血统链里的位置

六层的职责、版本与责任人

表 C-3 六层软件栈的角色与血统印记

层
角色
血统印记(示例)
驱动(Driver)
操作系统与 GPU 之间的接口;暴露能力与版本策略
驱动版本决定能支持到哪代硬件/哪版 CUDA;容器内“看不到卡”常是驱动/容器工具链问题
CUDA 平台(Runtime/驱动 API、编译器)
开发者写 kernel、编译与运行的最小接口;含“算力目标”概念
编译时指定 compute capability(如 8.0/9.0);新指令需新架构代号;PTX 兼容层让旧代码能“再编译”上跑新卡
库层(cuBLAS/cuDNN/cuFFT/NCCL 等)
把常见算子的高性能内核打包;每个算子内部常按架构分路径
“cuBLAS FP8 需 Volta 以后/算力 8.0 以上”一类条件;NCCL 对不同互连世代有不同算法路径
框架层(PyTorch、TensorRT 等)
把库能力编排成模型训练/推理接口;负责精度策略与算子选择
AMP 的 TF32 开关、FP8 量化工具、torch.compile 后端对 Triton 的支持,框架文档里处处是“某架构才生效”的注脚
平台/调度层
作业怎么选卡、共享、配额(2.2)
MIG 能力、NVLink 域拓扑、驱动版本一致性是调度器必须知道的“硬件事实”
你的训练/推理代码
最终消费者
代码假设的血统(用了 FP8?依赖某算力?)决定它在别处能否跑

看这六层时,用“谁在替谁做决定”的眼光比背下六个层名更有用。最上层是你的训练脚本或推理服务,只做策略决定:模型结构、batch、精度档位选哪个。往下到框架与库,做的是实现决定,把策略落成算子,把算子选到某个内核实现,cuDNN/cuBLAS 的 heuristics 会在多个内核里挑一个,依据就是硬件代际与形状。再往下到驱动与硬件,做的是执行决定:线程怎么排、指令怎么发。性能问题的本质,往往是某一层替你做的决定不符合你的预期,比如框架的 heuristics 给某个形状挑了个保守内核,或者驱动按默认策略没启用某个新特性。读性能时先问这个决定是哪层做的、依据是什么,往往比直接调参更快定位。

这也解释了两个常见困惑。一是换了新卡、代码没动,速度却没快多少:如果框架的库路径还停在旧代,容器里的 CUDA/cuDNN 是老的,新硬件的新特性根本没被用上,等于新瓶装旧酒。二是同样的模型在不同框架里性能可以差出一截:每层实现决定的质量不同,而质量取决于该框架对目标硬件的适配深度,也就是血统链上投入的工程量。所以软硬件血统不是纯硬件话题,它讲的是硬件能力如何在软件各层被逐层兑现、又可能在哪一层被截住。

按“出了错怪谁”给六层配一张对照表,排障能少走一半弯路。脚本层出错,比如显存不足、形状不匹配、数值 NaN,先查自己的代码与配置,别急着怪框架。框架层出错,比如算子不存在、设备不支持,查框架版本与该算子对硬件的支持表。库层出错,比如 cuDNN/cuBLAS 初始化失败、版本冲突,查库与 CUDA、驱动的兼容矩阵。驱动层出错,比如驱动崩溃、CUDA 上下文失败,查驱动与系统,包括内核、容器与宿主机。硬件层出错,比如 ECC 报错、温度异常,查卡的健康与供电散热。把报错按“它发生在哪一层”归位,再决定查什么文档,比全局搜索报错串高效得多,因为同一段报错文字在不同层有不同的含义与解法。

CUDA 内部:两层 API 与三种编译产物

“CUDA 版本”是个被用滥的说法,拆开至少有三样东西在各自走路。驱动 API 是最底层的一层,函数名以 cu 开头,由驱动提供的库实现,直接描述设备、上下文、内存与模块,能力最全,写法最啰嗦。运行时 API 在驱动 API 上包了一层,函数名以 cuda 开头,由工具链里的 runtime 库提供,替你管上下文与内存分配,写 kernel 时绝大多数代码用的是它。

nvcc 的输出也常被混着叫。PTX 是虚拟指令集,不绑定具体世代,能被更新一代的驱动在运行时重新编译成机器码;cubin 是某一代具体 GPU 的机器码,绑定算力代号;fatbin 是打包容器,把一个或多个 cubin、以及可选的 PTX 一起塞进宿主编译产物的某个段里。编译时把 cubin 汇编出来的是 ptxas,nvcc 在内部调用它。这一层关系决定了后面所有兼容性讨论的形状:cubin 管“现在就快”,PTX 管“以后还能跑”。

PTX 的即时编译发生在什么时候,值得单独记一句。当程序加载一份 fatbin,里面的 cubin 没有一个匹配当前设备、但带了 PTX 时,驱动会在加载时把 PTX 编成该设备的机器码,准确说是首次为该设备建立模块的时候,然后照常执行。代价是这段编译时间计入启动路径,收益是老二进制能跑在新卡上,C.4.2 会展开这套方向性。NVRTC 换个方向走,它把 CUDA C++ 源码在运行期编成 PTX,用于框架按形状即时生成内核、或者环境里根本没有 nvcc 的场景。两者受同一套驱动与架构约束,能编出什么仍由“驱动支持到哪一版 + 目标算力代号”决定。

表 C-4 PTX、cubin、fatbin 三者对照

产物
它是什么
什么时候产生
跨代行为
出问题的症状
PTX
虚拟指令集,不绑定某一代硬件
nvcc
 按虚拟架构(compute_XX)编译时产生
可由更新的驱动在运行时 JIT 成新卡机器码
首次启动变慢(JIT 计入启动路径),或 JIT 失败报不认识的指令
cubin
某一代 GPU 的机器码,与算力代号绑定
ptxas
 按真实架构(sm_XX)汇编产生
只对同代或同家族更高小版本可用
加载或启动时报找不到可执行内核
fatbin
把多个 cubin / PTX 打成一个包的容器
链接阶段嵌入宿主二进制
内含项越多,覆盖越广,体积越大
体积与编译时间上涨;判不清里面到底有没有目标架构

Python 侧的现实:wheel、cuDNN 与 arch 列表

走到 Python 这一层,血统被固化成几个包。预编译的 PyTorch wheel 在构建时就选定了 CUDA 版本与 cuDNN 版本,文件名带 +cuXXX 这类后缀,形如 +cu121,具体取哪个以官方安装矩阵为准。一个环境里只能装一个 torch,所以 Python 侧的“CUDA 版本”基本跟着 wheel 走。想在容器里换 CUDA 版本,最可靠的动作是换 wheel,补装库不靠谱。cuDNN 也是随包进来的,它的版本与 torch 的构建绑定,单独 pip 装一个 cuDNN 去替换,很容易做出一个既不是上游测试矩阵、也不是自己验证过的组合。

$ nvidia-smi --query-gpu=name,driver_version,memory.total --format=csv   # 驱动与卡型
$ python3 -c "import torch; print(torch.__version__, torch.version.cuda)"
$ python3 -c "import torch; print(torch.backends.cudnn.version())"
$ python3 -c "import torch; print(torch.cuda.get_device_capability(), torch.cuda.get_arch_list())"
# 输出示例(数值【示例】,实际以现场为准):
# name, driver_version, memory.total [MiB]
# NVIDIA ..., 5xx.xx, 81559 MiB
# 2.x.x+cuXXX 12.x
# 9xxx
# (8, 0) ['sm_80', 'sm_86', 'sm_90']

环境变量 TORCH_CUDA_ARCH_LIST 与上面几个字段用途不同,混用会踩坑。它只在从源码编译时生效。编译自定义算子或从源码构建 PyTorch 时,这个环境变量决定生成哪些架构的代码;不设时构建系统可能按当前可见的卡来推断,于是产物只对那一代有效。设得太窄,换一张新卡就要重编;设得太宽,编译时间与产物体积一起上涨。它不影响已经装好的 wheel。wheel 里有什么架构,装完就定了,改环境变量不改历史。

一张表查版本:层、组件、查法与责任人

把前面几节压成一张随手可查的表,见表 C-5。“谁负责”这一列比其余各列更重要。血统问题在组织里反复出现,往往因为每一环都有人能改、却没人承认自己该管,最后落在跑不通的那个人身上。

表 C-5 层 → 代表组件 → 版本从哪看 → 典型症状 → 谁负责

层
代表组件(可查证)
版本从哪看
出错的典型症状
谁负责
硬件与固件
GPU 本体、算力代号、MIG 配置
nvidia-smi
、torch.cuda.get_device_capability()
ECC 报错、降频、算力代号低于预期
机房/平台(采购与运维)
驱动
宿主机上的 GPU 驱动
nvidia-smi
 上半部分:驱动版本与 CUDA 版本上限
容器内无法初始化;同镜像换机器才复现
平台(宿主机基线)
工具链与运行时
nvcc
、ptxas、NVRTC、CUDA runtime
nvcc --version
;torch.version.cuda;镜像 tag
编译期拒绝该 arch;运行期动态库加载失败
镜像/构建(CI 基线)
算子库
cuBLAS、cuDNN、cuFFT、NCCL、CUB
随包进来的那份(如 torch.backends.cudnn.version())+ 官方支持矩阵
初始化失败、版本冲突;或静默回退,性能对不上
镜像/构建 + 框架升级评审
框架
PyTorch、TensorRT 等
包版本与 wheel 的 CUDA 后缀;torch.cuda.get_arch_list()
算子不存在、设备不支持、开关无效
项目(技术栈 owner)
业务代码
训练脚本、推理服务、自定义 kernel
编译参数、环境变量、配置文件
能跑但慢;数值与基线不符
业务团队

C.3 世代功能绑定:一张“什么能力从哪代开始”的清单

公开口径的世代清单与更细的判据表

把血统落成可查的表。算力代号方面,A100≈8.0,H100≈9.0 量级,Blackwell 对应更高代号,精确值以官方“compute capability”表为准。

表 C-6 硬件能力与起始代际(公开口径)(代际口径与卷 A 档案卡一致;算力代号只给量级,精确值以官方 compute capability 表为准)

硬件能力
从哪代开始(公开口径)
软件侧的你会在哪里遇到
Tensor Core(通用)
Volta(V100)起
深度学习训练的基础假设,0.6 主页面已述
TF32 支持
Ampere(A100)
AMP 配置、cuBLAS 默认路径
稀疏 2:4 加速
Ampere(A100)
推理剪枝后的加速开关
MIG 实例隔离
Ampere(A100)
调度器/配额语义
Transformer Engine / FP8 训练
Hopper(H100)
低比特训练与推理量化
TMA/异步拷贝、warpgroup MMA
Hopper(H100)
FlashAttention 类内核与编译器优化路径
HBM3e 大容量(141 GB 级)
Hopper(H200)
长上下文/大 batch 成为可能
FP4、NVLink5、双裸片域
Blackwell(B200/GB200)
推理量化继续下探、更大 TP 域

用法是这样的。在某篇文章看到“Blackwell-optimized”,就用这张表读出它至少用了 FP4、NVLink5、双裸片中的某一项;在旧卡上跑它报错或回退时,也知道去检查哪一项。这张表同时是迁移评估的第一页。

更细的判据表:在代码里怎么判、缺它会怎样

表 C-7 能力 → 起始代际 → 代码里怎么判 → 缺它会怎样 → 是否需硬件与库双支持

能力
起始代际(公开口径)
在代码或环境里怎么判
缺了它会怎样
硬件+库双支持?
Tensor Core(张量核)
Volta 起
看 profiler 里矩阵乘的内核名是否落在张量核实现上;框架是否把算子映射到 cuBLAS/cuDNN 的对应路径
退回通用 CUDA 核路径,同类算子速度差一个量级
是:硬件有单元,库还得有对应内核
TF32 档位
Ampere 起
精度开关(如 torch.backends.cuda.matmul.allow_tf32 一类);cuBLAS 是否走该路径
开关关着就按更高精度算,慢一些、结果也略有不同
是:硬件档位 + 库路径 + 代码开关三层
2:4 结构化稀疏
Ampere 起
权重是否已剪成 2:4 模式;推理引擎或库是否启用稀疏内核
稀疏权重跑在稠密内核上,加速比对不上,模型精度还已经付了代价
是:硬件 + 库 + 模型侧剪枝三处都要对
MIG 实例隔离
Ampere 起
设备可见的实例数与显存配额;容器里能否指定某个实例
切不开,小任务独占整卡,配额语义也跟着失效
是:硬件 + 驱动/容器工具链 + 调度器
FP8 与 Transformer Engine
Hopper 起
算力代号是否到该代;框架的 FP8/量化接口是否可用;库版本是否支持
只能留在 BF16/FP16,低比特训练与量化推理的收益拿不到
是:硬件档位 + 库(含缩放策略)+ 代码
TMA 与异步拷贝
Hopper 起
编译 arch 是否覆盖该代;内核是否用描述符式搬运(看源码与内核名)
搬运算在计算线程头上,带宽受限的算子拿不到应得的收益
是:硬件 + 工具链 + 库/内核实现
线程块簇与分布式共享内存
Hopper 起
内核是否声明簇;编译 arch 是否覆盖;库是否使用了簇级算法
协作范围只能停在块内,跨块要走 HBM 绕一趟
是:硬件 + 工具链(编译器要认)+ 库
HBM3e 大容量(141 GB 级)
Hopper(H200)
nvidia-smi
 的显存总量;调度器看到的容量口径
长上下文与大 batch 放不下,只能靠并行与重算换容量
否:纯硬件档位,软件侧不需要改
NVFP4 与块级缩放
Blackwell 起
算力代号是否到该代;量化工具链是否支持该格式与缩放粒度
量化只能停在更低一档;硬上 FP4 而缺缩放策略会掉精度
是:硬件 + 库(含缩放格式)+ 质量门禁
更大 NVLink 域与双裸片封装
Blackwell 起(NVLink 代际逐代加宽)
拓扑查询得到的域规模与互联带宽;通信库对该拓扑的识别
并行组画不大,跨域通信税吃掉并行收益
是:硬件 + 驱动 + 通信库

两个条件反射与“三层支持”

把“能力从哪代开始”的清单用活,关键是练成两个条件反射。第一个反射是看到新特性先问哪代硬件起的头:FP16 张量核是 Volta 起的头,BF16 与稀疏是 Ampere 加码,FP8 是 Hopper 引入,FP4 和更宽的低比特路径随 Blackwell 展开。有了这条时间线,你看到“某库支持 FP8”时能立刻判断,我的卡若是 Ampere,这条路径对我不成立,不用浪费时间复现。第二个反射是看到报错先想这报错在抱怨哪一代的什么能力,比如“requires compute capability 9.0”这类信息,直接指向能力清单上的某一格。把报错读回“能力×代际”坐标,是血统意识在排障中最直接的应用。

还有一个容易被忽略的点:清单上的“支持”分三个层次,硬件有、驱动开、库用上。硬件晶体管具备某项能力,比如 FP8 单元存在,只是第一层;驱动把该能力暴露成可用接口,CUDA 版本够不够,是第二层;上层库与框架真的为它写了优化路径并默认启用,才是第三层。你实际拿到的性能永远取决于最弱的那一层。硬件支持但驱动旧、库没适配,能力就是纸面支持。这也是同一张卡性能可以差出代差的原因,血统链上任何一环没跟上,整条链都打折,C.6/C.7 会讲怎么让三层对齐。三层各自怎么验、判错会怎样,见表 C-8。

表 C-8 “三层支持”的判定动作与判错代价

层
判定动作
这一层没到位时的表现
判错方向会怎样
硬件有
读算力代号;对照官方 compute capability 表与卷 A 档案卡
能力根本不存在,任何调参都无收益
误判成“库/参数问题”,在错误的方向上耗掉几天
驱动开
读 nvidia-smi 的驱动版本与 CUDA 上限,对照镜像里的 runtime 版本
高版本 runtime 不可用,或初始化直接失败
误判成“镜像坏了”,反复重建镜像而根因在宿主机
库用上
读库版本 + 官方支持矩阵;用 profiler 看内核名是否落在该代路径
能跑、不报错,但走的是上一代内核,性能对不上
误判成“硬件退步/被降频”,把软件栈问题算到硬件账上

世代能力清单要配一条使用纪律,用它做门槛判断,不做性能预测。清单能可靠回答“有没有”类问题,比如我的卡是否具备 FP8 路径、是否支持某代引入的互连特性、驱动是否够新,这些是门槛,答“否”就可以直接排除某条优化路线。清单不能可靠回答“快多少”类问题,同样具备 FP8,两张卡的实测差距还取决于库版本、算子形状与散热。把清单当门槛用,你会得到快速、可靠的筛选;把它当预测用,你会得到经常落空的预期。这条纪律也解释了为什么血统意识要配合测量工具一起使用,清单告诉你今天能走哪条路,测量告诉你那条路今天有多宽。

C.4 兼容性策略:前向、后向与“在哪儿跑”

三个兼容性问题与迁移心智

血统链带出三个兼容性问题,排障时经常撞上。

  • 新库可以跑旧卡,但有条件。CUDA 有前向兼容与 PTX 层,较新的工具链在较老的算力上常能运行,做法是编译成 PTX、运行时再编译,但需要旧卡的驱动支持到足够新。所以报错时先分清是算力不支持,还是驱动版本旧。
  • 旧库一般也能在新卡上跑。驱动向后兼容,老应用在新驱动、新卡上通常可运行,只是享受不到新血统的优化,这是“能跑但慢”的常见来源,别误判成硬件退化。
  • 真正决定能不能用某特性的,是“编译目标 × 驱动 × 库版本”这个三元组。官方文档与错误信息里的 “compute capability 9.0 required” 说的就是血统的这一环需要这个祖先。附录 B 与 2.4 章的报错排查流程,本质上都是在解这个三元组。

对平台团队来说,在集群上统一记录驱动版本、CUDA 版本与容器镜像,并允许用户显式声明算力目标,能消灭一大类“在我机器上能跑”的争论。

三类日常问题分别对应前向兼容、后向兼容与“在哪儿跑”。前向兼容指为旧卡写的代码通常能跑在新卡上,典型问题是升级机器后老脚本还能不能跑:多数能,但跑不出新卡的红利,直到你重新编译或启用新库路径。后向兼容指新代码未必能在旧卡上跑,典型问题是同事给了个在新卡上编译的二进制,我的旧卡能不能直接跑,答案常常是不能,报错多为“no kernel image available”或库版本不符。“在哪儿跑”这一条要问的是,同一份代码在不同机器上行为不一致时,先查“编译目标 × 驱动 × 库”三元组,而不是先怀疑代码逻辑。

这三类问题有一个共同的排查顺序,先确认目标硬件代际在代码与环境的哪里被写死。三个常见落点是编译参数里的 arch、容器里的 CUDA 版本、pip/conda 装的库轮子,很多轮子按编译时的算力下限分发。逐项对完,绝大多数“换个机器就挂”的案子都能定位到这三处之一。剩下的少数才轮到驱动 bug 或库 bug,那时你的现场信息,也就是版本三元组加一个最小复现,已经足够提交一个高质量 issue。

迁移最常见的坑,是把它当成一次性搬家。真实项目的硬件与软件升级从来都是长期并存的,新旧卡同池运行、不同团队用不同版本、线上与实验环境不一致,不存在“周末搬一次家”这回事。抱着“搬完就一致了”的心态,会在第一次发现新旧环境行为不同时手足无措。把迁移当成持续的兼容管理,你会从一开始就建立三条基线:环境基线,四元组可复现,C.6/C.7;行为基线,代表负载的数值与性能快照,1.1 章;回归流程,每次变更跑一遍基线对比。三条基线齐了,迁移就从赌一把变成带仪表盘的渐进切换。

还有一层心态上的转变:“我的代码没问题”和“我的代码在新环境没问题”是两句话。遇到行为差异时,先假设自己的代码或环境假设过时,占了血统链上某个旧环,再去怀疑工具链,这个顺序能让绝大多数排查在半小时内收敛;反过来先怪驱动和库,则常常绕远路。换新卡时的第一反应也值得留意,看自己属于哪一种:是“代码应该不用动”,还是“我该检查代码里哪些地方悄悄依赖了旧代假设”。

源码兼容、二进制兼容、性能兼容

这三种兼容混着说容易糊。换成一条三层的边界线就清楚了,见图 C-2。源码兼容指同一份源码换个工具链还能重编通过;二进制兼容指编好的产物不重编就能在目标机加载运行;性能兼容指跑起来之后,执行路径真的落在那代卡被优化过的内核上。三层是包含关系,外层成立不代表内层成立,内层成立则外层多半成立。三类兼容的判据与规避做法汇总在表 C-9。

表 C-9 三种兼容:判据、失败症状与规避做法

兼容类型
判据(怎么验)
失败症状
规避做法
源码兼容
用目标环境的工具链与库重编一次,看是否通过
编译期报错:不认识的 arch、指令或 API;头文件缺失
按目标环境的最低共识写代码;用条件编译隔离新 API;把 arch 作为参数而非硬编码
二进制兼容
把产物放到目标机直接跑一个最小用例,不重编
加载或首次启动报错,形如找不到可执行内核【示例】
产物里带上目标架构(或带 PTX 兜底);驱动版本不低于产物要求;
性能兼容
profiler 看内核名 + 与基线比吞吐,两者一起看
不报错、不崩,只是慢;内核名仍是上一代路径
升级库到含该代路径的版本;打开对应精度开关;确认编译 arch 未把新指令挡在门外

PTX 的前向兼容与 cubin 的架构绑定

三种兼容在 CUDA 里各自落在具体的产物上,所以能讲得比一般软件更精确。PTX 走前向兼容,它不绑定具体世代,只要驱动认得这份虚拟指令集,就能把它编译成当前设备的机器码,于是“给老卡编的代码能跑新卡”成立;代价是这段即时编译要花时间,落在首次加载或首次启动的路径上。cubin 走绑定,它是一代卡的机器码,只能在同一架构家族内向上使用,跨代就无效。

fatbin 里为什么要塞多个 sm_XX,答案就在上面两条的反差里。只带 cubin 启动最快,但覆盖范围被钉死在编译时选定的那几代,换一代就要重编。只带 PTX 覆盖面最广,但每次到一台没有匹配 cubin 的机器都要 JIT 一次,启动慢。两个都带是主流做法,用 cubin 覆盖已知的那几代,用 PTX 兜住未来的那几代。这个组合有成本,产物体积随架构数量近似线性增长,编译时间同样上涨。

谁能跑谁:驱动与 CUDA 的方向表

兼容性问题里最难记住的是方向。“新驱动 + 老应用”和“老驱动 + 新应用”是两件完全不同的事,混着记一定会出错。表 C-10 按方向列清楚,并给每行的依据。表中不写具体版本号,因为这套规则的版本门槛时效性很强,实际以当期官方兼容性矩阵为准。

表 C-10 谁能跑谁:驱动、runtime 与编译产物的方向表

场景
能否
条件与代价
新驱动跑旧版本 CUDA runtime 的应用
能
驱动向后兼容已发布的 CUDA 版本,这是升级驱动通常安全的原因;享受不到的是应用没写过的新能力
旧驱动跑跨大版本的新 CUDA runtime 应用
不能
跨大版本需要驱动同步升级;这类失败通常发生在启动阶段,日志指向 CUDA 初始化
旧驱动跑同大版本内更新小版本的应用
能(有条件)
这就是常说的 minor version compatibility:同一大版本内,较新的 runtime 可以在只支持该大版本早期版本的驱动上运行,前提是应用没有用到驱动尚未支持的接口
带 cubin 的旧产物跑新架构的卡
不能
cubin 与算力代号绑定,跨代无效;这也是“在老机器上编好拷到新机器”最常见的失败点
带 PTX 的旧产物跑新架构的卡
能(有条件)
由驱动在加载时 JIT,条件是驱动认得这份 PTX;代价是启动变慢
新架构编译的产物跑旧卡
不能
更高算力代号的内核无法在低代号设备上执行,形如找不到可执行内核的报错
为旧卡编译的库跑在新卡上
能但慢
没有该代的内核路径,能正确执行却吃不到新特性;这是“换卡没提速”最常见的解释

这张表最实用的一行是第三行。它解释了为什么“镜像里 CUDA 版本比宿主机驱动标称的版本新一点”有时能跑、有时不能。同大版本内可能只是小版本差,跨大版本就必然失败。也因此,把镜像里的 runtime 一路升到大版本最新是安全的,跨大版本升则必须同步升驱动。这条判断把很多“要不要升驱动”的争论变成了一个方向问题。

C.5 血统的三次实战:读稿、排障与迁移

实战一:读稿,判断一个能力在你的卡上能不能用

读官方性能博客时,一句“我们在 Hopper 上把某算子提速 X 倍”通常包含三层信息:基线是什么版本、什么卡;优化用了哪条血统路径,FP8、TMA 还是新指令;对旧卡是否回退。读完要能回答“这个提升我能复现的前提是什么”。绝大多数情况下前提是同代卡加新库,不是同一份代码。六问的完整版见表 C-11。

表 C-11 读稿六问:问什么、为什么要问、在哪儿查

要问的
为什么要问
在你的环境里怎么查
1 基线是什么:哪张卡、哪版库、什么精度口径?
倍数全靠分母,基线换一代结论就可能反过来
找发布说明的基线小节与图表脚注;找不到就把它降级为“不可复现”
2 用到了哪条血统能力?
决定这条优化在你的卡上能不能成立
对照表 C-7 与卷 A 档案卡,把“某代优化”翻译成具体能力
3 对没有该能力的卡是回退还是报错?
决定移植成本是“慢一点”还是“跑不了”
看文档里的支持范围;文档没写就跑一个最小用例
4 端到端还是单算子?
决定这点收益能不能传导到你的作业总时长
看图表标题与 benchmark 描述;只有单算子数据时按 1.1 章的模型折算
5 精度与正确性怎么验的?
低比特路线的收益和风险是同一个开关
找质量评估小节(4.2 的质量门禁),没有就自己补
6 版本边界写清了吗?
决定三个月后还能不能复现同一组数字
记进能力台账(C.8.2),把版本连同结论一起存

拿一句常见的话走一遍流程:“某库新版本为 Blackwell 优化,注意力算子提速若干倍”。第 2 问问出的是,它用了这代的低比特档位或新的搬运机制。第 3 问去看文档的支持范围,通常能看到“需要某代及以上的硬件”这类限定。第 4 问发现图是单算子、固定形状,于是把它换算成端到端预期时要打折。第 6 问把库版本抄进台账。四问做完,这句话从宣传语变成一条可验证的假设,剩下的就是跑一次最小用例。读稿的价值不在记住那个倍数,而在知道它成立的条件。

实战二:排障,一条报错链的逐层排除

同算子在新卡上反而慢,典型原因有三个:代码走了旧路径,没吃到新指令或新库分支;库版本太旧,没有该代优化;新卡为了省电和带宽,在某种负载下降频。排障顺序是先确认编译目标与库版本,再确认算子是否进入新路径,用 profiler 看内核名,最后才怀疑硬件本身。

比“慢”更难的是报错连着一串。真实场景里,一条报错常常引出另一条,每修一层又冒出下一层,人容易在第三层失去耐心、开始靠猜。表 C-12 把常见的六种症状按“先查哪一层”排好序。按症状归层比按报错文字搜索高效得多,同一句报错在不同层有不同含义。表中报错一律只给形态并标【示例】,实际措辞随版本变化,不要拿它做精确匹配。

表 C-12 报错链的逐层排除:症状 → 先查哪一层 → 查什么 → 常见结论

症状(形态【示例】)
先查哪一层
查什么
常见结论与动作
容器内启动即失败,日志指向 CUDA 初始化
驱动 vs 镜像
宿主机驱动支持的 CUDA 上限,对比镜像里的 runtime 大版本
跨大版本,换宿主机或把镜像降到同一大版本;同大版本内的小版本差通常可容忍(表 C-10)
跑到第一个自定义 kernel 时报“找不到可执行内核”一类错误
编译产物
产物里包含哪些 arch;卡的算力代号;是否带了 PTX
arch 写高或漏代:改 arch 列表,或补 PTX 兜底
某个算子报“不可用/不支持”
框架与库版本
框架版本、库版本、该算子在本代硬件上的支持表
库旧或该代无实现:升级库,或换等价算子,或回退到上游实现
能跑但数值异常(溢出、NaN、loss 跳变)
代码与精度开关
精度档位开关、缩放策略、混合精度的最小可复现脚本
档位或缩放不对: 不要先怀疑硬件
能跑但不慢反快、或慢得没道理
库路径
profiler 里的内核名 + 库版本 + 编译 arch
落到了上一代内核:升级库、打开开关、修正 arch(三层支持缺一层)
同一镜像在多台机器上表现不同
环境漂移
版本四元组(驱动/CUDA/框架/算力代号)
四元组不一致

表里有一行要单独说。“找不到可执行内核”这类错误的根因几乎总在编译产物,跟驱动版本无关,它说的是这份二进制里没有能在这块卡上跑的机器码。很多人第一反应是升级驱动,试过一轮才发现问题在 arch。反过来,“启动即失败、指向 CUDA 初始化”那一行根因几乎总在驱动或镜像大版本,与 arch 无关。两行症状相近、根因分布完全不同,这正是需要按层归位的原因。

实战三:迁移,A100 → H100 的四组检查清单

把训练栈迁到国产卡或 AMD 时,用血统视角可以把代码分三类。

  1. 纯高层,也就是 PyTorch 标准算子。它依赖的是库层血统,只要目标平台提供等价算子库、含其兼容层,迁移成本通常可控。
  2. 用了框架层优化,比如 torch.compile 的 Triton 内核。迁移成本取决于目标平台的编译器与算子集覆盖。
  3. 手写 CUDA/NCCL 特性。这一类成本最高,需要目标平台的指令级等价物或重写。

评估时真正该问的是“我用了血统链的哪几环、目标平台把这几环实现到什么程度”。这也解释了互操作层、CUDA 兼容层为什么会成为国产生态的争论焦点:真正的分歧在于第三环能不能被兼容实现。

同一套方法用在“同厂商换下一代”上更常见,也更值得做成清单。A100 到 H100 的迁移看着只是换卡,实际要动的是构建参数、库版本、数值精度与性能预期四组东西,见表 C-13。四组按顺序过,前一组不通过就不要急着看后一组,否则性能数据会被前一组的问题污染。

表 C-13 A100 → H100 迁移检查清单(四组)

组
检查项
判据
不通过时做什么
构建参数
arch 列表是否包含新代;是否保留了旧代或 PTX 兜底;自定义 kernel 是否重新编译
产物里能看到新代 arch;旧卡仍可运行(同池混跑时)
按 C.6.2 的写法补 arch,别只编新高代
库版本
CUDA runtime、cuDNN、cuBLAS、NCCL 是否都到了含新代路径的版本
torch.version.cuda
 与 torch.backends.cudnn.version() 与镜像基线一致;官方矩阵显示支持新代
整栈一起升,避免只升一半造成的“混合态”
数值精度
是否启用新档位(如 FP8);缩放策略是否配置;混合精度的回退是否仍生效
与 A100 基线比对 loss 曲线与关键指标,差异在既定阈值内
先关掉新档位跑一遍确认功能,再逐层开启并记录差异
性能回归
代表负载的步时间与吞吐;profiler 内核名是否落到新代路径;显存与通信指标
与基线对比有可解释的差异(档位差、带宽差),且无异常退化
按表 C-12 归层:先看内核名,再看是否降频

四组里最容易被压缩掉的是第二组。团队常常只把镜像 tag 一换就当升级完成,结果 runtime 升了、cuDNN 还是旧的,或者反过来。库之间的版本是成套的,框架的 wheel 绑定了一套 CUDA 与 cuDNN,单独替换其中一个,会做出一个既不在上游测试矩阵里、也没被自己验证过的组合。迁移时把整栈一起动、版本成套记录当纪律,比事后一个个追查便宜得多。

C.6 把血统写进构建:编译目标、二进制兼容与镜像基线

血统最咬人的地方不在手册里,而在构建与部署的每一个环节。有三个天天见、又最容易被归为玄学报错的点。

三个天天见的点:编译目标、二进制兼容与镜像

第一,编译目标,也就是 compute capability 与 arch 标志,决定代码里允许出现什么。编译时指定的算力代号越低,编译器越不敢用新指令;代号越高,跑在旧卡上就越可能直接报“no kernel image available”。实践中常见两种错,一是只按服务器型号猜 arch,二是用最低公共分母编译,导致在新卡上吃不到新指令。正确的做法是把 arch 与 C.3 的能力清单对着看,训练脚本固定 arch,推理打包再按目标卡放宽。

第二,二进制兼容不等于装好就能跑。SASS 二进制只对本代或向后兼容;驱动与 CUDA runtime 有各自的最低版本要求,cuDNN/cuBLAS 又各自带着编译它的那版 CUDA。所以“我在 A 机编译、拷到 B 机跑”至少要查三件事:驱动是否 ≥ 二进制所需、CUDA 版本是否覆盖库的要求、库文件路径是否进得了加载器。这三点就是 C.7 版本矩阵的最小雏形。

第三,容器镜像只冻结用户态,冻不住驱动。镜像里带的 CUDA runtime 与库可以精确复现,但底层驱动来自宿主机。所以“同一个镜像换个机器报错”多数时候是宿主驱动或架构不符,镜像本身没坏。基线化的做法是把宿主机驱动版本、容器 CUDA 版本与框架版本三者作为一个整体记录,升级时三者同动,C.8 的跟踪表就是记录这个三元组。

编译目标怎么填:arch 参数、环境变量与产物账

nvcc 里有两个容易混的概念。虚拟架构 compute_XX 决定生成哪一版 PTX,真实架构 sm_XX 决定生成哪一代的机器码。-arch=sm_90 这种写法只产出该代的机器码;要同时保留 PTX 兜底,就得用 -gencode 分别写清两边。多写几组 -gencode 就是多架构产物,其中 code=compute_XX 的那一组只留 PTX、不留机器码。

# 只给一代卡编机器码:启动最快,换一代就没法用
$ nvcc -arch=sm_90 -o app app.cu

# 虚拟架构与真实架构分开写:cubin 管现在,PTX 管以后
$ nvcc -gencode arch=compute_90,code=sm_90 \
       -gencode arch=compute_80,code=sm_80 \
       -gencode arch=compute_90,code=compute_90 \
       -o app app.cu

# 检查产物里到底打包了哪些架构(先确认,再谈兼容)
$ cuobjdump --list-elf app        # 列出 cubin
$ cuobjdump --list-ptx app        # 列出 PTX

Python 侧的自定义扩展走另一条入口,但决定的是同一件事。构建脚本读环境变量 TORCH_CUDA_ARCH_LIST 来决定生成哪些架构的代码;不设的时候,构建系统可能按当前可见的卡推断,于是同一份代码在不同机器上编出不同产物,环境漂移就是这样被“编”出来的。把 arch 显式写进构建配置,等于把血统固定下来。

# 环境变量:写算力代号,"+PTX" 表示额外保留该虚拟架构的 PTX
$ export TORCH_CUDA_ARCH_LIST="8.0;9.0+PTX"
$ python3 setup.py build_ext --inplace

# setup.py 片段:把 arch 写进构建,别让它随当前可见的卡漂移
import os
from torch.utils.cpp_extension import CUDAExtension, BuildExtension
os.environ.setdefault("TORCH_CUDA_ARCH_LIST", "8.0;9.0+PTX")
ext = CUDAExtension("myop", ["myop.cu"], extra_compile_args={"nvcc": ["-O3"]})

为什么不要只编一个架构,答案分三种场景。单一机型的离线推理可以只编一个,产物最小、启动最快,代价是换卡就要重编。训练镜像通常要覆盖在役的两三代卡,只编当前代,旧卡直接跑不了。要长期复现的镜像还得再加 PTX 兜底,否则三个月后新卡进集群,老镜像会以“加载失败”的方式退役。三种场景的取舍可以量化,见表 C-14。

表 C-14 arch 策略三档取舍

策略
产物覆盖谁
体积与编译时间
适合谁
主要风险
只编一个 arch
只有那一代卡
最小
单一机型的离线推理、专用设备
换卡即失效,且失败发生在启动路径上
编多个 arch(覆盖在役代际)
已知的每一代卡
随架构数近似线性上涨
训练镜像、混合卡池
产物体积与编译时间;忘掉某一代时症状隐蔽
多 arch + PTX 兜底
在役代际 + 未来代际
再多一小块(PTX 比机器码小)
需要长期复现的镜像、对外发布的库
新卡首次启动走 JIT;位级一致性要另验

体积这笔账值得用数字看一眼。算术推导:设扩展里有 K 个 kernel,每个 kernel 在每个 arch 上的机器码约 c KB,每份 PTX 约 p KB,单个 arch 的编译时间 t 秒,那么编 n 个 arch 的产物体积约 K·n·c、编译时间约 n·t。代入一组示例值复算。

$ python3 c_arch.py
arch 数 1: cubin   2.8 MB | 再加 PTX 共   3.5 MB | 编译约  45 s
arch 数 2: cubin   5.6 MB | 再加 PTX 共   6.3 MB | 编译约  90 s
arch 数 3: cubin   8.4 MB | 再加 PTX 共   9.1 MB | 编译约 135 s
arch 数 4: cubin  11.2 MB | 再加 PTX 共  12.0 MB | 编译约 180 s
每多编一个 arch:体积 +2.8 MB,编译时间 +45 s,PTX 兜底固定 +0.7 MB

这些输入都是示例值,K=120、c=24 KB、p=6 KB、t=45 s。结论的形状比数字重要:体积与编译时间随架构数线性增长,PTX 兜底几乎不花钱。这解释了成熟项目的默认做法,机器码覆盖已知代际,PTX 补一份兜底,不必每加一张卡就重新烫一遍。也解释了为什么“为了保险把所有架构都编上”是个坏主意,翻倍的编译时间会切碎迭代节奏,而真正需要的往往只有两三代。

镜像基线与依赖锁定:能冻住的与冻不住的

镜像 tag 的形状是 <CUDA 版本>-<变体>-<发行版>,变体一般分 base、runtime、devel 三档。base 只有最基础的部分,runtime 带 CUDA runtime 与库,devel 再加编译器与头文件。构建期需要 nvcc 就用 devel,运行期只需要 runtime 与库就用 runtime。把编译工具链留在生产镜像里纯粹是浪费体积与攻击面。

# 构建基线示意:tag 的三段分别是 CUDA 版本、变体、发行版
# 具体版本号见官方镜像页的当期 tag 列表 ⚠️【待核对】
FROM nvidia/cuda:<CUDA版本>-runtime-ubuntu<发行版代号>

# 把 arch 与库路径写进镜像,别让它们随宿主机漂移
ENV TORCH_CUDA_ARCH_LIST="8.0;9.0+PTX" \
    PIP_NO_CACHE_DIR=1 \
    LD_LIBRARY_PATH=/usr/local/cuda/lib64:${LD_LIBRARY_PATH}

RUN python3 -m pip install --no-cache-dir -r /opt/lock/requirements.txt

依赖锁定是另一半工程。锁定的关键在于钉的版本要能在同一镜像基线上解出来。基础镜像的 CUDA 大版本决定了 wheel 该选哪个后缀,钉错了会在构建期失败,或者更糟,装上一个能跑但与基线不同源的 torch。cuDNN 这类随框架包进来的组件不要在镜像里单独钉一个版本去覆盖它,那会做出一个既不在上游测试矩阵里、也没被自己验证过的组合,见 C.2.3。

# requirements.txt 片段:三段版本全钉,且与基础镜像同源
torch==<主.次.修订>+cu<CUDA大版本>
# 不要在这里单独钉 cuDNN 的 wheel:它随 torch 的构建绑定
# 需要 CUDA 侧工具时,靠基础镜像的 devel/runtime 变体提供,而不是 pip

镜像能冻住什么、冻不住什么,要分清。能冻住的有 CUDA runtime 与算子库、框架与 wheel、编译选项、arch 列表、依赖版本。冻不住的有宿主机驱动、GPU 型号与算力代号、拓扑与互连、MIG 配置、时钟与散热。同镜像不同表现,永远来自后半张清单。这条判断能把一大类争论从“镜像坏了”拉回到正确方向。

报错之前先收集四元组

构建期的血统问题,有一半能靠一条命令查清。在写任何“为什么跑不起来”的 issue 之前,先收集四样东西:当前驱动的 CUDA 版本,看 nvidia-smi 上半部分;编译器与工具链版本;运行时库版本,也就是框架打印的版本号;目标卡的算力代号。这四样拼起来就是 C.7 版本矩阵的最小切片。绝大多数“换个机器就挂”的案例,在把这四样摆到一起的瞬间就露出答案,不是 arch 太低,就是库版本对不上。养成报错先附四元组的习惯,自己排查快,求助时别人也能一眼定位,省掉来回追问的三轮对话。

再补两个容易在构建期踩的隐形雷。其一是“能编译 ≠ 能跑”。编译器常按最低公共分母生成可移植代码,或者带 PTX 做即时编译,所以编译成功只说明语法与类型没问题,不代表目标卡上有对应的内核实现。真正的验证是在目标卡上跑一次最小用例。其二是“容器里能跑 ≠ 换台宿主机也能跑”。容器冻结了用户态,冻不住驱动,宿主机驱动版本低于镜像要求时,容器内应用照样启动失败,C.6 前文的三元组提醒在这里再次生效。把这两个“不等于”写进团队的构建检查清单,能省掉大量线上才发现的环境问题。

C.7 血统在组织里显形:版本矩阵、构建基线与“环境漂移”

血统不只是单机知识,它在团队协作里表现为环境漂移。同一份代码在 A 的机器上 3 倍速、在 B 的机器上报错,多数时候是编译目标 × 驱动 × 库版本 × 容器镜像这条链不一致,代码本身倒没问题。有三件事把漂移管住。

版本矩阵:谁维护、按什么粒度

  • 维护一张版本矩阵,用表格记录某特性、某库版本需要的最小驱动与算力,比如 FP8 训练需 CC≥9.0 加某版 CUDA/cuDNN。排查报错时先查矩阵,翻论坛靠不住。
  • 构建基线,也就是 golden environment。为每个项目锁定一组驱动、CUDA、库与框架版本,并纳入 CI 镜像,让“我机器上能跑”变成“基线上能复现”。
  • 把环境当一等配置。日志与实验记录里记下环境指纹,包括 nvidia-smi、库版本与编译目标,否则性能对比与排障都会失真。

矩阵要能长期用,得先定清楚两件事,记什么粒度、谁改它。粒度上,按能力行记比按版本行记耐用得多。一行写“FP8 训练可用”,条件列写“算力代号 ≥ 该代 + 库版本 ≥ 支持该能力的那一版 + 框架开关可配置”,这样升级时只需改条件,不用重画整张表。按版本行记,例如“镜像 A 配驱动 B”,会随每次升级失效,三个月就没人看了。能力行的骨架见表 C-15。责任人上,每一行都必须有一个 owner,平台行归平台,框架行归技术栈 owner,没有 owner 的行等于没有这一行。

表 C-15 版本矩阵的骨架:能力行怎么写(本书整理;示例行为格式演示,“最小条件”一列刻意不写版本号,取值以当期官方兼容性矩阵为准)

能力
最小条件(硬件 / 驱动 / 库)
怎么验
owner
收口动作
低比特训练(FP8)
算力代号 ≥ 该代引入值;库版本支持该档位;框架开关可配置
最小形状 GEMM + 一层前反向,比对 loss 曲线
技术栈 owner
开关默认值写进基线配置,回归里加一条数值检查
大容量档位(长上下文)
卡型进入容量档;调度器能识别该容量
跑一遍目标长度的前反向,看显存占用与 OOM 边界
平台
容量口径写进调度器文档,配额按新口径重算
更宽的互连域
整机形态 + 驱动 + 通信库识别该拓扑
拓扑查询 + 一次多卡 all-reduce 带宽实测
平台
并行度上限写进作业模板,避免默认 8 卡假设
实例隔离(MIG)
卡型支持 + 驱动可切分 + 调度器可声明
切分后分别在实例里跑一个最小作业
平台
配额单位从“卡”改成“实例”,同步改账单口径

漂移来源、发现方式与止损动作

环境漂移这个词值得一个具体剧本,否则容易只停留在概念上。设想一个真实场景:同事 A 在 H100 上用 PyTorch 2.x + CUDA 12.x 训练,一切正常,每步 0.3 秒;同事 B 在 A100 上拉同一个仓库,报“no kernel image available”,或者某算子慢 3 倍。逐项排查会发现三种可能:代码里某处用了只为 Hopper 编译的内核,arch 指定过高;pip 装的库轮子按高算力下限编译;A 的容器里 CUDA 版本高于 B 的驱动支持。三者都属于同一份代码在不同环境表现不同,也就是环境漂移,本质是“编译目标 × 驱动 × 库 × 硬件”四元组不一致,写成代码 bug 是找错了方向。

治理环境漂移的成熟做法是“环境即代码”,把驱动版本、CUDA 版本、框架版本、关键库版本、编译参数全部写进可复现的配置,也就是容器镜像、锁文件加文档,让在任意机器上重建出同一环境成为可能。团队里常见的一半“灵异问题”,在环境可复现之后都会变成可复现、可定位、可修复的普通问题。最低要求是做到两件事,能说出自己当前环境的四元组,以及升级任何一元之前先看其余三元的兼容范围。做到这两点,你已经比多数踩坑的人领先一大截。

漂移可以拆成可处理的行,见表 C-16。每一行都要求写清发现方式,因为漂移问题最大的特点是没人主动报错,你先怀疑才会去看。不做定期对照,它会以“某个同事的机器慢一点”的形式存在半年,然后在新一轮扩容里集中爆发。

表 C-16 漂移来源 → 发现方式 → 止损动作

漂移来源
发现方式
止损动作
同一镜像落到不同驱动版本的宿主机
把镜像 tag 与 nvidia-smi 的驱动版本成对采集,按对分组比较性能
把宿主机驱动纳入平台基线,按批次升级;对旧驱动机器打标签,不让新镜像落上去
同驱动、不同库版本(有人 pip 装过东西)
启动时打印版本四元组并留档;镜像里禁止交互式安装
镜像不可变:任何新增依赖走重新构建;用锁文件固定版本
编译 arch 随构建机器变化
把 TORCH_CUDA_ARCH_LIST 与产物里 cuobjdump 的结果写进构建日志
arch 显式写进构建配置(C.6.2),构建机不按当前卡推断
卡型混池(同集群多代卡)
调度器按卡型标签分组统计作业的步时间与吞吐
作业声明最低代际要求;关键作业钉机型,不让调度随机落到旧卡
MIG 或共享配置被改动
定期采集切分后的实例与配额,与台账比对
配置纳入变更管理;配额与账单口径同步更新
时钟、功耗与散热差异(同型号不同机位)
同一作业在两台机器上对比时长与实际功耗;看降频迹象(卷 B.7)
把机位与风道纳入排障表;对热点机位做专项巡检

灰度与回滚:让漂移的爆炸半径可控

矩阵、基线、漂移表解决看得见的问题,灰度与回滚解决改坏了怎么办。升级驱动、换镜像、加一个新档位,都是对整条血统链的改动,一次性推全量等于把风险集中到一个时间点。可行的做法是把改动分级。先在单机跑最小用例,验证四元组是否成套;再进一个小池子跑真实作业,验证性能与数值;最后才推到主力池。每一级都留一个明确的判据,跑通、目标准确率不掉、步时间不退化,判据不过就停在这一级。

回滚的关键在于回滚点本身要被记录成一个可复现的坐标,而“当时那台机器”不算。可复现的回滚点至少包含镜像 tag、宿主机驱动、卡型与 arch 配置四项;缺任何一项,回滚就会变成“回退到某个大概能跑的版本”。实践里最常见的失误是在升级时顺手改了驱动和镜像两件事,出问题后无法判断是哪一件导致的。所以,一次只动一元,这条纪律比任何排障技巧都省时间。

C.8 如何持续跟踪血统,不跟着厂商稿走

读发布说明与兼容性矩阵的顺序

血统会随每代硬件与每个大版本更新。保持跟踪不需要刷新闻,固定做四件事。

  1. 订阅官方 Release Notes,CUDA、框架、库各一个,只读“新增支持/算力要求/行为变更”三节。
  2. 每代架构发布后看架构博客与白皮书,用卷 A.8 的三元组与速率方法消化。
  3. 每季度用一张目标集群的驱动、库与算力快照,对照你的项目基线找漂移。
  4. 遇到新特性为何慢、为何报错时,先做血统三查,也就是算力、驱动、库路径,再谈其它。

读发布说明有固定顺序,照着走能省掉大量跳读。先看“行为变更”一节,这一节写的是“以前能跑、现在行为变了”的事,风险最高,也是血统链最容易断的地方。再看“新增支持”,哪一代卡、哪个档位、哪个算子被纳入,这一节决定了你能不能吃到新能力。最后看“已知问题”,多数团队跳过这一节,而它往往直接写着“某代卡上某算子回退到通用实现”这类话,那正是“能跑但慢”的原因所在。三段读完,一篇发布说明的有效信息就取完了,其余部分可以略过。

能力台账:字段清单

跟踪的落点是一张自己的台账。它要提供的功能很窄,就是让“这条能力在我这儿到底能不能用、什么时候验过、谁验的”有一个唯一的答案来源,别让这个问题每次都从头查。字段不必多,但每一列都得能填出具体值,见表 C-17。

表 C-17 能力台账的字段清单

字段
填什么
为什么必须有这一列
能力名
用官方名称(如低比特档位、异步搬运、实例隔离),不用营销词
营销词会随代际改口径,官方名称稳定,便于跨代对照
起始代际
这条能力从哪一代硬件开始(对应表 C-6)
判断“我的卡有没有”的第一道门槛
依赖的软件层
需要哪些库/框架版本以上(不写具体版本号,写“支持该能力的版本”)
把“硬件有”与“库用上”分开记,避免纸面支持
在代码里怎么开
开关名、环境变量、编译参数
台账不落到“怎么开”,就只是科普读物
验证方式
最小用例是什么、看哪个指标
让第二个人能复现你的结论
已验证环境
版本四元组 + 时间
结论有保质期,四元组是保质期的刻度
owner 与复审时间
谁负责、下次什么时候回看
没有复审时间的台账,一年后会变成误导
结论一句话
“可用 / 可用但无收益 / 不可用 + 原因所在层”
让后来者三秒钟拿到答案,不必重读全部证据

每周十分钟的例行动作

跟踪血统的四件事,可以落成每周十分钟的例行动作。周一花十分钟做三件事即可。一是扫一眼 Release Notes 的标题级变更,只读“新增支持/算力要求/行为变更”三节,其余跳过。二是记一条“本周我在用的版本三元组”,也就是驱动、CUDA、框架。三是留意一条“与我相关的新能力”,例如某库新增了对某代卡的优化路径,若正是你在用的卡与算子,就记到“待验证”清单。十分钟的例行动作,长期积累下来,就是一份比任何新闻订阅都贴合你环境的血统档案。

为什么值得坚持?血统知识的保质期是按代际计的。一条 FP8 路径的经验在 Blackwell 代内都有效,跨代就要重新验证。只在要选型、要排障时才临时补课,往往来不及,现场问题不等人,现查文档的代价远高于平时积累。坚持跟踪还有个隐性收益,你会慢慢形成对“哪些新特性是真变化、哪些只是宣传”的判断力。看得多了,厂商稿里的“换代”与“换皮”在你眼里会迅速分层。

事实、口径、路线图三分:读一条消息时先分类

避免被牵着走的技术手段只有一个,读到任何一条消息,先把它归到三类中的一类。事实是已经能查证的东西,比如某代硬件具备某能力、某库提供某接口。口径是同一件事的不同量法,稀疏还是稠密、单卡还是机柜、峰值还是实测,数字本身不错,错的是拿它跟另一口径比。路线图是官方说将来会有的东西,写着可能变动的注脚。三类混读的后果不同,把口径当事实,会得出错误的横向对比;把路线图当事实,会把方案建在还没发生的事上。

C.9 卷 C 小结与自测

四句话与一张表带走

本卷要带走四句话。

  1. 硬件世代的能力会“遗传”成软件接口与习惯,这条血统链是理解文档、排障与迁移的主线。
  2. 读“XX-optimized”这类说法,要读出它用了该代的哪项能力。
  3. “能跑”与“跑得快”之间隔着一个库版本与编译目标。
  4. 迁移评估 = 盘点自己用了血统链哪几环。

四句话摊开的骨架是表 C-18。

表 C-18 卷 C 一张表带走

小节
一句话结论
什么时候会用到它
C.1 血统与判据
能力沿“算力代号 → 驱动 → 工具链 → 库 → 框架 → 代码”单向继承,四问判据比经验可靠
有人问“这张卡能不能用某能力”时,先走四问
C.2 软件栈逐层
每层都有可查的版本载体与责任人,PTX/cubin/fatbin 三者分工不同
要把现场描述清楚、或判断问题该找谁时
C.3 世代功能绑定
能力清单用来做门槛判断,不用来做性能预测
读一篇新发布稿、规划一条新优化路线时
C.4 兼容性三真相
源码、二进制、性能是三层嵌套的边界,只有前两层会报错
产物换机器、镜像换宿主机、升级驱动之前
C.5 三次实战
读稿三问(能力/路径/环境)、排障按症状归层、迁移按四组清单
评审方案、排查报错链、做跨代迁移时
C.6 写进构建
arch 显式写进构建,机器码覆盖在役代际,PTX 只做兜底
新建镜像基线、编译自定义算子时
C.7 组织与漂移
矩阵按能力行记、每行有 owner;一次只动一元,灰度分级推进
升级驱动/镜像、出现“同镜像不同表现”时
C.8 持续跟踪
发布说明按“行为变更→新增支持→已知问题”读,台账每行都要能落到动作
每周十分钟的例行跟踪、写方案引用数据时

C.10 术语增量登记

下表只收本卷正文确实用到、术语表中尚未成条或需补口径的词。并入时与已有条目互检,包括 NCCL、MIG、FP8、NVFP4、SASS 与 PTX、线程块簇与分布式共享内存,避免重复定义。

表 C-19 卷 C 术语增量登记

中文(English)
口径要点
软硬件血统(software-hardware pedigree)
硬件世代能力 → 驱动 → 工具链/运行时 → 库 → 框架 → 代码的继承链;本卷主线
算力代号(compute capability)
GPU 架构代号,编译参数写作 sm_XX;引用任何规格或能力都必须带它
驱动 API 与运行时 API(driver API / runtime API)
cu*
 前缀直接对话驱动,cuda* 前缀在驱动 API 上包一层管上下文与内存
PTX
虚拟指令集,不绑代际,可由更新的驱动在运行时 JIT;与 SASS 条配对使用
cubin(CUDA binary)
某一代 GPU 的机器码,与算力代号绑定,只在同一家族内向上可用
fatbin(fat binary)
把多份 cubin 与/或 PTX 打包进宿主二进制的容器
NVRTC(运行时编译)
把 CUDA C++ 源码在运行期编成 PTX,用于按形状即时生成内核或不带 nvcc 的环境
arch 列表(arch list)
TORCH_CUDA_ARCH_LIST
 控制的编译目标集合,只在源码编译时生效
前向兼容与后向兼容(forward / backward compatibility)
旧代码跑新硬件、新代码跑旧硬件,方向相反,排查时必须先分清方向
小版本兼容(minor version compatibility)
同一大版本内,较新的 CUDA runtime 可在只支持该大版本早期版本的驱动上运行,前提是未用到新接口
三层支持(hardware / driver / library)
硬件有、驱动开、库用上;实际性能取决于最弱的一层
环境漂移(environment drift)
编译目标 × 驱动 × 库版本 × 镜像不一致,导致同一份代码在不同机器上表现不同
版本矩阵(version matrix)
按“能力行”记录最小条件、验证方式与 owner 的表,区别于按版本行记录的清单
能力台账(capability ledger)
记录能力名、起始代际、依赖软件层、开启方式、验证环境与 owner 的字段清单
灰度与回滚点(canary / rollback point)
改动分级推进;回滚点须是可复现坐标(镜像 tag、驱动、卡型、arch 配置)

相关学习资料