斯坦福-CS149-并行计算笔记-全-
斯坦福 CS149 并行计算笔记(全)
001:为什么需要并行?为什么需要效率?








概述
在本节课中,我们将探讨并行计算的核心动机:为什么我们需要并行处理,以及为什么效率与并行性同等重要。我们将通过互动演示来理解并行化的挑战,并介绍计算机程序、处理器和内存的基本概念。
课程内容

欢迎与课程介绍
欢迎来到CS149课程。我是Cavon,另一位讲师是Kunle。Cavon主要负责软件相关的讲座,而Kunle作为硬件专家,将在课程后半部分深入讲解硬件工作原理。本课程旨在为来自硬件架构、软件编程、机器学习和图形学等不同背景的学生提供支持。
互动演示:并行化的挑战
为了直观理解并行计算,我们进行了一个课堂活动:计算一系列数字的总和。
首先,一位同学(Tina)被要求顺序相加16个数字,耗时约40秒。
接下来,我们尝试使用两位同学并行计算。尽管资源翻倍,但总耗时约为41.7秒,几乎没有提升。原因在于通信开销:一位同学完成计算后,需要将部分和传递给另一位同学进行最终求和,这个传递过程消耗了大量时间。
然后,我们尝试使用四位同学。他们采用了“工作池”策略:将所有数字卡片放入一个公共池,每位同学从中取出一张进行计算,最后汇总部分和。这次耗时约19秒。分析发现,前12秒四位同学并行完成了所有计算,但随后花费了7秒来汇总部分和。这再次凸显了负载均衡和最终同步的开销。
最后,我们挑战用全班约160人来统计总人数。大家提出了多种策略(如按行计数、分组计数等),但实际执行时,数据移动和协调同步的复杂性使得整个过程远慢于预期。这个练习表明,即使有大量并行资源,通信和同步的成本也可能抵消并行带来的收益。
并行与效率的核心主题
本课程不仅关注并行性,更强调效率。有时,一个高效的串行算法可能优于一个通信开销巨大的并行算法。
例如,在工作中,如果你使用10核处理器仅将程序加速2倍,这未必是坏事。如果这2倍的加速显著提升了用户体验(如网站响应速度或游戏帧率),或者抵消了使用更多硬件的成本,那么它就是有价值的。硬件设计师同样关注效率,他们希望在满足性能目标的前提下,使用最少的硬件以控制成本。
计算机程序与处理器基础
为了理解如何实现效率和并行,我们需要回顾一些基础知识。

什么是计算机程序?
从计算机的视角看,程序就是一个指令序列。这些指令最终会被编译或解释为处理器能够执行的基本命令。

处理器是做什么的?
处理器执行指令。执行一条指令意味着:
- 执行运算(如算术操作)。
- 改变状态:更新处理器寄存器或内存中的值。
我们可以用一个简化的模型来理解处理器:它包含控制单元(决定执行哪条指令)、执行单元(执行算术运算)和执行上下文(寄存器和内存的状态)。
性能提升的历史与挑战
过去,处理器性能的提升主要依靠两种技术:
- 增加时钟频率:让处理器每秒执行更多指令。
- 指令级并行(ILP):通过超标量、乱序执行等技术,在单个处理器内自动发现并并行执行多条不存在数据依赖的指令,而程序员无需修改代码。
然而,这两种方式都遇到了瓶颈:
- 频率墙:提升频率会导致功耗呈平方级增长,产生难以解决的热量问题。
- ILP墙:研究表明,典型程序中可自动提取的并行度有限,通常只能有效利用3-4个执行单元,增加更多单元收益甚微。
现代解决方案:显式并行与异构计算
由于无法通过提升频率或自动提取更多ILP来获得性能增长,唯一的出路是要求程序员显式地编写并行程序,以利用多核处理器。
现代设备,从手机到超级计算机,都包含多个处理核心(CPU和GPU)。例如,NVIDIA RTX 4090 GPU拥有超过1.8万个浮点运算单元。要充分利用这些硬件,必须编写并行代码。

此外,为了追求极致效率,异构计算和专用处理器成为趋势。例如,手机SoC中除了通用CPU核心,还集成了用于图像处理、神经网络推理等任务的专用硬件单元。谷歌的TPU、各大公司的AI加速器都是这一方向的体现。


内存层次结构:效率的关键
在并行计算中,数据移动往往是最大的瓶颈。这引出了对内存系统的理解。
什么是内存?
内存提供了一个抽象:一个按地址访问的字节数组。处理器可以通过加载(load)和存储(store)指令与内存交互。
内存的挑战:延迟
访问内存(尤其是DRAM)可能需要数百个处理器周期,速度很慢。如果一条指令需要等待从内存中加载数据,处理器将会空转。
解决方案:缓存
为了减少访问延迟,处理器内部设置了缓存。缓存是一小块高速存储,保存着最近使用过的内存数据副本。其设计基于局部性原理:
- 时间局部性:最近被访问的数据很可能再次被访问。
- 空间局部性:访问一个数据时,其相邻的数据也很可能被访问。
当处理器需要数据时,它首先检查缓存。如果数据在缓存中(命中),则访问速度极快;如果不在(缺失),则需从更慢的主存中加载,并通常会载入一个连续的数据块(缓存行)。
缓存形成了层次结构(L1、L2、L3等),离处理器越近,容量越小、速度越快。管理好数据在缓存中的位置,对于编写高效程序至关重要。



总结
本节课我们一起学习了:
- 并行化的动机与挑战:通过课堂活动,我们亲身体验了通信、同步和负载均衡如何影响并行程序的性能。
- 效率的重要性:并行不是目的,提升效率才是。有时串行算法或适度的并行加速更具实际价值。
- 程序与处理器的基础:程序是指令序列,处理器通过执行指令、改变状态来运行程序。
- 性能提升的历史瓶颈:时钟频率和指令级并行(ILP)的提升已面临物理和理论限制。
- 现代并行计算路径:必须依靠程序员编写显式并行代码来利用多核/众核处理器,并趋向于采用异构和专用计算单元以提高效率。
- 内存层次结构的核心地位:数据移动的成本巨大,理解缓存及其局部性原理是优化程序性能的关键。

在接下来的课程中,我们将深入探讨如何编写并行程序,如何理解硬件以提升效率,以及如何驾驭复杂的内存系统。
002:现代多核处理器 🚀








在本节课中,我们将学习现代多核处理器的核心概念,包括程序与处理器的抽象定义、缓存的工作原理,以及三种提升计算性能的关键技术:超标量执行、多核与向量化,以及多线程与硬件上下文切换。我们将通过一个计算正弦函数的示例程序来贯穿这些概念。



程序与处理器的抽象回顾


上一节我们介绍了程序与处理器的基本抽象。本节中,我们来看看这些抽象的具体含义。



什么是计算机程序?



从计算机的角度来看,一个程序就是一系列指令的列表。这些指令告诉机器执行特定的操作。


程序 = 指令列表





处理器的作用是什么?




处理器执行这些指令。执行指令的效果是修改机器的状态。我们主要关注两种状态:
- 寄存器状态:处理器内部的高速存储单元。
- 主存状态:程序可访问的大型数据存储空间。



一个简单的处理器模型在每个时钟周期执行一条指令,顺序修改状态。





指令级并行与超标量执行




然而,如果我们观察程序中的指令,会发现并非所有指令都依赖于前一条指令的结果。这意味着,在拥有额外硬件资源的情况下,某些指令可以并行执行,而程序的最终结果保持不变,只是运行得更快。
这就是超标量执行的核心思想。它允许处理器在单个时钟周期内,从同一个指令流中找出并执行多条独立的指令。这是一种硬件层面的优化,对程序员完全透明。
关键点:程序的语义(按顺序执行指令)与处理器的实现(可能乱序并行执行)之间存在差异,但最终结果一致。





内存抽象与缓存实现



上一节我们介绍了内存的抽象概念,本节中我们来看看其具体实现以及如何优化访问速度。


什么是内存?



抽象地看,内存是一个字节数组,每个地址对应一个值。程序可以读取或写入特定地址的值。




内存 = 地址 -> 值的映射
内存的实现与延迟问题
动态随机存取存储器(DRAM)是内存的一种常见物理实现。然而,访问DRAM的速度很慢(延迟高)。为了缓解这个问题,现代系统都使用了缓存。


缓存是一种更小、更快的存储单元,作为内存数据的副本。当处理器请求某个地址的数据时,首先检查缓存。如果数据在缓存中(缓存命中),则快速返回;如果不在(缓存未命中),则需要从较慢的主存中加载数据,并通常以缓存行为单位载入缓存。

缓存行是数据在缓存和内存之间传输的基本单位(例如64字节)。这种设计基于两个重要的局部性原理:
- 时间局部性:最近被访问的数据很可能在短期内再次被访问。
- 空间局部性:当程序访问某个地址时,很可能很快会访问其邻近的地址。

以下是缓存工作流程的一个简化示例:





处理器请求地址0 -> 缓存未命中(冷未命中) -> 从内存加载包含地址0的整个缓存行(如地址0-3)到缓存。
处理器请求地址1 -> 缓存命中 -> 直接从缓存返回数据。
处理器请求地址2 -> 缓存命中。
处理器请求地址3 -> 缓存命中。
处理器请求地址4 -> 缓存未命中 -> 加载包含地址4的缓存行(如地址4-7)。


当缓存已满且需要加载新数据时,会根据策略(如最近最少使用-LRU)驱逐一个旧的缓存行。


总结:缓存是一种硬件实现细节,旨在通过利用局部性原理减少内存访问延迟。移除缓存不会改变程序结果,只会使其运行变慢。现代计算机通常具有多级缓存(L1, L2, L3),形成存储层次结构,越靠近处理器,容量越小、速度越快。

并行计算的核心思想
现在,让我们回到处理器的主题,探讨三种通过增加硬件能力来提升性能的主要思想。我们将使用一个计算数组元素正弦值的程序作为运行示例。
示例程序(C代码):
for (int i = 0; i < n; i++) {
y[i] = sin(x[i]); // 通过泰勒级数近似计算sin(x[i])
}
这个程序的外层循环迭代是相互独立的,具有巨大的并行潜力。
思想一:多核处理器

超标量处理器试图在单个指令流中寻找并行性(指令级并行,ILP)。但对于像我们示例中这种明显的循环级并行,硬件动态分析的开销很大且不必要。
多核的思路是:简化单个核心的设计(减少用于提升单线程性能的复杂硬件,如大型缓存和激进的分支预测),将节省出的晶体管资源用于在同一个芯片上复制多个这样的核心。




- 之前:一个强大的核心,试图挖掘单个指令流中的ILP。
- 之后:多个较简单的核心,每个都能独立执行一个完整的指令流。

要利用多核,程序必须显式地创建多个线程(例如使用Pthreads),或者使用能表达并行性的高级抽象(如for all循环)。操作系统将这些线程调度到不同的核心上执行。





关键点:多核将并行性的责任从硬件转移给了程序员或编译器/运行时系统。


思想二:单指令多数据(SIMD)/向量化

在许多应用(如科学计算、图形处理、机器学习)中,相同的操作需要应用于大量数据项。SIMD架构利用了这一特性。

其核心思想是:扩展现有的标量指令,使其能同时对多个数据元素进行操作。这通过以下方式实现:
- 引入向量寄存器(能容纳多个数据值的宽寄存器)。
- 引入向量指令(一条指令完成多个数据元素的相同操作)。
对于我们的示例,我们可以将循环改写为每次迭代处理8个元素(假设向量宽度为8):
// 伪代码,使用向量内在函数
for (int i = 0; i < n; i += 8) {
vector8f vx = load_vector(&x[i]); // 加载8个float到向量寄存器
vector8f vy = sin_approximation_vector(vx); // 向量化正弦计算
store_vector(&y[i], vy); // 存储8个结果
}

优势:分摊了取指、译码等控制开销,在数据并行计算中能极大提升吞吐量。
挑战:需要执行一致性。即在同一时刻,所有被向量指令处理的数据元素必须执行相同的操作路径。如果代码中存在条件分支(如if-else),且不同数据元素走向不同分支,就会导致执行分歧,部分计算单元闲置,利用率下降。


SIMD与多核结合:现代处理器通常同时具备多核和每个核心内的SIMD单元,从而提供极高的理论计算峰值(核心数 × 每核心SIMD宽度)。
思想三:多线程与硬件上下文切换
我们之前提到内存访问延迟很高。当一个线程在等待内存数据(缓存未命中)时,强大的多核SIMD处理器中的许多计算单元可能会闲置。

解决方案借鉴了日常生活:如果一项任务需要等待(如洗衣),我们就去切换做另一项任务(如写作业)。在处理器中,这被称为多线程或硬件多线程。





具体实现是:在单个核心内部,复制多份执行上下文(即线程状态,如寄存器)。核心的硬件可以快速(通常在一个周期内)在多个线程之间切换。当一个线程因内存访问而停顿时,硬件立即切换到另一个就绪的线程执行,从而隐藏内存延迟,保持计算单元的高利用率。
效果:
- 利用率:接近100%,计算单元很少空闲。
- 单个线程延迟:由于需要与其他线程分时共享核心,完成时间可能变长。
- 总吞吐量:因为更有效地利用了硬件资源,整体任务完成速度更快。


总结
本节课中我们一起学习了现代多核处理器的核心架构思想:

- 缓存:通过存储层次结构利用局部性原理,减少内存访问延迟,是提升性能的基础。
- 超标量执行:硬件自动挖掘单个指令流中的指令级并行(ILP),对程序员透明。
- 多核处理:通过复制多个处理核心来利用线程级或任务级并行,需要程序显式创建并行性。
- SIMD/向量化:通过一条指令处理多个数据元素来利用数据级并行,要求代码具有一致的控制流。
- 硬件多线程:通过在单个核心内维护多个线程上下文并快速切换,来隐藏内存访问延迟,提高硬件利用率。


这些思想通常是正交的,可以组合在一起,构建出从手机处理器到超级计算机GPU的各种强大计算设备。理解这些原理是编写高效并行程序的关键。
003:多核架构第二部分 + ISPC 编程抽象









在本节课中,我们将深入学习硬件多线程的核心概念,并探讨如何通过ISPC编程语言来应用这些并行计算思想。我们将从回顾上一讲的关键概念开始,然后深入理解内存带宽与延迟的区别,最后通过ISPC的编程模型来具体实践这些理论。
硬件多线程回顾

上一节我们介绍了多核执行和SIMD执行。本节中,我们来看看如何通过硬件多线程更有效地利用处理器资源。


核心思想是:当处理器因等待某项操作(如内存访问)而无法继续执行当前指令流时,它不应该空闲等待,而是切换到另一个就绪的指令流去执行工作。这就像在办公室答疑时,当一个学生需要时间去思考时,教授不会干等,而是去帮助队列中的下一个学生。

多线程执行模型

考虑一个简单的处理器核心,它每个时钟周期只能执行一条标量指令。我们为其添加管理多个线程状态(如程序计数器和寄存器)的能力。虽然它一次仍只执行一个线程的一条指令,但当该线程因内存访问等原因停滞时,硬件可以立即切换到另一个就绪线程的指令。
公式:假设一个程序模式为:执行 M 个算术指令,然后发生一次停滞 L 个周期。
- 单线程时,处理器利用率 = M / (M + L)。
- 要隐藏停滞,达到100%利用率,所需线程数 ≈ (M + L) / M。
因此,程序中计算与内存访问的比率,决定了有效隐藏延迟所需的最小线程数量。硬件多线程并未提高处理器的峰值吞吐量,而是提高了其资源利用率。
带宽与延迟:系统的吞吐瓶颈
理解了如何通过多线程隐藏延迟后,我们需要认识现代计算中另一个更根本的约束:内存带宽。
高速公路类比


考虑从旧金山开车到斯坦福(距离50公里)。
- 延迟:以100公里/小时的速度,单程需要0.5小时。
- 带宽(吞吐量):如果规定高速公路上同时只能有一辆车,那么吞吐量是2辆车/小时。
提高吞吐量的方法有:
- 提高速度(降低延迟):车速提高到200公里/小时,延迟降至0.25小时,吞吐量升至4辆车/小时。
- 增加车道(增加带宽):保持100公里/小时,但增加为4车道,吞吐量升至8辆车/小时。
- 提高道路使用效率(流水线):保持单车道和100公里/小时,但允许车辆以1公里间距行驶,形成“流水线”,吞吐量可大幅提升。
在计算机中,延迟是完成一次操作所需的时间(如从内存读取数据),而带宽是单位时间内可以完成的操作数量(如每秒可从内存传输的字节数)。
计算与带宽的失衡
现代处理器(尤其是GPU)拥有极其强大的计算单元。例如,一个拥有5000个ALU的GPU,在1.6GHz频率下,理论峰值算力约为 8万亿次操作/秒。假设每个操作需要读取2个操作数和写入1个结果(共12字节),那么需要 ~100 TB/s 的内存带宽来“喂饱”这些ALU。


然而,即便是最先进的内存系统,其带宽也仅在 ~1 TB/s 量级。这导致了严重的失衡:计算管道的“吞吐能力”远高于数据供给管道的“带宽能力”。
结论:对于像 C[i] = A[i] + B[i] 这样简单的数组相加操作(计算与内存访问比为1:12),它在现代处理器上的运行效率会极低(约1%),因为它完全受限于内存带宽,而非计算能力。缓存对此类“流式访问”且无数据重用的程序帮助有限。提升性能的关键在于优化算法,提高每字节内存访问所执行的计算操作数。

ISPC 编程模型实践

为了将上述并行概念应用于实际编程,我们引入ISPC(Intel SPMD Program Compiler)语言。它提供了一个清晰的抽象,帮助我们思考并行执行。


ISPC 核心概念:程序实例组

ISPC 采用 SPMD(单程序多数据) 模型。当一个ISPC函数从C代码中被调用时,它会启动一个包含多个 程序实例 的“组”。每个实例独立执行相同的函数代码,但拥有自己的局部变量和唯一的 programIndex。

代码:基本的ISPC函数结构
// ISPC 函数
export void sine_ispc(uniform float* x, uniform float* result, uniform int n) {
// 每个程序实例都会执行这个循环
for (uniform int i = programIndex; i < n; i += programCount) {
result[i] = sin(x[i]);
}
}
programCount: 当前组中程序实例的总数。programIndex: 当前实例在组中的索引(0 到programCount-1)。
在上面的例子中,每个程序实例以“交错”的方式处理数组元素:实例0处理元素0, 8, 16...;实例1处理元素1, 9, 17...,以此类推。所有实例协作完成整个数组的计算。




foreach 循环:委托任务分配

手动通过 programIndex 和 programCount 来划分工作是一种方式。ISPC 提供了更高级的 foreach 循环抽象,程序员只需声明循环迭代是独立的,系统会自动将迭代分配给各个程序实例执行。

代码:使用 foreach 循环
export void sine_ispc_foreach(uniform float* x, uniform float* result, uniform int n) {
// 系统自动将迭代 i 分配给某个程序实例执行
foreach (i = 0 ... n) {
result[i] = sin(x[i]);
}
}
foreach 的语义是:确保所有迭代最终都被执行完毕,但系统可以自由决定执行顺序和分配方式。这为编译器优化和运行时调度提供了灵活性。



以下是 foreach 语义的一些有效实现方式:
- 交错分配:实例0: i=0,4,8...;实例1: i=1,5,9...
- 块状分配:实例0: i=0,1,2,3;实例1: i=4,5,6,7...
- 动态分配:实例在完成当前迭代后,从共享任务池中获取下一个迭代。

关键在于,无论系统选择哪种实现方式,只要保证了每个迭代都被执行一次,程序的最终结果就是正确的。这清晰地区分了 程序语义(做什么) 和 执行策略(如何做)。



总结

本节课中我们一起学习了:
- 硬件多线程 通过在线程间快速切换来隐藏操作延迟(如内存访问),从而提高处理器单元的利用率,但并未增加其峰值性能。
- 内存带宽 是现代并行计算的关键瓶颈。许多简单操作受限于数据从内存传输到计算单元的速度,而非计算本身。算法设计需要关注计算与内存访问的比率。
- ISPC编程模型 通过SPMD模型和清晰的程序实例抽象,帮助我们编写并行代码。
foreach结构进一步将工作分配的逻辑委托给系统,允许编译器进行多种有效的并行化实现。

理解这些概念——延迟隐藏、带宽限制以及并行抽象——是设计和优化高效并行程序的基础。
004:并行编程基础


概述
在本节课中,我们将要学习并行编程的基础概念,特别是通过ISPC编程模型来理解如何将工作分解、分配给多个执行单元,并协调它们以正确、高效地完成任务。我们将从理解程序语义开始,逐步深入到实现细节和性能考量。
理解ISPC程序语义
上一节我们介绍了并行计算的基本思想,本节中我们来看看ISPC编程模型的具体语义。
ISPC函数与普通C函数不同。调用一个ISPC函数意味着创建多个(由gang size定义)该函数的副本并运行它们。每个副本被称为一个程序实例,它们都执行相同的指令序列,但每个实例拥有一个不同的programIndex值,用于区分各自的工作。
例如,对于一个gang size为8的ISPC函数调用,会创建8个程序实例。从语义上讲,这等同于用一个for循环顺序执行该函数8次,每次循环设置不同的programIndex值。此时我们尚未讨论任何具体的并行实现。
核心概念:ISPC函数调用创建多个程序实例。
// 语义上等同于:
for (int i = 0; i < programCount; i++) {
programIndex = i;
// 执行函数体
}
工作映射与foreach结构

理解了程序实例的概念后,我们需要考虑如何将总工作量分配给这些实例。程序员可以显式地编写代码来映射工作,也可以使用更高级的抽象。




以下是两种常见的工作分配模式:
- 交错映射:在循环的每次迭代中,所有程序实例处理内存中连续的数据块。这有利于利用缓存局部性。
- 分块映射:每个程序实例负责处理数据中连续的一大块。在某些需要减少实例间通信的场景下可能更优。

ISPC提供了foreach结构,这是一种更高级的抽象。使用foreach时,程序员只需声明需要并行执行的总迭代次数,而将具体的实例到迭代的映射工作交给ISPC编译器决定。编译器通常会选择高效的映射策略(如交错映射以优化内存访问)。




核心概念:foreach将迭代分配到程序实例,具体策略由编译器决定。
foreach (i = 0 ... n) {
// 并行处理第 i 个元素
}

数据竞争与正确的并行模式

当我们把工作分配给多个并行执行单元时,必须注意它们对共享数据的访问。不正确的访问会导致数据竞争,产生未定义的结果。
考虑以下有问题的ISPC程序,它试图计算数组所有元素的和:
uniform float sum = 0; // uniform 变量在所有实例间共享
foreach (i = 0 ... n) {
sum += values[i]; // 错误!多个实例同时写入 sum,发生数据竞争
}
这个程序是错误的,因为多个程序实例可能同时读取和更新sum变量,导致更新丢失。
正确的做法是让每个程序实例先计算局部和,然后再安全地合并这些局部和。ISPC提供了跨实例操作符(如reduce_add)来完成安全的归约操作。
核心概念:避免对共享变量进行非同步的读写操作。使用局部变量和归约操作。
float partial = 0; // 每个实例有自己的 partial
foreach (i = 0 ... n) {
partial += values[i];
}
// 安全地将所有实例的 partial 值相加
uniform float sum = reduce_add(partial);
任务与线程池
foreach处理的是在一个线程内(通过向量指令)的并行。ISPC的task概念则用于在多个CPU线程(即多核)间分配工作。创建大量细粒度任务时,为每个任务创建一个操作系统线程是极其低效的,因为线程创建和上下文切换开销巨大。
高效的实现会使用线程池:预先创建与机器核心数相匹配的若干工作线程。当有任务需要执行时,将这些任务分配给线程池中空闲的线程。这避免了频繁的线程生命周期管理开销。



核心概念:使用线程池管理任务,而非为每个任务创建新线程。
线程池 (例如 8 个线程)
|
|-- 分配任务
|-- 分配任务
|-- ...


并行化策略与阿姆达尔定律

在实际并行化程序时,我们通常遵循几个步骤:分解(识别独立工作)、分配(将工作分配给执行单元)、协调(同步和通信)以及映射(将执行单元映射到硬件)。
一个关键的限制是阿姆达尔定律。该定律指出,如果一个程序的某一部分(比例为 S)必须串行执行,那么无论使用多少处理器,最大加速比不会超过 1/S。


核心公式:最大加速比 ≤ 1 / S,其中S是串行部分的比例。
例如,即使程序95%的部分可以被完美并行化(S=5%),在100个处理器上的最大加速比也不会超过20倍。因此,努力减少串行部分至关重要。


案例研究:网格求解器
让我们通过一个具体的例子——迭代网格求解器(如雅可比迭代)——来应用这些概念。原始算法中,每个网格点的新值依赖于其邻居的旧值,这导致强烈的数据依赖,难以并行。
我们可以改变算法,采用红黑排序的变体:
- 并行更新所有红色网格点(基于黑色点的值)。
- 同步。
- 并行更新所有黑色网格点(基于上一步已更新的红色点的值)。
- 重复直到收敛。
这种算法改变牺牲了一些收敛速度,但换来了巨大的并行潜力。


在实现这个并行算法时,我们需要处理同步问题。例如,所有线程完成当前颜色点的更新后,必须进行同步(使用屏障),才能确保所有新值对下一步可见,然后才能安全地检查全局收敛条件或开始下一轮迭代。
核心概念:有时需要改变算法以获得更好的并行性。使用屏障进行阶段同步。

总结


本节课中我们一起学习了并行编程的基础。我们从ISPC的程序实例和foreach抽象开始,理解了工作分配的概念。我们强调了避免数据竞争的重要性,并介绍了使用局部变量和归约操作的正确模式。我们还探讨了使用线程池高效管理任务,以及阿姆达尔定律对并行加速的根本限制。最后,通过网格求解器的案例,我们实践了算法转换、工作分配和同步(如锁和屏障)的使用。记住,编写正确、高效的并行程序需要仔细考虑工作分解、数据访问模式和执行单元间的协调。
005:性能优化 I - 工作分配与调度





概述


在本节课中,我们将要学习并行程序性能优化的核心策略之一:如何将工作有效地分配给多个线程或处理器。我们将探讨静态与动态工作分配方法,并深入了解一种名为“工作窃取”的高级调度算法,它常用于处理递归的、分治式的并行任务。






回顾:上节课的屏障优化





上一节我们介绍了通过案例分析来优化程序。我们回顾了一个数值计算程序,其中包含多个阶段和同步屏障。

该程序的核心结构是一个循环,在每个迭代中更新网格中的单元格,并检查是否收敛。我们使用了三个屏障来同步线程,确保所有线程都完成当前迭代的计算、累加和检查后,才能进入下一个迭代。
然而,我们发现了优化空间:程序在每次迭代中重复使用同一个变量 my_diff 来累计变化量,这造成了不必要的依赖。一个简单的解决方案是将 my_diff 改为一个数组,为不同迭代使用不同的副本。这消除了迭代间的依赖,从而可以将三个屏障减少到一个。这个技巧与我们之前将全局变量复制为线程本地变量以减少同步开销的思路是一致的。



本周主题:工作负载分配与调度

本节中我们来看看本周的学习路线。今天(第5讲)我们将专注于工作负载分配和调度,即如何决定哪个线程执行哪部分工作。周四(第6讲)我们将在此基础上,探讨如何通过减少通信成本和内存访问延迟来进一步优化调度。
在深入学习任何优化策略之前,有一个重要的免责声明:始终从最简单的方法开始。在编程作业(尤其是作业3)中,你应该先实现最简单、能正常工作的并行方案,然后测量其性能。只有在性能不佳时,才考虑应用更复杂的技术。过早使用复杂方案往往事倍功半。

工作负载平衡的重要性
工作负载不平衡是限制并行程序加速比的主要因素。如果一个核心(例如下图的P4)承担了远超其他核心的工作量,那么大部分执行时间实际上相当于在串行运行。

核心概念:当工作负载不平衡时,程序的并行部分效率低下。理想情况是所有处理器同时完成工作。


静态工作分配


静态分配是指在程序开始前或运行中的某个阶段,就预先确定好每个线程负责的工作块。


以下是静态分配的一些策略:

- 均匀划分:最简单的策略是将工作总量(如像素数量)平均分给所有线程。这在每个工作单元成本相近时效果很好。
- 交错分配:在曼德博分形渲染的例子中,我们将图像行交错分配给不同线程。由于相邻行的工作量相似,这种策略能实现较好的平均负载平衡。
- 适用场景:静态分配适用于工作成本可预测或平均成本相近的情况。即使单个任务成本有波动,只要任务数量足够多,各线程的累计工作量也会趋于平衡。许多长时间运行的模拟计算(如流体力学模拟)会采用“半静态”分配,即运行一段时间后根据情况重新分配。
动态工作分配

当无法预测单个任务执行时间,或者任务成本差异很大时,静态分配可能导致严重负载不平衡。此时需要使用动态分配。



动态分配的核心思想是使用一个共享的工作队列。所有待处理的任务被放入队列,空闲的线程从队列中取出下一个任务执行。


以下是一个动态分配的例子(伪代码):
// 共享计数器,初始为0
int counter = 0;
lock_t lock;
// 每个线程运行的函数
void worker() {
while (1) {
int my_index;
lock(&lock);
if (counter >= n) { // 所有工作已完成
unlock(&lock);
break;
}
my_index = counter;
counter++; // 原子地获取下一个任务索引
unlock(&lock);
process_task(my_index); // 处理任务,例如检查素数
}
}




概念解析:
counter和lock共同实现了一个简单的任务队列。- 每个线程通过获取锁、读取并递增
counter来领取一个唯一任务。 - 当
counter >= n时,所有任务已被领取,线程退出。



动态分配的权衡:
- 优点:能很好地适应任务成本不均衡的情况,实现优秀的负载平衡。
- 缺点:引入了同步开销(锁竞争)。如果任务粒度太小,同步开销可能成为瓶颈。

优化策略:增大任务粒度。不要让线程每次只领取一个最小工作单元,而是领取一个“块”(如多个数组元素)。这减少了访问共享队列的频率,从而降低了同步开销。需要在负载平衡和同步开销之间找到最佳平衡点。


处理任务依赖关系



到目前为止,我们假设所有任务都是独立的。但在实际中,任务间可能存在依赖关系(例如,任务B必须在任务A完成后才能开始)。


你的作业2将涉及实现一个支持任务依赖关系的工作队列调度器。这需要维护一个任务依赖图,并在任务完成时,解锁其所有依赖项已满足的后继任务。


分治并行与Cilk编程模型
我们之前看到的并行主要基于数据并行(对集合中每个元素执行相同操作)。另一种重要的并行模式是分治并行,例如快速排序。

快速排序的并行潜力在于:每次分区后,对左半部分和右半部分的排序是相互独立的,可以并行执行。递归地进行下去,会形成一棵潜在的并行任务树。

为了更方便地编写这类分治并行程序,我们可以使用 Cilk 这类编程模型。Cilk 对C语言的扩展只有两个关键字:
cilk_spawn: spawn一个函数,调用者可以与其异步执行。cilk_sync: 等待所有本函数内spawn的任务完成。


语义理解:
cilk_spawn foo()意味着“开始执行foo,但我(调用者)不必等它返回就可以继续往下执行”。cilk_sync是一个屏障,确保所有spawn出去的工作都已完成。- 在函数返回时,有一个隐式的
cilk_sync。

Cilk的语义只定义了逻辑上的并行工作单元,并未规定具体如何用线程实现。最简单的实现是忽略spawn/sync(串行执行),或者为每个spawn创建一个物理线程(效率低)。高效的实现依赖于工作窃取调度器。


工作窃取调度算法

工作窃取是一种高效的、用于执行Cilk这类分治程序的调度策略。




核心思想:
- 线程池:程序启动时创建一组工作线程(如等于CPU核心数)。
- 本地双端队列:每个线程维护一个自己的双端队列(Deque),存放它已生成但尚未执行的任务。
- 本地优先:线程总是从自己队列的尾部取出任务执行(后进先出,LIFO)。这有利于数据局部性。
- 窃取补救:当一个线程的队列为空时,它会随机选择另一个线程作为“受害者”,并从受害者队列的头部窃取一个任务执行(先进先出,FIFO)。



为何这样设计?
- 窃取大任务:在分治递归中,先被推入队列头部的通常是更大的子问题。从头部窃取意味着窃取者能拿到一个“大块”工作,可以独立工作很长时间,减少再次窃取的频率。
- 减少冲突:本地线程操作队尾,远程线程窃取队头,两者通常不会操作同一个元素,减少了同步需求。
- 理论保证:随机选择受害者进行窃取的策略,在理论上被证明能达到接近最优的调度效率。
Sync的实现:需要为每个spawn块维护一个引用计数。当任务被窃取时,引用计数增加。每个任务完成时,递减其所属spawn块的计数。当计数归零时,最后一个完成任务的线程负责执行该spawn块之后的代码(即sync之后的部分)。
总结
本节课中我们一起学习了并行程序性能优化的基础——工作分配与调度。
- 静态分配简单、开销低,适用于工作负载可预测或平均平衡的场景。
- 动态分配(通过共享工作队列)能更好地处理负载不均衡,但需注意同步开销和任务粒度的权衡。
- Cilk模型提供了一种优雅的方式来表达分治算法的潜在并行性。
- 工作窃取调度器是高效执行Cilk程序的关键,它通过维护每个线程的本地任务队列和空闲时从其他队列窃取任务的机制,在保证负载平衡的同时,最大限度地减少了线程间的通信和同步开销。


记住优化准则:测量优先,简单起步,复杂方案仅在必要时使用。下节课我们将关注如何优化内存访问模式以减少通信成本。
006:性能优化 II - 局部性、通信与争用 🚀



概述
在本节课中,我们将学习性能优化的第二部分,重点关注如何通过优化数据局部性和通信来提升并行程序的性能。我们将探讨共享内存与消息传递两种通信模型,分析通信开销的来源,并学习减少固有通信和人为通信的技术。
课程进度与展望 📅
上一节我们讨论了任务调度,今天我们将加入通信和同步开销的考量。
我们即将结束性能优化部分的讲座。下周的讲座将涉及GPU和GPU编程,而第8讲将是数据并行编程和思维。
之后课程内容将有所转变。在前几讲中,我主要讲解软件和性能优化,接下来Kunle将为大家介绍硬件等相关知识。
回顾:从调度到通信 🔄
在周二的课程中,我们讨论了调度。我们探讨了当拥有一定数量的线程或处理器以及大量待处理工作时,如何确保工作负载均匀分配到所有工作单元上,同时避免分配过程中产生过多开销的基本策略。
今天的课程将增加额外的考量:性能优化不仅仅是良好的工作负载平衡,通常还涉及减少通信和同步开销。因此,今天的内容将更多地围绕通信展开。
我将讨论如何降低处理器间的通信成本,这些是作为软件开发者可能使用的技术。最后,如果有时间,我想介绍一些通用的程序优化技巧,这些技巧不一定与你们的作业直接相关,但鉴于你们已经有了很多背景知识,讨论这类内容会很有帮助。
共享内存的复杂性 🧠
到目前为止,在本课程以及你们将要完成的所有作业中,我们基本上都假设所有处理器都连接到某个共享内存系统。换句话说,存在一个单一的地址空间,所有线程都可以读写该地址空间中的变量。
我已经给出了一些提示:尽管这个概念很简单,但所有处理器都能读写所有变量的实际底层实现其实相当复杂。例如,如果你的多核CPU中,数据可能全部存储在DRAM中,但该数据的许多副本存储在各个缓存中。
Kunle下周首先要讲的内容(或者下周晚些时候会开始涉及)就是:当一个核心复制了某些数据,另一个核心复制了相同地址的数据,并且它们都进行写入时会发生什么。现在,系统中不同的参与方写入同一个地址,这些副本可能有不同的值,这很快就会变得一团糟。
因此,我们将简要介绍现代系统如何保持一切一致性。以如今标准的英特尔CPU为例,它有几个核心,同一芯片上还有GPU。有一个网络连接所有这些核心,并将它们连接到同一个内存系统。
这些网络可能非常复杂。例如,在英特尔架构中,所有不同的处理器实际上通过一个环连接在一起并连接到内存。
所以,我们只是将其视为对值X的加载和存储,但从这里的处理器加载和存储值X(比如这四个核心)实际上是在这个环上发送一个请求,该请求最终被路由到内存。在这个环的不同部分,例如,如果是缓存命中,它实际上可能转到缓存的一部分并从缓存中获取数据。这实际上可能是一件相当复杂的事情。
有趣的一点是:你是否注意到所有不同的核心都以某种方式连接?如果你把这看作是一个核心加上它的L3缓存部分,请注意所有东西都连接到环上两次。你知道他们为什么这样做吗?
首先,是的,这是为了降低延迟。其次,它简化了设计:如果你总是按顺时针顺序发送消息,那么像死锁之类的问题就不太成问题。如果你想降低延迟,如果你有两个连接点,那么如果你要去左边的邻居或右边的邻居,而你只能向右发送,你实际上可以更快地到达那里。
这是另一个处理器(指Sun UltraSPARC T1/T2)。Kunle在这项技术的初始设计中发挥了重要作用。这是由当时尚存的Sun公司完成的,后来被Oracle收购。这是首批主流多线程芯片之一。在这个例子中,它有八个核心,每个核心都有多个线程。但所有这些核心都通过一个交叉开关连接到主内存。CCX就是交叉开关。交叉开关意味着每个核心实际上都物理连接到其他每一个核心,就像N个核心需要N²条线。
有趣的是,如果你看芯片图,一个处理器核心的实际面积(占位面积)大约与网络的面积相同。因此,这些在所有处理器之间提供高带宽连接的网络极其昂贵且复杂。
即使在……顺便说一下,如果我转到这个(指环形总线),实际上意味着根据你所在的核心、地址是什么、它实际在哪里以及L3缓存的哪个分片,L3缓存命中的成本可能不同。所以,这就像一个分片缓存,不同的地址去往不同的地方。
另一个例子是:如果你去买一个主板,上面有两个插槽用于两个物理CPU。假设有两个不同的四核CPU。有一个片上网络(那个环)连接着核心,就是我展示的那个。然后,在双插座主板上还有走线连接出去。
在那个图中,从核心1到地址X的加载或存储,可能比从核心8到地址X的加载或存储快很多,无论缓存行为如何。
因此,在现代系统中,随着系统变得越来越大(这在现代GPU等设备中表现得尤为明显),情况不仅仅是“数据要么在内存中,要么在缓存中”那么简单。实际上,这其中有很多细微差别,我们在编程时不一定需要考虑,因为那样会让我们的思维爆炸。但如果你真的想优化,你会意识到这种数据放置的重要性。
所以,尽管我们喜欢简单地认为计算机就是一大堆核心连接到一个共享内存,但不同线程通信的加载和存储操作,其成本可能非常非常不同。如果你是高手,你实际上会问:“这个地址在这台机器中的哪个位置?”并且你可能根据这个成本采取不同的操作。
消息传递模型:显式通信 📨
一种更容易推理通信的方式是考虑其他设计,其中数据移动更加显式。我想花点时间谈谈一种不同的计算模型,称为消息传递。如果你在Web环境中编写过任何分布式程序,你们所有人都应该熟悉它。
在互联网上,你并不是简单地说“我想要这个内存地址的数据”,然后互联网上的所有计算机都能访问一个统一的地址空间。当我们在分布式系统中通信时,我们通过发送消息来实现,可能是HTTP GET或POST请求之类的。
关键在于,我有两台不同的计算机或两个不同的线程,现在每个线程都在自己的地址空间中工作。
因此,线程1地址空间中的地址X,与线程2地址空间中的地址X不是同一个地址。它们是不同的地址空间。交换信息的唯一方式是显式地发送消息。
在这个例子中,我们不说HTTP请求,而是抽象地说:线程1将发送其地址X的内容给线程2,并用一个ID D标记消息,以便接收方知道它收到的是什么。
相应地,线程2将发布一个接收操作,表示“我想从线程1接收带有此ID的消息”。当我们收到数据时,我们将把该数据存储为我们自己地址空间中地址Y的内容。
因此,这些消息指明了:如何识别我地址空间中的数据,它要发送给谁,以及是否存在一个抽象的ID,以便对方知道监听什么。
在消息传递环境中,可以这样理解:共享内存就像一个公告板,任何人都可以在不询问的情况下发布消息,任何人都可以在不传递消息的情况下阅读该消息;而消息传递则更像发送实体邮件:你将数据打包在信封里,将其寄往特定地址,然后有人负责将其送达该地址。
当然,任何消息传递系统基本上都会有某种寻址机制。例如,这需要发送到哪里。区别在于:是在互联网规模上完成,还是在单台计算机内的线程规模上完成,或者可能是在小型计算机集群规模上完成,抑或是在大型机架等大规模上完成。如何识别接收者、数据如何移动(是TCP/IP还是UDP)可能有不同的机制。但概念上的区别是:
- 在基于共享地址的系统中,我们都谈论相同的地址,并且我们都拥有直接的读写访问权限。
- 在消息传递系统中,我们都在自己的地址空间中操作,我们发送消息,意思是说:“嘿,这是一些数据。去复制一份并放到你的地址空间里。”
示例:在消息传递中实现网格求解器 🧮
如果我们回到上次讨论的那个例子,我想这会变得更加明确。再次提醒大家,工作负载是:对于每个红色单元格,根据周围邻居的值更新红色单元格的值。如果尚未收敛,基本上再用黑色单元格重复此过程。
我想让你们思考一下,在比如说一个集群(而不是双核机器)上实现这个程序。假设是两台不同的计算机,我只能像互联网流量那样交换消息,比如通过以太网。
这是我的新型简单计算机:我有一个处理器及其自己的内存(DRAM),它实现了自己的地址空间。我有一个网络(无论是互联网、以太网还是信鸽),有一种方法可以将信息从一个内存传输到另一个内存。
重申一下,我只有发送和接收这两个操作。
首先,我必须在所有处理器之间划分数据,必须跨这些处理器对网格进行分区。
以前的情况是:我有一个大数组,就像我们在作业1中做的那样。然后程序代码会说:线程0或线程1,你应该访问这些地址;线程2,你应该访问这些地址。
现在情况有点不同了。我们没有共享地址空间,对吧?所以现在集群中的每个线程或每台机器(我突然跳到四台机器,只是为了让它更容易一些)都拥有数组一部分的自己的副本。
现在,在这四个不同的地址空间中有四个不同的分配,保存着每个工作单元负责的数据。
你看到了概念上的区别:以前我们有一个共享的分配,我们都只是接触自己负责的部分。现在,我们有四个不同的地址空间。图中的线程3无法直接说“加载这里的值”,就像你宿舍里的计算机和我办公室里的计算机一样,我无法从我的计算机发出加载/存储指令来将数据存储到你的计算机。
现在,要计算,假设我想计算这里这个元素的值,我需要什么信息?我需要左边的邻居、右边的邻居,以及下面的邻居。下面的邻居没问题,因为线程3拥有所有这些信息,但我还需要当前存储在我无法访问的地址空间中的信息。
因此,几乎所有消息传递系统中的做法都是:我需要能够访问这些数据,但我不能,因为它不在我的地址空间中。所以我要复制一份,并在我自己的地址空间中保留一个副本。
所以我现在实际上在每个节点上做的是过度分配。我分配了比我负责的部分多一行和少一行的额外空间。
我需要请求我的邻居通过消息发送我应该存储在这一行中的值。如果你看图中右侧的代码,这是每个线程正在执行的逻辑。请注意,它分配了一个比它负责的数据大两行的缓冲区。
一些术语:例如,在线程2中,有一个额外的过度分配,对应于保存这些数据的副本。这种额外的过度分配,即存储该线程不拥有或不负责更新的数据,你经常会听到它被称为幽灵行、幽灵单元格或幽灵值,这是科学计算中常见的东西。
消息传递求解器的完整迭代实现 🔄
现在,让我们看一下求解器应用程序一次迭代的完整实现,以消息传递的方式编写,而不是以加载和存储的方式。字体有点小,但请看一下,给它一些思考时间。
这段代码由每个线程执行,是一种SPMD的方式。每个线程根据其线程ID(这里是tid)执行操作。我给你们一分钟左右的时间,它某种程度上是有注释的,但请讨论一下,确保你们都理解这里的流程。
我想确保每个人都理解这个。这值得讨论。这个东西在做什么?有一个发送和接收数据的阶段,有一个执行你应该做的工作的阶段,有一个将更新后的数据发送给其他人的阶段,然后有一个确定我们是否完成以及是否需要再次重复的阶段。
好的,我们回来吧。我想大多数人的讨论已经达成一致了。让我们开始讲解,确保每个人都理解。
有一个关于发送和接收如何工作的问题,我故意没有澄清,因为我打算稍后更深入地讨论。如果一个线程调用receive,那么当某个其他线程发送了数据时,该调用才会返回。从某种意义上说,当那个调用返回时,数据现在就在我的地址空间里了。所以从技术上讲,如果我调用receive而没有人发送任何东西给我,我就会永远等在那里。
让我们看看这段代码,挑出一些细节。更有趣的事情之一是顶部的分配。我有这个变量local_a,它只是我这个线程对整个概念网格某一部分的本地副本,local_a分配了rows_per_thread + 2行。所以它是我负责的数组部分加上顶部一行和底部一行。
请注意,这些发送和接收调用中的if语句只是:如果我是第一个线程,我左边没有东西,所以我不需要。但请注意,它们将数据存储到local_a的第一行或最后一行。这样,当我迭代时,我就不必区分什么是幽灵行,什么不是幽灵行。在那个时候,我只是把所有数据放在它应该在的地方。所以我的代码很简单。
看起来第一轮也应该……我的意思是,它们都分配了相同的空间。在这种情况下,最后一个线程下面没有幽灵行,对吧?所以数据被分配了,但那里永远不会写入任何东西。只是为了保持代码简单,我不想在分配周围加条件。
中间会发生什么?顶部和底部的行将被访问到。我搞错了吗?我从rows_per_thread开始……我想我只是没有正确地保护它。抱歉。因为如果你看这个例子,现在我只显示了线程3的幽灵行。但正如你指出的,这里会有一个幽灵行,这里也会有一个幽灵行。老实说,如果你回想一下我们之前的代码,它只会从i=1迭代到我们计算值的最后一行(比如这一行)。这是问题的定义,对吧?
另一个有趣的事情是:记得上次在共享地址空间中,我们有锁和屏障。但这里没有锁,也没有屏障。那么这段消息传递代码如何确定我们是否应该继续?
看这里,它说如果线程ID不是线程0,那么每个线程(除了线程0)都发送它的local_diff。如果你是线程0,你等待接收所有其他线程的local_diff。你进行计算以确定我们是否完成。线程0确定这个计算,然后实际上将布尔值done = true发送给其他每个线程,然后被其他每个线程在这里接收。
当然,我们可以只计算总和并将总和值发送回去,然后每个人都可以独立计算是否完成。但这就是我在这段代码中的做法。
为什么这段代码里没有锁?因为没有什么东西是共享的,所以没有什么需要维护互斥。同步的唯一方式就是通过这些消息。因此,我们创造了一种情况:每个人都向一个线程(这里的一个参与者)发送部分和,一个线程完成所有数学计算,然后将结果返回给其他所有人。
为什么这里看不到任何屏障?本质上,屏障内嵌在我所做的这种通信模式中。由于每个人都有自己的done变量副本(因为没有共享地址空间),所以不用担心有人将来开始并覆盖别人收到的内容。
因此,通信在这些发送和接收中非常显式。
总结一下:注意,现在所有的数组计算都是相对的。如果我回到这里,请记住,所有线程现在都在自己的本地数组部分上迭代相同的索引,而之前在共享地址空间中,我做了一些数学计算以确保每个人都在迭代不同的索引。这是另一个例子:索引是相对的。
通信通过发送和接收执行。在这种情况下,我们决定一次发送许多元素,为了效率,进行一次发送而不是一堆小的发送。
同步不是通过锁和屏障完成的,同步体现在我们如何构造消息中。
阻塞式发送/接收与死锁风险 ⚠️
有一个问题触及了这一点:等等,让我们确保理解在所谓的阻塞式发送和接收中事情发生的顺序。
我们刚刚展示的发送和接收,我们都假设:如果发送方调用send(foo),那么发送方地址空间中变量foo的数据将被复制到网络中。网络将传输消息。假设接收方已经在其自己的本地变量bar上调用了receive,接收方将收到消息,将消息中的任何内容复制到本地变量中。
当接收方完成复制数据并拥有该数据时,它可能会向发送方发送一个确认,然后发送方的send调用返回。一旦我们保证接收方拥有数据,这就是阻塞式发送。
类似地,阻塞式接收:当接收方在其地址空间的相应变量中拥有数据时,receive返回。
如果数据永远不到达怎么办?例如,如果网络出现故障。在这种简单的阻塞式发送和接收定义中,接收方永远不会返回(目前是这样)。发送方也不会收到确认,发送方也永远不会返回。所以你在考虑它时,就像我在一个网络分布式程序环境中。故障会发生。我需要对其具有鲁棒性。
现在,假设你买了一台16核计算机,从一个核心到L3缓存的一条消息无法送达……你会把它扔掉,对吧?或者,协议的所有可靠性……如果我们谈论的是片上网络,所有故障重传都将在硬件层面处理。所以,如果一个处理器存储到内存时出错,你会把机器扔掉。这就是我现在希望你们思考的方式。或者,你应该认为在这个API之下有一些东西,可能是某些系统软件,它会进行所有重试,并一直重试直到那件事发生。
显然,你可以考虑替代的API,比如发送可能失败。例如,假设硬件返回一些错误代码。API可能会说,嗯,它没有发送成功,它会返回一个错误代码之类的。但是相信我,如果你正在编写这样的代码,你不会检查内存是否工作正常之类的错误代码。
代码中的致命错误:死锁 💀
你们都讨论过了,并且正确地告诉了我上一张幻灯片上的代码是如何工作的。但没有人告诉我代码中有一个致命错误。让我们回到代码。我想让你们再看一眼。我想让你们告诉我,如果我使用阻塞式发送和接收运行这段代码会发生什么。
我想让每个人都花15-20秒思考一下。我的提示是:它会像刚才关于内存不工作的评论一样糟糕。
看起来有些人的眼睛亮起来了。有人想告诉我哪里错了吗?想象一下,你们在这个房间里,每个处理器就像房间里的一行。所以你们所有人要做的第一件事是向后看(或者发送向后)。这就是你们正在做的。你们向后发送,只有当后面的人确认或进行匹配的接收时,你们的发送才会完成。但是后面的人做了什么?他们也在向后看。他们也在向后发送。所以他们永远不会进行那个接收。
我们如何修复这个问题,同时仍然只使用阻塞式发送?一个简单的解决方案是根据行的奇偶性将每个人配对。所以第一行向后发送,下一行首先向前接收。然后你可以……这是一种做法。如果你仔细看,有些人有时会说,第一行不发送任何东西,难道不会工作吗?实际上,它甚至不工作,因为第一行如果不向后发送,它会向前发送。所以每个人都会在发布第一个接收之前进行发送。这在任何情况下都会死锁。这是一种死锁形式:你根本没有取得任何进展,因为你正在等待另一个同样无法取得任何进展的人。
所以,继续前进,一种可能的实现是根据奇偶性配对。你说,好吧,我要有一个伙伴。对于每个伙伴,一个人先发送,一个人先接收,依此类推。
一个小小的错误就可能导致你的程序挂起,绝对是这样。
异步发送/接收:避免死锁 🚀
我们也可以朝不同的方向发展,我们可以朝通信是异步的方向发展。上次我们讨论任务时,我谈到了异步函数调用,一个可以与调用者潜在并发执行的函数调用。消息发送也可以被认为是异步函数调用。
这是异步发送和接收。现在,当一个线程调用send时,更像是“我希望这条消息在未来的某个时间点被发送”。所以发送方调用send,但send立即返回。此时你(线程)不知道数据是否已经发送。通常,API会返回一个句柄。它只是说,嘿,如果你需要知道这个是否完成,这是你可以用来检查的ID。我在这里称之为h1。
所以,在未来的某个时间点,消息库会开始将数据复制到网络并推送出去。在未来的某个时间点,接收方会实际进行接收。一旦我们知道数据已被接收,我们可以探测系统,我们可以说“你完成了吗?”,我在这里写的是检查发送状态。检查该消息的发送状态,如果返回true,我现在保证数据已经发送,我可以删除foo或修改foo。
请注意,如果我在这个点和这个确认之间对foo做任何事情,我不能保证消息传递库已经获取数据并将其发送到线路上。这就像我把一个包裹放在门廊上,告诉UPS来取,然后我在UPS出现之前更改包裹的内容。修改后的消息将被发送,对吧?或者如果我把包裹从门廊拿走,UPS会说你让我来取,但这里什么都没有。
另一方面,在接收端,这是异步接收。如果我说“我希望你接收一条我期望的消息”,它会立即返回一个句柄。然后接收方可以在稍后的某个时间点说,嘿,它到了吗?它到了吗?如果到了,我知道此时可以接触bar中的数据。如果我在这个时候读取bar,我不清楚会得到什么数据。
这就是这些操作的异步版本。异步版本可以使实现某些事情(比如我刚才谈到的)变得更容易,因为你不需要那么担心死锁。
一个很好的观点是:如果这个发送和接收是一个库(通常就是这样),如果你是这个库的实现者,你肯定会在send和receive内部放置各种所谓的内存屏障或其他东西,这样编译器就不会围绕这些指令重新排序。我将在后面关于实现同步的讲座中稍微谈谈这个。
确认将是通信传输机制的一个底层细节,因此它们不会特意出现在幻灯片上。从线程(消费者)的角度来看,我可以使用的是:我发起一个发送,我基本上得到一个跟踪号(即句柄),我可以通过该跟踪号稍后检查发送是否完成(是或否,或者可能是否失败)。网络层如何在底层实现可靠传输,那是一个完全不同的故事,超出了本课程的范围。
如果你有两个连续的发送,会怎样?在这张幻灯片上,如果我说send(foo),foo是我要发送的本地地址空间中的变量。如果我再在这里send(foo)一次,并且这些都是异步的,那么会发生什么是相当不确定的,因为不能保证这两条消息的发送顺序,它们实际上都在发送同一个本地变量的内容。所以,你是说,如果我有send(foo),然后是另一个send(fizz)之类的?我的意思是,我只是发送两条消息。所以除非库给出状态保证,否则没有根本原因保证它们会以相同的顺序到达接收方。除非消息传递API上有一些配置,说明如果你设置了这个标志,我们保证顺序相同。
一种方法是忙等待。另一种方法,你知道,有些库可能设计成你注册回调或其他东西。例如,在异步JavaScript中,更像是本质上将那个AJAX请求放入队列,当它完成时你会被调用。
如果发送是按顺序进行的,那么接收的顺序呢?我认为之前也有一个消息ID D。我这样做的方式是:我发送foo,ID为D,随便什么。然后接收时,你可以明确接收带有此ID的消息,或者你只是发布一个接收并说“我正在接收”。然后,一旦你检查了接收,你说,哦,消息在这里,这是它的ID。然后你的程序可能是:如果是ID 43,我这样做;如果是ID 42,我那样做。所以,每条消息都应该被认为有一个ID,发送或等待或接收可以是“等待任何消息”、“等待来自此发送者的消息”、“等待带有此ID的消息”。这些都是你的消息传递机制的参数和细节。
通信的抽象视角与内存延迟 🌐
尽管我将其设置为想象这些是不同的计算机在通信,但我希望你们记住,通信有许多不同的类型。因此,如果我们在谈论核心与其内存之间的通信,或者同一芯片上两个不同核心之间的通信,或者不同宿舍中两台不同计算机之间的通信,在概念上没有区别。抽象地说,我们可以在任何事物之间发送消息,对吧?通信可以发生在处理器与其寄存器之间、其本地L1或L3之间、我自己计算机的DRAM之间、别人计算机的DRAM之间,或者谷歌的DRAM之间。所以,我希望你们将通信视为抽象的概念。
一旦你开始思考这个,就像我在前几讲中展示的那些图表,我说想象一个处理器发出加载指令,然后有一些内存延迟,然后数据必须实际开始移动回我。现在,希望你对所有这些内存延迟的来源有了一点感觉,对吧?比如L1缓存查找、L2缓存查找。可能你实际上有一个TLB未命中(因为操作系统之类的)。可能你发送一条消息到内存说“我想要这个地址的数据”。在某个时间点,内存开始将数据发送回你。
如果你有B比特/秒的带宽,你开始在那个蓝色区域以B比特/秒的速度接收数据。
这就是为什么当我画这个图时,我画了一个像这样的例子:有一个程序执行两条指令,然后进行一次内存事务。就像:数学、数学、读取、数学、数学、读取、数学、数学、读取。
这个图表的主要思想是,如果我们仔细观察,首先,即使读取不会立即返回,只要处理器有一些能力来隐藏延迟(比如多线程之类的),我们实际上并不那么关心延迟。我们实际上最关心的是蓝色条的长度。
所以,仔细观察这个图表,再次说服自己:内存总是忙碌的,而处理器并不总是忙碌的。我画了这些黄色条来显示内存总是忙碌的,粉色条是处理器不执行指令的时间,因为它正在等待下一块数据从内存返回。
你可以把这个蓝色条想象成一个缓存行,比如64字节。如果每次读取都是一个唯一的缓存行。

消息传递与共享内存:对比与应用场景 ⚖️
问题:消息传递是我们上次讨论的工作队列的替代方案吗?它们如何协同工作?消息传递只是线程之间交换数据的一种方式。在线程之间传递数据的另一种方式是每个人都读写一个共享地址空间。
我提出消息传递的原因,除了让大家熟悉消息传递的概念外,还因为在消息传递的背景下思考通信很有帮助,因为你在程序中字面上能看到它:这里就是通信发生的地方。
而我们在作业1中讨论的,或者我在作业1中用作带宽限制问题的例子,通信在哪里?通信就是加载和存储。但那个加载和存储实际上是关于向内存发送请求并取回数据。因此,基于共享地址的程序中的通信,在内存的实现中是隐式的。
所以,你知道,我只是想说,有……但我可以在一个没有共享地址空间的集群中的一堆机器上,使用消息传递来实现共享工作队列。我可以很容易地用不同的队列进行动态工作窃取,只是现在窃取工作是向另一台计算机发送消息并取回工作,而不是直接访问它们的数据结构。
如果你在一个实际系统上编写程序(不是集群,而只是一个处理器),是否有理由使用消息传递而不是共享内存?肯定可以有,因为在某种意义上,消息传递迫使你思考所有的通信。
所以很多人实际上在多核共享内存系统上使用消息传递模型编写代码,只是因为锁很难。比如,如果消息就像把东西扔进消息队列让另一边捡起来,这实际上可能是一种更容易推理并发的方式。例如,在大多数多进程系统中,你可能会向另一个进程发送消息,而不是将地址空间内存映射到两个进程中。
因此,消息传递是一种高度结构化的通信形式,它迫使你努力使其正确,但一旦你弄对了,通信就非常明确,你大致知道任何停顿可能在哪里,可能更容易调试和性能调优。
共享内存只是一种不同的机制,它根本不强迫你有任何纪律。所以可能更容易让你的第一个程序运行起来,也许你只是在整个东西周围放一个大全局锁之类的。但是,当你开始进行性能调优时,你可能得不到那么多的结构或帮助。有不同的原因你可能想使用不同的东西。
关于消息传递还有一个问题:似乎当我们进行消息传递时,有一个额外的写入操作,你必须先写入网络,有一个副本。有办法绕过这个吗?有很多方法可以绕过这个。让我们回到这里。
在现代高性能网络中,比如现代数据中心,你有一堆机器。假设你正在运行一个需要分布在多台机器上的键值存储。人们对降低发送消息的延迟和成本非常感兴趣。所以很可能这个send(foo),这个foo是一个变量,这是一个指针。唯一发生的事情是这个指针被发送到你的网卡,你的网卡自己直接从内存中读取数据并通过线路推送出去。因此,有一些非常高性能的网络实现,不一定意味着数据被不必要地复制。
但即使在该实现中,直到数据在某个时间点被复制,数据必须被复制。这必须被复制到网络中。在该复制发生之前,调用线程对foo的任何修改实际上都可能在该复制之前更改位,这意味着即使你认为你发送的是调用时foo的内容,你实际上发送的是后来foo的内容。这将是一个错误。
所以,异步的,你知道,突然间,如果是同步的,你永远不会考虑调用和消息传输之间的并发性。异步的,正如所建议的,可能是一种更容易做事的方式,但也可能是一种更困难的方式:现在你在程序中引入了更多的并发性,你可能有更多的问题。
带宽限制与算术强度 📊
好的,让我们看看这里。我几讲前讲过这个,但我想再放一张幻灯片。这是我希望你们课后思考的东西。当你们看这张图时,我希望你们能够说,是的,那个程序是带宽限制的。它基本上是内存和处理器之间的通信限制。
我希望你们能够回答所有这些问题。例如,如果你增加内存延迟(意味着增加从这里到这里的距离),利用率或效率会改变吗?你的答案应该是否定的,只要你能隐藏那个延迟。
如果你增加系统的带宽会发生什么?蓝色条应该缩小。如果蓝色条缩小,意味着我在粉色区域的停顿更少。如果我增加每个蓝色内存请求的数学操作数量,利用率会上升。这些都是我希望你们能够思考清楚的事情。

归根结底,如果我们不担心延迟,如果我们有能力隐藏延迟(无论是多线程、预取数据还是其他什么),那么问题的关键就归结为算术强度,即每读取单位数据所执行的数学操作数量。
有些人喜欢称之为通信计算比,这是它的倒数。我喜欢算术强度,因为A听起来更酷,B更高更好,这对我来说更直观。
固有通信与人为通信 🧩
你会发现,将通信以两种方式分开是有帮助的:一种是固有通信,由于算法的性质而必须发生;另一种是人为通信,源于机器的实际工作方式。
第一个称为固有通信。第二个称为人为或人工通信,它来自于计算机如何工作的真实细节,并且存在这些细节的产物。让我在这里给你们一个例子。
在这个网格求解器应用程序中,我无法完成应用程序,除非我将这些数据移动到这个线程。这是计算固有的。所以这是必须发生的通信。我必须以某种方式支付那个成本。
我们上次稍微谈了一下,有多少通信?让我们看看这个。如果我在我的处理器之间划分工作(抱歉,在本讲中我交替使用P1和T1,处理器和线程对我来说是一样的)。
我们做了多少通信?对于每个我们处理的元素,处理元素的数量与发生的通信量之比是多少?让我们考虑一个线程。
如果数据总量是N²,并且我们在P个处理器之间划分,每个处理器做多少工作?N² / P,对吧?每个处理器做多少通信?2N,对吧?所以如果我们有N² / P的工作量(我会去掉常数),我有2N的通信。那么我的算术强度是N / P,对吧?
如果我采用这种交错分配,并考虑消息传递程序。我做了多少计算?仍然是N² / P。我移动了多少数据?另一种思考方式是:对于我计算的每一行,我必须移动多少数据?2行数据,对吧?所以现在我的算术强度从N / P(假设N可能比P大很多)变成了1/2。
所以,如果我回到那个图表,我的利用率将是……我在这里做N个操作,每通信P个元素;在那里,我每通信两个元素做一个操作。与左边的方案相比,我更有可能在右边的方案中受到带宽限制。
有人能想到如何做得更好吗?左边比右边好得多。你能比左边做得更好吗?算术强度基本上是区域面积与其周长的比值。在这种情况下,创建一个具有最高面积周长比的形状的方法是什么?是正方形,对吧?
让我们看看这个。让我们以这种方式划分工作。我必须在这里创建更多的处理器,只是为了让图表更明显一些。现在假设我有9个核心。
同样,我有N²个元素,P个处理器。每个处理器的工作量仍然是N² / P,没有改变。通信量是多少?每个这些边界是√N / √P,对吧?所以通信的元素数量基本上是4 * (N / √P)。因为我基本上,我划分了宽度为N的东西。我总共有P个处理器,所以一行有√P个处理器。
现在,我之前的算术强度是N / P。现在,它是N / √P,这是一个更大的值,如果我的P很大(在多核机器上),这可能非常重要。
因此,这种以瓦片格式(而不是行块)重新分配工作的技巧,意味着我有更高的算术强度,我每发送一个字节做更多的操作。我可以在较低的内存带宽下保持全速运行,或者如果我在一台有很多核心的机器上,我能在更长时间内保持更高的利用率。
最小化……嗯,我的意思是,你可以,但请继续。这取决于你。如果你受到带宽限制(通信限制),你为增加算术强度所做的任何事情都会转化为性能提升。
现在,你知道,你想把它切成圆形。你会怎么处理里面的区域?我不太确定,你可能要浪费很多数学计算来弄清楚你在做什么,以至于它可能不会更快。但是,这是一个相当大的改进。
换句话说,这样想:想象你在一台64核机器或16核机器上运行。√P与P的差异是4倍。使用这个方案,你可以用四倍少的带宽保持峰值利用率。这可能是一件大事。
缓存行为与人为通信:缓存分块 🧱
好的,这是一个减少固有通信的优化例子,因为那是必须移动的数据。
通常,我们正在与一堆固有或人为通信作斗争,这源于我们从未在计算机中移动一块数据。我们总是移动一个缓存行,或者最小数据包大小可能是一千字节之类的。所以总是有机器如何工作的细节,让你觉得“哦,那个通信不应该那么糟糕”,然后你又说“哦,天哪,那真的很糟糕”。
让我给你们一个常见的例子,那就是缓存。这就是为什么我们在本课程中教授缓存。
想想网格求解器的缓存行为。想象我有一个缓存,缓存行大小是四个元素。所以每个蓝色东西是四个点宽。所以是四个元素。想象我有一个缓存,有6行或总共24个网格元素。
所以想象一下,当我计算那个红点时,作为计算那个红点的结果,我读取了四个基本方向的邻居。希望你们同意,这些将是加载的缓存行。
因此,在产生那个红点之后,这些将是缓存行。我水平移动一个元素。会有任何缓存未命中吗?不,实际上,它们都是缓存命中。现在想象一下,我们水平方向进行,当我们到达行尾时,我已经计算了所有这些元素,这就是我的缓存状态。记住,我的缓存可以容纳6行。
现在,我想让你们思考一下,当我回到这里时会发生什么。我想处理这个红点。我的缓存中有蓝色区域的数据,但不久前,我拥有所有我需要的数据(除了红点下面的行)在缓存中。现在它不在那里了。所以我会再次在所有东西上未命中。
这有点像是人为通信,对吧?理论上,我加载了那个数据。如果我有某种神奇的缓存,我不会再次将其通信回处理器。但如果你实际看这个程序,你会说,不,每次我接触数据时,我都会遇到所有这些缓存未命中。
所以如果你仔细观察,这个程序每输出四个元素,我加载三个新的缓存行。这是一种思考方式。在这里可能更容易看到。
如果我继续……一旦我进入稳定状态。注意这里,第一个元素,三个缓存行;第二个元素,没有新缓存行;第三个元素实际上会加载三个更多的缓存行。但在整个过程中处于稳定状态时,我每加载三个缓存行做四个红点。
所以这是我的算术强度:每三个缓存行四个输出元素。这是工作与带宽的比率。
有很多人为通信的例子,比如缓存行、有限大小的缓存,或者必须在16字节边界上传输的网络流量等等。
减少人为通信:缓存分块技术 🧩
我的分块例子(瓦片化)是改变工作分配给处理器以减少固有通信的例子。我将世界划分为瓦片,我说,好吧,如果我做不同的工作负载平衡,你本质上会通信更少。
现在,让我给你们一个减少人为通信的技术。或者,实际上,也许你可以想出一种技术,而不是像这样遍历数组。有没有办法可以改变程序来增加其算术强度?
是的。固有通信是……给定方案,实际上有多少数据必须在处理器之间移动才能完成工作。在这个例子中,实际上,我去掉了并行性。所以假设这一切都在一个线程内进行。
所以现在我说,即使在一个工作单元内部,之前有跨处理器或跨工作单元的通信。这是工作单元内部处理器与其自身内存之间的通信。我能做得更好吗?
也许如果你像这样穿过顶部,然后只是向下移动……或者从左到右像锯齿形模式。所以我这里的目标是:当我回到左边时,我想足够快地回来,在数据从缓存中掉出之前。这就是正在发生的事情。
所以,如果我改变迭代这个数组的顺序,我计算出相同的答案。我只是要以不同的顺序迭代。这又与并行性无关。这只是关于通信。
现在,每当我回到这里时,我已经访问过的一些数据仍然有效并在我的缓存中。我只需要加载下面这一条新线。
如果你展开这种模式,以前是每三个加载的行四个输出。这种新模式是每两个加载的行六个输出元素。所以我再次相当大地增加了我的算术强度。
这被称为缓存分块。这可能是你在任何涉及张量和矩阵的代码中会做的最重要的技术。因此,任何现代矩阵乘法、任何现代张量操作,KNN如此之快的原因是因为选择了非常好的顺序来遍历数据。
以相反的顺序跨行迭代怎么样?比如一直跨过去,然后一直回来?那在这里也是一个合理的方案吗?实际上,如果你看这个图表,但想象N非常宽,它在这里不是一个合理的方案。如果N非常宽,当然你在两端得到一点重用,但你又回到了中间的故障模式。所以,是的,在这个图表上,你会看着它说,是的,两端的重用是关于一切的。但现在我假设N是1000。对于我们的参数,比如我们的L1缓存是16K或32K之类的。是的,我认为是32K。一个矩阵可能非常大,比如一个2K x 2K的矩阵会超出你的L3缓存。
但是,我们是从角落算出来的吗?没有我们,会想,哦嘿,这有点像正在发生的事情,这正好是缓存的东西。

如果我像我们一样通过性能剖析来弄清楚呢?性能剖析会告诉你你的算术强度是多少。比如,工具可以说你受到带宽限制。它们会告诉你这一点,但它们不会告诉你如何修复它。

循环融合:另一个提高算术强度的例子 🔄
这是另一个经常出现在你们生活中的例子。这是一个可能看起来像C语言的程序,但它很像你们可能在NumPy或类似东西中运行的代码,对吧?所以我有一堆用于数组乘法或加法的库函数。它非常像作业1中的saxpy。

但通常,如果你有那个库,你可能通过在数组上执行一些表达式来进行更复杂的计算,我在这里用C代码写了。所以,
007:GPU架构与CUDA编程 🚀


在本节课中,我们将学习现代GPU(图形处理器)的架构以及如何使用CUDA(Compute Unified Device Architecture)进行编程。我们将从GPU的历史发展讲起,了解其如何从专用于图形渲染的芯片演变为强大的通用并行计算平台。接着,我们将深入探讨CUDA编程模型的核心概念,并将其与我们已学的ISPC等并行编程模型进行对比。最后,我们将剖析现代GPU(以NVIDIA V100为例)的内部架构,理解其如何高效地执行成千上万个CUDA线程。






从图形处理器到通用计算处理器 🎮➡️💻


上一节我们回顾了多核、SIMD和多线程等并行计算的基本思想。本节中,我们来看看这些思想是如何在GPU上以更大规模实现的。
最初的GPU设计是为了解决一个特定问题:根据场景的数学描述(几何表面、光源、虚拟相机位置),模拟光线传播和材质交互,最终生成一张图像。这个过程被称为图形渲染管线。
为了实现实时渲染(例如每秒60帧),GPU需要处理数百万甚至数十亿像素。因此,制造商开始不断增加更多的处理核心和算术逻辑单元(ALU),以并行计算每个像素的颜色。这本质上是一种数据并行计算:对每个像素独立地运行相同的着色器程序。

大约20年前,当CPU单线程性能提升遇到瓶颈时,研究人员开始思考:既然GPU拥有如此多的核心且能运行程序,能否将其用于图形渲染之外的计算?于是出现了早期的“黑客”方法:程序员会渲染两个覆盖整个屏幕的三角形,从而触发GPU为每个像素调用着色器程序,但在着色器程序中,他们并不计算颜色,而是执行物理模拟、蛋白质折叠等通用计算任务。

受此类研究的启发,NVIDIA意识到需要提供一个更正式的编程接口,让开发者能直接编写通用目的代码来利用GPU的强大算力,而无需借助图形API的“障眼法”。这直接催生了CUDA的诞生。

CUDA编程模型 🧑💻

CUDA的编程模型与我们之前学习的ISPC非常相似,它是一种SPMD(单程序多数据)模型。这意味着你编写一个内核函数,然后指定启动大量该函数的副本(即线程)来并行处理数据。
核心概念:线程、线程块与网格
在CUDA中,执行的基本单位是线程。但与CPU线程不同,CUDA线程是轻量级的,并且通常以线程块的形式组织。多个线程块又构成一个网格。
以下是一个简单的CUDA矩阵加法示例,展示了如何启动内核:


// 主机(CPU)代码
int main() {
// ... 分配内存等初始化操作 ...
// 定义网格和线程块的维度
dim3 threadsPerBlock(4, 3); // 每个线程块有4x3=12个线程
dim3 numBlocks(12/4, 6/3); // 网格由多个线程块组成
// 启动内核
matrixAdd<<<numBlocks, threadsPerBlock>>>(dev_A, dev_B, dev_C);
// ... 后续操作 ...
}
// 设备(GPU)内核函数
__global__ void matrixAdd(float* A, float* B, float* C) {
// 计算当前线程负责的矩阵元素索引
int i = blockIdx.x * blockDim.x + threadIdx.x;
int j = blockIdx.y * blockDim.y + threadIdx.y;
// 执行加法
C[i * N + j] = A[i * N + j] + B[i * N + j];
}

代码解析:
__global__:声明这是一个CUDA内核函数,将从主机调用,在设备上执行。<<<numBlocks, threadsPerBlock>>>:这是CUDA特有的语法,用于配置内核启动的参数。它指定了网格中线程块的数量以及每个线程块中的线程数量。blockIdx,threadIdx,blockDim:这些是CUDA内置的变量,分别表示线程块索引、线程在线程块内的索引以及线程块的维度。内核函数中的每个线程通过这些变量来确定自己唯一的工作项。
内存模型

CUDA采用分离的地址空间模型:
- 主机内存:由CPU代码(如
malloc)分配,CPU可访问。 - 设备全局内存:由CUDA API(如
cudaMalloc)在GPU上分配,所有CUDA线程均可访问。 - 共享内存:每个线程块内部共享的一块高速内存,用于线程间协作,由
__shared__关键字声明。 - 本地内存:每个线程私有的内存(如局部变量)。
数据需要在主机内存和设备全局内存之间显式拷贝(使用cudaMemcpy)。共享内存的访问速度远高于全局内存,因此巧妙利用共享内存是优化CUDA程序性能的关键。
一个优化示例:1D卷积
考虑一个一维卷积操作,每个输出元素是其周围三个输入元素的平均值。一个简单的实现是每个线程直接从全局内存读取三个所需的值。

__global__ void convolveSimple(float* input, float* output, int N) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i >= 1 && i < N - 1) { // 处理边界
output[i] = (input[i-1] + input[i] + input[i+1]) / 3.0f;
}
}
然而,相邻的线程会读取重叠的输入数据(例如,线程i和线程i+1都会读取input[i]),导致对全局内存的重复访问。
我们可以利用共享内存进行优化:让一个线程块内的所有线程协作,先将该块计算所需的所有输入数据加载到共享内存中,然后再进行计算。这样,每个输入元素从全局内存只被读取一次。

__global__ void convolveOptimized(float* input, float* output, int N) {
__shared__ float s_data[BLOCK_SIZE + 2]; // 共享内存,大小比线程块多2
int globalIdx = blockIdx.x * blockDim.x + threadIdx.x;
int localIdx = threadIdx.x;
// 协作加载数据到共享内存
s_data[localIdx + 1] = input[globalIdx]; // 加载主数据
if (localIdx < 2) { // 边界线程加载额外数据
s_data[localIdx == 0 ? 0 : BLOCK_SIZE + 1] = input[globalIdx + (localIdx == 0 ? -1 : BLOCK_SIZE)];
}
__syncthreads(); // 确保所有数据已加载到共享内存
// 从共享内存中读取数据进行计算
if (globalIdx >= 1 && globalIdx < N - 1) {
output[globalIdx] = (s_data[localIdx] + s_data[localIdx+1] + s_data[localIdx+2]) / 3.0f;
}
}
代码解析:
__shared__ float s_data[...]:在共享内存中声明一个数组。__syncthreads():这是一个线程块级别的屏障同步。它确保线程块内的所有线程都执行到此位置后,才继续向下执行。在上面的代码中,它保证了在所有线程完成数据加载到s_data之前,没有线程开始执行卷积计算,从而避免读取未初始化的数据。
GPU架构揭秘 🏗️
现在,让我们看看GPU硬件是如何执行这些CUDA线程的。我们将以NVIDIA Volta V100架构为例。
核心构建块:流式多处理器
GPU由许多称为流式多处理器的核心组成。每个SM是一个功能强大的多线程处理器,内部包含:
- 多个CUDA核心:用于执行算术运算。实际上,这些核心以SIMD方式组织。
- 大量寄存器文件:为成千上万个CUDA线程提供执行上下文。
- 共享内存/L1缓存:供线程块内部使用的高速可编程内存。
- 指令调度单元:负责从多个线程中获取和解码指令。
关键概念:线程束
GPU硬件将32个连续的CUDA线程分组为一个线程束。这是调度和执行的基本单位。
- 隐式SIMD:线程束中的所有线程执行相同的指令。如果这32个线程的程序计数器相同,硬件会以SIMD方式在32个CUDA核心上同时执行该指令。这与CPU上编译器显式生成SIMD指令不同,GPU硬件是动态检测并执行SIMD的。
- 线程束发散:如果线程束内的线程由于条件分支而执行不同的路径(例如
if-else),GPU会串行化执行所有路径,并禁用不活跃线程的通道。这会导致性能下降,应尽量避免。
大规模多线程与调度
一个SM可以同时驻留多个线程块(例如V100的SM可驻留多达32个线程块),管理上千个CUDA线程。硬件调度器会从所有驻留的线程束中选择就绪的线程束,并将其指令分派到执行单元。
这种大规模多线程的设计主要目的是隐藏延迟。当一些线程束因为等待内存访问而停顿时,调度器可以立即切换到其他就绪的线程束,从而保持执行单元的繁忙,最大化硬件利用率。
资源限制与线程块调度
当启动一个内核网格时,GPU工作调度器会为每个线程块分配资源,包括:
- 执行上下文:线程块中每个线程所需的寄存器。
- 共享内存:线程块声明的共享内存大小。
调度器会将线程块分配到有足够可用资源的SM上。只有当线程块执行完毕,释放其资源后,该SM才能调度新的线程块。因此,编写内核时,需要合理设置线程块大小和共享内存使用量,以最大化SM的占用率,从而提升性能。
重要约束与总结 🎯
本节课中我们一起学习了GPU和CUDA编程。最后强调几个关键约束:
- 线程块内的线程并发执行:线程块被设计为在一个SM上并发执行。因此,线程块的大小不能超过目标GPU的SM所能支持的最大线程数。否则程序将无法启动。
- 线程块间的独立性:不同线程块之间没有执行顺序的保证,也不能通过全局内存的屏障进行同步。它们可以通信(例如通过原子操作),但必须假设它们以任意顺序执行。线程块间的依赖或同步可能导致死锁或未定义行为。
- 线程块内的协作:线程块内的线程可以通过共享内存和
__syncthreads()屏障进行高效协作。这是CUDA编程中进行优化和数据重用的主要手段。

总而言之,CUDA提供了一种基于大规模数据并行和层次化线程组织的编程模型。通过将计算任务分解为大量可并行执行的线程,并利用线程块内的协作以及GPU硬件的大规模多线程和SIMD能力,我们能够极大地加速适合并行处理的计算密集型应用。
008:数据并行思维 🧠



在本节课中,我们将学习一种不同的并行编程思维方式。到目前为止,课程主要围绕线程展开。今天,我们将深入探讨数据并行思维,即通过一组丰富的、已知具有高度并行实现的原语操作来表达计算,而不是直接管理线程和依赖关系。



从线程思维到数据并行思维 🔄
上一节我们介绍了GPU等硬件需要大量并行任务(例如数十万级别)才能充分利用其计算能力。本节中,我们来看看如何通过更高层次的抽象来组织和表达这种大规模并行性。

到目前为止,我们编写的代码大多可以归结为对数组的循环操作。数据并行思维的核心是:将计算表达为对数据集合(序列)的一系列标准操作,并假设这些操作本身已有高效的并行实现。
核心原语操作 ⚙️
以下是数据并行编程中几个最核心的原语操作。
映射 (Map) 🗺️
映射操作将一个函数应用到输入序列的每个元素上,产生一个输出序列。这是我们最熟悉的操作。

定义:
map :: (A -> B) -> Seq A -> Seq B
代码示例 (伪代码):
# 函数 f: x -> x + 10
输入序列 A = [1, 2, 3, 4]
输出序列 B = map(f, A) # 结果为 [11, 12, 13, 14]
并行性分析:
映射操作是天然并行的,因为每个元素的处理相互独立,没有依赖关系。实现时,可以将序列分割成多个子序列,分配给不同的处理器或线程并行处理。
折叠 (Fold) 📦

折叠操作(也称为归约)将一个二元结合操作符重复应用于序列的所有元素,最终产生一个单一的结果,例如求和、求最大值。

定义:
fold :: (B -> A -> B) -> B -> Seq A -> B

代码示例 (求和):
输入序列 A = [1, 2, 3, 4]
初始值 init = 0
二元操作 f = add # 加法
结果 = fold(f, init, A) # 计算 0+1+2+3+4 = 10
并行性分析:
折叠操作的并行化需要满足条件:二元操作符必须是可结合的 (Associative)。例如,加法 (a+b)+c = a+(b+c) 满足结合律,因此可以并行求和。实现时,可以将序列分割,各线程计算局部和,然后再合并这些局部和。
扫描 (Scan) 🔍
扫描操作是折叠的“扩展”版本,它计算并输出所有“前缀”结果,而不仅仅是最终结果。例如,前缀和 (Prefix Sum)。
定义 (包含性扫描):
scan :: (A -> A -> A) -> Seq A -> Seq A
对于序列 [a0, a1, a2, ...],输出 [a0, a0⊕a1, a0⊕a1⊕a2, ...],其中 ⊕ 是二元操作符。
代码示例 (前缀和):
输入序列 A = [1, 2, 3, 4]
二元操作 f = add
输出序列 = scan(f, A) # 结果为 [1, 3, 6, 10]

并行性分析:
扫描的并行化比折叠更复杂,因为元素间存在依赖关系。存在经典的并行扫描算法(如Blelloch算法),其工作复杂度为 O(n),并行跨度 (Span) 为 O(log n)。该算法分为“上扫”(Upsweep)和“下扫”(Downsweep)两个阶段,通过树形结构高效计算所有前缀。

实现考量:
算法的选择取决于硬件。在SIMD架构(如GPU warp)上,简单的 O(n log n) 算法可能因为指令一致性反而更快。而在多核CPU上,可能采用分块后混合并行与顺序的策略。
分段扫描 (Segmented Scan) 🧩
分段扫描是扫描的泛化,用于处理序列的序列(例如,图的邻接表、文档的词列表)。它对每个子序列独立进行扫描操作。

定义:
给定一个主序列和标记子序列起始位置的标志序列,对每个子序列进行扫描。
应用示例:
稀疏矩阵向量乘法 (SpMV)。使用分段扫描可以高效地并行计算矩阵每一行与向量的点积,即使每行的非零元素数量不同。
聚集与散播 (Gather & Scatter) 📥📤
这两个是数据移动原语。
- 聚集 (Gather):根据索引序列,从源数据序列中收集对应位置的元素,形成一个新的密集序列。
- 散播 (Scatter):根据索引序列,将数据序列中的元素放置到目标序列的指定位置。

硬件支持:
现代CPU的SIMD指令集(如AVX2)支持向量化聚集操作。高效的散播实现更具挑战性,有时可以通过排序、分段扫描等原语组合来实现。
数据并行思维的应用实例:粒子网格构建 🎯
上一节我们介绍了一系列数据并行原语。本节中,我们来看看如何运用这些原语解决一个实际问题:为大量粒子构建空间网格索引。

问题描述:
将空间划分为网格(例如4x4=16个单元格),对于数百万个粒子,需要快速构建一个数据结构,使得对于每个网格单元格,我们能立即获取位于其中的所有粒子列表。
传统线程化方法的挑战:
如果为每个粒子分配一个线程,并尝试将其添加到对应单元格的列表中,会导致对共享列表或锁的激烈竞争,可扩展性差。
数据并行解决方案:
以下是使用数据并行原语的解决步骤:
- Map:为每个粒子并行计算其所属的网格单元格ID。
- Sort:根据单元格ID对粒子索引进行排序。排序后,属于同一单元格的粒子在序列中连续排列,实现了“分组”(Group By)的效果。
- Map:再次并行遍历排序后的序列,通过比较相邻元素的单元格ID,找出每个单元格组的起始位置。
- 输出数据结构:现在我们得到了两个数组:
cell_starts和cell_ends。对于单元格i,particles[cell_starts[i] : cell_ends[i]]就是位于其中的所有粒子索引。
这个方案完全并行,并行度与粒子数量成正比,且避免了锁竞争和重复遍历。



总结与延伸 📚
本节课中我们一起学习了数据并行编程思维。其核心思想是将复杂算法分解为 map、fold、scan、sort 等已知可高效并行实现的原语操作。通过这种方式,程序员可以从繁琐的线程管理和依赖分析中解放出来,专注于算法逻辑,并能自然地获得良好的可扩展性。

这种范式被广泛应用于许多现代并行框架中:
- GPU编程:NVIDIA的Thrust库提供了类似的数据并行原语集合。
- 分布式计算:Apache Spark的核心抽象RDD (Resilient Distributed Dataset) 及其操作(如
map、reduceByKey)正是基于这一思想,从而实现了跨集群的并行、容错的数据处理。



在接下来的编程作业中,你将有机会实践这些概念,例如实现并行扫描算法,并体验数据并行思维如何简化并行程序的设计。
009:使用 Spark 进行分布式数据并行计算



在本节课中,我们将要学习如何使用 Spark 进行分布式计算。我们将探讨如何将数据并行编程思想应用于由多个独立操作系统实例组成的分布式计算机系统,并理解 Spark 编程模型的核心概念。


概述:分布式计算与 Spark
在之前的课程中,我们学习了如何优化单芯片多核的并行性能,以及如何使用 CUDA 编写拥有成千上万线程的数据并行程序。今天,我们将思考如何利用数据并行编程思想来编程一个分布式计算机系统,即由多个独立操作系统实例组成的计算机。
我们将要讨论的主要编程模型是 Spark。我们的目标是理解如何让数据并行编程模型扩展到数十万个核心,并高效地处理故障和内存使用问题。


为什么需要分布式集群?

使用机器集群而非单机的主要动机在于处理海量数据。例如,处理像 Facebook 这样大型网站的日志数据,可能涉及数百 TB 的数据。如果使用单节点处理,其性能将受限于磁盘 I/O 带宽。假设 I/O 速率为 50 MB/s,处理这些数据可能需要 23 天。然而,如果使用 1000 个节点,存储系统的带宽将提升一千倍,处理时间可缩短至 33 分钟。

因此,使用集群的一个关键原因是为了获得足够的 I/O 带宽。但随之而来的挑战是如何编程管理这数十万个核心,并处理系统中可能出现的故障。
仓库级计算机
集群的概念已发展为 仓库级计算机,这是谷歌、Facebook、亚马逊等大型网站背后的计算基础设施。这些系统由成千上万的计算机通过网络连接组成一个统一的计算环境。



最初,集群由通过以太网连接的商用 PC 组成,成本相对较低。但随着应用发展,人们发现网络性能是区分集群与超级计算机的关键。因此,现代的仓库级计算机开始采用定制化的高性能网络,其成本也逐渐接近高性能计算领域。

系统架构与节点组织
在仓库级计算机中,计算机被组织成机架。每个机架顶部有一个 机架顶部交换机,用于连接系统中的其他机架。机架内则堆叠着 20 到 40 台服务器。
单个节点的典型配置如下:
- CPU: 可能包含两个 CPU 插槽,每个芯片有 16 到 32 个核心。
- 内存: 连接到 DDR 芯片,提供 128 GB 到 2 TB 的主内存,带宽约为 100-200 GB/s。
- 存储: 固态硬盘,提供 10 到 30 TB 的存储空间。
- 网络: I/O 接口连接到网络。


一个重要的观察是,内存带宽(100-200 GB/s)远高于网络带宽(早期可能只有 0.1 GB/s,现代可达 2 GB/s)和磁盘带宽(约 2 GB/s)。随着网络带宽提升,从远程节点磁盘获取数据的带宽可能与访问本地磁盘相当。
节点间通信:消息传递
由于集群中的每个节点运行着独立的操作系统,它们无法共享内存。节点间的通信需要通过 消息传递 机制。

消息传递的抽象过程如下:
- 一个线程在其地址空间中有变量
X。 - 它发起一个
send调用,指定变量X的地址和目标线程。 - 消息通过网络发送。
- 运行在不同节点上的目标线程执行
receive操作,将消息存入其本地地址空间的变量Y中。



消息传递本身包含了同步语义,因此通常不需要额外的显式同步,但需要注意避免死锁。
数据持久化:分布式文件系统

为了确保在组件故障时不丢失数据,我们使用 分布式文件系统,例如谷歌的 GFS 或其开源版本 Hadoop HDFS。

分布式文件系统的设计基于特定的使用模式:
- 大文件: 文件大小可达数百 TB。
- 访问模式: 主要是读取和追加写入,很少原地更新(例如日志数据)。
其核心思想是将大文件分割成 块(通常为 64-256 MB),并在不同机架上 复制 这些块,以防止单个机架故障导致数据丢失。一个 主节点(NameNode)负责管理所有块的元数据(位置信息)。客户端读取文件时,先向主节点查询块的位置,然后直接从存有该块的数据节点读取。
编程挑战与 MapReduce 模型

假设我们要分析课程网站的页面访问日志,了解用户使用的设备类型。我们可以将日志文件分割成块,并分布在集群的四个节点上。
虽然可以使用消息传递接口(如 MPI)来编写分析程序,但这通常很复杂且不易处理容错。因此,我们回顾 数据并行函数式原语,特别是 Map 和 Reduce 模型。
Map 操作接收一个类型为 A 的序列,并通过对每个输入元素应用一个函数,产生一个类型为 B 的序列。其重要特性是:
- 易于并行化: 元素间无依赖。
- 无副作用: 不改变输入数据。
Reduce 操作接收一个类型为 A 的序列,并通过一个归约函数产生一个类型为 B 的单个元素。

MapReduce 编程模型的核心是 Mapper 函数 和 Reducer 函数。以词频统计为例:
- Mapper: 每个 Map 任务处理输入文件的一个块,读取每一行,并为每个单词生成一个键值对
(word, 1)。 - Shuffle/Sort: 系统根据键对中间结果进行分组,确保相同键的数据被发送到同一个 Reducer。
- Reducer: 每个 Reducer 任务处理分配给它的键,对相同键的所有值进行求和。
MapReduce 的执行与容错
任务调度:
- Map 任务: 通常调度在存储有对应输入数据块的节点上执行,以利用数据本地性,减少网络传输(特别是在网络带宽较低的早期)。
- Reduce 任务: 通过哈希函数将键映射到不同的 Reducer 节点,以实现并行。调度器会考虑将 Reducer 任务分配在拥有大部分相关中间数据的节点附近,以最小化数据移动。
容错与慢节点处理:
- 节点故障: 主节点通过心跳检测节点故障。如果 Map 节点故障,调度器会在其他拥有数据块副本的节点上重新运行该 Map 任务。由于 Map 函数是无副作用的,重算安全。Reducer 节点故障处理类似。
- 慢节点(Straggler): 调度器可以启动相同任务的备份副本,哪个先完成就采用哪个的结果,并终止另一个。

MapReduce 模型的优势在于提供了简单易懂的数据并行编程抽象,自动进行任务划分、负载均衡、本地性感知调度,并能优雅地处理故障和慢节点。

MapReduce 的局限性与 Spark 的动机
尽管 MapReduce 影响深远,但它存在一些局限:
- 编程模型简单: 仅限于 Map 后接 Reduce 的线性组合,限制了应用表达。
- 迭代算法低效: 例如 PageRank 算法,每次迭代都需要从分布式文件系统读写中间数据,I/O 开销大。
- 交互式查询低效: 每次即席查询都需要访问文件系统。
关键问题在于,MapReduce 严重依赖低带宽的存储I/O路径,而未能充分利用高带宽的内存。研究表明,许多大数据应用的工作集可以完全放入内存。因此,我们希望有一个新的编程模型,能像 MapReduce 一样友好,但能更密集地利用内存。

挑战在于如何实现 内存中的、容错的分布式计算。传统的容错方法(如检查点、日志记录)可能带来高性能开销。MapReduce 通过将中间结果写入文件系统来实现容错,但这正是性能瓶颈所在。
Spark 的核心抽象:弹性分布式数据集

Spark 引入了核心编程抽象——弹性分布式数据集。RDD 是一个只读的、分区的记录集合,它是不可变的。RDD 只能通过两种方式创建:
- 从持久化存储(如分布式文件系统)转换而来。
- 通过对其他 RDD 进行转换操作而来。
例如,分析日志的 Spark 程序可能如下:
val lines = spark.textFile("hdfs://...") // 从文件创建 RDD
val mobileViews = lines.filter(_.contains("Mobile")) // 转换:过滤出移动端访问
val safariViews = mobileViews.filter(_.contains("Safari")) // 转换:过滤出 Safari 浏览器
val count = safariViews.count() // 动作:触发计算并返回结果
这里,lines、mobileViews、safariViews 都是 RDD。创建 RDD 的一系列操作称为 血统。



Spark 的优化与执行
Spark 程序通过 转换 和 动作 来构建。转换(如 map, filter, join)是惰性的,它们只记录血统图,并不立即计算。只有当遇到 动作(如 count, collect, save)时,才会触发实际计算。
为了优化性能,Spark 允许用户将频繁使用的 RDD 持久化在内存中:
mobileViews.persist()
这样,当多次使用 mobileViews RDD 时,就无需重新计算或从磁盘读取。
在实现层面,Spark 可以通过分析 RDD 之间的 依赖关系 来进行自动优化:
- 窄依赖: 父 RDD 的每个分区最多被子 RDD 的一个分区使用(如
map,filter)。这允许在单个节点上流水线化执行,类似于循环融合。 - 宽依赖: 父 RDD 的一个分区可能被子 RDD 的多个分区使用(如
groupByKey,join)。这需要数据洗牌,是更昂贵的操作。
通过利用这些高层语义信息,Spark 运行时可以自动应用诸如融合和分块等优化策略,从而减少内存使用并提高性能。
总结

本节课我们一起学习了分布式数据并行计算。我们从为什么需要集群开始,探讨了仓库级计算机的架构和消息传递机制。我们深入分析了 MapReduce 编程模型,包括其执行、调度和容错机制,也认识了它在迭代计算和交互式查询方面的局限性。最后,我们介绍了 Spark 及其核心抽象 RDD,了解了它如何通过内存计算、血统和依赖关系分析来提供更高效、更灵活的分布式数据处理能力,同时保持容错性。Spark 代表了在利用高层语义进行自动优化方面的重要进展。
010:在 GPU 上高效评估深度神经网络


在本节课中,我们将学习如何将深度神经网络的计算高效地映射到现代 GPU 和 CPU 上。我们将从理解 DNN 的基本计算模式开始,探讨如何通过算法优化、软件实现和硬件特性来提升性能。
课程概述与作业说明
课程进度正在加快,但每节课引入的新概念数量可能有所减少。目前阶段的核心是完成作业3,并通过实践练习来巩固对实际材料的理解。
作业3包含多个部分。前两部分是热身练习,我们会提供一个算法,你需要用 CUDA 实现它,目的是学习如何编译代码、创建线程等基本操作。作业的核心部分是最后的渲染任务。
与大多数其他系统课程不同,这个作业不仅要求实现高性能,还需要设计出能够实现并行化的算法。
以下是作业需要解决的问题:渲染一张图片。为了简化(因为这不是计算机图形学课程),图片中只能绘制圆形。


我们提供每个圆的中心坐标 (X, Y)、半径和颜色。所有图像都是由圆形构成的。
渲染的基本循环非常简单:对于给定的每个圆,计算其边界框,然后遍历边界框内的每个像素。如果像素中心位于圆内,则根据圆的颜色更新该像素的颜色。这是最简单的圆形绘制算法。
挑战在于这些圆形是半透明的。例如,一个50%透明的红色圆叠加在蓝色圆上,与蓝色圆叠加在红色圆上的效果不同,因为透明度的影响。
问题的关键在于,我们会按特定顺序(例如从后到前)提供一个圆形数组。你必须按照这个顺序处理圆形。这个顺序约束使得问题变得有趣。
例如,蓝色在绿色之上,绿色在红色之上,与不同的叠加顺序会产生不同的结果。错误的顺序会导致错误的渲染结果。
因此,游戏规则是:如果你只是顺序地执行这个嵌套循环,这是一个有效的算法。但如果你尝试并行化这个循环(例如,为每个圆分配一个不同的线程并行处理),就会得到错误答案。
第一步是确认这种并行化确实会得到错误答案,然后进行修改。在“数据并行思维”课程中,我们讨论过一些可能性。这类似于“每个粒子属于哪个格子”的问题。
关键要记住的是,只有当圆重叠且处理顺序错误时,才会得到错误答案。如果圆不重叠,处理顺序无关紧要。


因此,第一步应该是修改代码以获得正确的答案。这个正确的答案将是并行的,但可能非常慢。然后,你的任务就是逐步优化,使其变得正确、并行且快速。这就是作业的流程。

换句话说,如果你能知道每个像素可能被哪些圆覆盖,问题就基本解决了。如果你有一个神奇的数据结构,能为屏幕上的每个像素提供一个可能重叠的圆形列表,你就可以安全地并行处理每个像素。
深度神经网络工作负载简介
由于课程安排原因,我们今天讨论高效评估 DNN 的主题,这对作业4会很有用。考虑到如今大家都在使用各种深度神经网络,我认为这个话题会相当有趣。
我将主要讨论如何在现代 GPU 和 CPU 上高效优化和运行深度神经网络。在课程最后,我会简要提及正在兴起的、用于 DNN 加速的专用硬件,但这部分内容将留到关于专用硬件的讲座中详细讨论。
今天的内容是关于将 DNN 映射到 GPU 和 CPU。


首先,为了确保所有人都熟悉 DNN 的工作负载,我需要介绍一下基础知识。我们可以用很少的数学知识来思考这个问题,这对本课程很有帮助。

从计算图到神经元

你在这门课中见过计算图。例如,在讨论超标量执行时,如果给你一个任务图,你现在就知道如何执行它。
考虑一个表达式。它基本上对应这里展示的计算:计算一些值的乘积,然后求和。仔细看,这实际上就是一个点积。


然后对这个点积的结果取最大值(max(0, value))。如果你上过早期的深度学习课程,你会看到一个类似下图的图表,他们称之为现代深度神经网络中的一个“神经元”。
本质上,它只是一个执行特定操作的电路。在这个神经元内部,有一组权重(一个向量)和一些输入(也是一个向量)。目前我们不需要考虑这些权重的具体含义。


这个神经元的输出可以看作是一个函数:它计算两个向量的点积,可选地加上一个偏置向量,然后通过某个非线性函数。在本课程中,我们将使用的非线性函数是 max(0, x)。在深度学习中,你可能听说它叫“修正线性单元”,但在这里它就是 max 函数。
因此,我们今天感兴趣的是将这个整体计算(即对一个大型矩阵代数运算结果应用非线性函数 F)重复多次。




你可以把这个神经元看作一个简单的二元分类器(从机器学习角度理解):如果输出大于0,表示“是”;小于0,表示“否”。作为计算机科学家,我们可以将这个简单函数连接起来,构建更复杂的函数。
网络层与连接方式

通常,这些网络以相当规则的方式排列。每一层都有一定数量的神经元,层的定义是:该层所有神经元的输出成为下一层神经元的输入。
在左侧图表中,你会看到一个全连接层的例子,意味着第 I 层的每个输出都是第 I+1 层每个神经元的输入。
在右侧图表中,你会看到一个非全连接层。实际上,这是一个一维卷积层,其中每三个输入(滑动窗口)连接到下一个神经元。


线性代数表示法

让我们换一种方式书写,使用线性代数符号,而不是视觉图表。

假设我有四个神经元,每个神经元有三个权重。输入向量 X 有三个值。计算输出的过程就是一个矩阵向量乘法。

当然,结果还要通过那个非线性函数 F。因此,所有这些花哨的图表很快就可以归结为密集的矩阵代数运算。
卷积操作
另一种思考方式是卷积。观察这段 C 代码:有一个宽度 x 高度的输入图像(实际上宽度+2 x 高度+2,考虑边界条件),输出是一个宽度 x 高度的图像。有一些权重。对于输出的每个像素,操作涉及输入图像中周围的像素。
这段代码做了什么?有些人可能会说它执行了2D卷积。那么2D卷积又是什么意思?
假设有一个输入图像,输出图像看起来是什么样子?它是模糊的。因为每个输出像素本质上是周围像素的平均值。
想象一个场景:一个白色输入像素周围是非常暗的像素。卷积会使这个白像素的值降低到平均值,同时使周围的黑像素值升高到平均值。所以,在这张图像上运行这段代码,你会得到一张模糊的图像。这是一个卷积的例子。
从卷积到神经元
在脑海中思考这个图像:如果我们把每个输出像素看作一个神经元,它的输出是9个输入的加权组合(在这个例子中,权重固定为 1/9)。这就是为什么我画了这张图,显示每个神经元有三个输入(在2D情况下是9个输入)。

如果我改变这些权重,使其中一些为负值,会发生什么?突然之间,这个计算不再是平均每个像素,而是说:我想要这些像素的正缩放值减去那些像素的值。这突然变成了一个有限差分计算,一个梯度计算。

因此,使用不同的权重,我的卷积不再模糊图像,而是在检测水平梯度或垂直梯度。
至少在图像处理中,本讲座前半部分将重点讨论这些卷积操作(在图像处理中很常见)。后半部分会提到 Transformer。但所有这些常见的网络(如 ConvNets)都包含大量卷积层,只不过权重不是由我给定的,而是通过学习得到的。
卷积网络与张量
这是一个经典 ImageNet 权重的例子:这里可视化了许多不同滤波器的权重。这是一个 11x11 的卷积。每个输出需要输入的 121 个元素。有 96 个不同版本的这些卷积。
如果用这些滤波器处理输入图像,你会得到不同的输出。可以说,不同的滤波器在图像的不同部分被“激活”,它们检测不同的特征。


因此,我希望你把这个计算看作是:对一堆具有不同权重的不同滤波器重复执行这段代码。这就是我们要处理的任务的核心。


可视化这些东西的常见方式是考虑这些 N 维张量。假设你有一个宽度 x 高度的输入图像,所以是 宽度 x 高度 x 1。然后想象我有所有这些不同的滤波器(例如 3x3 的值矩阵)。如果我有 num_filters 个滤波器,为了方便,我会将它们堆叠成一个张量,即 3 x 3 x num_filters。
如果我对输入进行 num_filters 次卷积,我应该得到 宽度 x 高度 x num_filters 的输出。然后我们将一遍又一遍地重复这个过程,产生一堆滤波器,将其通过函数 F(例如将值限制在0以上),可能还会进行一些其他计算(如最大池化,本质上是下采样)。

当你看到所有这些花哨的图表时,本质上,每个块主要就是这一系列卷积操作(至少是计算的核心部分),只是以不同的方式连接起来。例如,这里有 ResNet,这里有 Google 的 Inception 架构。
至此,我希望你明白:能够在一堆输入图像上执行大量卷积操作,产生一堆输出图像,然后在这些输出上再次执行卷积,这很重要。这就是你需要了解的工作负载。
加速 DNN 的途径
现在,让我们思考如何加速这些计算。在本课程中,这是一个很好的节点来讨论我们的途径,主要有三种:
途径一:算法与架构设计
第一种方式是我们上机器学习课时考虑的:设计更好的网络架构。例如,ResNet 和 Inception 所需的浮点运算次数和内存比早期现代深度学习浪潮中一些著名的 ConvNets 要少得多。显著的算法和拓扑设计改进减少了所需的内存和计算量。

这与我在本课程中教授的技能无关。这需要你去上机器学习课,思考诸如:“随机梯度下降的方式……我觉得通过跳跃连接在几层之间传递信息很有价值”(这是 ResNet 的一大创新),或者“我不需要很多卷积,只需要几个,但必须以特定方式连接它们”。
例如,来自 Inception-ResNet 模块的设计:如果你思考需要什么,你可能会觉得需要几条 3x3 卷积路径来处理这种空间范围的特征。可能需要几级串联。可能还需要一条具有更广泛特征的路径。作为这些网络的设计师,你会思考这类事情。
如果你在 Google 设计这些网络,并且要求让它们能在手机上运行,你可能会花两年时间尝试各种组合,最终得出一个看起来像 MobileNet 的架构。这是在 2017 年左右的设计。


关键是,首先,许多系统课程将当下最好的架构视为给定,并尝试优化它。但我想让你记住,在算法方面,一直有巨大的创新和迭代。


以图像分类任务为例(这很大程度上推动了深度学习的兴起)。这些是研究中人们设计的不同网络。基本上,它们的准确率随时间提高。这张图的 Y 轴是准确率(如图像中物体分类的准确度),下方图表是准确率与计算开销的比值。
更好的拓扑结构实际上是关于效率的。那张图实际上是单位成本准确率的二维图,你希望处于图的左上角。
如果我只看其中几个网络,每年都有变化。当时的准确率大致相同,但如果你看权重的数量(大致是滤波器的大小),存储模型所需的内存有显著下降,计算所需的数学运算量也有几乎相应的下降。


在大约四年时间里,算法带来了约 25 倍的提升。硬件不可能在四年内快 25 倍。

因此,如果你为某个网络设计了一个算法,当网络架构变化时,你可能已经偏离了最优路径。一个好的机器学习系统工程师必须跟踪这些前沿进展。
对于如今的大型语言模型和 Transformer,由于缩放定律,存在构建越来越大模型的巨大推动力。但在达到一定质量水平后,过去6到12个月里,随着 LLaMA 等模型的出现,工程师们开始大幅缩小这些模型。这使得“你的系统应该为什么而设计”成为一个复杂的问题。
途径二:在给定硬件上高效执行
第二种方式与本课程高度相关:在某个时刻,我们确定了要使用的架构和拓扑,我们需要让它在现有机器上高效执行。在本课程中,机器指的是多核 CPU 或 GPU。

让我以 3x3 卷积为例,稍微修改 C 代码来更详细地讨论。

首先,现在有 7 层循环。最外层是遍历图像批次(batch size)。通常我们会一次性将多个图像输入网络。然后是遍历每个滤波器。对于每个滤波器,我们需要遍历输出像素 I 和 J。但输入图像有多个通道深度,所以你实际上是在遍历所有像素和所有 RGB 或 512 个通道等。所以这是一个3D卷积,或者说是在3D输入上的2D卷积,针对每个滤波器,每个输出像素。
因此,这里有 7 层循环。实现起来并不难,但性能可能不会很好。
现在,问题在于卷积和矩阵代数之间的联系。
让我展示一个快速技巧。你可以使用 NumPy 或你喜欢的库调用矩阵乘法。你可以假设,无论是 MATLAB、PyTorch 还是 NumPy,如果你调用矩阵乘法,已经有人花时间很好地实现了它。
所以,如果你能把某些操作归结为矩阵运算,你可能就处于一个不错的状态。
事实证明,将卷积视为矩阵乘法非常容易。早期的一些卷积层实现就是这样做的。
想象我有一个输入图像(每个 X, Y 位置有一个值)和一个 3x3 卷积(有9个权重)。每个输出像素只是这些权重与输入像素某个子集的点积。
我将把相应的输入像素复制到一个矩阵中,这个矩阵有 宽度 x 高度 行,9 列。所以这个矩阵是原始图像的9倍大。
我只是复制数据,使得输出行(或该行与此行的点积)是卷积的第一个像素。
幻灯片上展示的是如何复制值。然后考虑下一行并复制这些值。最清楚的情况可能是在没有边界条件的地方:要产生输出坐标 (1,1) 的像素,我需要输入的那9个像素,我把它们复制到这里。
卷积的输出就是那里的点积。
至此,我希望你能看到,通过适当的填充和数据复制,我可以将卷积实现为矩阵向量乘积。
检查一下你的理解:现在,想象我想用多个滤波器进行卷积,而不仅仅是一个。我的设置如何改变?这个列向量(一个卷积的权重),我只需要堆叠更多的列,它就变成了一个矩阵。所以,如果我想一次性对输入图像进行多个卷积,我只需将图像复制到左侧矩阵,我的权重作为多个滤波器的列放在那里,现在我就有了矩阵矩阵乘积。

所以,如果你给我一个矩阵乘法库,我知道如何填充这个矩阵来产生输出。
再检查一下你的理解:想象输入张量不是单通道图像(每个 X, Y 只有一个值),而是有多个值(一个多维张量)。假设它有 512 个值。我的计算如何改变?因为现在每个卷积将是 3x3x512。这个设置如何改变?这会乘以 512,并且我需要 3x3x512 的权重放在下面。所以我会得到大得多的矩阵。
现在,如果你给我地球上最快的矩阵乘法,我可以为你生成输入,或者用它来执行一个卷积层。
因此,如果你有一个快速的矩阵乘法库,你可以使用我上一张幻灯片告诉你的转换来执行卷积层。


实现快速矩阵乘法
但也许我们应该简要讨论一下,如果你想成为那些整天在这些库上工作的疯狂黑客,你该如何实现矩阵乘法。
矩阵乘法实际上更简单。我展示了一个7层循环嵌套,而矩阵乘法只是一个简单的三层循环嵌套。
思考一下:假设我有一个 M x K 矩阵乘以一个 K x N 矩阵。为简单起见,假设所有维度都是 N。我们最终会接触多少数据?3 * N2。我们会做多少工作?N3,因为有三层 N 循环。


这个实现的问题在于,对于大的 N(因为我们设置的 N 可能很大,例如 51277,这些可能是 GB 大小的矩阵),考虑到你对计算机工作原理的了解,你认为问题是什么?
最内层循环读取 A 的一行和 B 的一列来输出 C 的一个元素。我们会有非常糟糕的缓存局部性。
让我们更详细地思考:我们正在按行读取 A,我们对此特别担心吗?不完全是,因为这可能是你能做到的最连贯的内存访问。对于 B,如果数据是行优先存储,我们访问的每个元素在地址空间中都相距很远,位于不同的缓存行上。对于 C,实际上是最好的情况,因为我只是读写同一个值,它会留在缓存中。
但退一步思考,如果你回想作业中的带宽限制问题,这实际上并不比作业中的问题好多少。因为我们要读取 A 和 B,对 B 的访问非常糟糕,然后只是累加到 C。所以你实际上只做一两次数学运算(乘加),却要读取两个值。这是经典的带宽限制场景。
这有点奇怪,因为矩阵乘法,你告诉我,是 N^3 的工作量,N^2 的数据访问,所以本质上它的算术强度应该是 O(N)。但你给我的东西算术强度像是 O(1)。你(或者说我写的代码)给出了一个带宽限制问题。
然而,如果我能正确地进行,你会告诉我:“哦,只要把矩阵 A、B 和 C 放在缓存里就行了。”那么算术强度应该是 O(N),N^3 的工作量对应 N^2 的数据。但我不能把 A 和 B 放在缓存里的原因是什么?它们太大了,可能是 GB 级别的矩阵。所以,低算术强度看起来不是根本问题,但至少按我现在写代码的方式,我搞砸了。
我们怎样才能做得更好?

一个关键见解是:我可以将矩阵乘法表示为分块矩阵乘法,而不是对矩阵的单个元素进行操作。我可以分层表达矩阵乘法,即在一系列子矩阵块上进行矩阵乘法。
我将这样重写代码:将最外层循环以下的整个部分视为两个子块的矩阵乘法。最外层循环是在块上进行矩阵乘法。
思考一下:取 A 的一个块和 B 的一个块,将它们加载到缓存中,相乘这些矩阵,得到一个输出矩阵(C 的一部分)。然后移动到 A 的下一个块和 B 的下一个块,相乘这些矩阵,并以与之前将元素累加到 C 相同的方式,将得到的子块结果累加到正在计算的 C 块中。
现在我的算术强度是多少?对于每一步,我必须加载的数据量大约是 B^2,我做的工作量是 B^3。所以我的算术强度是 B,而不是 N。随着块大小 B 增大到 N,我得到 O(N) 的算术强度;随着块大小减小到 1,我得到 O(1) 的算术强度。
因此,选择块大小的思路应该是:尽可能大。“可能”在这里意味着:我不能让块大小大到开始在缓存中产生容量缺失。你需要知道缓存大小,选择块大小使得 A、B、C 的块能放入缓存,然后以这种方式组织代码。
如果你在 C 中实现这个,对于许多 MB 或 GB 大小的矩阵,速度差异可能达到 1000 倍,非常惊人。
所以,如果你像这样在大型矩阵上实现矩阵乘法,你将以带宽为限。如果你进行分块,速度会快得多。
在普通 CPU 上,缓存只是存储地址空间中的行。即使我选择的块大小是缓存行大小乘以缓存行大小,A 块所需的缓存行在地址空间中也不是连续的。缓存会找到地方存放它们。在 CPU 上,缓存的整个要点是软件程序员不需要管理它。
但在 GPU 的共享内存上,我们会有所不同。我们会明确地让 CUDA 线程从地址空间加载数据,并将其放入共享内存的连续分配中。这就是缓存和“暂存器”之间的区别。暂存器是一个不同的地址空间,而缓存只是同一地址空间上的一个实现细节。
无论如何,假设我们可以将这些块放入缓存。对于每个子步骤,我们将加载 B^2 个元素(A块),加载 B^2 个元素(B块),忽略重用,再加载 B^2 个元素(C块?实际上 C 是累加,初始可能为0或读取后累加),然后进行矩阵乘法,做 B^3 的工作。所以,对于每 B^2 的 I/O,我们做 B^3 的工作。
另外,请记住,任何现代系统(至少 CPU)都会有 L1、L2、L3 缓存。你可能还会分块到寄存器。所以我的代码可以很快增加循环。我现在只是选择不同的块大小,使得块能放入 L3 缓存,然后将其划分为能放入 L2 缓存的子矩阵乘法,依此类推。你几乎肯定希望为 L1 缓存分块,但一级分块就能完成大部分工作,也许再多一两级分块能带来额外提升。
当然,这只是一个使用 += 的算法。我甚至还没有讨论 SIMD。多线程似乎很简单,我可能只是并行化最外层循环。但 SIMD 可能有点棘手,因为有专业的黑客一直在研究这些东西。
让我们稍微思考一下 SIMD。假设我决定用内部函数写这个。现在,我的图不是整个矩阵,我在思考如何相乘这些块。注意维度现在是块大小。
我可以这样思考:我应该从 B 取一个向量,从 A 取一个元素,复制那个 A 元素四次。现在我的点积实际上同时产生 C 的多个输出。这有点意思。但我仍然在浪费这个广播指令。我不喜欢我仍然不是按行优先顺序遍历 B。我不喜欢这里的每条指令都依赖于前一条指令(因为我在做点积),这可能会损害 ILP 等。
因此,设计空间变得有趣而复杂。例如,我可以预先转置 B 的块。如果我预先转置了 B 的块,那么我实际上一直在做直接的 SIMD 点积。或者,我可以预先转置 A 而不是 B,产生转置的 C,然后在最后重新转置。
我不会深入任何细节。这取决于你的 SIMD 指令集和机器。我的观点是:如果我想让你们做,这很容易成为一个为期两周的编程作业,你们可以尝试所有这些不同的方法。
请记住,策略可能是块大小或矩阵维度的函数。不同的块大小可能适合不同的策略。
如果我们回头看看这个特定的网络,这里是你的输入矩阵大小,每一层都不同。所以,如果你真的想要一个好的实现,你可能需要为不同的层使用不同的矩阵乘法实现。如果你只实现一种,对于像上面那种奇怪的长矩阵,你可能会有些次优。
这些东西变得很棘手,人们已经在 cuBLAS 和这些矩阵乘法例程上投入了大量工作。
隐式矩阵乘法与内存占用
另外,还有一个大问题。我有一个输入图像或几个输入图像。我做了什么?我基本上取了每个元素,并复制了很多次来创建这个矩阵。我取了一个可能只有几百 MB 的数据集,实际上通过数据复制生成了可能几 GB 的矩阵。特别是考虑到一定的批次大小,如果你上过机器学习课,知道在反向传播期间需要保留额外信息,你的内存会很快耗尽,这些小矩阵突然让你的内存占用达到 16 GB。
所以,要记住的一点是,一种非常现代的做法是:我们将其视为矩阵乘法,但在需要时从原始数据源动态获取数据来构建这些块。
因此,实际数据将来自原始张量。如果你看这个,这是一个矩阵乘法(为简单起见,这里没有分块)。但元素 A 不是来自 X[i*width + j],而是来自一个访问器,该访问器通过计算来找出原始输入张量中(而不是我创建的矩阵中)该矩阵单元所需值的位置。
换句话说,他们在内层循环中通过计算地址(作为张量维度的函数)来换取不将数据解压缩到这些大矩阵中。
那么缓存效率呢?现在可能不对,因为我们没有考虑连续性。你是对的。这个感觉像是在迭代矩阵一行的点积,实际上是在整个原始图像上跳跃访问。但别太担心,因为记住,这本来就很糟糕。这将会被分块。
你应该这样想:当你将数据从 DRAM 加载到缓存或共享内存时,你会在那个时刻进行复制。你不是将其复制到 DRAM 中的新位置,然后再加载进来。这是一个很大的区别。
这就是你在“隐式 GEMM”中会看到的。另外,还有一件事:好的实现会预先计算从宽度、高度、张量数量、批次大小到地址的所有数学运算。你实际上会制作一个查找表,消耗一点内存来换回地址计算,这对于较大的张量可能非常重要。
NVIDIA 有一个叫做 cuTLASS 的库。如果你想成为一个相当厉害的黑客,但又不想完全自己用 PTX 在 GPU 上实现矩阵乘法,你可能会使用 cuTLASS。它让你能够访问非常快的矩阵乘法库,这些库适合共享内存,并且能够从不同位置获取数据。所以,这介于编写自己的优秀 CUDA 汇编代码和使用高级框架如 TensorFlow 之间。
性能考量与批次大小
请记住,所有这些至少在大型 CPU 或 GPU 上是在相当大的设备上运行的。每个 SM 核心可能正在处理一个子块矩阵乘法。你仍然需要相当大的矩阵来填满这个 GPU。这就是为什么在机器学习中,当批次大小为1时,性能可能会下降,因为没有足够的工作。
这里有一些图表,我改变 N(输出大小 P 和 Q,滤波器大小 R 和 S:1x1, 3x3, 5x5)。请注意,对于不同的批次大小,你必须使输出图像尺寸足够大,GPU 才有足够的工作来保持忙碌。
当人们说“天哪,用这些大型神经网络,我只能运行批次大小2或3,否则内存会耗尽”时,他们会在性能上付出代价,因为当这些东西变小时,你将无法让这个大处理器满负荷运行。
Y 轴是吞吐量(每秒浮点运算次数,TFLOPS),越高越好。这三条线是三种不同的模型大小(输出张量尺寸 P 和 Q)。右边这张图(实际上可能是左边)今天更相关,因为现在很少使用很宽的卷积。
直接实现与循环分块
当然,我也可以回到我最初的卷积代码(6层或7层循环)。我刚刚花了15分钟讨论优化一个三层循环的矩阵乘法有多难。你也可以直接开始对这些循环进行分块。
如果你想自己做一个直接的实现并做好,你当然也可以。如今,有很多兴趣在于将这些信息交给一个好的编译器,让它为我生成分块策略树。例如 Google 的 XLA 或 OpenAI 的新 Triton。他们正在尝试这样做。
但总的来说,我仍然认为这些核心内层循环最好还是由人类手工完成。
让我再展示一些其他技巧,这些技巧实际上很有趣。请记住,我们可以将卷积视为一组权重作为右侧,与一个来自输入张量元素的矩阵进行矩阵向量乘积。
仔细观察,你可以将其视为 M1 + M2 + M3。M1 是这个乘积,M2 是这个乘积,M3 是那个乘积。如果我们仔细观察,这里面有一些公共子表达式。我们可以利用这些公共子表达式进行一些早期计算,然后将输出计算为这些公共子表达式的函数。
在这个例子中,这实际上是所谓的“Winograd 滤波器”的本质。你可能听说过 Winograd 卷积,因为我采用了直接卷积,以前需要6次乘法和4次加法,现在我实际上用了少得多的乘法,但多了很多加法。这是否是一个好的权衡,取决于你的机器等因素。
最常见的可能是信号处理中的技巧:卷积可以表示为傅里叶变换、逐点乘法和逆傅里叶变换。快速傅里叶变换基本上就是运用这类技术,利用公共子表达式来减少工作量。所以你可以把这看作是应用傅里叶变换的思想。
供应商库与算法选择
所有大型供应商都有自己的深度学习库,如 cuDNN(NVIDIA)或 Intel 的 oneAPI。如果你使用 PyTorch 或 TensorFlow,你可能熟悉可用的不同层类型库。我们讨论过2D卷积(今天讨论的),但其他类型更简单。
如果你在 PyTorch 或 TensorFlow 中使用这些,底层的这些操作会被编译成 cuDNN 等供应商特定的低级库。如果我们打开 cuDNN 的 API(NVIDIA 的核心库),查看卷积前向传播的参数,你会看到一堆有趣的算法或参数。
不仅仅是输入张量 X、权重 W 和输出 Y,你还会得到输入张量的描述符,以及算法选择。现在看这些算法应该更有意义了。
这里有“隐式 GEMM”。GEMM 是通用矩阵乘法。默认算法是隐式 GEMM,意思是:将我的张量视为一个大矩阵乘法,但永远不要实际创建这些大矩阵,只是在执行矩阵乘法时通过循环索引来查找值。
这里有“前向算法直接”。这就像是:直接使用我那个7层循环嵌套的分块版本来做卷积。

还有其他各种选项,比如 GEMM 但非隐式(实际将数据复制到大矩阵中,作为普通矩阵乘法执行)。还有 Winograd 选项、FFT 选项(通过 FFT 变换等)。所以,你基本上就是告诉 API,根据我对情况的了解,我希望你这样执行。
层融合与内存带宽优化
到目前为止,我们讨论的是如何实现矩阵乘法(本质上是如何实现卷积层)。顺便说一下,全连接层也是矩阵乘法,所以这也适用。
我们完全没有讨论的是,这些层是连续堆叠的,可能多达数百层。请记住,我刚告诉你,其中一层的输出可能是一个巨大的矩阵(巨大的张量),可能达到几十 MB 或几百 MB。
让我们思考一下卷积层的基本序列:我们可能进行这个大矩阵乘法,输出一个 宽度 x 高度 x N x K 的输出张量,将其存储在内存中。然后立即将其读回,进行像添加偏置这样的操作,再存储到内存。然后再读回,进行最大池化。
这个张量在内存中反复进出。对于卷积层或许可以分块,但缩放和偏置添加不行(你只是将每个元素乘以一个数字)。最大池化也没有太多计算(只是取每个 2x2 区域的最大值输出)。所以这些东西严重受带宽限制。

我们希望能够一次性完成所有这些操作,然后再将张量送出到内存。例如,一旦矩阵乘法完成,我就可以立即进行缩放和偏置。这节省了数百 MB 的内存往返。

一个有趣的问题是:如果你想内联进行最大池化,你会如何重写代码?这并不难,虽然代码会变得复杂,但概念上不难。你只需要确保块大小是倍数。想象这是分块的:我产生了一个输出块放在缓存中,我应该在那时立即进行最大池化,然后再写回。这样我不仅节省了存储和加载,而且我存储的数据比我产生的要小四倍。所以这里有巨大的收益。
事实证明,这些东西真的很重要。当你的层类型变得复杂时,没有编译器支持的情况下,TensorFlow 开始出现像 conv2d_fused_batch_norm 这样的 API 入口点。API 变得庞大,因为他们觉得“这个序列很慢,我们需要为它做一个特例”。
如今,人们希望借助 JAX 和 Triton 这样的工具,如果编译器了解这些操作,可以为你进行一些融合。但这仍然不完美。让我告诉你为什么。
Flash Attention:一个融合优化案例
我想告诉你一个大约一年半前在斯坦福发明的很酷的技巧,它显著提高了 Transformer 层在大语言模型中的性能。退一步看,这个技巧基本上是任何上过 CS149 的同学,如果给你一个 Transformer 层,你看了之后都会说“你绝对应该这样做”的东西。

我快速转向 Transformer,不再讨论卷积模型。我想谈谈序列到序列的 Transformer。例如,输入可能是一个令牌序列(也许是一系列单词),模型的输出是产生下一个单词(自回归地)。具体应用无关紧要,重要的是工作负载。
当我们查看这个神经网络的关键部分(称为“注意力”的方框)时,输入是一堆张量:三个张量 Q、K 和 V。我可以给你一个解释:Q 是查询,K 是查询匹配的键,V 是值。你可以说,对于输入序列中的每个元素,我将其视为在一个数据库中查找匹配的其他令牌,基于匹配情况影响输出。但现在,就把它看作我有三个嵌入向量:Q、K 和 V,它们是 N 维数组,数组的每个点有一个 D 维嵌入。
这个注意力层基本上计算查询、键和值之间的交互。换句话说,我们将取查询向量 Q 和键向量 K 的外积。如果它们是 N x D,我的输出将是一个 M x N 矩阵。然后,对于该矩阵的每一行,我将执行一个称为 softmax 的操作。

如果你不知道 softmax 是什么,没关系。让我告诉你重要的是什么:softmax(X)(X 是向量的一行)只是对所有元素进行缩放,但该缩放取决于向量中的最大元素。为什么这很重要?因为在我计算完整个向量之前,我不知道向量的最大元素是什么。这就像是说:计算数组中所有元素的和,然后用和来归一化所有元素。
问题是:我进行矩阵乘法,产生一个 M x N 矩阵。然后对于矩阵的每一行,我必须计算最大元素,然后使用该最大元素进行新的计算(另一个矩阵向量乘积)。
让我为你画出来:我有 Q 和 K 向量(N x D),将它们相乘得到一个 N x N 矩阵。N 很大(如果考虑数万个令牌的序列,N 为 10000 时,N^2 就是一个巨大的多 GB 矩阵)。然后对于该矩阵的每一行,我计算 softmax(同样,你可以理解为计算向量中所有元素的和或最大值)。我用该和归一化所有行,然后进行矩阵向量乘积来计算最终结果。
问题是这个矩阵太大了。你可以进行分块矩阵乘法来高效计算这个矩阵,但你仍然必须将其存储到内存,然后逐行读回以计算 softmax,再读回以计算矩阵向量乘积。我还没有展示其他一些步骤(如掩码操作)。你需要多次读入它。

Transformer 中的计算成本就在这里。实际上这里计算量不大,这才是问题。

技巧在于:我们能否以块为单位完成整个序列?你刚告诉我如何在卷积中间进行最大池化。这里的问题是一样的:我们能否融合通过这个 softmax?它看起来像是需要矩阵的每个元素才能继续。
一些人看了看说,softmax 的数学是这样的:对于向量 X 的每个元素,我们将其缩放为 e^(x - max(x)),然后除以总和。我必须计算那个最大值和总和才能知道如何缩放。
事实证明,你可以分解这个计算,并分块进行。
细节我们可以线下讨论,但大致思路是:将 X 视为前半部分和后半部分。X 的最大值显然是 x1 和 x2 最大值的最大值。计算 F(X) 时,如果我知道最大值,我可以在前半部分计算 F,然后通过已知的因子重新缩放。

我不指望实时跟上这些数学推导。但经过10分钟的数学运算,你会注意到在这个项里面有 e^{x1},这些项会抵消,一切都会成立。关键是:softmax 可以分块计算,只要你保持一个运行的最大值和总和。
因此,我可以融合整个流程:加载一个 Q 块、一个 K 块、一个 V 块,计算子矩阵乘法,计算整个行的一个块(子行)的 softmax,然后进行矩阵乘法,最后累加到输出 O 中。

突然之间,我的内存需求从 N^2 减少到块大小的平方,这意味着我可以在 GPU 芯片上容纳更多的数据,从而可以处理更长的序列。并且因为我在某些情况下不受带宽限制,我可以运行得更快。
速度提升是适度的(我认为是几个小的常数因子),但内存占用大幅减少,这使我们能够处理的序列长度从 8000 增加到 32000 等。我相信 GPT-4 就受益于这样的优化。
这是基本的生产者-消费者局部性优化。你可以想象,编译器很难进行数学分析来发现这是可能的。但一些研究人员做到了。一旦你知道如何分块 softmax,你就可以分块处理整个流程。这本质上只是在这里那里重新排序一些循环,但却是一个非常大的变化。
融合的重要性与编译器支持
这些类型的事情最重要。一旦你有了一个好的矩阵乘法实现,这些类型的事情就很有趣。我之前提到过,大约八九年前,人们说“哦,你需要融合,但我们不知道怎么做。所以那些实现了好的矩阵乘法的人会去实现一个融合了批归一化的矩阵乘法,或者融合了调整大小、填充、卷积2D,最后可能还加杯咖啡”。这基本上就是当时的情况。
CUDA 一直有这种模板化的融合能力:任何卷积操作后跟一个“逐点操作”(逐点操作就像 map 函数),再跟另一个逐点操作,任何符合这种模式的,他们会尝试将逐点操作融合到矩阵乘法中。考虑到 MaxPool 等的好解决方案在5秒内就被提出来,这在智力上并不难。难的是在任意的张量程序上做到这一点。
所以,当你看到像 JAX 这样的框架时(它现在是一个相当复杂和成熟的框架),它们现在开始分析你的张量循环嵌套,并说“是的,我们知道如何融合这个,并为你生成代码”。所以情况正在好转。
事实证明,你需要两者:一个优秀的卷积或矩阵乘法实现,以及出色的智能,使得深度网络中其他所有带宽受限的部分不会拖慢一切。你需要这两种优化才能有一个好的网络实现。
其他优化技术


在我结束之前,我想谈谈还有很多其他技术。例如,加速的下一件事是停止使用常规精度数学,转而使用低精度数学。人们正在将其推向极致,我认为 NVIDIA GPU 现在开始使用大约4位精度的数学。
所以技术空间包括:首先,你需要从好的算法开始。如果你是一个系统人员,忽略了算法创新,你将会落后。有很多初创公司因为只看到“一切都要更大”的曲线而落后,我认为那个阶段已经过去了。
然后,你需要运用 CS149 的原则和好的编译器原则来优化这些模型,使其在现代硬件上运行良好。我们今天没有太多讨论近似计算(如低精度和稀疏性),但这些技术也存在。
硬件平台:GPU 与专用加速器

最后一块拼图是你在什么硬件上运行这些东西。让我们用最后一分半钟来结束这个话题。
根据你所知,为什么我们说 GPU 是运行深度神经网络计算的好平台?有哪些特性?并行性:这些是巨大的矩阵乘法操作。还有什么?如果你做得正确,算术强度很高。所以我们有很多好的算法和排序优化来提高算术强度,从而利用所有计算能力。GPU 有很多 CUDA 核心,并且这些处理器本来就有大量用于图形处理的算术单元,所以它们恰好在十多年前赶上了机器学习的浪潮。这就是为什么每个人都喜欢 GPU。
另一方面,为什么 GPU 可能是 DNN 评估的次优平台?因为 GPU 是一个任意的通用处理器,而大部分工作都是这些非常简单的矩阵乘法(也许是矩阵向量乘法)操作。
我们会有专门的一节课讨论这个,但我想现在稍微提一下,以便那些上 229 课程或做作业的同学思考:SIMD 指令的动机是什么?为什么架构师要在处理器中加入 SIMD 指令?是为了分摊非数学工作(指令流控制、数据访问等)的成本。

我们不必止步于 SIMD 指令。我们可以添加像点积这样的指令,这对矩阵乘法计算非常有帮助(例如一个4元素点积,做四次数学运算并将它们相加)。或者我们可以说,一个执行 4x4 矩阵乘法(一个小矩阵乘法)的指令怎么样?我知道如何使用
011:缓存一致性


在本节课中,我们将要学习缓存一致性的概念、问题以及解决方案。首先,我们会完成对Spark分布式计算系统的讨论,然后深入探讨在多处理器系统中,当多个核心拥有私有缓存时,如何确保内存数据的一致性。
完成Spark讨论


上一节我们介绍了Spark中用于实现容错和高性能内存计算的关键抽象——弹性分布式数据集。本节中我们来看看Spark如何通过转换操作的依赖关系来优化计算,以及如何实现容错。
Spark运行时系统可以分析RDD之间的转换操作,以优化执行。当转换操作之间存在窄依赖时,系统可以进行操作融合,从而避免不必要的中间数据存储和节点间通信。
以下是窄依赖的示例:
lower分区0仅依赖于lines分区0。mobileViews分区0仅依赖于lower分区0。


在这种情况下,所有转换都可以融合在一起,在一个节点内高效执行,无需跨节点通信。

然而,某些转换会产生宽依赖,例如groupByKey操作。为了计算RDD B的分区0,可能需要从系统中所有其他节点获取不同分区的数据。这会导致大量的节点间通信,且无法进行完全的融合优化。
另一个例子是join操作。如果参与连接的两个RDD使用相同的哈希分区器进行分区,并且键的分布使得连接键只存在于对应的分区中,那么依赖关系也可以是窄的,从而允许优化。



val partitioner = new HashPartitioner(8)
val mobileViewsPartitioned = mobileViews.partitionBy(partitioner)
val clientInfoPartitioned = clientInfo.partitionBy(partitioner)
val joined = mobileViewsPartitioned.join(clientInfoPartitioned)
在这种情况下,运行时系统可以检测到两个RDD使用了相同的分区器,从而判断依赖是窄的,并可能进行融合。
Spark的容错机制
现在我们来谈谈容错。Spark系统的核心目标是在提供高性能内存计算的同时保持容错性。其关键在于RDD的血缘关系。
RDD的血缘关系是应用于初始数据(从分布式文件系统加载)的一系列确定性、函数式转换操作的日志。这个日志可以用来重新计算任何丢失的RDD分区。
例如,要得到timestamps RDD,需要经历:从HDFS加载 -> filter -> filter -> map。这个操作序列就是它的血缘。
由于转换是函数式的(不修改输入),并且RDD是只读的,因此总是可以从持久化的原始数据(如HDFS中复制的数据)开始,重新应用血缘来重新生成任何RDD。




考虑一个场景:在计算timestamps时,节点1崩溃,丢失了timestamps和mobileViews的分区2和3。恢复过程如下:
- 系统检测到故障。
- 利用血缘关系,从HDFS中持久化的
lines数据开始。 - 重新应用转换操作(
filter->filter->map)。 - 重新计算出丢失的
timestamps分区2和3。
这种机制使得Spark能够在利用内存获得高性能的同时,不牺牲分布式数据处理所需的容错性。
Spark性能与适用范围

与Hadoop等依赖磁盘存储中间结果的系统相比,Spark能带来显著的性能提升。例如,在逻辑回归和K-Means等迭代算法中,Spark首次迭代需要从HDFS读取数据,但后续迭代可以直接在内存中进行,速度比Hadoop快一至两个数量级。


Spark生态系统已扩展到多个领域,包括Spark SQL(数据库处理)、MLlib(机器学习库)和GraphX(图计算)。
然而,横向扩展并非总是最佳选择。如果数据集能够放入单台服务器的内存中(例如,现代大型服务器可配备0.5TB到2TB内存),那么使用分布式系统会引入不必要的开销,其性能可能反而不如精心优化的单机多线程程序。
因此,选择Spark或类似分布式框架时,应考虑数据规模。对于数百TB的数据,分布式系统是必需的;而对于TB级以下的数据,单机系统可能更高效。
缓存一致性导论
现在,让我们转向今天的主题:缓存一致性。这是一个至关重要的话题,因为它既影响性能,也影响程序正确性,软件开发者需要理解其原理。
现代处理器芯片的很大一部分面积被缓存占据。缓存通过利用时间局部性(重复访问相同地址)和空间局部性(访问连续地址)来提升性能,避免昂贵的内存访问。
缓存未命中可以分为三类(Three Cs模型):
- 冷未命中:第一次访问某个缓存行。
- 容量未命中:缓存容量不足,需要替换现有行。
- 冲突未命中:由于缓存并非全相联,导致映射到同一组的行相互冲突而被替换。
缓存设计涉及数据块和元数据。元数据包括:
- 标签:标识该缓存行对应的内存地址。
- 脏位:指示缓存行中的数据是否已被修改,且与主内存不一致。
写操作策略有两种:
- 写直达:同时写入缓存和主内存。无需脏位。
- 写回:只写入缓存,并设置脏位。仅当该行被替换时才写回主内存。
在写回缓存中,如果发生写未命中且策略是写分配,则需要先将整个缓存行从内存载入缓存,然后再修改其中的部分数据。

缓存一致性问题
在多处理器系统中,每个核心通常拥有私有缓存。这引入了缓存一致性问题:同一内存地址的数据可能同时存在于多个私有缓存中,当某个处理器修改其缓存副本时,其他处理器的缓存副本就会变得过时。
直观上,我们对共享内存多处理器的期望是:对某个地址的读操作应该返回该地址最后一次被写入的值。然而,由于私有缓存的存在,这个简单的期望可能无法实现。

问题的核心在于,我们需要为每个独立的内存地址定义一个所有处理器都能同意的读写操作串行顺序。对于单个处理器(或线程),其读写顺序由程序顺序决定。对于整个系统,我们需要将所有处理器对同一地址的操作交错排列成一个全局的串行顺序,并且每个读操作返回的值,必须是该串行顺序中上一个写操作写入的值。

缓存一致性解决方案
我们可以通过维护两个不变量来实现一致性:
- 单写者多读者不变量:在任何时刻,对于任一缓存行,系统要么处于“读写”阶段(仅一个处理器可修改它),要么处于“只读”阶段(任意多处理器可读取它)。
- 数据值不变量:在“只读”阶段中,所有处理器读取到的值,必须是前一个“读写”阶段中那个唯一写入者所写入的值。
实现一致性需要在这两种阶段之间切换。软件方案(如通过操作系统刷新缓存页)粒度粗且速度慢。因此,现代系统普遍采用基于缓存行粒度的硬件解决方案。

两种主要的硬件一致性协议是:
- 侦听协议:所有缓存通过一个共享总线互联,每个缓存控制器“侦听”总线上的事务,并相应地更新自己缓存行的状态。
- 目录协议:使用一个中心目录来记录每个缓存行的状态和共享者信息。这是当前更主流的方案,可扩展性更好。
侦听总线协议基础
我们将从更直观的侦听协议开始。总线具有两个关键特性,非常适合实现一致性:
- 串行化:总线一次只处理一个事务,这自然地为所有操作建立了全局顺序。
- 广播:总线上的事务能被所有连接的缓存控制器看到。
在基于写回缓存和失效的侦听协议中,核心思想是:在处理器写入一个缓存行之前,必须获得该行的独占所有权。这通常通过设置脏位来标识。协议必须确保同一时刻最多只有一个缓存的脏位被设置。


缓存一致性协议由硬件逻辑实现,它响应本地处理器的加载/存储操作,同时也响应来自总线上其他缓存控制器的消息。



一个经典的例子是MSI协议,其名称来源于缓存行的三种状态:
- 修改:缓存行是脏的(已被修改),且仅存在于本缓存中。本处理器拥有独占所有权,可以读写。
- 共享:缓存行是干净的(与内存一致),可能存在于多个缓存中。所有持有者只能读。
- 无效:缓存行不在本缓存中,或数据已失效。
协议涉及两类操作:
- 处理器发起:读、写。
- 总线事务:总线读、总线写独占、总线写回。这些是由其他处理器的操作在总线上触发的事务。

本节课中我们一起学习了Spark系统的优化与容错机制,并引入了缓存一致性的核心概念与问题。下节课我们将深入探讨MSI协议的状态转换细节,理解硬件如何通过协作来维护内存一致性。
012:内存一致性 📚




在本节课中,我们将完成对缓存一致性的讨论,并探讨其复杂的一面——内存一致性。我们将了解如何通过协议在多处理器系统中维护数据的一致性视图,以及程序员应如何理解不同内存访问顺序对程序行为的影响。


缓存一致性回顾 🔄
上一节我们介绍了缓存一致性的定义和目标。本节中,我们来看看实现它的具体协议。
缓存一致性的目标是:对于一个共享地址空间中的特定内存地址,所有处理器对该地址的读写操作,都能被排序成一个全局的、线性的顺序。这个顺序需要满足两点:
- 与每个处理器线程的程序顺序一致。
- 每次读操作返回的值,都是该顺序中最近一次写操作写入的值。
为了实现这个目标,我们需要维护两个不变式:
- 单写者多读者不变式:在任何时刻,对任一缓存行,要么只有一个处理器可以写入(读写阶段),要么有多个处理器可以读取(只读阶段)。
- 数据值不变式:在只读阶段读取到的值,必须是最近一个读写阶段最后写入的值。



写回缓存与MSI协议 🧠
我们之前提到,直写缓存性能不佳,因为每次写操作都会占用总线。而简单的写回缓存会导致不一致。因此,需要一个基于无效化的写回协议来维护上述不变式。
核心思想是:确保系统中任一缓存行在任一时刻最多只有一个副本处于“已修改”状态。只有持有“已修改”状态的处理器才能执行写操作。当其他处理器需要读取该数据时,由这个“所有者”提供最新数据。
缓存行状态与总线事务

在MSI(已修改/共享/无效)协议中,缓存行有三种状态:
- 无效:该行不在缓存中,或数据无效。
- 共享:该行在一个或多个缓存中有效,且内存中的数据是最新的。
- 已修改:该行仅在一个缓存中有效,且已被修改(脏位为1),内存中的数据是旧的。
处理器可以发起两种操作:读和写。这些操作会触发三种一致性总线事务:
- 总线读:因处理器读缺失而发起,请求获取缓存行以进行读取。
- 总线读独占:因处理器写缺失(或从共享状态升级)而发起,请求以独占方式获取缓存行以进行写入。
- 总线写回:当持有“已修改”状态缓存行的处理器需要替换该行时,将脏数据写回内存。
状态转换图解析

以下是MSI协议的状态转换图。绿色标签表示由处理器发起的动作,红色标签表示由总线事务触发的动作。






处理器发起动作的转换:
- 无效 → 共享:处理器
读→ 触发总线读。 - 无效 → 已修改:处理器
写→ 触发总线读独占。 - 共享 → 已修改:处理器
写→ 触发总线读独占(升级操作)。 - 已修改 → 已修改:处理器
读或写→ 无总线事务(命中)。 - 共享 → 共享:处理器
读→ 无总线事务(命中)。

总线事务触发的转换(监听):
- 共享 → 无效:监听到
总线读独占(其他处理器想写)→ 使本地副本无效。 - 已修改 → 共享:监听到
总线读(其他处理器想读)→ 执行总线写回以提供数据并更新内存,然后状态转为共享。 - 已修改 → 无效:监听到
总线读独占(其他处理器想写)→ 执行总线写回以提供数据,然后状态转为无效。
运行示例
假设有三个处理器(P1, P2, P3)和地址X。初始时所有缓存为空(X为无效状态)。


| 处理器动作 | 总线事务 | P1状态 | P2状态 | P3状态 | 数据来源 |
|---|---|---|---|---|---|
| P1: 读 X | 总线读 | 共享 | 无效 | 无效 | 内存 |
| P3: 读 X | 总线读 | 共享 | 无效 | 共享 | 内存 |
| P3: 写 X | 总线读独占 | 无效 | 无效 | 已修改 | 内存 |
| P1: 读 X | 总线读 | 共享 | 无效 | 共享 | P3缓存(写回) |
| P2: 写 X | 总线读独占 | 无效 | 已修改 | 无效 | 内存 |
这个例子展示了数据如何在处理器间移动,以及状态如何根据协议变化。
MSI如何维护不变式


- 单写者多读者:通过确保只有“已修改”状态是独占的来实现。总线串行化所有事务,保证了状态的正确转换。
- 数据值:通过“总线写回”机制实现。当缓存行离开“已修改”状态时,持有者必须将数据写回(或提供给请求者),从而将最新值传播给后续的读者。
协议优化与可扩展性 🚀
MSI协议存在优化空间。例如,从“共享”升级到“已修改”需要一次“总线读独占”事务(即一次缺失)。为了优化,可以引入独占状态,形成MESI协议。
独占状态表示缓存行是干净的(与内存一致),但只有当前缓存拥有它。如果处理器读数据时发现没有其他缓存共享该行,可以直接进入“独占”状态。后续的写操作可以直接在本地进行,无需总线事务,从而将“升级”操作从缺失变为命中,提高了效率。

然而,基于总线的监听协议存在可扩展性问题:所有处理器都能看到所有事务,总线成为带宽瓶颈。为了解决这个问题,现代多核处理器使用目录协议。

目录在内存中(如末级缓存)为每个缓存线维护一个位向量,记录哪些处理器拥有该行的副本。当需要无效化或获取数据时,只需与目录中记录的特定处理器通信,而非广播给所有处理器。这使得可以使用更可扩展的互连网络(如环),取代广播总线。
缓存一致性对程序的影响 ⚡

缓存一致性机制对程序员的主要影响体现在性能上,尤其是伪共享问题。
伪共享发生在不同处理器频繁写入同一缓存行中不同的独立变量时。尽管这些变量逻辑上不共享,但由于它们位于同一缓存行,一方的写入会导致另一方缓存行的无效化,引发不必要的缓存缺失和通信。


示例:
// 可能发生伪共享
int per_thread_data[NUM_THREADS];




// 减少伪共享(通过填充或结构体确保变量不在同一缓存行)
struct AlignedData {
int data;
char padding[CACHE_LINE_SIZE - sizeof(int)];
};
struct AlignedData per_thread_data[NUM_THREADS];
在第二个例子中,通过填充字节,确保每个线程的数据结构独占一个缓存行,从而消除了伪共享,可以显著提升性能。


数值计算中,如果网格划分的边界落在同一缓存行,被不同处理器访问,也会导致伪共享。随着缓存行增大,真共享缺失可能减少(空间局部性更好),但伪共享缺失可能增加。

程序员可以使用性能分析工具(如Intel VTune, Apple Xcode Instruments)来识别缓存缺失和伪共享热点,并通过调整数据布局来优化。

内存一致性 🧩

缓存一致性关注单个地址的读写顺序。内存一致性则定义了对不同地址的读写操作,在不同处理器看来应有的顺序。它规定了多线程程序在共享内存系统中的合法行为。
为什么需要内存一致性模型?因为硬件和编译器为了性能,可能会对内存操作进行重排序。程序员需要知道这些重排序的规则,才能编写正确的并发程序。
顺序一致性
最直观的模型是顺序一致性。它要求满足两点:
- 所有内存操作(来自所有处理器)可以排列成一个全局的线性顺序。
- 每个处理器的内存操作在这个全局顺序中,保持其程序顺序。
顺序一致性维护了所有四种内存操作顺序:写后读、读后读、读后写、写后写。这符合程序员的直觉,但严格限制了硬件和编译器的优化。

为什么需要更弱的模型?
为了提升性能!例如,处理器写一个地址(可能缓存缺失,耗时很长),紧接着读另一个地址(可能缓存命中)。在顺序一致性下,读必须等待写完成。如果允许写后读重排序,处理器就可以先执行读操作,隐藏写的延迟。这可以通过写缓冲区来实现。
然而,重排序会改变程序行为。考虑这个经典例子:
// 初始:A = B = 0
// 处理器 P1 // 处理器 P2
A = 1; B = 1;
r1 = B; r2 = A;
在顺序一致性下,结果(r1, r2) = (0, 0)是不可能的。但如果允许写后读重排序,两个处理器的写操作都可能被暂存在写缓冲区,而先执行了读操作(读到0),从而导致(0, 0)的结果。
弱一致性模型与栅栏
因此,现代处理器(如x86, ARM)实现了比顺序一致性更弱的模型。例如:
- 完全存储定序:维护除“写后读”外的其他顺序。
- 部分存储定序:允许更灵活的重排序。
- 松弛一致性:如ARM模型,允许更多的重排序以获得高性能。


在这些弱模型下,要保证同步操作(如锁、信号量)的正确性,必须使用内存栅栏指令。栅栏会强制在该点之前的所有内存操作完成,并对其他处理器可见,然后才能执行之后的操作。

对程序员的启示:
- 数据竞争:对同一内存位置的无同步读写是未定义行为的根源。
- 数据竞争自由编程:正确的做法是,对共享数据的访问总是通过同步原语(锁、原子操作)进行保护。
- 分工:应用程序员可以依赖正确的同步库;而系统程序员、编译器开发者和同步库实现者必须深入理解底层的内存一致性模型。

总结 📝

本节课我们一起学习了:
- MSI缓存一致性协议:通过无效化、共享和已修改三种状态,以及总线事务,在写回缓存中维护了单写者多读者和数据值不变式。
- 协议优化与可扩展性:MESI协议通过引入独占状态优化升级操作;目录协议通过点对点通信取代广播,提升了可扩展性。
- 对程序的影响:缓存一致性机制可能导致伪共享,显著影响性能。程序员需要注意数据布局,并使用工具进行分析优化。
- 内存一致性:定义了多线程程序中内存操作的合法顺序。顺序一致性最直观但性能限制大。现代硬件使用更弱的模型并配合内存栅栏来平衡性能与正确性。程序员应遵循数据竞争自由的编程范式,正确使用同步原语。


理解缓存一致性和内存一致性是编写高效、正确并行程序的基础。对于系统级开发者而言,掌握这些概念尤为重要。
013:细粒度同步与无锁编程 🧵




在本节课中,我们将要学习多线程编程中的高级同步技术。我们将探讨如何避免死锁、活锁和饥饿等问题,并深入了解锁的实现原理、性能优化以及如何设计细粒度同步和无锁数据结构。


概述
当程序中涉及锁和同步时,可能会出现死锁、活锁和饥饿等问题。本节我们将首先明确这些术语的定义,然后深入探讨锁的底层实现机制,最后学习如何设计更高效、更安全的并发数据结构。
同步问题:死锁、活锁与饥饿
在深入锁的实现之前,我们需要理解多线程编程中可能遇到的几种典型问题。
死锁 🔒
死锁是指一组进程或线程因争夺资源而陷入的相互等待状态,导致所有成员都无法继续执行。
死锁的发生需要满足四个必要条件:
- 互斥:资源一次只能被一个线程持有。
- 持有并等待:线程在等待获取新资源时,不会释放已持有的资源。
- 不可抢占:资源不能被强制从持有它的线程中夺走。
- 循环等待:存在一个线程-资源的循环等待链。
一个经典的现实例子是十字路口的四辆车互不相让,每辆车都需要前方道路畅通才能前进,但前方道路又被另一辆车占据,形成了一个循环依赖。
在计算系统中,一个简单的死锁例子是两个线程互相等待对方从工作队列中取出数据,结果双方都因等待而阻塞。
活锁 🔄

活锁与死锁不同,线程并未被阻塞,而是在不断尝试某个操作,但由于彼此间的协调失败,始终无法取得实质性进展。

一个常见的现实例子是两个人迎面走来,同时向同一侧避让,结果又撞到一起,如此反复。在系统中,这可能表现为多个线程不断重试获取锁,但每次都因冲突而失败,导致CPU繁忙但任务无法完成。
饥饿 🍽️

饥饿是指一个或多个线程因为无法获得所需的资源(如CPU时间或锁)而长期无法执行。即使其他线程正在取得进展,这些“饥饿”的线程也可能永远得不到服务。
一个现实例子是次要道路上的车辆在高峰期永远无法汇入车流不断的主干道。在程序中,一个低优先级的线程可能永远无法获得锁,因为总有更高优先级的线程抢先获得。
缓存一致性回顾 🧠
在讨论锁的实现之前,有必要回顾缓存一致性的概念,因为锁的实现与内存系统的行为密切相关。


缓存一致性协议解决了多核处理器中各个私有缓存数据副本的同步问题。其核心思想是:当一个处理器写入某个内存地址时,必须确保所有其他缓存中该地址的副本要么被更新,要么被标记为无效。
MESI协议是一种常见的缓存一致性协议,它定义了缓存行的四种状态:
- 修改:缓存行已被修改,与内存不同,且是唯一副本。
- 独占:缓存行与内存一致,且是唯一副本。
- 共享:缓存行与内存一致,但可能存在其他只读副本。
- 无效:缓存行数据无效。
协议通过处理器间广播“读”、“写”等总线事务来协调状态转换,确保所有缓存对数据状态有一致的视图。
锁的底层实现 🔧

理解了缓存一致性后,我们来看看锁是如何在硬件层面实现的。

原子指令:测试并设置
现代处理器提供了特殊的原子指令来构建锁。一个经典的指令是测试并设置。
其伪代码逻辑如下:
int TestAndSet(int *lock_ptr) {
int old_value = *lock_ptr; // 读取旧值
*lock_ptr = 1; // 无条件写入1
return old_value; // 返回旧值
}
这个操作是原子的,意味着在它执行期间,其他处理器无法干扰这个内存地址的读写。
基于TestAndSet,可以实现一个简单的自旋锁:
void lock(int *lock) {
while (TestAndSet(lock) == 1) { // 如果锁已被持有(值为1)
// 自旋等待
}
// 成功获取锁
}
void unlock(int *lock) {
*lock = 0;
}
获取锁的线程执行TestAndSet。如果返回0,表示锁之前是空闲的,该线程成功获取锁并将其置为1。如果返回1,则表示锁已被占用,线程在循环中不断重试。
锁的性能问题与优化
简单的TestAndSet锁存在性能问题。当多个线程竞争锁时,每个线程的TestAndSet操作都会导致总线上的“写”事务,引发缓存行的无效化和在核心间“乒乓”传递,产生大量互联流量。
一种优化是“测试-测试并设置”锁:
void lock(int *lock) {
while (1) {
while (*lock == 1) { // 1. 先通过普通读操作自旋
// 等待锁被释放
}
if (TestAndSet(lock) == 0) { // 2. 再尝试原子获取
break; // 成功获取锁
}
}
}
在锁被持有时,其他线程在第一个while循环中通过读操作自旋。由于是读操作,缓存行可以保持在“共享”状态,不会产生总线流量。只有当锁被释放(值变为0),线程才会尝试TestAndSet。这大大减少了锁持有期间的争用流量,但在释放锁时仍会引发一波TestAndSet尝试。
另一种更高级的锁是“票号锁”,它模拟了银行或熟食店的取号系统,保证了公平性:
int next_ticket = 0; // 下一个可用的票号
int now_serving = 0; // 当前正在服务的票号
void lock() {
int my_ticket = atomic_fetch_add(&next_ticket, 1); // 原子取号
while (my_ticket != now_serving) { // 等待叫号
// 自旋
}
}
void unlock() {
atomic_fetch_add(&now_serving, 1); // 服务下一个
}
线程通过原子递增获取一个唯一票号。释放锁时只需递增now_serving。这保证了先到先服务的公平性,并且锁释放操作只有一个写操作,争用更少。
比较并交换
TestAndSet功能有限。更通用的原子原语是比较并交换。
其伪代码逻辑如下:
int CompareAndSwap(int *ptr, int expected, int new_value) {
int actual = *ptr;
if (actual == expected) {
*ptr = new_value;
}
return actual;
}
这个操作也是原子的。它检查内存位置的值是否与预期值expected相等,如果相等,则将其更新为new_value。
利用CAS,可以实现各种原子操作。例如,实现一个原子的“最小值”更新:
void atomic_min(int *addr, int x) {
int old;
do {
old = *addr; // 读取当前值
if (x >= old) {
break; // 我的值更大,无需更新
}
// 尝试更新:仅当值未变时,才将最小值设为x
} while (CompareAndSwap(addr, old, x) != old);
}
如果CAS失败(返回值不等于old),说明在此期间有其他线程修改了该值,那么当前线程需要重新读取并重试。这种“读取-计算-验证并更新”的模式是无锁编程的基础。
并发数据结构设计 🏗️
掌握了同步原语后,我们来看看如何设计线程安全的并发数据结构。
粗粒度锁
以线程安全的排序链表插入为例。最直接的方法是使用一个粗粒度锁,在整个insert或delete操作期间锁住整个链表。
void insert(List *list, int value) {
lock(&list->global_lock);
// ... 执行插入操作
unlock(&list->global_lock);
}
这种方法简单且正确,但性能极差,因为它完全串行化了所有访问,无法利用链表不同部分可并行修改的特性。
细粒度锁(手拉手锁)
为了提高并发性,可以为每个链表节点配备一个锁。遍历链表时,采用“手拉手”加锁策略:
- 先锁住前驱节点
prev。 - 再锁住当前节点
curr。 - 在锁住
curr后,可以释放prev的锁(如果确定不再修改它),然后curr成为新的prev,继续锁住下一个节点,如此交替前进。
关键的不变式是:在修改节点间的链接关系时,必须同时持有涉及的两个节点的锁。例如,要删除curr节点,必须同时持有prev和curr的锁,以确保在修改prev->next指针时,没有其他线程能同时修改prev或curr。


这种细粒度锁大大提高了并发性,但代价是锁操作本身的开销可能变得显著,且实现复杂度更高,需要仔细处理边界条件(如头节点)。
无锁编程
无锁编程的目标是设计不需要互斥锁的并发数据结构。其核心思想是乐观并发控制:线程直接执行操作,在最后提交修改时,使用原子操作(如CAS)检查数据是否已被其他线程修改。如果未被修改,则提交成功;否则,回滚并重试整个操作。
无锁算法的优点:
- 避免锁带来的问题:如死锁、优先级反转、线程阻塞导致的整体吞吐量下降。
- 高并发性:即使一个线程被延迟或挂起,其他线程仍然可以取得进展。
缺点:
- 实现极其复杂,容易出错。
- 可能带来“忙等待”,消耗CPU资源。
- 不保证公平性,可能出现线程饥饿。
一个经典的无锁栈push操作示例(简化):
void lock_free_push(Stack *s, Node *new_node) {
Node *old_top;
do {
old_top = s->top; // 1. 读取当前栈顶
new_node->next = old_top; // 2. 准备新节点
// 3. 尝试更新栈顶:仅当栈顶未变时,才将其设为新节点
} while (CompareAndSwap(&s->top, old_top, new_node) != old_top);
}
如果CAS失败,说明在步骤1和3之间,有其他线程修改了栈顶,那么当前线程只需回到步骤1重试即可。

总结
本节课我们一起学习了多线程编程中高级同步技术的核心内容。
我们首先区分了死锁(相互等待)、活锁(忙碌但无进展)和饥饿(无法获得资源)这三种并发问题。
接着,我们回顾了缓存一致性,理解了锁的实现与内存系统交互的底层机制。我们探讨了如何利用原子指令(如TestAndSet和CompareAndSwap)来实现锁,并分析了简单自旋锁的性能缺陷及其优化方案,如“测试-测试并设置”锁和公平的“票号锁”。
最后,我们探讨了并发数据结构的设计。从串行化一切的粗粒度锁,到提高并行度的细粒度锁(如链表的手拉手锁),再到挑战更高的无锁编程。无锁数据结构通过乐观并发控制和原子CAS操作,避免了传统锁的许多弊端,但以更高的实现复杂度为代价。

理解这些概念和技术,对于构建高效、健壮的多线程应用程序至关重要。
014:期中复习课




在本节课中,我们将回顾课程前半部分的核心概念,包括多核架构、并行程序优化策略、GPU编程模型、数据并行思维以及缓存一致性与锁等主题。我们将通过问答和示例来巩固这些知识。



课程主题概览




课程前半部分涵盖了几个广泛的主题。第一个主题是理解多核架构,这在第1、2、3讲中进行了介绍。期中考试很可能会涉及相关内容。


另一个主要主题是优化并行程序的通用策略。这些策略通常在编程作业中实践,例如识别和解决工作负载不均衡问题。

另一类优化通常涉及改善缓存性能不佳的程序,例如通过调整循环顺序来提升缓存性能。




我们还讨论了GPU架构。你应该将其视为前几讲内容的延伸,其工作原理是相似的。理解CUDA编程模型的基础知识很重要,包括线程块的组织和其中的线程,这在完成第三次编程作业后应该已经熟悉了。

我们有一讲关于深度神经网络。虽然不需要掌握任何特定深度神经网络的细节,但需要理解其中的两三个主要概念。一是缓存局部性至关重要,我们讨论了分块技术。其他内容则更多涉及专用硬件等,这些内容在课程后续部分会深入探讨,更像是期末考试的主题。
我们讨论了数据并行思维。因此,在期中考试中,可能会要求你完成一个数据并行思维的练习。
上一讲我们讨论了细粒度锁定。我们谈到了比较并交换操作以及如何实现一些锁。我们还讨论了这与缓存一致性工作原理之间的关系,以及在一些有趣的关联。我们讨论了在细粒度锁定场景中使用锁,并给出了一些练习题。在期中考试层面,细粒度锁定的练习题会更简单,可能不会比链表之类的更复杂。但在期末考试前,你会有更多时间来处理哈希表、图、树等其他数据结构上的练习题。

当然,Kunle教授还讨论了MSI协议、缓存一致性的含义及其背后的主要思想。我们还讨论了宽松内存一致性的概念,即尽管一个线程可能先写X再读Y,但其他处理器可能会以不同的顺序看到这些写操作或读操作,这带来了一些有趣的影响。
比较并交换与无锁编程
到目前为止,我们在课程中主要通过两种方式完成所有同步。一种是使用锁。那么,锁或者说互斥意味着什么?互斥意味着在任一时刻,只有一个线程、一个执行者或一个工作者被允许进入代码的特定部分。这意味着如果我正在该代码段中,所有其他人都被阻止运行它。
原子操作基本上也是这种思路,因为原子操作通常用于读-修改-写操作。它表示如果我在读取和修改变量,那么其他任何人都不能同时读取和修改这个变量。
比较并交换是原子操作的一种形式。它是一种读-比较-写操作。它表示我将读取这个变量的值。如果它具有特定值(例如,由参数compare给定的值),那么我将把这个新值写入该位置。否则,我基本上会将旧值写回该位置,保持其不变。这是一个原子操作。无论你在什么硬件平台上运行,调用比较并交换都能保证其原子性。

那么,给定原子比较交换操作,我们如何实现一个原子最小值操作呢?一种思考方式是,硬件直接给我一个新的原语atomic_min。它能确保互斥,因为“原子”意味着如果我对这个变量进行最小值操作,它会确保没有其他线程或核心能同时读写它。
但这不是我们设计系统的方式。我们不喜欢为每一个想做的事情都构建一堆新的原语。我们更愿意拥有一个坚固且可能快速的原语,并在各种上下文中使用它。

让我们看看这个原子最小值操作,并回顾其工作原理的哲学。最小值操作要求我从内存中读取值,检查内存中的值是否小于我试图与之比较的新值。如果我的值更小,则需要将内存中的值更新为更小的值。所有这些操作都必须是原子的。
首先,请确认,如果我给你一把锁,你可以轻松地不使用比较交换来实现这个操作。你会获取锁,读取值,检查它是否更小,可能更新值,然后释放锁。这绝对是正确的,并且能确保互斥,一次只有一个线程尝试执行原子最小值操作。
那么,有人能描述一下这段代码在做什么吗?它并没有确保整个最小值操作的互斥性。不同的线程可以同时从内存中读取该值,并检查自己的值是否小于它。
如果我们查看得出主要结论所依据的条件,它确保在写入时该条件仍然有效。其思想是:如果我从内存中读取了值,然后开始做任何我需要做的工作(在本例中是计算某个值的最小值),那么我必须原子性地执行一些计算,然后将值写回。
我完成我的工作(即取旧值和我拥有的值的最小值),然后我返回并检查:如果内存中的值仍然是我开始时的值,那么我可以确信没有其他人介入,或者即使有人介入,他们至少没有更新最小值。这就是原子比较交换的作用:如果这个地址中的值仍然是old,请将其更新为new。我知道这是否成立,因为原子比较交换返回内存中的值,如果那个值等于old,就进行检查。

那么,如果在我尝试执行原子最小值操作时,另一个线程介入,读取了值,检查后发现自己的值太大而不需要更新,我的原子最小值操作会成功吗?这里没有互斥,我们俩同时运行在这个所谓的“重要同步区域”中。但是,除非我们俩的操作造成了需要同步的情况,否则我们可以继续执行。换句话说,我们是在推测。我们假设不会发生冲突,然后继续执行。在最后写入之前,我会去检查这个假设是否成立。如果成立,我所做的工作就是有效的,我可以继续;如果不成立,代价是什么?我必须重做一遍,我在旧值上所做的所有工作都可能被浪费。
这种无锁思维更像是:我需要确保呈现出与互斥相同的结果,但实际上并没有强制执行互斥。这是巨大的思维差异。感恩节后,Kunle教授将讲授几节课,其中两节实际上是关于事务内存的概念,这个概念将这种思想扩展到任意函数写入多个变量的情况。你只需编写你的函数,它可能读写一堆变量,系统会判断这些读写是否与另一个处理器的读写冲突,并实际上中止并回滚其中一个处理器。

对于明天的评估,我希望你了解这个程度的无锁知识。在后面的课程中,如果你感兴趣,我会展示如何实现无锁栈等数据结构。例如,无锁链表的实现在一些并行计算课程中会涉及,要正确实现它非常复杂,可能需要一整节课的时间只讲链表的无锁插入和删除。因此,我不会在考试中问你如何实现无锁链表的插入和删除,因为这实际上非常棘手。
MapReduce 与数据并行系统
在所有这类编程系统中,首先要思考的是API及其含义。在MapReduce中,实际上只有两个API函数。MapReduce的历史是,它是一篇来自谷歌的非常简单的论文,大概在21世纪初。谷歌的人说,谷歌有很多任务需要在海量数据上运行。为了处理海量数据,比如整个网络索引,他们实际上构建了GFS(Google文件系统)。基本上,世界上所有的数据都在这个文件系统中,文件系统分布在大量计算机上。他们有很多应用程序,比如数据挖掘等。
第一个函数调用叫做map,之所以叫map是因为它受到了数据并行map的启发。第二个函数调用叫做reduce,受到了reduce的启发,但它们与我在数据并行思维讲座中给出的定义略有不同。
map的定义如下:假设我们要对一个巨大的TB级文件的所有行进行map操作,文件的不同部分分布在不同机器上。map表示对于文件中的每一行(或者对于所有内容),运行这个函数F,就像map一样。在MapReduce中,map的输出不是一个任意类型T,而实际上是一个键值对。输入可能是一个字符串(文件中的一行),然后函数F被映射到文件的所有行上,为每一行生成一个键值对。例如,键可能是用户代理(浏览器),值可能只是数字1。
然后系统会进行一些“魔法”操作。MapReduce的巧妙之处(或者说非常死板)在于,系统会获取所有这些键值对。请记住,map函数可以在所有机器上运行。如果你的文件分布在1000台机器上,它只会在已经持有文件部分的机器上运行map函数,这样数据就不需要移动。
接下来是大数据通信部分。每次调用F都会产生一个键值对。然后MapReduce的设计硬性规定,API会将所有具有相同键的键值对组织到一个文件中。这样,我就有了针对所有不同键的不同文件。每个文件只是匹配该键的所有值的列表。这就是数据四处移动的地方,类似于一次大的重排。
然后你有了reduce。reduce以键和值列表作为参数,并产生一个结果。因此,reduce函数实际上被映射到所有唯一的键上。reduce函数的输入是它正在处理的键,加上与该键关联的值列表(本质上是文件的内容)。此时,它可以做任何想做的事情并产生一个结果。之所以叫reduce,可能是因为在这个例子中,它获取所有具有相同键的值,并聚合了具有该键的行数,然后返回结果。


实际上,reduce这个名字并不是并行reduce之类的意思。它实际上是map,接着按键分组,然后再map。在现代并行编程中,这就是它的含义。如果你觉得这很奇怪:我拥有所有这些计算机,我的数据分布在所有计算机中,而我们的通信方式竟然是写入文件系统,然后从另一个节点读回来?Spark的人说这太蠢了。他们说我们应该在内存中完成所有这些,并使用一套恰当的数据并行操作符,而不仅仅是MapReduce的按键分组。这就是Spark背后的整个思想。
关于MapReduce的一个动机是,当时的商用硬件非常便宜,而且由于数据量巨大,人们最初并不太关心延迟,更关心吞吐量。但吞吐量也会受到延迟的影响。不过,在具有大规模并行性的情况下,延迟可以被隐藏。你关心的是带宽和吞吐量。真正的问题是在计算阶段之间,整个数据集(TB级数据)从磁盘读取,写回磁盘,然后再读回来。即使按键分组本身没做什么,也存在窄依赖问题。



当时的人们觉得,他们可以用大约10行代码编写程序,使用1000台机器,获得容错性和并行性,所有这些都封装在这两个抽象中。但后来有些人指出,人们过于迷恋并行性而忘记了局部性。有人在一台笔记本电脑上展示了,一个单核笔记本电脑可以超越一个5000节点的Spark集群,仅仅是因为人们被并行性吸引而忘记了局部性等因素。如今在2023年,花3美元一小时就可以在AWS上获得一台拥有512GB内存的机器。大多数时候,我们处理的问题规模并没有超过你能用很少钱买到的计算资源。因此,更多地思考局部性,并扩展到4TB大小的机器,比尝试让你的代码在10000个节点的集群上具备容错性和鲁棒性更有意义。

缓存一致性协议演示
让我们通过一个演示来理解MSI缓存一致性协议。假设我们有两个缓存:Cache 0和Cache 1。最初,两个缓存中都没有任何数据。
- Cache 0 加载 X:Cache 0发出总线读X请求。由于没有其他缓存拥有X,Cache 0将X放入共享状态。
- Cache 0 再次加载 X:Cache 0已经有X在共享状态,可以直接读取,无需发送任何消息。
- Cache 0 写入 X(值=1):Cache 0需要将X从共享状态升级到修改状态。它发出总线写X请求,通知其他缓存。Cache 1没有X,所以忽略。Cache 0将X状态改为M,并更新值为1。此时,内存中的X值可能仍是旧值(例如0)。
- Cache 0 再次写入 X(值=2):Cache 0已经有X在M状态,可以本地写入,无需通知其他缓存,值更新为2。
- Cache 1 读取 X:Cache 1发出总线读X请求。Cache 0拥有X在M状态(脏数据),因此必须将数据写回(刷新)到内存(或直接提供给Cache 1,在更高级的协议中)。Cache 0将X状态降级为S(或I,在基本MSI中),并将值2写回内存。Cache 1接收到值2,并将X放入共享状态。
- Cache 1 写入 X(值=3):Cache 1需要将X从S升级到M。它发出总线写X请求。Cache 0收到请求,将其拥有的X副本置为无效(I)。Cache 1将X状态改为M,并更新值为3。
- Cache 0 加载 X:Cache 0发出总线读X请求。Cache 1拥有X在M状态,必须刷新数据(值3)到内存,并将状态降级为S。Cache 0接收到值3,并将X放入共享状态。
- 引入新变量 Y:两个缓存都可以独立加载Y到共享状态,互不影响,因为这是不同的地址。
- Cache 1 写入 Y:Cache 1需要将Y从S升级到M,过程与X类似,会通知Cache 0将Y置为无效。
关键点:
- 缓存一致性是关于同一内存地址的状态管理。不同地址的缓存行有各自独立的状态机。
- 任何缓存本地状态的改变(如S->M, M->S, ->I)都需要通过总线(或点对点消息)通知其他缓存,以便它们更新自己的状态,从而做出正确的访问决策。
- M(修改)状态意味着该缓存拥有该行的唯一、最新副本,并且是“脏的”(与内存不一致)。
- S(共享)状态意味着该行是干净的(与内存一致),但其他缓存也可能有副本。
- I(无效)状态意味着该缓存行数据无效(要么不在缓存中,要么已过时)。
- 更高级的协议(如MESI)增加了E(独占)状态,用于优化“读-后-写”场景:当只有一个缓存拥有该行的干净副本时,它处于E状态,后续写入可以直接升级到M而无需总线通信。

关于原子操作:像比较并交换这样的原子读-修改-写操作,在缓存一致性协议中需要被视为一个写操作来处理。它必须直接从无效状态获取缓存行并进入修改状态,或者通过一次原子性的总线事务来确保整个操作期间没有其他缓存能访问该地址,从而保证操作的原子性。不能先读(进入S状态)再写(升级到M),因为中间其他缓存可能修改了值。

宽松内存一致性
宽松内存一致性是关于不同内存地址的操作顺序在多个处理器/线程视角下的可见性问题。
- 程序顺序:在单个线程内部,指令必须按照程序顺序执行并产生结果。硬件和编译器的乱序执行、并行执行必须对程序员透明,确保单线程语义正确。
- 全局顺序:在顺序一致性模型中,所有线程的所有内存操作(读/写)可以排列成一个全局的总顺序,这个顺序与每个线程的程序顺序一致。
- 宽松一致性:放松了对不同地址操作顺序的约束。例如,一个线程先写X,再写Y。在宽松模型下,其他线程可能会观察到Y的更新先于X的更新。这被称为写-写重排序。
- 与缓存一致性的关系:缓存一致性保证了对同一地址操作的全局顺序(所有处理器最终看到对该地址的修改顺序是一致的)。宽松一致性则处理不同地址操作之间的可见性顺序。两者是正交的概念。
- 重要性:宽松一致性允许硬件和编译器进行更多优化(如写缓冲区、更灵活的内存操作调度),从而提升性能。但程序员(或高级语言/库)有时需要使用内存屏障(fence)或同步操作来强制保证特定顺序,以确保程序正确性。

CUDA执行模型回顾

CUDA的执行模型基于层次化线程组织:

- 线程:最基本的执行单元。
- 线程块:一组线程的集合。线程块内的线程可以通过共享内存和同步原语进行协作。线程块在GPU的一个流多处理器上执行。
- 网格:所有线程块的集合,构成一次内核启动。
调度与执行:
- GPU硬件(如NVIDIA的SM)拥有固定数量的执行资源(ALU、寄存器、共享内存等)。
- 当启动一个内核(网格)时,GPU调度器会将线程块分配给可用的SM。
- 分配决策基于资源约束:每个SM能同时驻留的线程块数量受限于其拥有的线程上下文数量、寄存器总量和共享内存大小。
- 目标:尽可能让所有SM都忙碌,以最大化硬件利用率。因此,调度器倾向于将线程块分散到不同的SM,而不是堆在同一个SM上,除非资源限制迫使它这样做。
- 线程块内的执行:线程块内的线程被分组为线程束。一个线程束通常包含32个连续线程ID的线程。SM以线程束为单位进行调度和执行。在一个时钟周期内,SM的一个执行单元(如一组ALU)会执行一个线程束的一条相同指令(SIMD风格)。由于分支发散,线程束内的线程可能执行不同的代码路径,这会降低效率。现代GPU可能会尝试在硬件层面重新组织线程以改善SIMD效率,但这通常是实现细节且不透明。
与CPU向量化的对比:在CPU上(如使用ISPC),程序员或编译器需要显式地生成与硬件宽度匹配的向量指令(如SSE, AVX)。在GPU上,程序员只需声明大量的标量线程,硬件负责将这些线程映射到其宽SIMD执行单元上。这提供了更好的可移植性:同一份CUDA代码可以在具有不同SIMD宽度的未来GPU上运行,而无需修改。
总结

本节课我们一起回顾了并行计算课程前半部分的核心内容。我们涵盖了多核架构基础、并行程序优化策略、GPU编程模型(CUDA)、数据并行思维与MapReduce、无锁编程与比较并交换操作,以及至关重要的缓存一致性协议(如MSI)和宽松内存一致性模型。理解这些概念对于设计和分析高效、正确的并行程序至关重要。希望通过这次复习,大家能巩固所学知识,为期中考试做好准备。
015:领域特定编程语言




在本节课中,我们将学习领域特定编程语言(DSL)的概念,并通过两个案例研究——Halide和Liszt——来探讨它们如何帮助领域专家在保持高生产力的同时,获得高性能的计算结果。我们将看到,通过限制语言的表达能力,编译器可以获得足够的信息来生成高度优化的代码,甚至自动探索优化空间。
课程剩余安排
感恩节之后有两节关于事务内存的重磅课程,考试中几乎肯定会有相关题目。
在学期结束前的另外三节课中,今天的课程是关于领域特定编程系统。最后一周,Kim将更多地讨论专用硬件,例如不可编程处理器及其能效。
这三节课更偏向于概念性介绍,目的是让大家了解并意识到这些内容,以便在未来进一步学习。考试题目也会更偏向概念性,而非具体计算。
课程目标与背景
本节课的目标有两个。首先,到目前为止,本课程中大家实现的代码都运行在相当底层的编程系统上,本质上是C++、CUDA或ISPC。虽然这些系统接管了生成SIMD指令的责任,但大部分关键决策仍需由你做出。这是有意为之的,目的是让你了解线程池等底层机制是如何实现的。
现在,我们将探讨更高层次的抽象。这些抽象适合那些精通某个特定领域,但可能没有上过CS149或对底层实现不感兴趣的专家使用。今天我将通过两个案例研究来讨论这个问题,这两个例子都与斯坦福大学有关。
案例研究一:Halide
我们将讨论用于编写Google Android手机照片应用中所有图像处理的编程语言Halide。据我所知,至少在几年前,每张经过Instagram处理的图片都使用了名为Halide的语言编写的滤镜和处理程序。

另一个是斯坦福大学开发的研究性语言Liszt。虽然你可能永远不会遇到它,但它很好地展示了高层次抽象的价值。
领域特定语言(DSL)的核心理念
到目前为止,我们使用的编程系统都需要相当聪明的人来优化。在程序员群体中,CS149的学生已经非常稀少,而能写出高度优化代码的人更是凤毛麟角,这一点从作业1、2、3,尤其是作业4中可以得到证明。
在计算机科学中,无论是系统、编程语言还是编译器领域,人们一直追求一种理想编程语言应具备的特性。首先,我们希望能够高效地表达想要编写的程序。如今,借助大语言模型,人们甚至希望从想法直接生成可工作的正确代码。Python因其高生产力而成为许多人的首选语言。
其次,在许多场景下,如机器学习、数据科学、处理TB级图的大图算法,或处理千万像素的图像处理,我们都需要高性能,因为代码需要在iPhone或数据中心上运行。
历史上,人们还希望编程语言是通用的,能够编写任何程序。这就形成了评价编程语言的三个维度。
如果我们观察许多广泛使用的编程语言,它们往往在某些维度上表现突出。例如,Web开发人员使用JavaScript可以快速编写各种程序,但性能可能不佳。而使用C++或Rust(甚至包括Java、CUDA)则是为了追求高性能。
今天我们要讨论的是位于生产力和性能之间空白地带的语言——领域特定语言。我们非常关心生产力,例如,使用PyTorch可以快速搭建新的神经网络。同时,我们也希望它极其高效,不仅仅是并行高效,还要能利用GPU或TPU等加速器。为此,我们愿意放弃表达任意程序的能力。

你可能已经遇到过许多DSL的例子。PyTorch不是用来构建任意程序的通用语言,而是用来表达张量运算的。SQL是一种受限的语言,但它是查询数据库的绝佳语言,你完全不需要考虑查询如何在大型数据库上并行执行。Matlab也属于此类。其他例子还包括地理空间数据库查询语言、OpenGL等图形API,以及构建在PyTorch之上、提供更少灵活性但更易用的各种机器学习工具。
这就是今天课程的核心论点。我认为,过去5到10年对领域特定语言的重视已经结出硕果。大约10年前,放弃C++、Java的灵活性而迫使人们走一条狭窄的道路可能还有些争议。但如今,这已成为一个重要的承诺。
通常,我们在讲完专用硬件后才上这节课。虽然我们还没讨论专用硬件,但请记住,高性能不仅意味着并行,还可能意味着使用TPU、加速器,或Apple Watch中的五种不同处理器等。



DSL的设计思路
其理念是快速编写程序,并且只编写一次。我们使用系统编译器所了解的原始操作来编写。例如,PyTorch知道什么是ND张量;SQL知道当你选择某行时,“选择”意味着什么。系统将利用这些知识,在你运行的任何系统上提供最佳实现。
例如,在SQL中执行“SELECT * FROM ...”时,数据如何布局、使用什么算法和索引结构(是二叉树还是B+树)——所有这些实现决策都由系统处理,程序员只需请求一个满足过滤条件的关系表。在不同的系统上,这个实现可能截然不同。
在PyTorch中,你可能会说“请将这个张量通过这个卷积层”。如果在GPU上运行,这将是某个cuDNN实现;如果在Intel CPU上,可能完全是另一个实现;如果在Google TPU上,又将是不同的实现。
让我们通过一些例子来思考设计这些领域特定抽象集时所做的决策。
图像处理的工作负载分析
在讨论Halide提供的原始操作之前,我们必须先了解该领域人员试图编写的工作负载是什么。
这里有一个例子,我很好奇在30秒内,你能告诉我这段代码是做什么的吗?

它模糊了一张图像,从函数名可以看出来。但它是如何模糊图像的?滤波器大小是多少?如何并行化?如何向量化?这段代码相当复杂。这就像在作业1时期,我们用你的MMX内部函数_mm_load_128替换了某些东西。在CS149的背景下,你可能需要自己实现这些。
现在,我问另一个问题:这段C代码是做什么的?这次我给你函数名,所以你不能作弊。这是一个卷积。给定一个图像(一个2D数组,像素矩阵),这里是一个单色图像,因为每个像素只有强度值,没有红绿蓝。每个输出像素是其周围相邻像素的平均值。
所以,这是针对每个输出像素的循环。然后,对于每个输出像素,循环遍历其3x3的邻域像素块并将它们平均起来,加起来然后除以或乘以权重,这里权重处处都是1/9。
在之前的讲座中我提到过,如果你运行这段C++代码,你会模糊一张图像,结果看起来有点像这样。
那么,我们来分析一下这段代码。每个像素做了多少算术操作?基本上这里有一次乘法和一次加法。所以,我做了9次乘加操作,然后是一次存储。因此,完成这项工作的成本是 9 * width * height。如果我们考虑滤波器是n x n而不是3x3,那么工作量就是 n^2 * width * height。这就是我们做的总操作数。
现在,我需要给你一个小信息。如果你了解图形学,你会知道这个2D卷积滤波器实际上可以分离成两个1D卷积。在这种情况下,我可以先对所有行进行一维水平模糊(每个像素是其自身及左右邻居的平均值),然后对那个结果进行垂直模糊。仔细想想,水平模糊后,我加起了三个数;垂直模糊后,我又将三个这样的部分和加在一起。我做的数学运算完全相同。
所以,代码可能看起来像这样。我在左边用C语言写了出来,右边展示了内存分配。输入大小是width x height。+2并不重要,我只是不想在输出中处理边界条件。然后,在水平模糊时我将其缩小了两个元素,在垂直模糊时又缩小了两个元素。
那么,我现在做了多少工作?以前是 n^2 * width * height。现在是多少?现在是 2n * width * height。从每个像素9次操作降到6次,可能看起来不那么显著。但如果是一个7x7的滤波器呢?那就是每个像素49次操作对14次操作,我们在数学上开始获得显著的提升。如果你在Photoshop中模糊图像,经常可能使用像100x100模糊半径的滤波器,所以情况会很快变得复杂。
这样很好,我们减少了完成这项工作所需的数学运算量。
你发现什么可能不太好的地方吗?考虑到我们在这门课中经常思考的其他方面。
一个不幸的事情是,我基本上将内存占用增加了33%。以前我需要一个输入和一个输出缓冲区。现在,我需要输入、输出和一个相同大小的临时缓冲区。如果我在手机上处理一张1200万像素的图像,分配另一个1200万像素的缓冲区(假设是RGBA四通道浮点数,约48MB),这在手机上可能很重要。
你还注意到什么?我们经常考虑的事情包括:并行性(这里还没有讨论)、完成的工作量、内存占用、内存带宽与算术强度、缓存一致性等。但这里还没有并行性。
那么,让我们仔细看看这个程序。每个输入被读取了多少次?从技术上讲,每个输入被读取了九次,但从内存中读取的次数呢?可能只有一次。不过要小心,因为我们讨论过分块。这意味着如果你能在缓存中容纳两行数据,那么你就有足够长的时间将数据保留在缓存中,从而只从内存读取一次。所以,只要几行数据能放入缓存,这个程序就会将每个输入元素读取一次,并确切地将每个输出元素写入一次。
现在看这个分离的版本。这个版本将每个输入元素读取一次,写入临时缓冲区一次,然后再次读取每个临时缓冲区元素,最后写入输出一次。所以,我的算术强度不仅因为内存占用增加而降低,而且大约降低了2倍。如果我们之前受带宽限制,现在会更慢;如果我们之前受计算限制,也许我们有所改进。
所以,我们实际上已经讨论过这一点。我指出了所有数据被重用的地方。
那么,给观众提个问题:有没有办法做得更好?让我用一段看起来有点像这样的算法来启发一下思考。我希望你确保理解这段代码在做什么,或许可以和人讨论一下。我已经给出了一些提示来帮助你理解。
这段实现没有分配超过三行的临时缓冲区。
需要一点时间来消化。如果你想和人讨论,或者自己想明白,我会给大家大约30到45秒的时间来思考。
我高亮的关键点是,最外层循环是遍历行(rs),然后内层循环是0到3。
那么,这里发生了什么?我们如何解释它?如果你在办公时间向我用高级语言解释,这里发生了什么?
一种思考方式是,你正在执行与上一张幻灯片相同的算法,但是你在不同的数据块上多次执行。是的,所以我的做法是:我不需要整个临时缓冲区,不需要整个中间结果。我所做的是,我先进行第一遍处理,但只生成三行输出。我取三行数据,对它们进行水平模糊。这就是我随后进行垂直模糊以得到最终一行输出所需的全部信息。
换句话说,你可以这样理解代码:最外层的j循环是针对每一行输出。然后,生成产生该行输出所需的三行临时数据。接着,取这些行,压缩它们,并进行垂直模糊以产生该行输出。
我喜欢这样理解这段代码:只看循环,然后说,对于每一行实际输出,首先产生所需的输入,然后消费这些输入以产生该行输出,然后重新开始。
我这样做的好处是,我的临时缓冲区分配从整个图像的大小减少到大约三行,这很好,因为这三行现在可能可以放入缓存。我找回了我的算术强度。我做了这个两阶段算法,我认为这很好。没有额外的分配,并且我使临时缓冲区足够小,对该缓冲区的写入和后续读取将命中缓存。
但是我付出了什么代价?有时行会被多次计算,如果它们重叠的话,因为你每次处理三行。是的,所以请注意,临时缓冲区的每一行实际上可以被重用三次。但我产生一整行临时数据(三行),使用它,然后丢弃所有这些信息,再计算另外三行临时数据,如此反复。所以我重新计算了很多东西,这削弱了这种两阶段方法的好处。
事实上,如果我计算每个像素做了多少次操作,你能算出来吗?或者也许计算每行的操作数更容易思考。对于每一行输出,我首先做什么?我对三行数据,每行每个像素做三次操作。然后我在这里再做三次操作。所以我最终每个像素做了 3*3 + 3 = 12 次数学操作。这实际上比我开始时更糟。有点倒退的感觉。
但感觉我们更接近了。那么有什么想法?我想最小化所做的数学运算,希望接近 2N。但我想要高算术强度和低内存占用。我能做什么?
一个潜在的想法是保留最后两行,然后写入。如果我们顺序思考,我们可以把那个红色缓冲区当作一个滚动缓冲区。你向下滑动两行,可能再加一行新的。然后我们就可以继续了。现在,我们将支付向下滑动两行的成本,可能只是一个内存拷贝。缓存方面可能会有点麻烦。或者我们实际上可以让代码使用间接引用来引用数据。但现在代码会变得有点难看。
事实上,如果你这样做,你会发现,在现代计算机上,你为索引额外添加的数学运算会使代码变慢,超过其他优化带来的好处。
还有其他想法吗?顺便说一下,如果你这样做,这个滑动窗口滚动缓冲区的方法,你突然在每一行之间创建了依赖关系。而我们之前并没有行间的依赖关系。所以这可能会反过来困扰我们,因为我们想并行化这个东西。
还有其他想法吗?将缓冲区分成三部分,然后保持这些独立的部分。你是建议我将输入水平切成三列吗?不完全是。让我确认一下我理解对了。临时缓冲区目前是三行。你建议也将其水平切成三列(三个块)。然后呢?但这是一样的。你只是利用了C语言中二维索引的便利性,而我自己将其扁平化了。但在底层,编译器会以完全相同的方式扁平化。我认为我们说的是同一件事。
或者,也许像分成方块之类的,在边缘会有一些重叠,你在那里预先计算。你可以有更小的缓冲区?这实际上有几种方法可以实现。我喜欢这个想法。首先,想想我在这里是如何设置问题的。以一种按行思考的方式,我会说,对于每一行输出,独立地计算你所需的三行临时数据,然后处理那行输出。然后对所有输出行都这样做。
你说的问题在于,我的解决方案中,对于每一行输出,我们都要额外计算其上方和下方的一行。所以,对于我做的每一件事,都有两行的开销。那么,如果我们不是只考虑每一行输出,而是说,对于每10行输出,计算12行中间结果,然后快速处理它们。我们仍然在计算一些额外的东西,但现在对于每10行只有2行额外开销,而不是每行都有2行额外开销。这就是我下一张幻灯片要讲的内容。
所以,我在这里所做的就是,现在看临时缓冲区,它是 (chunk_size + 2) * width。在我脑海中,我是说,对于每 chunk_size 行输出,首先产生 chunk_size + 2 行临时数据,然后产生你的输出。现在的开销是多少?随着 chunk_size 变得越来越大,这将趋向于我原来的 2N 算法。所以,我倾向于使 chunk_size 尽可能大。
但是,如果我把 chunk_size 设得太大,会发生什么?块将无法放入缓存,那么分块就完全没有好处了。这与分块思想类似。所以,这是一种改变程序的方法。例如,如果 chunk_size 是16,我们粗略计算一下,每个像素大约需要6.4次操作,比 2N 多一点,但肯定不是9,对于更大的块大小来说,也肯定不是 N^2。
所以,这就像你在SpMV中首次看到的生产者-消费者融合技巧,然后我们在矩阵乘法讲座中又看到了它,现在你再次看到它——重新排序计算以最大化算术强度。但在这里,我们实际上说,我们愿意重新计算一点东西,以最大化算术强度。这在其他例子中没有出现过。
现在,我们还没有完成,因为我们还没有讨论SIMD,也完全没有讨论并行化。顺便问一下,你将如何并行化这个计算?首先,根据我的设置,什么可以被并行化?每个输出块可以并行吗?是的,如果你需要更多的并行性,你实际上也可以将输出块在水平方向上进一步分块。然后我只需要块大小的行,而不是整行的宽度,只是块的宽度加2。
还有另一种方法来实现这个,就是回到只有一个几行大小的临时缓冲区的算法,然后采用你的滑动窗口想法。然后我通过将输出在列上分块来获得并行性。我将只是沿着每一列滑动窗口,希望我的块之间有足够的并行性。这是另一种做法。所以有几种不同的方法可以实现。

所以,本质上,如果你仔细看这段代码,它做的正是我们想出来的。对于输出的每一个块(实际上每个块是256x32),首先计算一个比它宽2个元素、高2个元素的中间块。这就是这两个for循环在做的事情。所以,它是以瓦片(tile)的形式产生输出。如果你看最外层的循环,它是针对y和x方向上的每一个瓦片。首先,使用SIMD指令计算一个 (256+2) x (32+2) 的块(这个块大小能放入缓存),然后利用那个块产生输出。这很难一眼看出,但用英语解释起来相当容易。
这就是领域特定语言发挥作用的地方。顺便说一下,每次你想尝试一些不同的东西,比如你想试试这个选项,然后又说,我想试试那个滑动窗口方法。想象一下,从这段代码到一个滑动窗口实现需要多长时间?如果你非常熟练,可能也需要一整天才能搞定。
所以,Halide是一种语言,它的设计初衷并不是像PyTorch那样让不懂并行编程的人也能获得高性能。它是一种让CS149学生能更快完成作业的语言。换句话说,它是一种为那些基本上知道“我想这样分块,在那个循环上向量化,但我不想写代码”的人设计的语言。如果ISPC说“我不想写这个”,那么Halide就是帮你解决这个问题的。对于图像处理,Halide说,我甚至不想处理所有这些循环之类的东西。
Halide代码示例
这里有一些Halide代码的例子。Halide是完全函数式的。注意,这段代码中完全没有循环。Halide有一个“函数”的概念,因为它是函数式的,但你可以把它们想象成张量。
让我为你解读一下这段代码。blur_x 是一个以 x 和 y 为参数的函数。换句话说,blur_x 是一个函数,如果你给我 x 和 y 值,函数会给出该位置像素的值。这个函数是根据其他函数的输入和输出定义的。它说,blur_x 函数在 (x, y) 处的输出值是 in 函数在 (x-1, y), (x, y), (x+1, y) 三个位置值的平均(乘以1/3)。还有另一个函数 blur_y,它在 (x, y) 处的值是这些 blur_x 值的平均。
我正在构建一个表达式树来说明:如果你想知道 blur(x, y) 的值,这个表达式定义了如何根据前驱函数计算它。注意,in 在技术上是一个缓冲区,它是一个从实际输入数据加载的特殊函数。
你可能会问,如何处理边界条件?例如,如果你输入 x=0, y=0,你会得到 in(-1, 0)。这正确吗?我想不处理它。你应该直接查找 in(-1)。就像你写程序时那样。你真正的问题是,in(-1) 是什么?是错误吗?如果它是一个数组,那就是错误。但既然这是一种领域特定语言,而DSL旨在提高生产力,我没有在幻灯片上展示的是,我可以轻松地设置 blur_y.boundary_condition = 0 之类的。Halide编译器会插入检测边界条件的数学运算,并输出正确的值。这就是为什么我不想在讲座的其余部分处理它。但这是语言处理边界条件效率高的一个生产力优势。实际上,Halide会生成代码,可能不会在内层循环中使用if语句,而是为 i=1 到 n-1 生成一个没有if语句的循环,然后为边界条件生成其他循环,如果我把这些都展示给你看,那将是一团糟。
所以,这是生产力的部分。我想再给你一些例子。这段代码说,如果我有一个定义在 x 和 y 上的函数 blur_y,它在 (x, y) 处有某个值。那么这里有一个函数 bright(x, y),它被定义为 blur_y 的值乘以1.25,然后钳制到255。所以 bright(x, y) 就是 blur_y 乘以1.25,但要确保将它们钳制到255。
甚至还有一个聚集(gather)操作。我们讨论过数据并行聚集:output(x, y) 可以使用 bright(x, y) 的值作为索引,在这个函数中查找像素位置。这是一个聚集操作。所有其他操作都是对所有 x 和 y 的映射。
所以,它是一种函数式编程语言。我们定义了如何计算 (x, y) 的表达式。最后一行代码只是说,给我一个实际的C风格缓冲区,其中的值是我在 x 从0到124,y 从0到124范围内对所有值求值函数 out 的结果。这基本上是延迟求值。
所以,这很酷的一点是,我们有这些函数。如果你想想图像处理,如果我向你解释什么是模糊,我不会给你一个卷积公式,我通常会说,计算模糊的方法是,取每个像素,平均它周围的所有像素。这正是这段代码的样子。所以,这个表达式具有生产力,因为它处理了边界条件,而且代码看起来就像数学和我们讨论算法的方式。在某种意义上,代码看起来很像NumPy之类的。但请记住,这些是函数,不是数组或张量。你可以这样想,但最好不要。
那么,如果我想把这个程序看作一个有向无环图(DAG),一个任务列表,我会把每个函数看作一个节点,它们依赖于先前的函数。所以在这个例子中,我们有一个输入函数 in,blur_x 派生自 in,blur_y 派生自 blur_x,bright 派生自 blur_y,out 派生自 lookup 和 bright。让我们检查一下,是的。所以我有一个依赖链,输出来自 bright 和 lookup 中的值。
问题:它可能是在iPhone上吗?首先,大家是否大致理解这个程序应该计算什么?具体来说,它应该计算什么,而不是如何计算。如果我用一堆像素填充 in,用一堆像素填充 lookup(比如加载一张图像),你会说,是的,我知道 out(x, y) 应该是什么。这个图给了你答案。
Halide表示的两阶段模糊
这是Halide表示的两阶段模糊,我们一直在讨论它。我们有一个来自图像的输入 in。我们有第一个函数 blur_x,它表示 blur_x 在 (x, y) 处的值,每个像素应该来自 in 中这三个像素的平均值。然后我有 out(x, y),它来自 blur_x 中这三个垂直方向像素的平均值。
所以,我整个C代码,如果我们回头看,这个算法的C代码看起来像这样。而在Halide中,它看起来像这样。所以它基本上遵循了数学公式。这很酷。首先,它更优雅一些。你可以阅读代码。如果你是一个算法开发者,你可能知道这意味着什么。现在,这段代码有两个阶段,就像右边的依赖图。如果你看更现实的图像处理程序,它们有很多阶段。大约六七年前,你的Google HDR+相机应用程序大约有2000个Halide阶段。

那么,当你思考这个Halide程序的含义时,如果你是Halide编译器,你现在脑子里可能在想,好吧,如果我是Halide编译器,我的程序在顶部。但我在上一张幻灯片向你展示了,我在心里将这个程序翻译成这个类似C语言的实现,针对每个函数。
分配一个数组。只是试图将函数转换为张量,即2D数组或矩阵。然后,对于每个等式语句,对于每个表达式,那是一个针对输出数组中每个像素运行的操作,并计算值。所以,在某种意义上,你能确认这有意义吗?这不是……这是上面代码的有效实现。所以Halide编译器基本上只是为你写了这些循环。
首先,谁知道Halide的事情呢?它来了。首先,就像这样,现在精确了。这个编译生成了一个顺序的C程序。假设这些是for循环。它分配了这三个缓冲区,正是我们之前说的。我在讲座早些时候用C写过这个。所以我首先确定这是该程序的有效编译,可能还有其他有效的编译方式。
DSL系统的关键方面
任何DSL系统的一个关键方面是思考你的用户是谁,思考对他们来说什么是困难的,你想让什么变得更容易?我想说,即使我刚才在幻灯片上展示的内容,我能用两行代码写东西,这很好。但你本可以在10分钟内写出C代码。所以,如果我写两行代码,而你用5到8分钟写C代码,那好处并不大。可能有一些语法糖之类的东西,但好处不大。如果我们把边界条件加进去,可能会对你有点帮助。但这不是真正的问题。真正困难的是,如果我回头看,你需要多长时间才能想出这个?即使你知道在CS149该怎么做,你也不知道对于特定机器来说正确的答案是什么。想想你在编程作业中做了多少迭代。
所以,Halide是……Halide程序员曾经用汇编写过很多这些东西,对吧?他们说,我们想要的是,我们不想……拥有一个简洁的语法很好。但我们真正想要的是探索那些我们已知的可能优化选择,并且我们想非常快速地做到这一点。

所以,任务不是表达图像处理计算本身,手头的挑战,手头的任务是完成你的CS149作业,也就是让它变快。Halide引入了一系列思想,允许你在我们讲座讨论的层面上描述你希望如何让它变快。
Halide的调度(Schedule)
Halide中,白色背景部分是用来表达“计算什么”的Halide程序,即表达算法。它由另一组编程原语补充,我稍后会解释,称为“调度”。调度是关于如何为此生成代码的明确指示。
我将快速用英语为你解读,然后在接下来的几张幻灯片中分解它。这里,程序员已经明确指定了我们想出的解决方案,即:我希望你以256x32的瓦片形式计算输出。请继续,将这些瓦片的循环命名为 x 和 y,然后是 xi 和 yi。所以 x 和 y 是我们在哪个瓦片,xi 和 yi 是那些瓦片内的内层循环。然后我希望你将名为 xi 的循环用8宽的SIMD向量化,并且我希望你在 y 循环上进行线程并行。

这样做的结果将是我上一张幻灯片给你的代码。如果你觉得,我不知道这是否有效,也许我应该改变我的瓦片大小。你只需改变这里的参数,就会得到新的瓦片大小,它会为你重新调整。或者如果你说,哦,我不想向量化 xi 循环,让我们实际上在最外层循环上做SIMD之类的。你只需改变那些参数。这就是我们要讲的内容,对吧?

这些调度指令为你提供了几种不同类型的原语。一些原语与如何遍历函数的元素有关:行优先、列优先、分块等等。这些只是迭代的一些不同例子。比如,我希望你在y维度上串行执行。或者我希望它是x主序且串行。或者我希望它是列主序。或者我希望在所有y上串行,但在x方向上向量化。或者我希望在所有行上线程并行,所有不同的y是不同的线程,但在这个方向上向量化。所以你有能力表达所有你认为可能需要的不同排列组合。
好的,那么让我们深入一些细节。
让我们看看调度中的这一行。再次强调,我不在乎你是否能记住这个。但这些具体的例子能让你感受到什么是可能的。我们说,out.tile 基本上是说,我不希望你默认按行主序计算输出,而是以256x32大小的瓦片形式计算。这就是它的意思。我们决定要这样做。
我们可能需要命名循环变量,所以我们将称之为……代码说,如果你将来需要一个句柄,我们将创建……记住有四个循环,我们将把这些循环称为 x, y, xi, yi。
你可以把每一个Halide调度语句看作实际上是在操作程序中存在的循环嵌套。默认情况下,我们有一个循环嵌套,看起来像这样:默认情况下,如果那是程序,Halide有这个循环嵌套,即对于所有 x, y,创建 blur_x,然后对于所有 x, y,创建 out。所以那个语句是说,哦,创建 out 的循环嵌套?那是我们的代码所做的。它说,我希望你修改它,使其成为一个分块的循环嵌套,并且我希望你将由此产生的四个循环命名为 x, y, xi, yi。
所以Halide实际上有两种语言:一种用于指定你的算法,另一种是命令式语言,描述你希望对循环嵌套进行的一系列转换。
想象一下,我们做了一个转换,创建了四个循环,输出以256x32的瓦片进行迭代。
好的。现在,一旦你有了那个循环嵌套……out.tile 返回新的循环嵌套。然后我说,哦,请向量化,当你为 xi 循环生成代码时,我希望它是向量化的。当你在 y 上迭代生成代码时,我不希望它是顺序的,我希望它在线程池上并行化。
这就是整行的意思。这样做的结果将是,blur_x 循环的代码(以前是对于所有 x 和 y)现在注意它有四个循环变量,并且 xi 循环是向量化的。而这个循环,想象一下它是用线程并行化的。
你是在高层次上告诉编译器你希望如何做这件事。好的,所以循环排序是你做的一类事情。另一类事情是我们做了融合。融合就像把一些循环放到另一些循环内部。
这里我说 out.tile 创建了这个四层循环嵌套。然后记住,在我的解决方案中,对于每一个瓦片,我首先必须计算所需的临时缓冲区,然后使用它来产生输出。这就是这个循环嵌套。默认情况下,如果我说 blur_x 循环嵌套,有一个叫做 compute_root 的命令,基本上意味着在层次结构的根部计算它,也就是不要在别人的循环嵌套内部计算它。
所以这段代码会计算整个临时缓冲区。注意我们已经分配了临时缓冲区。然后在分块迭代时访问那个临时缓冲区。这是一个有效的程序,但不太明白你为什么要这样做,不过它是有效的。

现在我要做的是,我要说,嘿,这个 blur_x,让我们把它塞到这里。这样对于每一个输出瓦片,我们首先生成一个临时瓦片,然后使用那个临时瓦片。所以我要说,嘿,blur_x,实际上我希望你在 out 的 xi 循环处计算它,实际上我把它塞得更深了,我把它塞进了最内层循环。
所以现在Halide说,哦,对于每一个最内层循环,每一个输出像素需要三个临时像素,所以我要创建三个临时像素,然后我们使用它。我要取三个临时像素,然后我们使用它。
为了得到我们实际讨论过的代码,我实际上希望 blur_x 在 x 处计算。blur_x.compute_at(x) 会把它塞进 x 循环。所以它说,对于每一个瓦片,首先计算256x34个元素,然后使用它们,然后丢弃,再计算另一组元素,然后使用它们。
所以,实际上到达我在汇编代码中展示的那个循环嵌套的总结就是那两行Halide调度。是的,你为输出设置循环嵌套,然后你决定把生产者放在那个循环嵌套的哪里。
这其实很有趣,对吧?这不是最典型的系统。这里的理念是,程序员负责描述图像处理流水线,那是算法。但程序员也负责进行所有关于如何优化它的概念性思考,那就是调度。有两种协作的领域特定语言来做这两件事。
然后编译器负责基本上执行它的命令。如果在ARM上,生成这些内部函数和线程代码。如果在x86上,做这个。适当地处理边界条件和分配等等。因为通过函数式了解了所有的依赖关系,基本上没有指针追逐之类的事情。编译器知道它可以移动这些循环嵌套并为你改变所有索引,而永远不会改变程序的正确性。所以Halide的理念是,如果你修改调度,你永远不会改变输出。这只是一种优化。

然后是一些非常早期的结果,现在基本上有十年了。最初的论文表明,哦,我们可以编写更少的代码,并且大多数时候获得比手写汇编更高的性能,即使编译器生成的汇编代码不如优秀程序员手写的好,但程序员能够更快地迭代高层次设计空间,尝试更多的东西。所以全局结构更好,即使代码可能比优秀程序员慢10%、20%、30%。
所以,就是这样。然后Google,做这个的一些人去了Google。他们在Google全职做这个,尽管它长期保持开源。这变得足够健壮,成为Google图像处理流水线的编译器。所以这就是这里的历史。再次强调,我想强调,如果我们退一步看,Halide并不能帮助你编写快速代码。嗯,它有帮助,但它不能帮助一个对性能不敏感的天真程序员做任何事情。如果你没上过CS149,你甚至不知道怎么写调度,你不知道任何概念。但它帮助像你们这样的人,你们知道想尝试的事情的空间,在几个小时内,对于一个下午对于更复杂的函数,完成那个空间的探索。结果证明,并没有很多CS149程序员。即使在Google,大约有80名程序员编写Halide算法,而实际编写调度程序的程序员数量非常少。我认为数量非常少,比如3个。就像,当其中一个人休产假时,Halide就有一段时间没人写调度了。

现在,你,自从大约2000万……在过去的三年里。这些调度抽象所做的事情之一是,它们为你提供了这个非常有结构、有组织的设计空间。优化程序的过程就是选择循环嵌套以及决定把东西放在循环嵌套的哪里。这个极其结构化的设计空间结果证明对于指导自动搜索也非常有用。
所以有这样一个想法:我们到底怎样才能摆脱Google那三个程序员,对于大多数这些计算?我们在这方面做了一些早期工作。然后在2016年和2019年,那篇论文由Halide的联合创始人之一领导,我提供了一些帮助。但真正的工作者是Andrew Adams,他是……他是这方面的重要工作者,他能够提出一些算法,只是使用游戏玩法技术,比如树搜索之类的,从AI课程中学到的,来搜索调度空间,并提出相当不错的调度。就像你们所有人都会非常努力地获得好的调度一样。
我将向你展示一些有趣的结果,不是来自这篇论文。我要展示的结果没有这篇论文那么好。我们退回到两年前,因为这篇论文没有这样的图。这是人类研究和辅助论文。X轴是时间。Y轴是吞吐量,单位是像素每秒,越高越好。这是三个不同的图像处理应用程序,具体是什么不重要。
所以我们去找了Google那三个世界上优化调度最好的人中的两个。我们说,你以前从未见过这个程序。这是Halide算法。请为它编写调度。基本上,这些人的工作方式是,他们编写调度,然后跳过去看汇编,说,嗯,这不是正确的汇编。让我……所以他们实际上在看汇编,但从不写汇编。有趣的是,这是自动调度器输出的结果。你知道,就像在一毫秒内。那是绿线。这就是为什么它没有变得更好。它只是算法所做的。然后这是Andrew和Dylan在几分钟内的表现。
他们只是编写调度,运行程序,性能分析。好吧,那个不行。让我试试另一个东西。所以这就像他们处于一个互动的CS149作业情境中。这相当令人印象深刻。所以自动调度器在三个中赢了两个。显然,我们只给了他们一个小时。如果他们继续下去,肯定会打败这些东西。但更新的自动调度器甚至比这个更好。它会调度得与大多数人一样好或更好。
他们自己写的吗?程序就像我写的这个,在我的CS149记分板上得了这个分数。嗯,这只是他们当时尝试的。所以他们正在探索设计空间。这就是为什么它下降了。是的,Andrew不笨,他创造了Halide。他不笨,但他正在尝试不同的东西。所以他……他得到了一个好程序。让我看看我能不能在另一个方向上做得更好一点。然后大约45分钟后,他说,我好了。我放弃。这不太顺利。我放弃。

他们实际上写吗?但他们看……嗯,他们,他们,他们的方法论。我,我,我在模仿他们。这是很久以前的事了,他们就像,哦,好吧,我得到了这个性能。我想知道为什么。

我想知道,比如,它在那个内层循环上做得很好。让我去看看。没有停止。他们只是在脑子里跳来跳去。是的,但他们就像,他们在这里有一个Halide调度打开的文本编辑器,这里有一个编译器打开,然后他们运行代码并查看输出。是的,一旦你只是,哦,我知道有时候当我做这个循环分配或展开这个循环时。所以让我看看L是否会展开,等等。只是为了继续下去,就像很多人对高性能图像处理如此感兴趣,有一个问题是我们为什么还要编译到CPU。
所以有很多不同小组的工作。这是斯坦福大学的一个,他们有一种语言,很像Halide,如果你看的话,在某种意义上是一个简化版的Halide,不,我们根本不生成指令。我们将直接生成FPGA电路。


所以我们将跳过可编程处理器,直接从高层次表示生成硬件。课程的最后一周,我们将更一般地回到这个话题,如果你关心性能,为什么要承担所有这些包袱?在某种意义上,你可以把可编程处理器的指令集架构(ISA)看作是硬件实现和软件实现之间的接口。但如果你在用
016:事务内存 1 🧠








在本节课中,我们将要学习一种名为事务内存的编程概念。事务内存旨在简化共享内存编程中的同步问题,让程序员在获得正确程序的同时,也能获得高性能。我们将探讨事务内存的基本概念、它与传统锁机制的区别、其核心语义(原子性、隔离性、可串行化),以及实现事务内存时涉及的关键设计权衡,如数据版本化和冲突检测策略。

上一节我们回顾了共享内存编程的基础,本节中我们来看看如何通过事务内存来简化同步编程。
为什么需要事务内存?🤔

使用传统的同步原语(如锁)进行编程存在一个根本性的权衡:程序员必须在程序正确性和高性能之间做出选择。

- 粗粒度锁:锁定整个数据结构甚至整个共享内存。这确保了正确性,但并发性低,性能差。
- 细粒度锁:对数据结构的各个部分分别加锁。这能提高并发性和性能,但实现复杂,容易引入死锁、竞态条件等正确性问题。

事务内存的目标是提供一种像粗粒度锁一样易于使用的编程模型,同时又能像细粒度锁一样提供高性能。它允许程序员声明一段代码(事务)需要原子地执行,而由系统底层负责实现这种原子性,并尽可能并发地执行无冲突的事务。

事务内存的核心语义 🔑
事务内存的灵感来源于数据库事务,它提供以下关键语义:
- 原子性:事务中的所有读写操作要么全部生效,要么全部不生效(“全有或全无”)。
- 隔离性:在事务提交之前,其内部的所有读写操作对其他事务都不可见。
- 可串行化:系统可以找到一个顺序,使得所有事务的执行结果等同于按该顺序串行执行的结果。这个顺序由系统决定,而非程序员指定。
事务内存提供的一致性模型本质上是顺序一致性,只不过每次“步骤”是一个完整的事务,而非单个内存操作。
事务内存 vs. 传统锁机制 ⚖️
以下是事务内存与传统锁机制的关键区别和优势:

- 声明式 vs. 命令式:事务内存是声明式的。程序员使用
atomic等构造声明“我希望这段代码原子执行”,而不指定如何实现(例如用哪个锁)。锁是命令式的,程序员必须显式地获取锁、执行操作、释放锁。 - 可组合性:使用锁时,组合多个模块可能因锁的获取顺序而导致死锁,需要全局策略(如按固定顺序获取锁),这破坏了模块性。事务内存天然支持可组合性,嵌套的事务会自动被外层事务包含,系统会处理冲突。
- 故障恢复:在事务中发生异常时,只需中止事务,系统会自动回滚所有修改,无需程序员手动释放锁和恢复状态。使用锁时,必须在异常处理中小心地释放已持有的锁。
- 性能潜力:事务内存系统只会在检测到真实的数据访问冲突时才序列化事务。如果两个事务访问不相交的数据集,它们可以完全并发执行,从而获得类似细粒度锁的性能。

事务内存的实现:核心设计空间 🛠️



实现事务内存主要涉及两个核心策略的选择:
1. 数据版本化策略
这决定了如何管理事务未提交的中间状态和已提交的状态。
- 积极版本化:事务一旦写入数据,就立即更新主内存(或缓存)。需要一个撤销日志来记录被覆盖的旧值,以便在事务中止时恢复。
- 公式/过程:
- 写操作:
undo_log[address] = memory[address]; memory[address] = new_value; - 提交:丢弃撤销日志。
- 中止:
memory[address] = undo_log[address];(对所有写入的地址)
- 写操作:
- 特点:提交快(数据已更新),中止慢(需恢复),需处理隔离性(其他事务可能看到未提交的写)。
- 公式/过程:
- 惰性版本化:事务的写入操作先暂存到一个写缓冲区中,仅在提交时才批量更新到主内存。
- 公式/过程:
- 写操作:
write_buffer[address] = new_value; - 读操作:先检查写缓冲区,若命中则返回缓冲区值,否则读主内存。
- 提交:将写缓冲区内容写入主内存,清空缓冲区。
- 中止:直接丢弃写缓冲区。
- 写操作:
- 特点:中止快(直接丢弃),提交慢(需更新内存),读操作可能更复杂(需合并缓冲区视图)。
- 公式/过程:


2. 冲突检测策略
这决定了何时以及如何判断两个事务发生了冲突(即访问了相同数据且至少有一个是写操作)。事务的读集是它读取的所有地址集合,写集是它写入的所有地址集合。冲突发生在两个事务的读集和写集存在交集时。

- 悲观检测:假设冲突很可能发生,因此在每次内存访问时都立即检查是否与正在运行的其他事务冲突。
- 特点:能早期发现冲突,可以采取“等待”策略让先到的事务完成,避免浪费工作。但可能增加每次访问的开销,且在某些场景下(如两个事务循环竞争写入同一数据)可能导致活锁。
- 乐观检测:假设冲突很少发生,因此允许事务自由执行,只在事务提交时才检查其写集是否与其他活跃事务的读集冲突。
- 特点:执行期间开销小,但可能让注定要失败的事务(“ doomed transaction”)运行很久,在提交时才中止,造成计算资源浪费。提交的事务总是优先,会迫使与其冲突的未提交事务中止。




这两种策略与数据版本化策略需要配合使用。例如,积极版本化常与悲观检测搭配,因为立即写入了内存,需要尽早检测冲突以维护隔离性。惰性版本化则与乐观检测更契合,因为写操作暂存在本地缓冲区,在提交前不影响全局状态。











本节课中我们一起学习了事务内存的基本概念和动机。我们了解了事务内存如何通过提供声明式的原子性抽象,来简化并行编程,同时兼顾正确性与性能潜力。我们还深入探讨了实现事务内存的两个核心维度:数据版本化(积极 vs. 惰性)和冲突检测(悲观 vs. 乐观),以及它们各自的特点和权衡。在下一讲中,我们将继续探讨事务内存的具体软件和硬件实现机制。
017:事务内存 2 💾


在本节课中,我们将继续讨论事务内存。我们将探讨软件和硬件的具体实现方案。
概述 📋
上一讲我们介绍了事务内存的基本概念、需要保持的特性及其优势。我们还讨论了事务内存设计中的关键考量点:用于跟踪已提交旧数据和执行中新数据的数据版本化策略,以及检测事务间冲突的策略。我们提到了两种数据版本化策略(急切 和 惰性)和两种冲突检测策略(悲观 和 乐观)。本节中,我们将深入具体的实现细节。
软件事务内存实现 🖥️
首先,我们来看看如何在软件中实现事务内存。一个典型的方法是使用软件屏障。
软件屏障与代码转换

为了实现事务内存,程序中的读写操作需要被转换为对事务内存系统的调用。这些调用被称为软件屏障。

以下是转换过程的示例:
// 程序员编写的原始代码
atomic {
a = b + c;
d = e * f;
}
// 转换后的代码(添加软件屏障)
atomic {
TM_Read(&b);
TM_Read(&c);
TM_Write(&a, b + c);
TM_Read(&e);
TM_Read(&f);
TM_Write(&d, e * f);
}
每个 TM_Read 和 TM_Write 都是对事务内存系统的调用,用于执行必要的簿记工作。许多这类调用是冗余的,可以通过编译器优化来消除。
核心数据结构


软件事务内存系统需要两种核心数据结构来跟踪状态。
1. 事务描述符
这是每个线程或每个事务的私有数据结构。它包含:
- 撤销日志
- 冲突检测信息
- 读集合
- 写集合


2. 事务记录
这是与每个数据元素(如对象或字段)关联的元数据。它记录了数据如何被访问,类似于缓存一致性协议中的状态位,用于指示数据是被多个事务共享还是被单个事务独占。
数据粒度权衡
事务记录可以与不同粒度的数据关联,这带来了权衡。




- 对象粒度:每个对象有一个事务记录。
- 优点:管理开销较低,如果重复使用同一对象,可以分摊开销。
- 缺点:可能导致假冲突,降低并发性。
- 字段/元素粒度:每个数据字段有一个事务记录。
- 优点:减少假冲突,提高并发性。
- 缺点:管理开销增加。
以下是一个假冲突的例子:
// 事务1: 读写对象A的字段X和Y
// 事务2: 读写对象A的字段Z
如果使用对象粒度,两个事务会冲突。如果使用字段粒度,则不会冲突。在实践中,混合使用不同粒度是一种有效的折衷方案。
具体STM算法:McRT 🧮
接下来,我们看一个具体的软件事务内存算法实例——Intel的McRT-STM。它采用以下策略组合:
- 急切版本化
- 乐观读
- 悲观写
版本管理与事务记录
该系统使用时间戳进行版本管理:
- 全局时间戳:当事务提交时递增。
- 本地时间戳:事务开始时获取。
每个数据元素关联一个32位事务记录。其最低有效位是关键:
- 0:表示数据被锁定(处于独占模式,正被某个事务写入)。此时事务记录是一个指向锁定事务的指针。
- 1:表示一个版本号(处于共享模式)。
STM操作流程
以下是核心操作的伪代码描述:
STM读操作(乐观):
- 检查目标数据的事务记录。
- 如果数据被锁定,则等待。
- 如果数据未锁定且其版本号 ≤ 本地时间戳,则:
- 直接从内存位置读取值。
- 将数据地址加入读集合。
- 返回值。
- 如果数据版本号 > 本地时间戳,说明数据在事务开始后被修改,需要验证整个读集合。若验证失败,则中止事务。
STM写操作(悲观):
- 检查目标数据的事务记录。
- 如果数据未锁定且其版本号 ≤ 本地时间戳,则:
- 获取锁(将事务记录最低位设为0,并指向本事务)。
- 将旧值记录到撤销日志。
- 执行就地写入(急切版本化)。
- 将数据地址加入写集合。
- 如果检查失败,则根据策略处理(如等待或中止)。
读集合验证:
- 获取当前全局时间戳。
- 遍历读集合中的每个条目:
- 检查其事务记录。
- 如果条目被锁定,或它的版本号 > 事务的本地时间戳,则中止事务。
- 如果所有条目验证通过,则更新事务的本地时间戳为验证时刻的全局时间戳(相当于事务从此刻重新开始)。
STM提交操作:
- 原子地将全局时间戳增加2(因为最低位用于锁标志,所以每次+2以更新版本号)。
- 使用增加前的旧全局时间戳来验证读集合。
- 如果验证通过:
- 遍历写集合中的每个条目:
- 释放锁(将事务记录最低位设为1)。
- 将事务记录的版本号设置为新的全局时间戳。
- 提交成功。
- 遍历写集合中的每个条目:
- 如果验证失败,则中止事务,并使用撤销日志回滚所有写操作。
性能与优化
软件屏障会带来开销。未经优化的STM可能导致70-80%的单核性能开销。然而,结合编译器优化(如消除冗余屏障调用),开销可以显著降低至30-40%。虽然这个开销仍然高于简单的粗粒度锁,但STM提供了更好的可扩展性。
硬件事务内存实现 ⚙️

软件实现有开销,硬件支持可以降低这些开销。硬件事务内存通常基于现有的缓存一致性机制构建。


基本原理
硬件事务内存的核心思想是:
- 利用缓存进行数据版本化:将写缓冲或撤销日志功能放在缓存中。
- 通过增强的一致性协议进行冲突检测:在现有的缓存一致性消息中增加事务状态信息。
- 检查点寄存器状态:以便在事务中止时恢复。
缓存行元数据扩展
为了支持事务内存,我们在每个缓存行上扩展元数据,在原有的MESI等一致性状态位之外,增加两个位:
- R位:表示该缓存行是否在本地事务的读集合中。
- W位:表示该缓存行是否在本地事务的写集合中。
冲突检测
冲突检测通过监听一致性协议请求来实现:
- 读-写冲突:如果本地缓存行的W位被置位(已写),而收到一个对该行的共享读请求,则检测到冲突。
- 写-读冲突:如果本地缓存行的R位被置位(已读),而收到一个对该行的独占写请求,则检测到冲突。
- 写-写冲突:如果本地缓存行的W位被置位(已写),而收到一个对该行的独占写请求,则检测到冲突。
示例与策略分析
考虑一个简单事务:读A、B,写C=5。
- 事务开始时,对寄存器状态进行检查点。
- 加载A和B时,缓存行被获取,并设置其R位。
- 写入C时,数据被写入缓存,并设置其W位。此时,该写入对系统其他部分不可见(隔离性)。
- 提交时:
- 对写集合(C)发出读独占请求以升级所有权。
- 在此过程中,如果其他处理器对A或B发出了独占请求,而本地的R位已置位,则会检测到冲突并导致本事务中止。
- 如果没有冲突,则提交成功,C的新值变得全局可见。

这种设计对应于惰性版本化(写操作在提交时才发布)和乐观冲突检测(在提交时检测冲突)。

硬件TM的现状

英特尔等公司曾在其指令集架构中引入了硬件事务内存支持(如TSX扩展)。然而,由于多种原因(包括软件生态支持不足、事务容量限制易导致中止,以及后来发现的安全漏洞),这些扩展在实际产品中并未被广泛采用或已被禁用。目前,硬件事务内存尚未成为主流。
总结 🎯
本节课我们一起深入探讨了事务内存的具体实现。
- 我们首先分析了软件事务内存的实现,包括通过软件屏障进行代码转换、核心数据结构(事务描述符和记录)以及粒度权衡。我们以McRT-STM为例,详细讲解了其基于时间戳的急切版本化、乐观读和悲观写策略的操作流程。
- 接着,我们探讨了硬件事务内存如何利用现有的缓存一致性基础设施来降低开销,通过扩展缓存行元数据(R位、W位)并在一致性协议中检测冲突来实现。我们分析了其通常对应的惰性乐观策略。
- 最后,我们了解到,尽管事务内存概念优美且研究广泛,但由于实现复杂性、性能开销及硬件支持的现实挑战,其在商业系统中的广泛应用仍局限于特定场景(如某些数据库内核),软件实现比硬件实现更为常见。

事务内存为同步编程提供了一种有吸引力的替代方案,但其成功部署需要编译器、运行时系统和硬件之间的紧密协同。
018:硬件专业化




在本节课中,我们将继续讨论能效计算。上一讲末尾,我们谈到了异构性,其动机在于不同的程序特性可以通过更专门的架构来利用。我们目前关注的核心是利用数据并行计算,这可以通过GPU等架构来挖掘,其带来的关键能力是高性能,更重要的是能效。今天我们将更深入地探讨能效,并讨论为何这在现代计算环境中是一个如此紧迫的问题。
😊



硬件专业化与算法特定编程
硬件专业化和算法特定编程是超越异构计算环境的下一步,即针对特定应用进行高度专门化的设计。能效计算是当前的核心约束,这源于底层半导体技术的现状。
过去,随着新一代处理技术的出现,每代技术都能提供更多晶体管,同时这些晶体管的功耗更低。这被称为登纳德缩放定律。但大约十年前,这种趋势结束了。现在,每次增加晶体管数量,功耗也会增加。因此,我们处在一个受功耗和能量约束的环境中。
为了理解其工作原理,能量是功率乘以时间:能量 = 功率 × 时间。我们能提供的功率是固定的。

因此,为了提升性能,我们必须降低每次操作所需的能量。这是当前的根本状况:在固定的功耗预算下,若想获得更高性能,就必须提高能效。提高能效的关键机制是变得更加专门化,以消除那些不直接推动计算进展的额外功耗。
😊
为何能效约束无处不在

能效约束遍布整个计算领域。在拥有成千上万甚至数十万个核心的超级计算机和数据中心中,需要提供电力和冷却来维持整个系统运行,这带来了巨大的能源成本。在大型网站(如谷歌和脸书)背后的数据中心,同样面临约束。在为计算资源供电和冷却的三年生命周期内,其能源成本可能超过获取计算资源本身的成本。
在移动设备领域,能效约束同样存在。移动设备没有风扇,因为风扇会带来不便,散热必须被动进行。此外,设备依赖电池提供能量。因此,在整个计算领域,我们都受到能效约束。
能量公式如前所述:能量 = 功率 × 时间。😊
由于半导体工艺的限制,功率是固定的。因此,若想提升性能,就必须变得更节能。实现这一目标的方法是采用专门化的功能,以减少开销。
问题是,与由CPU组成的通用处理环境相比,专门化能带来多大的改进?让我们深入探讨。
我们已经研究了利用GPU大规模专门化处理数据并行应用。当然,在GPU架构内部,我们也看到了SIMD处理,它同样在利用数据并行性。😊
经验法则是可以获得巨大的改进,我们稍后会回到这个话题。但问题是,我们花了很多时间讨论如何从现代CPU和GPU中为特定算法获取最佳性能。那么,为什么CPU从根本上如此低效?
让我们来看一下。观察执行一条指令(例如乘法-加法指令)所消耗的能量,你会发现大部分能量并非用于执行实际计算。在本例中,实际计算仅占6%,其余能量用于处理指令、确定指令要做什么、获取数据、移动数据以及控制电路和分发时钟(保持一切同步)的开销。

执行一条指令需要完成许多事情:读取指令、确定指令要做什么、检查该指令与正在执行的其他指令的依赖关系、确定执行该指令所需的资源是否可用、确定操作数位置、从寄存器中获取它们、如果是加载或存储指令,可能还需要在缓存间移动数据。然后,在最底层才是执行实际的算术运算。最后,还需要移动结果。因此,最终用于特定指令实际计算的能量非常少。
问题是,如何改善这种情况?SIMD如何让这个饼图看起来更好?是的,通过将非绿色部分的开销分摊到更多的绿色部分上。即,在更多数据元素上执行,SIMD的宽度决定了你潜在的效率。
那么,如果我能用一条指令做8个数据操作,为什么不做16、32或64个呢?是的,但实现起来很困难。宽度越大,峰值性能越高,但平均性能可能会差很多,因为你可能无法填满所有的SIMD槽位。这就是问题所在:你可以做得更好,甚至可以走向极端,但最终你可能看不到保持所有SIMD单元忙碌所需的数据并行性或SIMD数据并行性。
那么,SIMD能改善情况吗?确实可以。这是一项几年前在斯坦福进行的研究,观察了在支持SIMD的CPU上进行H.264视频编码(这是一个相当数据并行且SIMD友好的任务)消耗了多少能量。可以看到,SIMD能量部分(由红色方框表示)并不高。
因此,如果想做得更好,就需要考虑实现更专门的组件。我们想看看其他类型的架构。以快速傅里叶变换(FFT)为例,它是许多信号处理应用的核心算法,被一些人称为有史以来最重要的算法。可以考虑为其实现专门的硬件,从而在硅片面积利用率和能效方面获得巨大提升。

这是一项相当古老的研究(40纳米工艺),但可以看到,与CPU(酷睿i7,在每平方毫米的千兆浮点运算方面最低)相比,专用集成电路(ASIC,图中的菱形)提供了最高的能效和芯片面积利用率。CPU在能效和芯片面积利用率上最低。与为特定算法高度专门化的设计相比,CPU在芯片面积利用率上可能相差1000倍,在能效上相差100倍。
那么,ASIC方法的缺点是什么?是的,它只能用于那一种算法,并且需要设计它。因此,如果你想启动一个新应用并有一个新想法,可能需要等待18个月来设计一个ASIC。除非它像FFT那样是一个非常重要的算法,否则可能不值得。
为了证明ASIC实现的合理性,还有其他方法可以获得比CPU更高的效率。其中一个被广泛使用的想法是数字信号处理器(DSP)。其理念是,你想使用FFT和滤波等DSP算法进行大量信号处理。事实证明,通用计算机中的指令和寻址模式可以改进。DSP具有非常复杂的指令,专门针对特定算法;它们具有非常复杂的寻址模式,例如,允许你进行FFT所需的位反转寻址。
问题是,如果给你这套复杂的指令集,你能为其编写编译器吗?答案可能是否定的。因此,伴随着DSP这些非常复杂的指令集,通常需要进行底层编程来实现所有这些算法。这比开发ASIC容易得多,但比编程通用CPU要困难得多。因此,在效率和可编程性之间存在这些权衡。
另一个专门化计算单元的例子是由D. E. Shaw开发的。他在金融领域赚了很多钱,决定将部分资金用于造福人类。他决定开发一个用于分子动力学的专门加速器。如果你想理解蛋白质如何折叠,归根结底是弄清楚分子间的相互作用。分子动力学是化学中的一个重要领域,有人因此获得了诺贝尔奖。他们开发了名为Anton的加速器,通过精心设计算法与硬件,获得了相对于CPU和GPU的巨大性能提升。😊
他们为分子间相互作用的模拟设计了专门的硬件。据我所知,他们现在已经有了三代Anton,每一代都更好。当然,也有使用加速器的方法,但也有使用统计方法解决问题的途径。因此,在从事加速器开发的人员和从事统计方法的人员之间存在一种张力。
如今,机器学习风靡一时。事实上,你们上一个编程作业就与机器学习有关。激发人们对开发机器学习新架构兴趣的加速器之一是谷歌的Tensor处理单元(TPU)。可以将TPU视为使密集矩阵乘法变得非常快。最初的TPU进行256x256的整数矩阵乘法,后来的版本进行128x128的16位浮点乘法。😊
尽管这些引用是几年前的了,但在架构领域有大量工作试图理解如何为机器学习领域开发新的特定架构。😊
这些架构大多数实际上具有一定程度的可编程性,因为需要适应机器学习算法的变化。但根本上,它们专注于执行这些ML算法中的核心计算内核,即矩阵乘法(通常是密集矩阵乘法,但也有稀疏版本,也很有趣)。
那么,存在为特定算法设计固定硬件的问题。问题是,是否存在一种中间地带,允许开发具有一定可编程性的硬件?这就是现场可编程门阵列(FPGA)架构的整个原因和动机。你们中的一些人可能在数字设计课程中接触过这类技术。
关键思想是拥有一堆称为可配置逻辑块(CLB)的单元,它们本质上是布尔代数的查找表。例如,一个四输入布尔函数可以使用查找表计算,然后与组合逻辑块和寄存器(提供存储)结合。然后将这些可配置逻辑块排列成阵列并连接起来。你可以将它们连接成更复杂的逻辑块,例如,如果你有一个六输入查找表,并想生成一个40输入的与门,你可以级联这些六输入逻辑块来创建更复杂的函数。😊
查找表基本上是将一个二进制数映射到一个输出,允许你计算各种函数。现代FPGA将可配置逻辑块与更专用的功能(如密集内存和乘法器,称为DSP块)结合在一起。完全用可配置逻辑块构建所有东西的问题在于会带来很多开销,包括连接这些块的开销,以及使用这种技术实现计算单元的实际开销。因此,如果你想更密集、更高效地利用硅片面积,就需要用于内存和乘法的硬宏块。😊
你可以将它们组合起来。如果你想使用它们,可以访问我的实验室,或者去亚马逊EC2,他们也提供FPGA资源。他们通过云服务提供相当先进的FPGA能力,这些FPGA当然有到内存(如DDR4)的链接,我们还没有详细讨论内存,但也许在本讲末尾有时间的话,我们可以谈谈不同类型的内存技术。它们通过PCIe接口与CPU连接,并链接到其他FPGA,然后它们有一个完整的环境,允许你进行软件开发以实际编程这些FPGA。
因此,我们正在审视从最容易编程的通用CPU到ASIC的整个频谱。我们看到在可应用的计算技术空间中,能效和可编程性之间存在权衡。作为系统设计者,你需要根据所需的能效约束以及多快需要让应用运行、愿意投入多少努力来选择合适的方案。
如果你能用CPU获得所需的性能,那就直接用高级语言编程你的应用。如果需要更高性能,则继续向右移动:使用GPU,可能需要写一些CUDA代码;使用DSP,可能需要进行汇编语言编程;使用特定领域计算(如机器学习),可以使用PyTorch或TensorFlow等框架编程加速器;如果使用FPGA,则基本上需要成为硬件设计师;而ASIC,你肯定是一名硬件设计师。😊
随着向右移动,你能获得更高的能效,尤其是使用ASIC时能效提升巨大。但你也必须更努力地工作,并花费更多资金,特别是移动到最右边时。
到目前为止,关于能效与可编程性之间的权衡空间以及空间中的不同点,有什么问题吗?显然,ASIC的能效要好得多,但中间地带存在权衡。为什么GPU不直接采用ASIC的方案?我认为目前尚无定论。GPU正在从TPU中汲取经验,它原本是通用线程架构,但现在加入了张量核心单元使其更专门化。但作为CUDA程序员,尝试编程这些张量核心单元实际上相当具有挑战性。这个空间正在变化,并由机器学习这个高价值应用驱动。😊
DSP更专注于数字信号处理,具有许多专门的寻址机制和计算。因此,如果你尝试进行数字信号处理,DSP会更高效。但如果你尝试进行机器学习,可能就不是这样了。
现在,让我们看看再向右移动一点需要什么。我们在这门课上花了很多时间思考如何编程现有架构(如通用处理器或GPU)中提供的固定资源集。但现在,让我们思考一下指定或编程加速器需要什么,在加速器中你可以控制许多在通用环境中无法控制的东西。特别是,你可以拥有定制的内存系统。在任何特定硬件中,你能获得的许多性能改进都与如何组织内存以利用应用程序中特定的局部性和访问行为有关。
你还可以获得与应用程序需求匹配的专门化计算。问题是,我们如何思考或编程专门化处理器或加速器?传统上,你必须成为硬件设计师,并在寄存器传输级或硬件描述级思考问题。你必须使用VHDL或Verilog等语言编写代码。
最近,出现了高级综合(HLS)的想法。其方法是,我将编写一个C程序,然后让一些智能编译器将其转换为硬件。这个想法有两个问题。一是C程序并非旨在描述硬件,因此你必须对硬件应该做什么做出各种推断,因为C程序是为通用处理器设计的,而不是为硬件设计的。这是第一个问题。第二个问题是,为了克服C语言的不足,他们加入了这些编译指示(pragmas)。😊
问题在于,为了正确放置所有这些编译指示,你本质上需要了解很多硬件知识。这样一来,你就违背了提升到高级C语言水平的初衷。实际上,为了获得任何有价值且性能良好的东西,你必须通过使用这些编译指示下降到硬件层面。
因此,今天我们不讨论高级综合,而是看一种我们称为Spatial的语言。它是一种用于设计硬件加速器的高级语言,旨在使面向性能的程序员能够指定硬件。到目前为止,这门课上的每个人都是面向性能的程序员,你们整个季度都在做这件事。因此你们符合条件。😊
面向性能的程序员喜欢思考的关键是并行性和局部性。这是我们整个季度一直在处理的问题。在局部性方面,我们可能要考虑一些专用内存以及如何进行数据移动。这就是Spatial。快速描述一下:😊
Spatial是一种用于加速器设计的领域特定语言(DSL)。它具有表达并行模式的构造,你们也相当熟悉。例如,对集合的数据并行模式:Map、Zip、Reduce。这些都是你们熟悉的概念。我们想做的是思考如何使用两种类型的并行性来执行这些并行模式:一种是你们非常熟悉的独立并行性,即考虑使用独立计算单元运行Map;另一种是依赖并行性,我认为你们中的一些人也相当熟悉。

依赖并行性是指并行单元彼此依赖。那么如何执行依赖并行单元?你们中有人知道如何执行一组计算,其中计算的组成部分实际上是相互依赖的吗?😡
就像在流水线中做的那样,对吧?指令执行流水线的不同组成部分都是相互依赖的,但你有一堆独立的指令,你以同样的方式执行它们,就像在工厂里装配汽车一样。你创建一条装配线,每个工位独立工作,然后你在流水线的不同部分之间获得并行性。因此,流水线是另一种实现并行性的方式,其中存在依赖关系。所以我们想看看如何实现流水线并行性。
并行模式可以嵌套,因此你可以获得层次化的控制。我们说过,硬件设计师或希望控制应用程序局部性、利用局部性的人的关键机制之一是能够明确指定内存层次结构及其使用方式。还有能够使用参数审视整个设计空间的概念。😊
你希望将这些暴露给编译器,并允许编译器为你探索设计空间。😡
这里的关键是,让我们专注于在获得高性能方面有趣且重要的东西,即如何利用并行性(包括独立并行性和依赖并行性)以及如何管理和确定局部性。我认为,对于在机器学习上下文中可能看到的这类应用,这比从线程层面思考更直观。😡
Spatial语言:内存模板

好了,让我们谈谈Spatial语言,并从内存模板开始。正如我所说,你有明确的内存层次结构。因此,你可以指定什么内存是片上的,什么内存是片外的。例如,片上可能有SRAM,并且你可以指定数据类型。😊
例如,unsigned int8,以及数组,你可以指定元素数量。你还可以指定DRAM。同样,这里是8位值,这是一个二维数组。因此,在这个例子中,你有image和buffer。
当然,你还有寄存器。有各种不同的寄存器,有累加器,还有FIFO(先进先出队列)。我们稍后会详细讨论使用FIFO。如果你在进行图像处理,可能会有行缓冲区的概念,这是一个可以通过行进行移位的二维数组。然后你可能还有移位寄存器,其精神与行缓冲区相似。
在CPU中,只有一个主地址空间,即程序员可见的内存地址空间。然后,作为程序员,你可以编写缓存友好的代码,但你无法控制数据在主存和缓存之间的移动,这是由底层硬件控制器自动处理的。在Spatial中,情况并非如此。你,作为程序员,必须明确地在内存层次结构的不同级别之间来回移动数据。在这个例子中,你正在使用加载操作将数据从DRAM的image移动到buffer。

这是一种密集的数据移动。接下来是聚集(Gather)。我们讨论过Gather,有人能告诉我这里Gather是如何工作的吗?😡

你从image中获取数据,这里获取10个元素,地址或位置将由某个数组A指定。本质上,你是将稀疏数据变得密集,放入buffer中。因此,你可以想象有加载和聚集,还有存储和分散(scatter)。😡
你还可以创建流,并可以流入和流出数据。流式传输将是获得效率的关键组成部分。
控制模板
那么控制模板呢?顺便说一下,Spatial语言嵌入在一种名为Scala的语言中(由于历史原因,我们不会深入探讨)。Scala实际上是一种非常适合嵌入DSL的语言,因为它非常灵活。我们之前在讨论Spark时实际上见过Scala,所以你们以前确实见过它。它曾经作为嵌入式语言非常流行,但由于JVM的使用存在某些缺陷,限制了其广泛使用。


你有这些Accel块,它们将把你的程序划分为加速部分和仅在CPU上运行的部分。问题是你是运行Accel块一次,还是连续运行(Accel*语法)。然后还有有限状态机的概念,我们不会重点讨论。我们将重点关注用于并行模式的关键机制,即foreach(本质上是Map)和reduce(归约)。
这表示,对于集合C中的所有元素,你将按步长1遍历,并执行花括号内指定的代码块(即循环体)。

你可以指定许多设计参数。你可以指定特定的foreach和reduce的并行化程度,可以指定它们的调度方式(例如流水线化或流式化),我们稍后会简要讨论这些。你可以指定参数,例如你希望缓冲区的默认大小为64,但范围可能是64到1024,然后编译器可以探索这个范围。😡
如果你指定的内容需要使用内存分体(banking),编译器会为你处理。因此,如果你并行化了某些东西,并且并行化意味着对特定内存单元的多次访问,那么编译器有责任确保通过复制内存或适当地分体内存来实现实际的并行化因子。但这是你不必考虑的细节,编译器会为你处理。
示例:点积加速器
好了,让我们看一个例子来巩固这些概念。我们将做一个点积,这是你们最喜欢的小内核之一。我们想在Spatial中构建一个加速器。我们有这里的代码,下面有生成硬件的草图,让我们看看会发生什么。
首先从C代码开始,确保每个人都清楚。我们将两个向量V1和V2相乘,然后使用简单的折叠循环计算点积:我们将每个元素相乘,然后将它们全部相加。😊
这很清楚,对吧?这就是你为C代码编写的内容。那么,如果你想为此构建一个加速器,你会怎么做?记住,你现在必须控制所有内存。假设V1和V2是DRAM中的整数数组。这里通过指定DRAM清楚地表明这两个数组将驻留在DRAM中。
我们还没有确切说明DRAM如何工作,但假设我们有一种使用直接内存访问(DMA)在DRAM和加速器之间移动数据的方法,这是在主存和加速器之间移动数据的有效方式。
我们有一个Accel块,这是我们将进行加速的地方。首先,我们需要将数据从DRAM移动到加速器中。我们需要一个地方让这些数据落地。因此,我们必须在加速器内定义一些数据结构。我们将为此创建两个SRAM块,tile1和tile2。它们的大小为tileSize,并且是SRAM。问题是它们应该有多大,我们稍后再讨论。
因为我们将通过分块(tiling)来做这些事情,所以我们需要一个双重嵌套循环。单层循环不行,因为我们将通过分块计算点积。为什么我们希望从DRAM获取一个数据块而不是单个元素?是的,为了获得更好的DRAM与加速器之间接口的利用率。这就像去杂货店,你永远不会只买一样东西,那样非常浪费。你花力气去一趟杂货店,买一大堆东西带回来放在冰箱或食品柜里,这样你就不必每次想吃东西时都回杂货店。同样的道理,访问DRAM成本高,你希望获取多于一个东西,可能希望获取整个tileSize大小的数据,并且需要在内存中有一个地方来存放它。😊
当然,如果这是CPU,这可能只是通用缓存,数据移动将由缓存算法控制。在这里,你可以显式地移动数据并编程数据移动。😊
现在我们要做的第一件事是,对tileSize个元素进行归约,因为最终我们需要通过加法归约元素以生成输出。😡
首先,我们将加载两个向量。将V1的tileSize个元素加载到tile1,将向量2的tileSize个元素加载到tile2。😡
然后,我们将在块内进行归约(步骤2)。然后跨块进行归约(步骤3)。现在我们有了这个三步过程:加载一个块,进行块内累加,然后进行跨块累加。😡
现在的问题是,我想提高硬件的性能。为此,我需要利用并行性。这个算法中的并行性在哪里?是的,你可以流水线化步骤1、2、3,这是利用并行性的一种方式。在点积表示中,哪里还可以利用并行性?是的,在步骤2内部。所以我们可以并行化步骤2。并行化步骤2的最佳方式可能是什么?从你熟悉的东西中找找。是的,如果你总是做相同的操作,SIMD将是并行化步骤2的好方法,因为我们对块中的所有元素进行相同的操作。😊
我们正在进行乘法和归约。如果你想并行化这个归约,你需要某种并行乘法,然后是一个归约树。Spatial允许你这样做,它有归约树的概念。在这个例子中,你可以并行化因子为2(没有太多树),但你可以做得更宽。做得更宽的缺点是什么?归约树更大。还有什么更大?硬件,你使用更多资源。你可以控制使用多少资源,基于你想要多少性能改进。没有免费的午餐。😡
但你可以根据想要的性能改进程度来控制使用多少资源。
好的,让我们暂时保留流水线的想法。你还可以控制块大小,决定每次去内存时想要获取多少数据,并优化它。你可以指定要使用的块大小。😡
最后,你可以说,与其一次一步地运行外部归约,不如通过指定流水线调度来重叠它们。这里的流水线化意味着重叠。流水线化工作的关键是什么?如图所示。也许你可以读出来。但我们需要没有冒险,对吧?但我们需要什么额外资源?是的,我们需要能够进行双缓冲。因此,当阶段1正在从内存填充数据时,阶段2必须能够处理来自阶段1前一次执行生成的数据。因此,你本质上需要某种双缓冲。这是额外的开销。所以流水线化是硬件中尽可能接近免费午餐的东西,但并非完全免费,因为你增加了内存。😊
因此,你决定流水线化,在这种情况下,最佳情况是流水线化三个阶段,获得3倍的性能提升。当然,这并不总是成立。但这样做的开销将是在每个阶段接口处需要的额外块内存,以确保在进行流水线化时数据不会被覆盖。
大家都明白了吗?很好。我们看到了三种优化类型:并行化、如何处理数据局部性以及流水线化。

程序员与编译器的职责
为了确保你们都理解,作为Spatial程序员,你的职责是什么?是的,指定算法。你能更具体地说明你实际上要做什么吗?😊
你必须能够使用foreach和reduce构造来表达你的算法。你还需要负责什么?是的,处理内存,明确的内存层次结构,确定数据应该驻留在你定义的不同内存中的位置。还有什么?并行化,多少?在哪里?呃,我想大概就这些了:指定算法、指定内存层次结构、进行显式数据移动,然后选择分块因子、并行化和调度。😡
编译器的职责是内存的分体和缓冲,以最大化性能并最小化资源,以及为特定目标生成配置的一些底层工作。😡
当然,如果你想提高性能并理解性能,需要某种方式获取关于你的特定代码在任何目标上可能达到的性能以及它们可能使用的资源的反馈。😡


应用:注意力机制与流式执行
Spatial已被用于将Tensorflow表示的机器学习算法转换为硬件。我认为更有趣的可能是你们非常熟悉的东西,即如何优化像注意力机制(如Flash Attention)这样的算法。这是你们刚刚思考过的东西。我们讨论过融合注意力(fused attention)。那么融合注意力的主要好处是什么?

是的,你不需要物化整个注意力矩阵,而是将事物分块,然后一次计算一个块,并且还获得了将注意力算法的不同组件融合在一起的想法。😡
通过这样做,最小化了内存带宽,从而获得性能和内存大小的好处。事实证明,如果你使用这种Spatial流式编程模型来编写东西,你可以通过更简单的编程模型获得许多这些好处,而不必编写显式的融合内核。😡


让我们看看这是如何工作的。回到Flash Attention出现之前的时代。在Flash Attention之前,你有这种基于内核的执行模型。正如我们所说,Flash Attention防止了完整矩阵的物化。
使用流式执行模型,你也能获得这些好处,但你不必编写Flash Attention内核。特别是,你不必支付额外的计算成本。事实证明,为了处理softmax,你必须进行额外的计算,以处理你需要整行数据来计算softmax的行计算。而通过流式处理,你可以避免这样做,代价是可能需要多一点内存。
让我们看看这是如何工作的。如果我们考虑softmax,如你所知,它实际上是一个三步过程。首先,必须计算特定S_ij值的指数。然后必须进行行归约,然后必须将指数除以行信息。这个三步过程在这里以图示方式展示。首先是指数,然后是行归约,然后是除法。

如果没有Flash Attention的优化,你会物化整个矩阵。这增加了内存占用和内存带宽。这展示了概览,显示了必须在加速器(可以认为是顶部发生)和GPU内存(底部发生)之间物化和移动的所有数据。因此,所有跨越这条线的数据都是计算注意力所需的内存带宽。
使用流式执行模型,可以避免矩阵的物化。让我们展示流式如何工作。本质上,在这个例子中,我们将计算指数,然后通过归约行来计算行和。在Spatial中编写的方式是,第一个foreach(类似于Map)进行指数计算。😡
但是,与其将输出放入另一个矩阵,不如将输出入队到一个FIFO中。Spatial的语义是,这个foreach和下一个foreach同时执行。😡
现在,你可以将第一个foreach视为生产者,第二个foreach正在消费生产者的输出。因此,归约通过从第一个foreach出队一个元素并保持连续和来实现,最后完成后生成输出,同样输出到一个FIFO中,是单个元素。😡

大家都明白这是如何工作的吗?本质上,你有这两个foreach,它们以流水线方式运行。流水线之间是一个FIFO。😡
最初,我们定义片上内存S和两个FIFO。我们计算指数元素,然后将其入队到FIFO。在第二个foreach中,我们从FIFO出队。这展示了流式如何工作。在流式之前,你会物化整个N×N矩阵;使用流式,数据只是流过这个两元素FIFO。这相当于我们在第一个例子中展示的双缓冲。但这里你有一个显式的FIFO。因为你发现这就是你所需要的,所以你大大减少了所需的内存量。


但为了让这工作,你的编程模型必须考虑这两个内核同时运行,通过一个FIFO连接。这是一种非常自然的硬件思考方式,但不是一种自然的软件思考方式。Spatial允许你以匹配高效硬件实现的方式思考流水线并行性的概念,而这与你在软件中通常的做法不匹配。😡





回到原始的内核接内核方案,你物化了所有内存,发生了所有不必要的数据移动。而如果你使用流式处理,数据可以通过FIFO在不同内核之间流动,因为一个内核将数据放入FIFO,下一个内核取出并进行计算,你永远不需要物化整个矩阵。在需要数据的情况下(例如进行行操作),你需要在FIFO中累积整行数据。因此限制是,你需要使这个FIFO的长度等于矩阵的一行。😡


这可能会成为一个限制。你可以查看细节,细节会很清楚。问题在于,你需要一行数据来计算P矩阵的一个元素。你可以查看分配情况。那么,我们能用Flash Attention做得更好吗?答案是肯定的。😊
因为如果你的矩阵大小(序列长度)变得非常长,那么即使一行数据也太多了。因此,如果你想限制FIFO的大小,可以应用Flash Attention来优化这种基于流式的优化。Flash Attention重新排序操作,并使用运行和与重新缩放,而不是朴素的归约来进行计算。现在我们可以显著减少FIFO中对行数据量的需求。

流式与内核接内核的比较
如果你比较流式与内核接内核的方案,使用流式实现,你可以获得这样的想法:你可以利用更多并行性,因为你可以使用这种流水线类型的执行来重叠内核之间的计算。你可以空间映射每个计算,并通过流水线进行通信。😡
然后,你可以重叠和流水线化不同输出块的计算。因此,通过流式实现,你获得了额外的并行性和性能维度。
流式实现的另一个潜在好处是,你不必显式创建融合内核。你可以想象这些内核是单独实现的,你可以利用编译器的能力进行这种双缓冲技术。但如果你想要进一步优化,你可以用类似FIFO的编程表达式替换双缓冲,就像我刚刚在这个例子中展示的那样,获得更高的效率。这比使用传统编程模型创建显式融合内核更容易。因此,流式执行模型给了你这种额外的自由度:如果你使用FIFO编写东西,操作会自动融合。😡




即使你使用缓冲区编写,也可以采用双缓冲技术,然后编译器可以自动生成融合执行。😡

这里有什么问题吗?是的。为了确认,流式执行模型独立于加速器设计的概念吗?比如你可以编写一个流式程序,但它可以在现有的架构上运行吗?是的,它可以。你可以想象它在现有架构上运行。问题是,在CUDA中编写流式程序非常困难。你基本上没有能力让内核的不同部分独立运行。因此,你通常需要编写某种融合内核。你可以想象,我认为你和开发CUDA的人谈过,他们正在尝试启用这种执行方式,但我还不知道如何做到。我不是说永远不可能,但目前还不可能。😡
如果FIFO深度变得太大,流式执行模型有什么替代方案?那么你可以使用缓冲区,即使用SRAM而不是FIFO。你可能需要稍微不同地编写你的应用程序。或者你会尝试思考从根本上减少FIFO大小的技术,比如Flash Attention。因此,你可以以一种简单的方式使用FIFO,而不必太费力。但最终,也许FIFO变得太大,然后你必须更努力地工作,并做一些真正转换应用程序的事情。
他给出的例子使用了Spatial,但Spatial可以针对各种目标吗?是的,是的。它本意是作为一种硬件模型。它非常适合我们定义的一种称为可重构数据流的新架构风格。它并不真正匹配GPU。当然,你可以让它运行在CPU上。但我想让你们理解的关键点是流式执行模型的概念,即内核以流水线执行模式并行运行的想法,以及你可以在不显式的情况下获得融合和分块的好处。





总结


加速器可以带来显著的能效改进,你可以获得100到1000倍的提升。设计加速器完全在于理解你的应用程序(这是我们整个课程一直关注的重点),然后弄清楚如何利用你的应用程序所展现的特定并行性和局部性。我们已经看到了这一点,但现在在加速器设计的背景下,你可以明确地定义将使用什么资源来利用并行性,以及如何设计内存层次结构以确保获得最大的局部性。因此,你可以定义大小,可以定义数据何时从内存层次结构的一个部分移动到另一个部分。😊
你需要洞察应用程序:瓶颈在哪里?是内存受限还是计算受限?当我改变算法或实现时,这会如何变化?Spatial是一种编程模型和思考方式,用于探索当你想要进行这些权衡时的设计空间。这比你在这里所做的稍微前进了一小步:你现在理解了应用程序,理解了并行性,理解了不同类型的并行性。那么,如果你实际上可以控制并定义硬件,你会做什么?然后思考这意味着什么。
我们没有时间讨论内存的设计。也许Cavin会在周四讨论,也许不会,这取决于他。但就异构计算中的加速而言,我们已经完成了。谢谢。

本节课总结

在本节课中,我们一起学习了硬件专业化如何成为实现高能效计算的关键途径。我们从能效约束的普遍性出发,探讨了从通用CPU到高度专门化ASIC的能效与可编程性权衡谱系。我们深入了解了Spatial语言,这是一种用于设计硬件加速器的领域特定语言,它允许程序员明确控制内存层次结构、数据移动、并行化(包括独立和流水线并行)以及分块策略。通过点积和注意力机制(如Flash Attention)的示例,我们看到了流式执行模型如何通过避免不必要的数据物化和利用流水线并行性来显著提高性能并减少内存占用。最后,我们认识到设计高效加速器需要对应用程序的并行性和局部性有深刻理解,而Spatial等工具为探索这一设计空间提供了强大的抽象。
019:访问内存与课程总结 🧠💾


在本节课中,我们将学习计算机内存系统的工作原理,特别是DRAM的访问机制。理解这一点对于解释为何某些内存访问模式比其他模式快得多至关重要。课程最后,我们将对整个课程进行总结,并探讨后续的学习方向。
内存系统概述
到目前为止,课程中内存一直是一个相对抽象的概念。在脑海中,你可能想象着一个处理器,以及一些被称为DRAM的内存。为了减少内存访问时间并增加内存带宽,现代计算机中设置了缓存。
如果你将CPU或核心想象成一个盒子,里面有一个处理器核心。例如,你们使用的Intel芯片有四个核心,而AWS上的机器可能提供了8核16线程的配置。然后,还有一些缓存。在下面的图表中,我只画了其中一种,即最后一级缓存。如果你的系统有L1、L2和L3缓存,那么L3就是最后一级缓存。我们通常关注最后一级缓存,因为这是缓存未命中时请求必须前往内存的节点。
任何现代处理器上都有一个称为内存控制器的模块。当最后一级缓存发生未命中时,处理器必须从内存获取数据。内存控制器负责向内存发出请求,取回结果,并确保数据进入适当的缓存。
DRAM的工作原理
内存是以DRAM的形式实现的。如果你打开笔记本电脑,实际上会看到一些DRAM芯片。你可以将单个DRAM芯片想象成一个巨大的内存单元阵列。图表中的每个框都可以看作存储着一个比特,这是一个内存单元。在这个层面上,我们实际上是在模拟世界中工作。一个比特是通过每个单元中存储的电荷量来表示的。这有点像数码相机中光电元件的逆过程:在相机中,光线照射到感光材料上产生电压,电容器则保持该电压以记录光子数量。在这里,我们则是试图存储信息:如果我们想存储一个“1”,就将其编码为某个电压;想存储“0”,则编码为另一个电压。

所有这些比特都组织在DRAM芯片上,实际上是二维排列的。在芯片底部,有两个重要的结构部分。首先,有连接芯片与计算机其他部分的数据引脚线。这里有8个数据引脚,意味着这个DRAM芯片在任何一个时钟周期只能发送8比特的信息。为了访问DRAM芯片,芯片实际上并不是直接将数据从DRAM阵列发送到线上。它在这里有一个缓冲区,就像一个数字缓冲区,用于存储DRAM芯片中某一行的比特值。这被称为行缓冲区,它存储一行数据。因此,当你从DRAM芯片访问数据时,实际上只访问存储在这个行缓冲区中的一个字节大小的数据块。
假设我们发生了一次缓存未命中,加载指令需要访问地址X(此时我们讨论的是内存中的物理地址X)。内存控制器从处理器收到这个命令或请求:“嘿,给我X。”实际上,这意味着“给我包含X的缓存行”。当我说X时,可以将其视为缓存行的起始地址。处理器可能访问的每个字节都将是这个DRAM阵列中某个连续行的一部分。假设我们想访问图中用红色高亮显示的字节。目标是读取这些内存单元中的值,然后通过这些位线将这些值传回处理器。
基本上,将电容器上存储的电荷电压转换为数字1和0需要几个步骤。DRAM术语中的第一步实际上是预充电,这类似于为读取特定行做准备。换句话说,可以将其理解为:这些位线是贯穿整个芯片的导线,它们应该读取这些单元中的电压,并将整行的电压一次性传送到行缓冲区。如果这是软件,我会说我们需要复制其中一行的数据,将其放入行缓冲区以便访问。但进行这个复制实际上是沿着这些导线传输电压,因此我们必须将这些导线设置为某个已知的电压水平。这大约需要10纳秒。
然后,我们基本上锁定想要的行,进行行激活。行激活基本上是从该行读取电荷,并将其带入芯片底部的行缓冲区。这是信息的复制。有趣的是,由于这是模拟过程,这个复制实际上会破坏该行中的值。因此,此时该行信息唯一存在的地方就是下方的行缓冲区。我花了10纳秒准备好通信线路,又花了10纳秒读取该行的电压,现在信息就位于行缓冲区中了。
然后,根据我想要的具体字节,我可以选择该信息。这被称为列选择,因为你想要的字节是选择该行中的某些列,并将这些比特传输到内存总线上。现在,它们正移回芯片上的内存控制器,芯片会将它们放入正确的缓存中。因此,读取一个字节的过程大致是:准备就绪,激活相应的行,然后选择正确的列,最后将那八列的内容移动到导线上。

如果下一个要读取的字节是同一行中的下一个字节会怎样?你将无法从行中读取它。实际上,你不仅不能,而且也不想这样做,因为整行数据已经位于行缓冲区中。你只需移动过去即可。因此,如果你的第一次访问需要行激活和列选择,那么第二次访问实际上会更快,因为你只需要读取行缓冲区中已有的下一个字节。
这很有趣,对吧?DRAM的访问时间是可变的,取决于你的访问模式。DRAM可能没有准备好处理你的请求,它可能需要额外的20纳秒来准备,这可能是一个大问题。
内存控制器与调度
处理器请求地址X处的数据时,是内存控制器的实现负责将其映射到那个二维阵列中的位置。在软件层面,你几乎没有机制来控制这一点,这其中的原因可能稍后会更加明显。
你注意到的是,假设我必须对图中用不同颜色高亮显示的四个位置进行内存访问。例如,你正在遍历一个链表,或者不知道数据在地址空间中的具体位置。我的操作包括预充电(准备位线)、行访问选通(选择行并将数据带到行缓冲区)以及列访问(从行缓冲区读取相应的列)。我将列访问标为红色,因为这是数据通过内存总线(内存引脚)移动的时刻。如果内存引脚是系统中最宝贵的资源,那么你的目标应该是始终充分利用这些引脚。如果你没有100%利用它们,就是在浪费系统中最稀缺的资源。
在这个例子中,内存总线的利用率是多少?大约只有十分之四。那么,从这门课的标准技巧来看,有哪些方法可以更好地利用资源呢?我们可以进行流水线操作。如何做到这一点?有几个不同的方向:增加请求的最小大小,或者使用多个行缓冲区。基本上,你是在说需要一些多线程的思想。
首先,最简单的方法是让芯片支持批量传输。芯片的命令不是“给我一个字节”,而是“给我64个连续的字节”。在任何计算机系统中,如果你想分摊开销,批量处理是首要方法。如果你的访问模式允许处理大的连续数据,这是一种方式。顺便说一下,如果你连续地遍历内存系统,会获得最佳性能,这不仅仅是因为你使用了整个缓存行,还因为如果内存控制器知道访问模式是连续的,它就能以最高效的方式与DRAM系统通信。这也是存在缓存行的原因。现代缓存行是64字节,这有助于表明大的连续粒度传输更高效。
你们提到的都是非常好的想法,而这正是将要发生的事情。基本上,这里的问题是经典问题:如果一个操作有延迟,我需要隐藏这个延迟。在这门课中,如果你想隐藏任意延迟,就使用多线程;如果你想进行更结构化的延迟隐藏,就使用流水线,即让更多操作同时进行。我们肯定会对这个过程进行流水线操作。
每个DRAM芯片都有一个这样的阵列和一个行缓冲区。实现流水线的方法是复制这些结构。我们将共享数据引脚,但会构建更多的DRAM阵列。在DRAM术语中,这些不同的阵列被称为存储体。这样,当我们在一个存储体上等待行激活时,我们可以向其他存储体发出请求,并开始从其他存储体读取数据,通过总线发送数据。请注意,如果你的地址交错分布在各个存储体上,我可以在启动存储体1的行访问时,开始处理存储体0的数据读取。这就是经典的流水线例子。
每个DRAM芯片都有一定数量的数据引脚(基本上是8个),一个行缓冲区(用于存储来自任何DRAM阵列的行),以及多个被称为存储体的DRAM阵列。这些被打包在一起。如果你想要一个64位宽的总线(这是一个8位宽的总线),你就开始将它们并排放置。当你去购买一个DRAM DIMM(双列直插内存模块)时,DIMM上就有8个这样的DRAM芯片。现在,你有了一个64位的内存总线。你的标准Intel处理器连接到一个64位内存总线。内存控制器发送一个命令,这个命令不是“我想要地址X”,而是“我想要存储体B、行R、列X处的字节”。所有8个DRAM芯片都收到相同的命令,它们都返回存储体B、行R、列0处的数据,总共就是8字节的数据。

因此,内存控制器的责任是:如果我应该获取某个地址处的64字节缓存行,那么在存储体、行和列这个三维地址空间中,这些数据位于哪里?然后你发出请求。
人们看起来有些困惑。我们没有复制数据,只是简单地有多个存储体。如果我访问存储体0,这里是存储体0的预充电,这里是将行抓取到行缓冲区,然后是从行缓冲区为存储体0的访问读取数据。当我们在从存储体0的行读取数据时,我们正在对存储体1进行预充电。垂直的数据线是复制的,有n个不同的DRAM阵列,它们都存储不同的数据。
让我们思考一下读取一个缓存线。一个缓存线是64字节或512比特。想象一下我做最简单的事情:我把整个缓存线放在一个DRAM芯片的连续行中。读取整个缓存线需要多长时间?我的第一个字节来自这个DRAM芯片,第二个字节来自那个DRAM芯片,第三个字节来自另一个,依此类推。即使我有一个64位的内存总线,我也只使用了8位。这表明答案应该是:我必须在DRAM芯片之间交错地址空间,这样当我发出读取存储体B、行R、列0的命令时,所有芯片中的相同位置恰好是物理地址空间中连续的8个字节。当然,如果我想要缓存线的64字节,我需要在时间上重复这个过程八次。
当你有一个存储体但只有一组数据引脚时,情况会怎样?在这个图中,每个框都是一个DRAM芯片,每个DRAM芯片内部都有多个存储体(我在幻灯片上没有进一步说明)。你可以将DRAM芯片看作一个模块,它接受输入请求:“给我存储体B、行R、从列0开始的字节”。请记住,如果我这样做,假设我将字节跨DRAM芯片交错存放,我们在这里得到8个连续的字节是合理的。但我们有整个缓存线,我们需要接下来的8个字节。我可能会将下一个字节交错存放在各个DRAM芯片中,但将其放在这些DRAM芯片的不同存储体上,因为我希望在下个周期立即启动对下一个字节的行访问。
在每个时钟周期,内存控制器都在发送命令。假设为了简单起见,它立即开始处理该命令。在下一个时钟周期,它将开始预充电存储体B+1,因为下一个命令会说“我想要存储体B+1处的数据”。内存控制器将以一种基于其对内存工作原理了解的方式向内存系统发出请求,以便尽可能快地满足这些请求。如果是一个完整的缓存线(比如64字节),该请求实际上可以是突发传输模式:“我想要从B、R、列0开始的8个字节”。因为我可能会将下一个字节放在每个DRAM芯片的同一行中。在这种情况下,我不会启动新的存储体传输,我只会说:“看,我需要从所有DRAM芯片的同一行中获取8个连续的字节,乘以8个DRAM芯片,这样我就能得到我的64字节。”
来自处理器的请求也会经过相同的路径吗?通常还有一个命令总线。有一个64位数据总线,然后还有一个命令总线。我自己没有实现过这些,不确定引脚是否有巧妙的时分复用,但在现代DDR中,你实际上是在时钟的上升沿和下降沿进行两次传输,所以除非你非常有创意,否则我看不出如何能压缩更多信息。
内存层次结构与性能
这就是你的基本内存单元。有时你会听到双通道内存系统之类的说法。你可以将其视为内存控制器和一个内存模块之间的一个通道。请注意,一个通道每个时钟可能有一个命令,相同的命令会发送给所有模块。双通道内存系统就是复制这一套结构。所以,这里有一个例子。
请记住,处理器在这里,它只是产生缓存未命中,缓存未命中会转化为“我需要这个缓存行”。这些是对内存控制器的请求。内存控制器接收所有这些对缓存行的请求,并将其转换为内存通道请求,例如“我需要这个存储体、这个字节、这一行”。因此,内存控制器将物理线性地址映射到DRAM阵列中的位置。
现在,内存控制器接收到的只是某种随机的缓存未命中。现代内存控制器会将这些请求排队、缓冲,然后重新排序所有这些缓存未命中,以便如果它能找到需要访问同一行的请求,它会重新排序请求以最大化这种命中率。因此,它基本上会重新排序所有处理器的内存请求,以尝试获得最高的存储体流水线效率和最高的行缓冲区局部性。请记住,处理器的内存请求可能来自系统上所有不同的应用程序。你可能有一个应用程序正在线性读取内存,另一个运行在不同核心上的应用程序也在线性读取内存。想想这两个内存请求流将如何交互。突然之间,你以为完美布局的数据,现在核心0在这里,核心1在那里,你就会使那个DRAM芯片发生抖动。
我们要等待多少个请求?这是一个实现细节。一方面,缓冲会增加延迟;另一方面,缓冲和重新排序可以增加带宽。如果你的应用受带宽限制,你希望积极缓冲以最大化带宽。如果你的应用对延迟敏感(比如某些实时应用),你可能不会对重度缓冲的内存控制器感到满意。NVIDIA的内存控制器可能是芯片中最复杂的部分,实际上可能缓冲了数万个内存请求,因为GPU上的一切都受带宽限制。它就像在等待,看看在接下来的10纳秒内是否有更多对这个已打开行的请求,如果有其他请求进来,就让它们排到队列前面,因为它们会非常廉价。
延迟与带宽。内存控制器是动态决定这一点,还是你构建一个内存控制器时就已经确定了?作为内存控制器的实现者,这是你的自由裁量权。发生的情况是,所有乱序、复杂超标量处理器的复杂性,我们在这方面已经不再追求更复杂了。我们甚至一度构建了更便宜或更简单的处理器。例如,现代GPU在核心中做的智能处理可能远不如现代CPU。我们开始构建所有这些核心,它们都共享内存系统。因此,我们过去用来调度指令的所有调度逻辑,现在变成了论文和算法,用来研究如何查看传入的缓存未命中流,猜测这是否受延迟限制,以及如何制定良好的调度策略。所以,在2012年到2015、16年左右,计算机体系结构领域都在研究这个,而在90年代或21世纪初,他们则专注于如何为指令流构建分支预测器。
他们将复杂性从处理器中移出,基本上塞进了内存控制器,而内存控制器正在接收来自16个核心或32个超线程的请求,并试图找出如何重新排序所有这些来自不同进程的请求,以最大化DRAM总线的引脚利用率或能效。另一件事是,你实际上也在尝试最大化能效或最小化能耗。
现代内存技术
最后一部分是复制我们刚刚构建的东西。如果你想要更多带宽,我们就开始复制。如果你听说双通道DDR4内存系统,那只是一个连接到DIMM的64位总线,现在有两个这样的通道连接到两个DIMM。当你有两个通道时,你实际上可以说,这个通道向这个DIMM发送的命令可能与另一个DIMM不同。但连接在同一通道上的所有设备接收的是相同的命令。
这里有一个例子,比如你在网站上可能看到的DDR4。DDR4是一种内存技术,后面的数字实际上是内存总线的时钟频率。所有这些现在都是64位内存总线。2400这个数字听起来更大,在计算中更好。它实际上是一个1.2 GHz的内存总线。所以它发送数据,或者说周期是1.2 GHz。事实证明,第一个D(DDR)实际上是双倍数据速率内存技术,它实际上在每个时钟的上升沿和下降沿发送两次事务。所以,如果你将1.2乘以2,你得到每秒2400次内存事务,通过一个64位总线。如果你说64字节乘以2乘以1.2,你实际上得到19.2 GB/秒。如果是双通道,你只需将所有乘以2,然后查看内存系统的规格,它会说类似“内存系统可以提供38 GB/秒的带宽”。这个数字就是这样来的。
这就像一台普通PC中的标准配置。然后它说,在我查资料的时候,大约有13纳秒的延迟。这就是将已经在行缓冲区中的数据从DRAM芯片中取出并通过总线传输所需的时间。
你必须考虑到这一点,因为如果你有一个友好的内存访问模式,问题是你能以多快的速度取出数据?这并非无关紧要。想想现代处理器运行在3 GHz。1 GHz是1纳秒,对吧?如果运行在3 GHz,那就是大约0.3纳秒,所以你仅仅为了从行中取出列数据,就要花费30倍的时间。当然,你的内存访问时间还包括从处理器出发、经历几次缓存未命中、直到离开芯片所需的所有周期。所以,除了访问DRAM本身之外,还有更多的延迟。
内存系统的其他方面
我没有在这里讨论任何容错机制,比如比特翻转。通常发生的情况是,你有这些纠错码。如果你购买稍贵一些的内存或服务器内存,通常DIMM上不是8个芯片,而是会有第9个芯片。第9个芯片是冗余的。基本上,你可以检查是否有错误,具体取决于你如何处理,你实际上可以检测是否存在错误,因此你只是牺牲了一点容量来实现这个功能。
我认为关键要点是:这就是DRAM工作原理的一点介绍。重要的结论是,内存控制器调度器中存在大量的复杂性,用于接收来自处理器的请求并重新排序,以便这些请求以非常友好的顺序访问DRAM。你可以把它想象成你在Flash Attention中所做的:你重新排序计算以命中缓存。这是关于重新排序缓存未命中,以便它们以良好的方式访问DRAM,显然这是由硬件完成的,而不是由程序员完成的。如果你在面试中被问到“如何调度DRAM请求流?”,这很酷。
高带宽内存与未来趋势
当然,问题是我们总是受带宽限制。所以,你可以这样想:DRAM在这里,电路板上,然后那些引脚穿过整个电路板到达这里的处理器。问题是,我们只有8个引脚的原因是这些引脚非常昂贵。因此,人们非常有兴趣让处理器更靠近内存,以减少信息传输的距离,这使得构建更多引脚在经济上变得可行。
如今,在高端性能领域,最主要的方式是通过芯片堆叠和将内存与处理器放置在同一硅片上的组合来实现。这是一张示意图,展示了如果你今天购买服务器级GPU,可能会听到的HBM(高带宽内存)或HMC(混合内存立方体)。我认为HBM基本上赢得了竞争,所以现在你经常看到HBM。它的工作原理是这样的:这是我的GPU或CPU。通常,那个GPU或CPU是插在主板上的。然后,有迹线(基本上是主板上的铜线)连接到一个插在主板上的DRAM芯片。
如今,如果你想要这种高带宽内存,这是你的CPU或GPU,它就在一个硅芯片上。在同一个硅芯片上,紧挨着它的是一个内存模块。现在,这两者之间的连接实际上是硅片上的导线,而不是主板迹线。然后,DRAM芯片实际上是堆叠的,这意味着这些DRAM芯片物理上叠放在一起。关键技术是这种称为TSV(硅通孔)的东西。所以,你可以想象,过去是芯片边缘的8根导线,现在变成了从DRAM阵列中穿出的导线。因此,你获得的是整个芯片面积的空间,而不仅仅是芯片一条边的空间,所以你可以从芯片底部引出更多的导线,这些导线必须穿过下方的DRAM芯片,然后直接进入GPU所在的硅片。如果你购买其中一个,所有这些都在同一个封装里,看起来就像一个芯片。
这使得人们能够从内存系统向CPU引出更多的导线。我谈论的不是64位了,而是1024位。所以,如果你购买现代的Intel高端产品(比如超级计算机用的)、AMD或NVIDIA的GPU,这就是现在的芯片。这是处理器,这是所谓的硅中介层(一块硅片,处理器在上面),然后旁边有四个不同的堆叠DRAM,所有这些都是1024位连接到芯片。这是一个4000位的内存总线,这很快。现在,它提供了每秒720GB的芯片带宽,而这还是2016年的数据。现在可能更高,我认为我们接近2TB/秒了。
但这是固定数量的内存。所以,在2016年,这只有16GB内存。现在更高了。如果你需要大量内存,那么你有一个传统的内存总线连接到DDR5或DDR4,你可能拥有数百GB或TB的内存。所以,这是一种DRAM形式,它不是缓存。这里可能有一个80MB的L3缓存。如果你错过了80MB的L3,你可以访问(在这种情况下)16GB的、约1TB/秒的堆叠DRAM。如果你的数据不适合堆叠DRAM,那么你必须将数据放在传统内存中,现在你又回到了每秒300到400MB或GB的速度。
因此,内存层次结构变得非常非常深。你们实现的Flash Attention最初就是为了让中间矩阵乘法适应16GB的HBM,而不是去访问这些芯片上的DRAM。它也是更高的带宽、更低的延迟。在GPU中,我们不太关心延迟,因为我们可以用多线程或流水线来隐藏它。但确实,更低的延迟、更高的带宽、更低的能耗。
课程总结与展望
如果你在课程的最后一天能带走什么,那就是很多时候并行化其实相当容易,真正困难的是弄清楚如何将数据送到你的处理器面前。调度是困难的。你们在这门课中一直在自己做这件事,比如Flash Attention,你们的CUDA编程作业很大程度上就是关于局部性的。然后,硬件人员正在为你们设计更好的内存系统。
通用的设计原则是:要么数据需要位于处理器旁边,要么处理器必须更靠近数据。我们在这门课中没有过多讨论的一点是,如果你受带宽限制,数据压缩几乎总是值得的。因为如果你受带宽限制,你的CPU或GPU是空闲的。所以,实际上让它执行更多指令来移动更少的数据是划算的。例如,GPU在处理纹理数据时,实际上在发生缓存未命中时,会在将数据放入内存之前压缩数据。它实际上移动更少的数据,然后当你需要数据时,它将压缩后的数据移动到芯片上,然后解压缩到完整的缓存行中。这在图形处理中非常常见。
关于当今内存的内容就讲这些。有没有人实际研究过这些东西,比如在NVIDIA或Apple实习过?在Apple,如果我不能说的话就算了。我想我们基本上讲完了。让我总结一些技术要点。
在可预见的未来,有几个要点。我的意思是,如果你看看现在的Apple Silicon或者你的iPhone或Android手机里的东西,它是一个异构多核处理器,有多个CPU核心、多个GPU核心、神经核心(专门用于处理睡眠时间表等数据)。在未来,除非在技术层面出现一些重大突破(比如更光学化或更量子化),否则提高效率的唯一途径将是并行化,这已经发生了,并将通过某种程度的专业化来实现。因此,让这些专用处理器更容易编程引起了极大的兴趣。
我真正希望人们从这里带走的是:大多数软件与现代计算机实际能做的事情相比,效率低得惊人。就像我们在作业1中看到的,一个编写良好的C程序在我们的笔记本电脑上比一个精心编写、使用向量单元和SIMD以及专用处理的程序慢40倍,而后者在此基础上还能再快10到100倍。所以,当你在实验室工作或进行未来研究时,如果有人说要扩展到大型集群,有时这是正确的想法,因为优化程序可能很困难。但有时,我们本可以努力扩展到大型集群,或者等待一夜,也可以投入一点精力让程序运行快1000倍。有时,这对人们来说绝对是改变游戏规则的,或者它可能使得以前认为在移动设备上不可能的事情成为可能。
这门课有趣的一点是,如今几乎所有的计算应用实际上都属于我们在这门课中讨论的类型。我们不必去大规模科学计算领域寻找应用。你现在去Apple面试移动团队职位,能效和软件效率是他们关心的。你去Waymo,他们也会关心这些。所以,从这门课出发,有很多地方可以去,尤其是现在所有这些大型基于AI的语言模型都极度受效率限制。
我们讨论过几个主题,比如识别并行性(我常说这对每个人来说都是最容易的事情),而一旦知道了依赖关系,调度这些并行性则要困难得多。我们在小规模和大规模上都讨论过这个问题。我们还讨论了很多关于如何通过依赖更高级别的抽象来让我们的生活更轻松。在未来的课程中,如果你用TensorFlow、PyTorch或SQL编程,你可以想想,哇,幕后进行了多少并行化和效率优化,而你不需要考虑,因为有人已经建立了一个有用的抽象。
我们还稍微讨论了硬件的工作原理,这样至少当有人走过来告诉你这个计算需要三个小时时,你可以稍微质疑一下。你会想,如果仔细想想,这个东西应该只需要5秒。你一定在调度上做错了什么。你会惊讶这种情况发生的频率。
后续步骤与实践建议
接下来是一些实际的事情。我们确实教授其他课程。Konley下个季度有他的硬件编程课。如果你对使用空间计算感兴趣,我想他们教的是Spatial。你们有些人知道,还有229S这门新课,它与这门课同时进行,也许我们应该考虑将来把它们安排在不同的季度,因为感觉你应该先上149,然后从229中学到很多东西。但那是一门很棒的课程,我一直在关注这门新课,真的很酷。下个季度我教图形学,如果你对图形学感兴趣,欢迎加入我们。但如果你对图形学不感兴趣,我教的是混合图形学,基本上如果你学了149,又学了视觉和图形学,把它们结合起来就是春季的348K。这是一门小课,我们通常保持在30人左右,我们阅读关于如何构建系统的论文,比如Google的视频处理系统,或者现在如何服务ChatGPT数据中心之类的。我们讨论非常近期的论文,然后这是一门基于项目的课程。想法是如何思考构建这些视觉计算系统,其目标是生成或理解像素。
当然,还有很多标准课程,比如EE280s,如果你想深入了解硬件操作系统,这里有很多课程以不同方式涉及并行性。
然后是研究的想法。在完成了我们布置的这些作业(它们并不容易)之后,你们中的许多人通过这门课程真正积累了一些实际的编程能力,你们知道了基础知识。所以,如果你有兴趣,现在可以开始考虑融入系统组(比如K和I或其他系统教师)的某个研究实验室。我鼓励人们考虑这一点,并不是因为我真的提倡研究。经常有人和我们一起工作,他们说我不想成为学者或研究员,但他们想把独立学习当作一门课,他们会修3或4个学分,他们的角色是跳入一个现有的研究项目,通常承担更多的软件工程或支持工作,比如“嘿,我们正在考虑并行化这个,但我们没有人来做。你想看看能否让它快100倍吗?如果可以,我们可以把你列入论文,你可以参与写论文。”我认为在斯坦福最酷的事情之一就是,与世界其他地方相比,你可能可以在世界各地的许多大学上一门相当好的并行编程课。但真正有趣的是斯坦福的同龄人群体,对吧?比如与你的伙伴合作,或者与班上的其他人或助教合作,这才是这里真正有趣的地方。如果你能相信,你现在合作的硕士生或本科生,你会想,哇,这些人真的很酷。我们的博士生也不差。所以,这真的可以开阔你的眼界,你会想,哇,那个人只比我大2、3岁,他们太棒了。也许我也能达到那个水平。所以,如果你找到合适的契合点,这可能会很有趣。
让我更具体地谈谈我实验室的一个项目。正如你们许多人可能经历过的那样,人们现在非常有兴趣训练AI智能体,无论是机器人还是软件智能体,让它们在现实世界中做事。我来自图形学领域,所以对我来说,你们都在这些模拟器中做所有这些事情。那么,游戏引擎不就是一种模拟器吗?所以很多这项工作,比如你可能听说过的OpenAI Dota游戏机器人,现在算是旧闻了。另一个例子是2对2的捉迷藏游戏,两个智能体必须躲藏,另外两个智能体必须找到它们。在机器人等领域有很多这样的东西。问题是,一般来说,人们基本上是使用Unity或Unreal,并在其中运行模拟。如果你不了解现代AI的东西,基本上有两种方法:你可以让LLM告诉你该怎么做(有时有效),另一种有时有效的方法是进行大量的试错。机器人尝试执行这个任务,哎呀,你脸着地了。好吧,把它当作一个错误,做一个小修正,再试一次。由于所有这些试错,人们使用相当庞大的资源来学习这些技能,通过GPU集群,数十亿时间步的试验。在这些3D环境中计算所有这些时间步花费了所有时间。因此,他们只是在大型集群上加载。
我的一个学生看到了这一切,然后说:他们这样做是因为他们在不同的服务器上运行了10000个Unity副本,玩同一个游戏。他想,或者你在同一个盒子上运行30个Unity副本,然后它们开始抖动,如果你的目标是玩10000个游戏,这是一种糟糕的做法。所以,他开始思考,如果我们从头设计一个迷你游戏引擎呢?这个游戏引擎的目标不是为人类渲染图片,而是同时进行10000个独立的游戏副本,但以一种锁步的方式进行,这样我们可以获得所有良好的相干性和并行性属性。
这是一个在他的系统中创建的游戏示例。你可以看到一些要求。这是那个捉迷藏的东西。应该有两个团队,这两个家伙和那两个家伙,你看到他们学会了通过像小孩子一样把东西拉到脸前来赢得游戏。根据游戏规则,从技术上讲,他们不能被看到,所以他们赢了。我再放一遍,他们必须学会去看到别人,比如在这种情况下,他们必须学会去看右边的人。但发生了一些有趣的事情。这最初是由OpenAI完成的,我们只是复制了它。
有很多相当复杂的计算,比如你必须根据游戏规则模拟物理世界,或者只是为了让人感知环境,你实际上必须进行大量的光线追踪,因为你必须判断谁能看到谁。然后就是游戏逻辑规则,比如当我靠近这个方块并按按钮时,它应该附着在我身上,因为我捡起了它。所以有任意的逻辑。你希望能够像这样编写游戏逻辑:好的,这是一个事件处理程序,这是一些基本脚本,说当我靠近这个东西并捡起它时,它的所有者属性就变成我。
所以你用正常的方式编写游戏。但我们有方法基本上像ISPC或CUDA那样跨所有东西进行并行化,我们实际上像这样运行模拟。你不需要考虑并行,但我们将其并行化。现在我们同时排列成千上万个游戏。仅仅通过这种基本的CS149式思考,我们可以运行得相当快。与现成的开源方案相比,这大约有2到3个数量级的加速。所以,过去需要64个GPU集群运行一周的东西,现在可以在单个GPU上一两个小时完成。或者有人训练这个“Overcooked”AI(一个常见的强化学习基准),过去一个训练运行大约需要4小时,现在变成了3秒,因为这些是相当简单的游戏。
所以,这就是一个例子。在这样的项目中,很容易说,哦,我们很希望有人为游戏引擎添加一个功能。我们有一个本科生,他是个好学生,在大一或大二时上了我的348课,他实际上刚刚构建了一个全新的渲染系统,该系统分摊了10000个不同场景的开销并构建了渲染系统。所以现在我们可以渲染这些东西了。如果我们不渲染像素,我们可以以大约每秒200万帧的速度运行这个东西。但现在我们实际上在渲染像素,以防你想训练一个实际接收图像并采取行动的智能体,我们可以以每秒20万帧的速度进行,对于小图像之类的东西来说。这是一个相当大的差异。每秒20万帧,好吧,让我们降到10万,因为你还得运行深度网络之类的东西。所以,大约一半一半的模拟,也就是每秒10万帧。在10秒内,你就有了一百万个经验样本。所以,在一个周末内,你就能接近十亿个样本。所以,也许这将允许一些事情发生。
所以,有很多方法,比如,如果能够用Python编写脚本,并将Python编译成CUDA PTX代码,这样你就可以用Python而不是CUDA编写脚本,那就太好了。有很多像这样的小项目,技术性极强,人们可以帮助完成。所以,这是一个项目的例子,如果人们是优秀的实现者,很容易让你参与进来。其他项目则更难参与。我们有一个硕士生,去年上了149,他只是想开始在这个东西里实现Minecraft。所以他正在实现他自己的Minecraft,运行速度大约60万帧/秒。
这里的假设是,就像现在你已经完成了斯坦福学业的一半或四分之三,可能是时候开始思考你毕业后到底想做什么了。有时候,开始做一些自己的项目或一些不那么有指导性的东西,如果你对此感到失望,可能比只是上下门课、按照教授布置的五个作业去做要有用得多。所以,这只是我想告诉人们在他们职业生涯的这个阶段的一些事情。斯坦福的传统路径是什么?老实说,这和其他顶尖大学的传统路径一样:你们都获得了很好的AP分数,如果你们上了更多的AP课,就会比别人强。有一种观念认为,如果你在CS课上得了很多A,你就是在做一份好工作,对吧?所以,你获得了4.0的GPA,你可以去那里工作,你可以参加一些免费食物活动,有些人会给你回电,你会得到一份好工作。你肯定会得到第一轮面试,你会得到一份好工作。无论现在好工作在哪里,我不知道现在热门的地方是哪里。但过去,如果你能在毕业年拿到Facebook(或当时的Meta)、Google和Dropbox的offer,让它们互相竞争,然后你就完成了。去那里待两年之类的。如今,实际上我不确定所谓的默认好工作offer是否还那么明显。我很好奇,现在是否还有像过去那样,每个人都追求的三四家公司?我感觉现在不再是那样了。但过去CS确实是这样。然而,现在更有效的方法是,我在这张幻灯片上用了我的名字,我不是说来找我工作之类的。但每年都有一些人来说,嘿,我上了你的课,无论是哪门课,都很酷。我还能做些什么吗?有时候我会说,嘿,是的,我记得你的作业3,你做了所有的额外加分。是的,当然,你看起来知道自己在做什么。然后我会说,哦,我们现在肯定在找人来编写一些硬核的CUDA代码,或者实现一个新的应用程序,对其进行大量基准测试,因为我们想将该基准与我们实验室正在做的事情进行比较。人们就是这样开始参与的。你只是在某种程度上变得有用。
有时,并非总是如此,我大约50%的时间,也许30%的时间,确实是合适的学生与我的博士生一起参与。他们基本上就像博士生一样进行独立学习,我们了解他们,他们过得很愉快,开始做一些比我在课堂上合理要求你们做的任何事情都要困难得多的事情,因为如果我要求班上的每个人都做我的博士生做的一些事情,那将是一门艰难的课。通常发生的情况是,有很多推荐信之类的流程。但通常发生的情况是,那个学生在高年级时四处活动。在某个时候,比如,我不知道,我无聊了。我会说,嘿,顺便问一下,你明年要做什么?他们会说,我不知道,我收到了这些公司的offer,但我对它们不太感兴趣。或者他们只是真的很想进入这个行业。有时候,我认识一些人。我的意思不是我在打电话求情。这份工作更有趣的方面之一是让优秀的学生去他们想去的地方。所以,对我来说,如果学生上过我的课,并且在实验室待了六个月,我对他很了解,那是一件好事。那时我会打电话给我的朋友,说,哦,他们想进入游戏行业。比如,我不知道,Roblox给他们提供了一个入门级的驱动职位之类的,那完全是浪费。那时我会打电话给Roblox的首席工程师,说,斯坦福这里有一个超级明星想为你工作,你能给他一份好工作吗?所以,这就是打开大门的东西。不是说你必须认识人才能得到好工作,而是斯坦福教授与行业中做有趣事情的人联系紧密,因为他们曾经是我们的学生,或者我们和他们一起玩等等。世界上每个人都喜欢把真正优秀的人推荐给别人,让事情发生。所以,这是我工作中非常棒的一部分。我比处理下周必须参加远程考试的人要喜欢得多,那是我最不想做的事情。在那里,你能找到相当有趣的东西。
我给你举个例子:有一个学生,现在大概五年前了,他在我的CS348K课上找到我。他说,我对图形学(实际上是当时的摄影,比如Google摄影)的PhD项目非常感兴趣。那个学生很好,但我说你没有机会进入好的PhD项目。让我们说实话,你没有做过研究,你没有进入你想去的那种好地方的背景。我说,我知道你足够好,但在录取过程中无法证明。所以我说,你想做什么?他说,我想做非常酷的、像Google相机应用那样的东西。我说,哦,好吧,我们要做的是:Google刚刚发表了一篇关于他们的HDR和人像模式如何工作的论文。我说,好吧,在我的课上,因为这是一个开放式的课程项目,我想让你做的是:阅读这篇论文,尝试实现这篇论文。这是你的课程项目。如果你能实现这个东西并让它工作,那对我来说就是完美的机会,去告诉我认识的那个负责那个小组的人,你想和他的博士生一起工作,因为这是一个高年级下半学期的学生,他们没有机会再做研究或建立简历了。那些人看到了那个课程项目,他们说,是的,如果你上过Calin的课,Calin说这个人不错,而且这个人已经通过阅读我们的工作并实现他们自己的版本付诸行动,我们认为这个人可以雇佣,即使我们雇佣的大多是博士生。所以他们达成了一个协议:只要你承诺,如果我们投入时间培养你,你需要待两年,我们就让你和我们所有的博士生一起工作。他说,好吧,没有比这更好的方式为你准备研究生院,并去和当时Google该领域的顶尖人物一起工作了。事情就是这样解决的。他和他们一起工作,到了第二年,他们给了这个人一些写论文的机会。然后他拥有了世界上最强的简历,两年后进入了伯克利,已经完成了一半,四年后从伯克利毕业。我想他现在在Anthropic之类的公司。所以,这只是一个例子,说明如果你多了解你的教师一点,我们想帮忙。
一些实用建议
所以,只是一些建议。再次声明,不是针对我,也许是对斯坦福这里的任何人,你可能更喜欢其他课程,但通常你在这里是因为这里的人很好,所以利用这个资源。
关于这一点,有一些实际的事情。几乎总是这样:我经常有学生说,我对并行系统很感兴趣,我能在你的实验室做些什么吗?起初,我很高兴,太棒了。然后我开始想,我能为学生找到一个好的契合点吗?很多学生在修149或这些课程之前就来找我说这个。对于这些学生,我不得不说,看,我们必须假设你有一定的基础知识。我们不能在独立学习中教你149的作业,你不如直接去上课。通常,教师们会看:学生是真的感兴趣,还是只是觉得自己感兴趣?如果他们真的感兴趣,他们会修你的课,并且至少在课程的某些方面做得非常好,以表明他们真的感兴趣。如果有人来找我说我在149得了B,我会想,如果你真的感兴趣,我给了你我能想到的最酷的东西,为什么那没有让你兴奋?有时候他们会说,嗯,我有其他事情。但如果你仔细看,我最后两个项目很棒。我说,是的,没错,我们继续吧。所以,在课堂上有足够的机会来表明你超级感兴趣,尤其是在有项目的课程中。


所以我会问这样的问题:他们修过与该领域相关的课程吗?是否有他们超越期望的例子?这个人真的感兴趣吗?他们来办公室时间和我聊过吗?他们在相关课程中做过什么

浙公网安备 33010602011771号