ARTICLE · 1243684
《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 训练与量化推理。
为什么必须懂,有四个理由。
定位问题不靠猜。报错和性能问题常源于代码假设的架构能力与实际运行的硬件、库版本不匹配,比如某内核要求算力 9.0 的 FP8 指令,跑在 8.0 卡上会报错或回退。懂血统的人先查谁在哪个世代引入这项能力,再查运行环境,范围立刻缩小。 读文档能抓住要点。官方发布稿和 Release Notes 里大量出现“某库新版本 is optimized for Hopper/Blackwell”这类句式。它的意思是这条代码路径用了该代专属的硬件特性,比如 TMA、FP8 或新指令,并非说别的卡不能用。 性能预期才成立。同一算子在不同代 GPU 上的快慢差异,往往来自有没有为该代写过专门的内核路径,以及手上的库版本是否包含这条路径。不懂血统,就会把库优化差异误当成硬件算力差异。 迁移与选型才安全。把代码迁到 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 | ||
nvidia-smi | ||
nvcc --versiontorch.version.cuda、镜像 tag 里的 CUDA 段 | ||
torch.backends.cudnn.version(),其余看发行说明与包元数据) | ||
+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。
硬件这一环有没有。这块卡属于哪一代,X 是哪一代引入的能力。 驱动这一环开不开。宿主机驱动支持的 CUDA 版本上限,够不够跑用到 X 的那版 runtime。 库这一环有没有路径。你手上这版 cuDNN/cuBLAS/NCCL 与框架里,有没有面向这代卡的 X 实现。 代码这一环走不走。精度开关、编译 arch、后端选择有没有把这条路打开。
表 C-2 能力可用性判据:四个问题、查什么、答“否”时的结论
torch.cuda.get_device_capability()nvidia-smi --query-gpu=name 对照官方算力表;编译产物里的 arch | ||
nvidia-smi | ||
torch.backends.cudnn.version() 一类字段 | ||
torch.cuda.get_arch_list()、profiler 里的内核名 | ||
C.2 软件栈逐层:每一层在血统链里的位置
六层的职责、版本与责任人
表 C-3 六层软件栈的角色与血统印记
看这六层时,用“谁在替谁做决定”的眼光比背下六个层名更有用。最上层是你的训练脚本或推理服务,只做策略决定:模型结构、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 三者对照
nvcccompute_XX)编译时产生 | ||||
ptxassm_XX)汇编产生 | ||||
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 层 → 代表组件 → 版本从哪看 → 典型症状 → 谁负责
nvidia-smitorch.cuda.get_device_capability() | ||||
nvidia-smi | ||||
nvccptxas、NVRTC、CUDA runtime | nvcc --versiontorch.version.cuda;镜像 tag | |||
torch.backends.cudnn.version())+ 官方支持矩阵 | ||||
torch.cuda.get_arch_list() | ||||
C.3 世代功能绑定:一张“什么能力从哪代开始”的清单
公开口径的世代清单与更细的判据表
把血统落成可查的表。算力代号方面,A100≈8.0,H100≈9.0 量级,Blackwell 对应更高代号,精确值以官方“compute capability”表为准。
表 C-6 硬件能力与起始代际(公开口径)(代际口径与卷 A 档案卡一致;算力代号只给量级,精确值以官方 compute capability 表为准)
用法是这样的。在某篇文章看到“Blackwell-optimized”,就用这张表读出它至少用了 FP4、NVLink5、双裸片中的某一项;在旧卡上跑它报错或回退时,也知道去检查哪一项。这张表同时是迁移评估的第一页。
更细的判据表:在代码里怎么判、缺它会怎样
表 C-7 能力 → 起始代际 → 代码里怎么判 → 缺它会怎样 → 是否需硬件与库双支持
torch.backends.cuda.matmul.allow_tf32 一类);cuBLAS 是否走该路径 | ||||
nvidia-smi | ||||
两个条件反射与“三层支持”
把“能力从哪代开始”的清单用活,关键是练成两个条件反射。第一个反射是看到新特性先问哪代硬件起的头: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 “三层支持”的判定动作与判错代价
nvidia-smi 的驱动版本与 CUDA 上限,对照镜像里的 runtime 版本 | |||
世代能力清单要配一条使用纪律,用它做门槛判断,不做性能预测。清单能可靠回答“有没有”类问题,比如我的卡是否具备 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 三种兼容:判据、失败症状与规避做法
PTX 的前向兼容与 cubin 的架构绑定
三种兼容在 CUDA 里各自落在具体的产物上,所以能讲得比一般软件更精确。PTX 走前向兼容,它不绑定具体世代,只要驱动认得这份虚拟指令集,就能把它编译成当前设备的机器码,于是“给老卡编的代码能跑新卡”成立;代价是这段即时编译要花时间,落在首次加载或首次启动的路径上。cubin 走绑定,它是一代卡的机器码,只能在同一架构家族内向上使用,跨代就无效。
fatbin 里为什么要塞多个 sm_XX,答案就在上面两条的反差里。只带 cubin 启动最快,但覆盖范围被钉死在编译时选定的那几代,换一代就要重编。只带 PTX 覆盖面最广,但每次到一台没有匹配 cubin 的机器都要 JIT 一次,启动慢。两个都带是主流做法,用 cubin 覆盖已知的那几代,用 PTX 兜住未来的那几代。这个组合有成本,产物体积随架构数量近似线性增长,编译时间同样上涨。
谁能跑谁:驱动与 CUDA 的方向表
兼容性问题里最难记住的是方向。“新驱动 + 老应用”和“老驱动 + 新应用”是两件完全不同的事,混着记一定会出错。表 C-10 按方向列清楚,并给每行的依据。表中不写具体版本号,因为这套规则的版本门槛时效性很强,实际以当期官方兼容性矩阵为准。
表 C-10 谁能跑谁:驱动、runtime 与编译产物的方向表
这张表最实用的一行是第三行。它解释了为什么“镜像里 CUDA 版本比宿主机驱动标称的版本新一点”有时能跑、有时不能。同大版本内可能只是小版本差,跨大版本就必然失败。也因此,把镜像里的 runtime 一路升到大版本最新是安全的,跨大版本升则必须同步升驱动。这条判断把很多“要不要升驱动”的争论变成了一个方向问题。
C.5 血统的三次实战:读稿、排障与迁移
实战一:读稿,判断一个能力在你的卡上能不能用
读官方性能博客时,一句“我们在 Hopper 上把某算子提速 X 倍”通常包含三层信息:基线是什么版本、什么卡;优化用了哪条血统路径,FP8、TMA 还是新指令;对旧卡是否回退。读完要能回答“这个提升我能复现的前提是什么”。绝大多数情况下前提是同代卡加新库,不是同一份代码。六问的完整版见表 C-11。
表 C-11 读稿六问:问什么、为什么要问、在哪儿查
拿一句常见的话走一遍流程:“某库新版本为 Blackwell 优化,注意力算子提速若干倍”。第 2 问问出的是,它用了这代的低比特档位或新的搬运机制。第 3 问去看文档的支持范围,通常能看到“需要某代及以上的硬件”这类限定。第 4 问发现图是单算子、固定形状,于是把它换算成端到端预期时要打折。第 6 问把库版本抄进台账。四问做完,这句话从宣传语变成一条可验证的假设,剩下的就是跑一次最小用例。读稿的价值不在记住那个倍数,而在知道它成立的条件。
实战二:排障,一条报错链的逐层排除
同算子在新卡上反而慢,典型原因有三个:代码走了旧路径,没吃到新指令或新库分支;库版本太旧,没有该代优化;新卡为了省电和带宽,在某种负载下降频。排障顺序是先确认编译目标与库版本,再确认算子是否进入新路径,用 profiler 看内核名,最后才怀疑硬件本身。
比“慢”更难的是报错连着一串。真实场景里,一条报错常常引出另一条,每修一层又冒出下一层,人容易在第三层失去耐心、开始靠猜。表 C-12 把常见的六种症状按“先查哪一层”排好序。按症状归层比按报错文字搜索高效得多,同一句报错在不同层有不同含义。表中报错一律只给形态并标【示例】,实际措辞随版本变化,不要拿它做精确匹配。
表 C-12 报错链的逐层排除:症状 → 先查哪一层 → 查什么 → 常见结论
表里有一行要单独说。“找不到可执行内核”这类错误的根因几乎总在编译产物,跟驱动版本无关,它说的是这份二进制里没有能在这块卡上跑的机器码。很多人第一反应是升级驱动,试过一轮才发现问题在 arch。反过来,“启动即失败、指向 CUDA 初始化”那一行根因几乎总在驱动或镜像大版本,与 arch 无关。两行症状相近、根因分布完全不同,这正是需要按层归位的原因。
实战三:迁移,A100 → H100 的四组检查清单
把训练栈迁到国产卡或 AMD 时,用血统视角可以把代码分三类。
纯高层,也就是 PyTorch 标准算子。它依赖的是库层血统,只要目标平台提供等价算子库、含其兼容层,迁移成本通常可控。 用了框架层优化,比如 torch.compile 的 Triton 内核。迁移成本取决于目标平台的编译器与算子集覆盖。 手写 CUDA/NCCL 特性。这一类成本最高,需要目标平台的指令级等价物或重写。
评估时真正该问的是“我用了血统链的哪几环、目标平台把这几环实现到什么程度”。这也解释了互操作层、CUDA 兼容层为什么会成为国产生态的争论焦点:真正的分歧在于第三环能不能被兼容实现。
同一套方法用在“同厂商换下一代”上更常见,也更值得做成清单。A100 到 H100 的迁移看着只是换卡,实际要动的是构建参数、库版本、数值精度与性能预期四组东西,见表 C-13。四组按顺序过,前一组不通过就不要急着看后一组,否则性能数据会被前一组的问题污染。
表 C-13 A100 → H100 迁移检查清单(四组)
torch.version.cudatorch.backends.cudnn.version() 与镜像基线一致;官方矩阵显示支持新代 | |||
四组里最容易被压缩掉的是第二组。团队常常只把镜像 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 策略三档取舍
体积这笔账值得用数字看一眼。算术推导:设扩展里有 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 版本矩阵的骨架:能力行怎么写(本书整理;示例行为格式演示,“最小条件”一列刻意不写版本号,取值以当期官方兼容性矩阵为准)
漂移来源、发现方式与止损动作
环境漂移这个词值得一个具体剧本,否则容易只停留在概念上。设想一个真实场景:同事 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 漂移来源 → 发现方式 → 止损动作
nvidia-smi 的驱动版本成对采集,按对分组比较性能 | ||
TORCH_CUDA_ARCH_LIST 与产物里 cuobjdump 的结果写进构建日志 | ||
灰度与回滚:让漂移的爆炸半径可控
矩阵、基线、漂移表解决看得见的问题,灰度与回滚解决改坏了怎么办。升级驱动、换镜像、加一个新档位,都是对整条血统链的改动,一次性推全量等于把风险集中到一个时间点。可行的做法是把改动分级。先在单机跑最小用例,验证四元组是否成套;再进一个小池子跑真实作业,验证性能与数值;最后才推到主力池。每一级都留一个明确的判据,跑通、目标准确率不掉、步时间不退化,判据不过就停在这一级。
回滚的关键在于回滚点本身要被记录成一个可复现的坐标,而“当时那台机器”不算。可复现的回滚点至少包含镜像 tag、宿主机驱动、卡型与 arch 配置四项;缺任何一项,回滚就会变成“回退到某个大概能跑的版本”。实践里最常见的失误是在升级时顺手改了驱动和镜像两件事,出问题后无法判断是哪一件导致的。所以,一次只动一元,这条纪律比任何排障技巧都省时间。
C.8 如何持续跟踪血统,不跟着厂商稿走
读发布说明与兼容性矩阵的顺序
血统会随每代硬件与每个大版本更新。保持跟踪不需要刷新闻,固定做四件事。
订阅官方 Release Notes,CUDA、框架、库各一个,只读“新增支持/算力要求/行为变更”三节。 每代架构发布后看架构博客与白皮书,用卷 A.8 的三元组与速率方法消化。 每季度用一张目标集群的驱动、库与算力快照,对照你的项目基线找漂移。 遇到新特性为何慢、为何报错时,先做血统三查,也就是算力、驱动、库路径,再谈其它。
读发布说明有固定顺序,照着走能省掉大量跳读。先看“行为变更”一节,这一节写的是“以前能跑、现在行为变了”的事,风险最高,也是血统链最容易断的地方。再看“新增支持”,哪一代卡、哪个档位、哪个算子被纳入,这一节决定了你能不能吃到新能力。最后看“已知问题”,多数团队跳过这一节,而它往往直接写着“某代卡上某算子回退到通用实现”这类话,那正是“能跑但慢”的原因所在。三段读完,一篇发布说明的有效信息就取完了,其余部分可以略过。
能力台账:字段清单
跟踪的落点是一张自己的台账。它要提供的功能很窄,就是让“这条能力在我这儿到底能不能用、什么时候验过、谁验的”有一个唯一的答案来源,别让这个问题每次都从头查。字段不必多,但每一列都得能填出具体值,见表 C-17。
表 C-17 能力台账的字段清单
每周十分钟的例行动作
跟踪血统的四件事,可以落成每周十分钟的例行动作。周一花十分钟做三件事即可。一是扫一眼 Release Notes 的标题级变更,只读“新增支持/算力要求/行为变更”三节,其余跳过。二是记一条“本周我在用的版本三元组”,也就是驱动、CUDA、框架。三是留意一条“与我相关的新能力”,例如某库新增了对某代卡的优化路径,若正是你在用的卡与算子,就记到“待验证”清单。十分钟的例行动作,长期积累下来,就是一份比任何新闻订阅都贴合你环境的血统档案。
为什么值得坚持?血统知识的保质期是按代际计的。一条 FP8 路径的经验在 Blackwell 代内都有效,跨代就要重新验证。只在要选型、要排障时才临时补课,往往来不及,现场问题不等人,现查文档的代价远高于平时积累。坚持跟踪还有个隐性收益,你会慢慢形成对“哪些新特性是真变化、哪些只是宣传”的判断力。看得多了,厂商稿里的“换代”与“换皮”在你眼里会迅速分层。
事实、口径、路线图三分:读一条消息时先分类
避免被牵着走的技术手段只有一个,读到任何一条消息,先把它归到三类中的一类。事实是已经能查证的东西,比如某代硬件具备某能力、某库提供某接口。口径是同一件事的不同量法,稀疏还是稠密、单卡还是机柜、峰值还是实测,数字本身不错,错的是拿它跟另一口径比。路线图是官方说将来会有的东西,写着可能变动的注脚。三类混读的后果不同,把口径当事实,会得出错误的横向对比;把路线图当事实,会把方案建在还没发生的事上。
C.9 卷 C 小结与自测
四句话与一张表带走
本卷要带走四句话。
硬件世代的能力会“遗传”成软件接口与习惯,这条血统链是理解文档、排障与迁移的主线。 读“XX-optimized”这类说法,要读出它用了该代的哪项能力。 “能跑”与“跑得快”之间隔着一个库版本与编译目标。 迁移评估 = 盘点自己用了血统链哪几环。
四句话摊开的骨架是表 C-18。
表 C-18 卷 C 一张表带走
C.10 术语增量登记
下表只收本卷正文确实用到、术语表中尚未成条或需补口径的词。并入时与已有条目互检,包括 NCCL、MIG、FP8、NVFP4、SASS 与 PTX、线程块簇与分布式共享内存,避免重复定义。
表 C-19 卷 C 术语增量登记
sm_XX;引用任何规格或能力都必须带它 | |
cu*cuda* 前缀在驱动 API 上包一层管上下文与内存 | |
nvcc 的环境 | |
TORCH_CUDA_ARCH_LIST | |















































































