Skip to content

原文:第 4 章 说明:忠实翻译原网页内容,并补入与经典文献、业界系统的对照。术语首次出现给出英文锚点。

第 4 章 CUDA 编程从架构开始

本章在体系中的位置

第 3 章讲了 GPU 是什么、为什么它是一台为吞吐而生的机器。本章要回答的,是把那些架构知识落成代码时会撞上的问题:kernel 到底是什么、warp 为什么是真实的执行单位、寄存器为什么成了最金贵的资源、共享内存与缓存各管哪一摊。换句话说,第 4 章是第 3 章的自然落点——架构的语言在这里翻译成 CUDA 编程模型的每一个概念。衔接在下一章:第 5 章会用矩阵乘法(GEMM)把这些概念逐个实操——tile 怎么选、共享内存怎么排、占用率怎么调。三章连起来是一条线:第 3 章问「机器为什么长这样」,本章问「你在这台机器上该怎么思考」,第 5 章才轮到「把手放上去改代码」。

一个初学者常常以为 CUDA 编程从语法开始:写一个 kernel,用一个 grid 和一个 block 把它启动,然后让 GPU 自己跑。这个起点是错的。真正的 CUDA 编程开始得更早——早到架构那一层。你得先搞清楚:GPU 是一台什么机器?它的访存层级为什么和 CPU 有本质差别?它的执行模型为什么围绕 warp 而不是单个线程?寄存器为什么成了第一等的优化目标?为什么写高性能 kernel 不是「把代码并行化」,而是在一个资源受限的分层系统内部,同时摆平计算、存储与调度?

本章就沿着这条路径走。它不急着贴代码,而是从第一性原理把 CUDA 编程背后的逻辑重建一遍。故事线很简单,含义却很深:GPU 只对某一类工作负载有价值。一旦确认工作负载属于这一类,下一个问题就不是「代码怎么写」,而是「数据在 CPU 与 GPU 之间怎么流动」「工作怎么从数据空间映射到线程空间」「底层架构怎么存储和调度这些线程」「访存层级怎么决定性能」。这些地基打完之后,读 PTX、看 SASS、推演寄存器分配、分析占用率、理解 DeepSeek 与 Blackwell 为什么靠缓存策略、张量内存与瓶颈移除(而不是多堆算术单元)来获取性能提升,才有意义。

从这个角度看,CUDA 编程不是一个小小的编程技巧,而是软硬件协同设计(software–hardware co-design)的入口。程序员写的是代码,真正的问题却是架构映射:编译器把抽象降成机器行为,硬件在延迟压力下调度 warp,访存层级放大或摧毁局部性——只有当这些层协同运转,性能才会浮现。所以这一章的目标不是让你熟悉术语,而是给你一个结构化的心智模型,为后面写 kernel、调 kernel,以及最终理解「为什么机器学习编译器变得不可或缺」打底。


4.1 为什么 GPU 只对「对的那类工作负载」才有意义

任何严肃的 CUDA 讨论,第一个问题都不是「kernel 怎么写」,而是「什么样的工作负载才配得上 GPU」。这个问题重要,因为 GPU 不是通用加速器。程序里「有计算」是不够的,这些计算还得有正确的结构。两个性质缺一不可:

  1. 高并行度(high parallelism)——计算能拆成大量相似、能独立推进的操作;
  2. 高算术强度(arithmetic intensity)——每次搬动一个字节的数据,都要摊上足够多的算术。

两个条件都是必要的,单独哪一个都不够。

直觉。 卷积是个好例子。计算输出特征图时,每个输出元素由输入的一小块邻域决定。数据流里确实有依赖,但很多依赖在并行执行眼里是「良性」的:两个输出只是重叠读了同一块输入,那是读后读(read-after-read),不是真正的数据冒险(data hazard)。于是大量输出元素可以同时求值——计算里藏着一大片数据并行前沿(data-parallel frontier)。

但并行本身不足以证明该用 GPU。 第二个条件是算术强度。工作负载必须在每次数据搬移上执行足够多的算术,把访问内存、搬数据的成本摊薄。如果算术太 trivial、数据搬移占大头,GPU 的计算资源就会闲着,而系统还在为启动 kernel、把数据搬过访存层级付钱。这就是为什么课上一再强调:理想的 GPU 工作负载,是「大量可并行的数据项 + 同构的计算模式 + 足够多的算术」三者凑齐。

不是哲学,是硬上界

这一点不是哲学问题,而是被一个硬性上界支配的。如果一个程序能拆成顺序部分与并行部分,那么最好情况下的加速比被顺序部分卡死——这就是 Amdahl 定律:

其中 是并行部分占比, 是处理器数。 时,:顺序部分哪怕只占 5%,加速比也超不过 20 倍。再叠加上主机端初始化、设备内存分配、数据搬运这些固定与可变开销,有效上界还要更低。

停下来想想

试想一个负载:给 100 亿个元素整体加上一个常数。它并行度爆表,可每个元素只做一次加法——搬一个数只换来一次算术。把它放上 GPU,你会发现 kernel 时间只占总时间的一小截,大头全在拷贝与启动上。这题的根本毛病不是并行度,是算术强度。

所以 GPU 只在一种情况下有吸引力:并行部分既占主导、算术又重到足以压过加速的成本。判断一个负载要不要上 GPU,先问两句——能拆出大量独立操作吗?每搬一个字节能摊上足够多的算术吗?两句都答「是」,再谈下一步。


4.2 GPU 是加速器,不是自成一体的世界

初学者最常见的误解,是把 GPU 当成「一块更快的处理器」。实际上 GPU 坐在一个更大的主机–设备(host–device)系统里。CPU 负责指挥:准备数据、分配设备内存、把输入拷进 GPU、启动 kernel、再把结果拷回来。GPU 只负责设备端的计算。这段主机–设备关系,才是 CUDA 编程的运作起点。

教科书式的 CUDA 流程有六步,把这层关系说得明明白白:

  1. 定义 CUDA kernel;
  2. 在 GPU 上分配内存;
  3. 把数据从 CPU 内存拷到 GPU 内存;
  4. 启动 kernel;
  5. 把结果从 GPU 内存拷回 CPU 内存;
  6. 释放设备资源。

概念上,kernel 只是这条长得多得执行流水线里的一个环节。所以课上一再强调:要看完整路径,别只盯着 kernel 那一小段。kernel 优化当然重要,但它嵌套在更宽的搬数据流程里。

向量加法把这点戳得最疼。 数学工作几乎等于零:对每个下标 ,算 。但 CUDA 版本要干一堆杂活:分配显存、拷贝向量、启动 kernel、拷回结果、释放资源。课上的测量结果是:对现实的输入规模,kernel 执行时间只占总运行时间的零头。GPU 算得飞快,但系统还是得为设置和数据搬运买单:

这个分解不只是个公式,它是 CUDA 编程的第一堂系统课:瓶颈往往不在算术,而在搬运。 从 256 MB 涨到 4096 MB 的实测扩展里,主机初始化、主机到设备传输、设备到主机传输、kernel 时间几乎都是线性的——主导成本跟着数据量走。在向量加法这种微型算术 kernel 里,真正的计算可能只占几秒总运行时间的几毫秒。这就是为什么算术强度差的工作负载,再怎么「看起来并行」,也不想要 GPU。

这条推理也解释了现代硬件为什么反复出现统一内存(unified memory)与更紧密的 CPU–GPU 集成。 课上举了 Grace–Blackwell:让 CPU 与 GPU 更直接地共享同一块内存空间,而不是完全依赖分立式的 PCIe 类接口,从而缩小主机–设备分离的代价。这种集成不消除所有成本,但它直击要害:两个世界之间搬数据很贵,现代系统一直在朝「缩小或重组这条边界」演化。


4.3 kernel 是线程到数据的映射

承认系统级开销之后,下一个概念台阶是 kernel 本身。CUDA kernel 不只是「跑在 GPU 上的函数」——它是每个 GPU 线程都要执行的那段程序。这一句话已经包含了 CUDA 执行模型的全部本质:程序员写一份代码体,硬件把它复制到成千上万个线程上执行。所以 CUDA 不是传统意义上的循环式编程模型,而是 SPMD:单程序多数据(single program, multiple data)。

随之而来的自然问题:既然所有线程执行同一个 kernel,每个线程怎么知道自己该负责哪块数据?答案是索引(indexing)。每个线程有身份,每个数据元素——更一般地,每个逻辑工作项——也有身份。CUDA 的性能从这两个身份之间的映射开始。最简单的一维情形:

这个公式远比它看上去重要。它是从抽象的数据并行计算到具体硬件执行的桥。普通串行循环里,你要写一个显式迭代变量,一个元素一个元素地扫过数组;在 CUDA kernel 里,元素的循环是隐式的——启动动作创建了大量线程,索引公式告诉每个线程它拥有哪个逻辑元素。看似一行算术,实际上是「把整个迭代空间摊到 GPU 上」的机制。

二维、三维问题里,同一个想法自然扩展:线程与块的标识变成向量,你可以把线程拓扑和数据拓扑对齐。数据本身有空间结构(图像、体数据、多维张量)时这特别有用。关键是:映射是显式的、可编程的。 CUDA 从不去猜程序员的意思。性能取决于这个映射是否同时贴合数据布局与硬件偏好的执行模式。


4.4 为什么非要 grid–block–thread 三级层次

乍看之下,CUDA 的 grid–block–thread 三级层次像是不必要的复杂。如果目标只是「把线程 ID 分给数据 ID」,为什么不把整个线程空间压平成一维、一整排工人呢?答案在访存层级里。计算层次之所以存在,是因为存储层次存在。 这是本章最深的架构思想之一。

GPU 里有好几级内存和存储,容量、共享范围、访问延迟天差地别。一个线程访问私有寄存器;一个线程块共享所在 SM 的片上共享内存和 L1 缓存;整个 grid 通过全局内存通信。这不是硬生生罩在硬件上的软件抽象——软件层次镜像了物理层次。块是协作计算的基本单位,因为它恰好住在同一个 SM 上、能高效共享旁边的存储;grid 是全设备执行的基本单位,因为分布在各个 SM 上的块,只能通过更下层、更全局的内存互相通信。

这也解释了为什么分块(tiling)不可避免。真实的张量太大,塞不进最快的存储。于是把大问题切成 tile,让每一段计算的活动工作集(active working set)能落进寄存器与共享内存。grid–block–thread 层次给了你一门语言,把这种嵌套结构原样说出来:数据又大又全局,tile 中等、由块内共享,线程局部的片段私有而微小。想通这点,这套层次就不再像「软件仪式」,而像存储系统的天然计算映像。


4.5 访存层级才是性能的真正来源

课程花大篇幅讲访存层级,因为在 GPU 计算里,性能根本上是个内存问题。算术单元很快,存储很慢。程序员的任务是安排好数据流动,别让快的单元干等慢的单元。本章后面所有概念——tiling、occupancy、warp 调度、寄存器压力、缓存策略——最终都从这一条事实派生出来。

层级从顶往下数:

  • 寄存器(registers)——线程私有,延迟极低,大约一个周期。立即数、地址、临时标量、累加的中间结果都住在这里。
  • 共享内存(shared memory)——片上、软件管理、块内共享。延迟远高于寄存器,但仍远低于全局内存。
  • L2 缓存——更大,全设备共享。
  • 全局内存(global memory)——由 HBM、GDDR 或 LPDDR 实现(取决于平台),容量巨大,延迟极高,常常是数百个周期。

这些层级不只是速度不同,作用范围也不同。寄存器是每线程的,共享内存是每块的,L2 与全局内存是全设备的。这个差别很关键,因为通信与复用只在合适的层级才有意义:线程私有数据该待在寄存器,块内的跨线程协作复用该用共享内存,设备级的持久数据该放全局内存。高效 CUDA 编程,本质就是按「复用方式 + 可见范围」把数据放进正确的层级。

H100 的例子能把这套层级钉在真硬件上。 每个 SM 里装着执行流水线、一个很大的寄存器堆、一片可在共享内存与 L1 缓存之间配置的存储区,还有张量内存加速器(Tensor Memory Accelerator,TMA)之类的支持单元。SM 之下是设备级的 L2,再往下是全局 HBM。具体容量随世代变化,架构模式却一直稳定。这套层级不是装饰——它在 kernel 运行之前就决定了哪些设计可行、哪些注定完蛋。


4.6 快内存太小,所以必须分块

你可能会问:GPU 编程文献为什么对 tile 这么着迷?答案直接跟上一节走:大的矩阵或张量放不进最快的存储,只能切成能放得下的更小块。

课上讨论矩阵乘法时给了具体版本。用 BF16 数据,一个 的 tile 占 字节:

两个这样的 tile(两个输入矩阵各一个)就是 64 KB。这套工作集正好塞进 SM 上共享内存量级的存储,于是可以先把数据暂存在那里,计算过程中反复复用。

反过来,如果把 tile 尺寸加得太猛,工作集立刻超出片上容量。此时计算保不住局部性,kernel 被迫频繁和下层内存通信——延迟上去了,有效吞吐下来了。所以 tiling 不是风格偏好,它是「把计算重新裁到能装进机器快存储」的机制。更深一层,tile 尺寸是算术强度、局部性、并发性三者的联合决策:tile 太小,每字节的计算量太低,算术单元吃不饱;tile 太大,存储压力爆炸、occupancy 崩溃。只有当 tile 尺寸和访存层级匹配,kernel 才算优化到位。


4.7 warp:GPU 执行的真实单位

尽管程序员按「线程」来写,硬件却按「warp」来执行。一个 warp 有 32 个线程,是 NVIDIA GPU 上调度与锁步执行的真实单位。这是学生必须完成的最重要的概念转身之一:线程是编程抽象,warp 是架构现实。

一个 warp 内的 32 个线程以锁步(lockstep)方式执行同一条指令——这就是 SIMT 的操作含义。GPU 一般不会逐个调度线程,而是整块整块地选 warp 来发射。如果一个 warp 在等全局内存的数据而停住,调度器就从另一个就绪的 warp 发射指令。这就是 GPU 隐藏延迟(latency hiding)的方式:它并没有把全局内存变快,只是保证当一组线程在等数据时,另一组线程在干有用的活。

所以 warp 同时是「机会」和「痛苦」的基本单位。机会在于:一个 warp 让硬件把指令取指和发射的成本摊到 32 个线程头上。痛苦在于:warp 里的线程在时间上不再独立。程序员尽可以把每个线程想成单打独斗,硬件却把它们绑成一个集体执行单元。于是控制流、访存模式、数据交换,都得放到 warp 这个粒度上来推理,才能真正理解性能。


4.8 发散:当 warp 不再像一个团队

warp 执行最有名的后果是发散(divergence)。假设一个 warp 有 32 个线程,一条分支把前 4 个送去一条路,剩下 28 个走另一条。因为 warp 是锁步的,硬件没法真同时执行两条路径,只能串行化:先让一个子集执行、另一个子集干等,再让另一个子集执行、前一个熄火。活还是干完了,有效并行效率却掉了。

这就是为什么 warp 内部的分支是性能问题。 问题不在于分支非法——分支完全合法;问题在于分支撕裂了 GPU 赖以提效的集体执行模型。换个说法:发散让 GPU 表现得像「被迫在锁步执行的地基上模拟独立线程控制」的机器。一些现代硬件技术,比如动态 warp 成形(dynamic warp formation),能把共享同一程序计数器的线程重新编组来缓解;但基本的架构事实没变:一个 warp 的所有线程走同一条路时,它才最好使。

发散还揭示一条更宽的原则。GPU 编程喜欢流式计算,偏爱同质、规整的控制流。一旦代码变得深度分支、高度依赖数据,GPU 就开始用最不讨人喜欢的方式模仿 CPU——不是获得了 CPU 的灵活性,而是在一台为吞吐优化的机器里付出 CPU 式的控制成本。所以许多高性能 kernel 只要能,就把控制复杂度换成数据流的规整性。


4.9 PTX 与 SASS:编译器的意图与硬件的现实

要更深地理解 CUDA,得钻到 CUDA C++ 源码之下,看中间表示和最终机器表示。这里就要介绍 NVIDIA 的编译工具链。nvcc 驱动把主机代码和设备代码分开:主机代码走常规 CPU 编译器;设备代码走 CUDA 前端和 PTX 生成路径,再由 ptxas 把 PTX 降成架构特定的 SASS,最后链接成一个可执行文件。这条流水线不只是实现细节——它解释了 CUDA 为什么既表达力强、又可移植、又和硬件绑得这么紧。

PTX 是 NVIDIA 的虚拟 GPU ISA(virtual GPU ISA):有类型、人类可读、跨设备世代可移植。精神上有点像 GPU 世界的 Java 字节码——抽象到足够稳定,具体到能表达线程、内存空间、算术、谓词与同步。SASS 是流式多处理器真正执行的机器 ISA。如果说 PTX 捕获的是编译器意图,SASS 暴露的就是硬件现实:PTX 说「编译器想让硬件做什么」,SASS 说「硬件实际会执行什么」——真实的寄存器、融合后的指令、缓存修饰符、架构特定行为,全在这里。

这个区别对性能工作至关重要。PTX 用来理解结构无可替代,但要看真实的指令构成、真实的寄存器用量、真实的内存操作和实际的调度影响,得看 SASS。现代专家级优化常常两者都读:PTX 让你看清概念上的降级;SASS 让你看清这套降级在与真机器接触后是否还活着。


4.10 用向量加法看懂 PTX

课上的向量加法 kernel 故意写得极简单,正因为简单,降级过程才跟得上。PTX 版本做的事依次是:声明虚拟寄存器、加载 kernel 参数、读含块 ID 与线程 ID 的特殊寄存器、计算全局线程索引、做边界检查、把通用指针转成全局内存指针、加载两个输入值、相加、存结果。逻辑上没有任何意外——真正耐看的是机器模型被摆得有多明白。

再小的 kernel 也暴露出好几样 GPU 特有的概念。参数从参数空间(parameter space)加载;特殊寄存器提供线程与块标识;内存空间是显式的——指针必须按全局内存地址处理,不能当成随便一个通用指针;谓词(predicate)被用于控制流;编译器显式算好线程到数据的映射,而不是把它藏在一个循环后面。换句话说,PTX 把高级 CUDA 代码只是暗示的 GPU 执行结构,原样摊开给你看。

PTX 还提醒我们它是虚拟 ISA:PTX 里的寄存器名还不是硬件的最终物理寄存器,编译器仍然可以随意降级和变换它们。所以 PTX 是理解程序结构的正确位置,却不是十拿九稳清点资源用量的最终位置——要数清楚,最终得看 SASS 或编译器报告。


4.11 读 SASS:同一个 kernel,换成硬件的语言

同一个向量加法 kernel 换成 SASS 看,代码变得更具体、也更难读。S2RS2UR 这类特殊寄存器搬运指令把线程 ID 和块 ID 搬进寄存器堆;整数融合乘加(fused multiply-add)指令算全局索引;常量内存加载取出 kernel 参数;置位谓词的指令实现边界检查;全局内存的 load/store 搬真正的数据。抽象变窄了——你现在看到的是机器特有的视图。

看 SASS 的回报,是真实的寄存器可见了。 如果代码用到的寄存器一直到 R9,再加上几个谓词寄存器和统一寄存器,你就直接摸到了 kernel 的真实寄存器足迹。课上专门对比了 PTX 与 SASS 的寄存器声明,并演示 ptxas -v 如何报出实际寄存器数。这不是无事好奇:寄存器数决定 occupancy,occupancy 决定机器能藏多少延迟。读 SASS,就是在读性能约束。


4.12 CPU 线程和 GPU 线程不是一回事

课程里另一个重要的概念转身,是 CPU 线程与 GPU 线程的区分。「线程」这个词被过度使用了,危险得很。在操作系统里,线程是相对笨重的、由软件管理的执行上下文:它有保存的寄存器组、程序计数器、栈、优先级和调度状态之类的元数据,由操作系统管理。CPU 线程之间的上下文切换很贵,因为状态必须存进内存再读回来。

GPU 线程完全是另一回事:它是轻量级的硬件执行上下文。它也有状态,但管理哲学不同——GPU 靠让大量线程上下文常驻片上,这样在就绪 warp 之间切换的开销极低。关键的线程局部状态被放在 SM 的寄存器堆里,而不是每次调度决策时推回内存。这正是 GPU 延迟隐藏模型成立的原因:如果每次 warp 切换都要付一次完整的 CPU 式上下文切换成本,这台吞吐机器会被自己的调度开销压垮。

一句话概括:CPU 线程是恰好跑在硬件上的软件线程;GPU 线程是暴露给软件的硬件执行上下文。 这句话听着抽象,但它解释了 GPU 线程模型为什么这么轻、为什么能撑起这么大的并行度、为什么寄存器堆会成为核心瓶颈。


4.13 寄存器:GPU 上最金贵的资源

寄存器是 GPU 里最快的存储——但这句话藏着一个更重要的真相:它们也是最金贵的。每个活跃线程都需要寄存器放它的局部状态、中间值、地址和与控制相关的数据。因为 GPU 同时跑海量线程,总寄存器堆必须很大。但「大」是相对的:比 CPU 大得多,却仍挡不住几千个活线程的胃口。

所以课程不止讲有哪些种类的寄存器,还讲每个线程用多少个。通用寄存器(general-purpose registers) 存整数、地址和浮点值;谓词寄存器(predicate registers) 为谓词执行存布尔条件;统一寄存器(uniform registers) 存 warp 内共享的值(比如 kernel 参数或描述符),省去不必要的复制;特殊寄存器(special registers) 暴露硬件生成的只读值,比如线程 ID、块 ID、lane ID;张量运算还可能用到隐式的内部片段。这些状态合起来,定义了线程的活动足迹(living footprint)。

线程用多少寄存器,编译期按内核复杂度定下来。更多活跃变量、更激进的循环展开、更强的指令级并行,通常都推高寄存器需求。编译器在性能与资源之间找平衡,但逃不开那个底层权衡:每线程要的寄存器太多,SM 上能同时驻留的线程就变少。 问题不只是存储,是丢了并发。


4.14 寄存器分配在编译期完成,以及为什么 GPU 拒绝 CPU 式的重命名

课程在 GPU 与 CPU 的寄存器管理之间做了一次重要对比。在 GPU 上,寄存器分配本质上是编译期决定:为 kernel 发射的指令,显式引用物理或近物理的寄存器分配,硬件期望这些分配在执行期间保持稳定。运行时再做动态分配会复杂化机器,也破坏 warp 级锁步执行所需的可预测性。

CPU 则相反,常把编译期寄存器分配与运行时寄存器重命名结合起来。乱序 CPU 在运行时把架构寄存器动态重映射到物理寄存器,用来消除假依赖、挖出更多指令级并行。GPU 不走这条路,因为它隐藏延迟的策略不是「在少数线程内部做激进的指令重排」,而是「保持大量线程就绪、在它们之间切换」。在 GPU 这种为海量并行吞吐而造的机器上做运行时重命名,只会同时增加硬件复杂度和运行时不确定性。GPU 选择了静态寄存器分配 + 线程级延迟隐藏,而不是 CPU 式的动态寄存器操作。


4.15 溢出:寄存器用完的那一刻,性能坠崖

如果 kernel 要的活跃存储超过寄存器能给的上限,编译器必须溢出(spill):把一些值从寄存器挪进本地内存(local memory)。「本地内存」这个词很骗人——听起来像线程私有的小块片上空间。实际上,本地内存是软件可见的概念,背后通常由全局内存在撑。作用域上看着「本地」,延迟上可能痛苦地「全局」。

这就是寄存器溢出危险的原因。 本来用寄存器一个周期就能访问的值,现在变成可能要花几百个周期的 load/store。如果被溢出的数据时间局部性足够好、能留在 L1/L2,缓存有时能减轻打击——但这不保证。课程把溢出称为「沉默的杀手」:它往往在编译之后才出现,程序员常常要等寄存器报告或 SASS 检查暴露溢出行为,才明白 kernel 性能为什么崩了。

所以实战 CUDA 编程永远包括盯编译器诊断:不是写个 kernel 就祈祷。编译,看寄存器数,查有没有溢出,想清楚当前代码结构是否和预期的 occupancy 兼容。


4.16 共享内存:对抗重复搬数据的显式武器

寄存器是私有的,但很多 kernel 需要线程之间共享数据。矩阵乘法是经典例子:块里的多个线程可能要用同一批输入矩阵的重叠区域。如果每个线程都独立地到全局内存去取这些值到自己私有寄存器,系统就是在反复为同一份数据买单,亲手毁掉自己的算术强度。共享内存的存在,正是为了阻止这场灾难。

共享内存是程序员控制的片上 SRAM,允许同一块内的线程把数据协作地加载一次、反复使用多次。如果一个线程块把一个输入 tile 暂存进共享内存,许多线程在做各自那部分计算时都能访问它——全局内存流量降下来,有效算术强度抬上去。在矩阵乘法、注意力这类负载里,整个性能故事就押在这份协作复用上。

这里有一个重要的哲学点。共享内存之所以交给软件管理,是因为许多机器学习负载的访问模式足够可预测,程序员或编译器能做得比通用缓存启发式更好。当复用是结构化、已知的,显式管理胜过尽力而为的猜测——这是 GPU 编程的定义性特征之一。


4.17 缓存与共享内存:两种不同的优化哲学

CPU 程序员习惯重度依赖缓存。缓存由硬件管理,靠猜测「哪些数据很快会用到」来利用时间与空间局部性。对运行时行为在编译期难以预测的通用负载,这招很好使。所以 CPU 在大型硬件管理的私有缓存上砸了很多资源。

GPU 走混合路线:仍然提供缓存,但把共享内存储成一块一级的、软件管理的存储层。关键区别是控制。共享内存在正确使用时给出「保证的复用」——因为数据是程序员显式暂存进去的;缓存给出「机会主义的复用」——硬件碰巧检测到有利的访问模式时才发生。课程总结得很好:GPU 把显式软件管理的内存,和隐式硬件管理的缓存,合在了一起。这里的含义很深:高效的 GPU 编程,不是抽象地纠结「要不要用缓存」,而是搞清楚哪份数据值得显式控制,哪份数据交给硬件的尽力而为机制更省事。


4.18 为什么 GPU 缓存很大,并发之下却像很小

现代 GPU 的 L2 缓存可以大到惊人,几十甚至上百兆字节。这会让粗心的学生以为「缓存应该能解决大部分问题」。课程仔细纠正了这个误解:总缓存容量不等于每个活跃计算分到的有效容量。 GPU 同时跑着很多 SM、很多 warp,全都在抢同一层下级缓存。于是任何一个 kernel、块或 warp 实际可用到的 L2 份额,可能小得吓人。

所以要从 L2 受益,工作集不能只是「比原始 L2 小」,而要小到——并且复用得快到——在并发压力下不会被驱逐。大而流式的工作集很快丢掉局部性,退回全局内存。这条观察对大语言模型尤其重要:它们的张量可以极其巨大,动态行为足以打爆朴素的缓存假设。它也解释了为什么聪明人有时会绕过 L1 或直接操纵缓存策略,而不是默认硬件配置最好。


4.19 DeepSeek 的教训:PTX 级控制的存在有其理由

DeepSeek 的例子令人印象深刻,因为它展示了专家优化钻到 CUDA 源码之下、进入 PTX 的真实案例。原因不是审美,是架构:在 H100 和 H800 上那些带宽关键的大语言模型 kernel 里,编译器默认策略给的缓存行为控制不够。DeepSeek 修改 PTX,让一次全局加载故意绕过 L1 分配、直接从 L2 取数——实际上,他们拒绝把稀缺的 L1 容量浪费在不会有效复用的流式数据上。

对照官方记录

DeepSeek-V3 技术报告在「PTX 级优化」一节记录了同类做法:对部分关键算子直接写 PTX、手动覆盖默认缓存行为(例如为全局加载指定缓存驱逐策略),以获得编译器默认策略给不了的控制。

这一课同时点出抽象的力量与限度。CUDA 藏起大量底层细节,是为了让 GPU 编程可控;但一旦负载性能关键到默认选择不可接受,你就得收回显式控制。这时 PTX 成为「精修编译器意图、让它更好匹配架构现实」的地方。在热 kernel 里收益也许只有 5%–10%,放大到集群规模就很可观。更广的教训是:内存搬运、缓存策略、数据流式行为不是边角料,它们常常就是真正的瓶颈。


4.20 运行时决策:编译器止步之处,硬件登场

如果就此以为「重要的东西都在编译期定死了」,那就错了。GPU 执行是静态决策与动态决策合作的结果。寄存器分配是编译期的;溢出是编译期资源压力的产物;指令调度大体由编译器塑造。但 warp 调度是运行时的,块到 SM 的分配是运行时的,跨 SM 的资源分配和就绪工作的实际交错,也都是运行时现象。

这构成一个两级控制系统:编译器定下计划,硬件对实际状况做反应,只有两层对齐,性能才会出现。 一个写得完美的 kernel,如果运行时调度没让机器忙起来,照样吃亏;反过来,再聪明的调度器也救不了一个已经毁掉局部性或吃下不合理资源的 kernel。课上强调得对:编译期规划与运行时调度,单独哪一个都不够。GPU 是最强意义上的协同设计系统。


4.21 占用率(occupancy):资源与并发的核心权衡

占用率是访存层级与执行层级之间的桥。它衡量的实际是:一个 SM 上能同时活跃的线程或 warp 数,相对于理论容量有多少。但占用率不是一个「无脑往大调」的数字——它是更深层权衡的可见结果。每线程多占寄存器,本地计算更顺、溢出更少;每块多占共享内存,数据复用更好。可两者都在烧有限的片上资源,留给「同时驻留的块」和「备用 warp」的空间就少了。

所以课程把占用率表述为每线程资源用量与并发之间的权衡。占用率掉得太低,GPU 就藏不住内存延迟——一个 warp 在全局内存上停住时,可用的备用 warp 太少。反过来,用「饿着每个线程的寄存器、饿着每块的共享内存」把占用率顶到极高,算术单元可能还是喂不饱,因为 kernel 的数据流太弱、复用太差。高占用率帮延迟受限的 kernel;指令级并行强的计算受限 kernel,低占用率下照样跑得好。 不存在普适最优,最优值是架构与负载共同决定的。

可以拿 H100 估个量级:每个 SM 有约 64K 个 32 位寄存器、上限约 2048 个线程。一个 kernel 每线程用 32 个寄存器,能驻留 个线程,满占用率;每线程用到 128 个,就只剩 512 个线程,占用率 25%——同一个 SM、同一堆算术单元,藏延迟的底牌少了四分之三。(具体数字世代而异,量级关系不变。)


4.22 这些思想如何塑形 kernel 设计

前面这些零件拼齐之后,CUDA 编程的实用设计规则就开始说得通了。每块线程数通常取 32 的倍数,因为一个 warp 就 32 个线程;128–256 线程的块大小常常在占用率与调度灵活性之间给出不错的折中;grid 通常远大于 SM 数量,让运行时块调度自然均衡负载;块维度要和数据布局对齐,访存才规整;共享内存用量要管住,因为它决定一个 SM 能塞下几块;寄存器用量要盯着,因为它决定能驻留几个 warp。换句话说,这些耳熟的设计规则不是民间传说——它们是架构的推论。

这也是为什么高性能 kernel 优化这么难:设计空间是离散的、多维的、高度耦合的。块大小、tile 大小、寄存器压力、共享内存足迹、展开、向量化、内存布局、访问顺序,全都互相纠缠。课程把它描述成「十到二十个有意义的决策变量」的问题——这个直觉很准。困难不会因为让计算机来搜索就消失,它只是变成了编译器问题。 机器学习编译器正是在这里自然登场:当学生理解手动优化有多难,就会明白自动调优、编译器搜索、甚至基于强化学习的内核优化为什么这么重要。


4.23 Blackwell 的 TMEM:张量计算的新数据路径

现代 GPU 的演进印证了本章的中心论点:性能前沿越来越由数据流与存储塑造,而不只是算术单元。Blackwell 引入 TMEM——每 SM 约 256 KB 的专用张量存储器——在从 HBM 到 Tensor Core 的路径上,插入了一层新的片上、面向张量的存储。在 Hopper 上,张量操作数最终必须先住进寄存器才能被 Tensor Core 消费,这个设计把巨大压力压在寄存器堆上:寄存器既要当线程状态,又要当张量计算的操作数暂存区。

Blackwell 重构了这条路径:不再强迫所有大型张量 tile 穿过通用寄存器,而是提供更直接的、面向张量的存储机制。概念上流程变成

这个改动没有让寄存器变得无关紧要,但卸掉了它们肩上的重担。通过缓解寄存器压力,TMEM 能抬高有效占用率、改善数据复用、让搬运与计算重叠得更好。所以 TMEM 不是孤立的功能,而是对「tile 尺寸、寄存器需求、占用率」三者张力的一次直接架构回应——正是本章一路铺开的那条线索。


4.24 为什么 GB300 没加多少 SM,性能却跃升这么多

GB200 到 GB300 的对比,为现代架构演进提供了最后一课,具体又实在。报告的 FP4 稠密吞吐从约 10 petaflops 涨到约 15 petaflops——50% 的跃升——而活跃 SM 数只是从约 148 增加到 160。教训立竿见影:性能增长不再由主计算引擎的数量主导。

课程对决定性因素的判断很准:每 SM 的特殊功能单元(special function unit,SFU)数量翻倍,加速了 softmax 这类已成为 transformer 负载瓶颈的运算;L2 容量增加、HBM 容量显著扩大,让系统能更有效地喂饱 Tensor Core、减少卸载压力。信息微妙但有力:一旦张量算术被优化到极致,瓶颈就迁移了——迁进访存层级、特殊功能吞吐和数据搬运。架构演进靠的是修掉这些瓶颈,而不是无限放大同一套算术资源。


4.25 大图景:CUDA 编程是受架构约束的优化

现在回到开头的问题:为什么 CUDA 编程要从架构开始,而不是从代码开始?答案应该清楚了。一个 CUDA kernel 不只是「一段并行代码」,它是把数据流映射到一台机器上——这台机器的性能由分层访存层级、warp 式执行、静态资源分配与运行时调度共同支配。寄存器、共享内存、L2、全局内存、线程块、warp、块调度、占用率,不是一串互不相干的技术琐事,它们是一个系统的零件。

优化 kernel,因此是求解一个受约束的映射问题:工作怎么切进线程和块,tile 取多大,共享内存和寄存器各花多少,访存怎么对齐,局部性怎么保住,并发怎么暴露到足够藏住延迟又不被资源压力压垮。每一个选择都落在一个离散的设计空间里——这就是手动优化难的原因,这就是编译器重要的原因,这也是 GPU 明明被营销成「大规模并行机器」、却应该被理解成一台以隐藏延迟为本、靠软硬件结构化协作来提效的机器的原因。

本章的中心结论一句话就能说清:GPU 计算不是为了多写线程而多写线程,而是在一个受约束、分层的架构里,编排计算、内存与调度。 一旦内化这一点,CUDA 编程就不再像一袋子 API,而开始显出它真正的样子:一场架构优化练习。


延伸阅读