GPU 写入内存时会发生什么

Hacker News Top 新闻

摘要

本文详细介绍了 GPU 内存写入的过程,追踪了 STG.E 指令在 RTX 4090 上通过各种硬件组件如负载/存储单元、合并器和 L1 缓存的执行路径。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/09/10 23:17

# 当GPU写入内存时会发生什么 | Doubleword 来源: https://blog.doubleword.ai/what-happens-when-a-gpu-writes-memory 在上一篇文章中(https://fergusfinn.com/blog/what-happens-when-a-gpu-reads-memory/),我们跟踪了一个`LDG.E`指令在RTX 4090的各个硬件单元中的完整路径——经过L1缓存、地址转换、通过交叉开关到达L2缓存切片,最终抵达DRAM。该请求获取了结果,然后沿路返回其站点,将结果交还给其warp,warp随后继续执行内核中的后续部分。 该warp执行的是一个执行向量加法的内核。同一个内核在加载了两个向量的元素后,将它们相加,然后存储结果。 `` /*00c0*/ IMAD.WIDE R6, R6, R7, c[0x0][0x170] ; // &c[i] /*00d0*/ FADD R9, R4, R3 ; // a[i] + b[i] /*00e0*/ STG.E [R6.64], R9 ; // c[i] = ... /*00f0*/ EXIT ; `` `STG.E`是指令,负责将计算出的和写回全局内存。在本文中,我们将跟踪`STG.E`经过相同的站点,弄清楚每一步发生了什么。同样,这些信息并非全部公开;在不公开的地方,我们将进行新的实验。 场景设定:`LDG.E`已返回给warp,`FADD`已将`R4`和`R3`的内容相加到`R9`中,现在,该寄存器的内容必须被存储。warp在其子分区中变得可执行,其lane开始执行`STG.E`。 ## 离开warp (https://blog.doubleword.ai/what-happens-when-a-gpu-writes-memory#leaving-the-warp) `STG.E [R6.64], R9`是将寄存器`R9`中的32位数据全局存储到`R6`和`R7`指定的64位地址。在`LDG.E`中,我们读取了寄存器文件的两行,而在`STG.E`中,我们必须读取三行:组成存储地址的两行,以及数据本身。 然后,指令发出到**加载/存储单元**。LSU发送操作码(“存储到这些地址”)、32位活跃lane的掩码以及32个计算出的地址。 **SM可以多频繁地发出存储操作**一个warp大约每6.1个周期(在2.6GHz下约为2.3纳秒)可以推送一个新的`STG.E`指令通过寄存器/LSU/合并器/L1缓存,无论它为多少个lane发出指令。 SM的出口每周期可以持续存储(或加载)32字节,因此如果所有warp都在发出指令,它们将在此处成为瓶颈[^1]。 下一站是**合并器**。它的任务是将32个四字节的访问转换成可实现的最小数量的32字节**扇区**。我们的内核写入128个连续字节,因此是四个扇区,或一行[^2]。 加载总是拉取每个扇区的所有32字节,然后在LSU中过滤结果,仅将SASS实际请求的数据写入寄存器。对于存储,每个扇区请求都带有一个**字节掩码**,指示该指令正在写入该扇区的哪些字节。 四个扇区、四个掩码和128字节数据继续传送到L1。 ## 经过L1 (https://blog.doubleword.ai/what-happens-when-a-gpu-writes-memory#passing-through-l1) 这四个扇区随后到达**L1缓存**。上次我们展示了L1缓存是每个SM的4路组相联、虚拟索引、虚拟标记的缓存。无论该行是否已存在,扇区、它们的掩码和数据都直接传向L2:L1是**写穿透**的[^3]。 如果我们的存储需要在该组中分配空间,旧的槽位将按严格的LRU(最近最少使用)顺序让出。 在L1下方,存储的虚拟地址被转换(参见上一篇读取文章中的转换部分),这四个扇区通过交叉开关到达拥有该行的L2切片。 ## 到达L2 (https://blog.doubleword.ai/what-happens-when-a-gpu-writes-memory#arriving-at-l2) 请求通过交叉开关发送到36个L2切片之一,该切片由与上次我们逆向工程得出的相同的物理地址函数选择。 它携带该行的地址、最多四个扇区的数据以及这些扇区的掩码。每个行只发出一次存储请求[^4]。 在一个切片内部,每个缓存是16路组相联的,有1024个组,通过物理地址哈希。如果该行在查找时已经存在,则将每个扇区掩码选中的字节写入该槽位,并将这些扇区标记为**脏**。如果该行不存在,切片需要为它找到一个槽位——这样做可能意味着将其他行的字节驱逐到DRAM。一旦找到位置,它就将字节写入槽位,掩码记录该扇区的哪些字节是有效的。字节进入槽位后,切片通过交叉开关向SM发送一个**确认**。 确认之后,这四个扇区就坐在它们的槽位中,在掩码下处于脏状态。 ## 完成我们的写入 (https://blog.doubleword.ai/what-happens-when-a-gpu-writes-memory#completing-our-write) 确认随后通过交叉开关返回给发出存储的SM。发出存储的warp早已继续执行:实际上,到这里,整个内核已经完成。因此,确认到达LSU并在那里被消费。 事实上,对于这个内核,数据实际上从未到达DRAM!在我们最初的文章中,内核使用`cudaMemcpyDeviceToHost`读回这些写入的结果,直接从L2通过PCIe总线传输到主机DRAM。这本身就是一个有趣的后续话题,改日再谈。 硬件现在在L2中持有我们的数据,处于脏状态。我们已经完成了`STG.E`指令:要使其有意义,任何后续内核指向我们数据的任何指针都应该能找到我们的数据。但它在缓存中是脏的:它还没有到达DRAM。它是如何到达那里的?何时开始这段旅程? ## 存储指令的后续 (https://blog.doubleword.ai/what-happens-when-a-gpu-writes-memory#the-afterlife-of-a-store-instruction) L2作为芯片上所有通信的序列化点,但在某个时刻它会耗尽空间,某些内容需要被驱逐。 我们的数据坐在L2中。另一个内核将在我们之后运行。那个内核可能读取数据或写入数据,它可能需要我们的行,也可能需要其他行。当那个内核运行时,我们的数据将不得不前往DRAM。 ### 数据如何到达DRAM (https://blog.doubleword.ai/what-happens-when-a-gpu-writes-memory#how-data-gets-to-dram) 我们数据中的每一行都附带一个2位的“再引用预测值”(RRPV)[^5],设置为00、11或22(这是示意图,并非数字0、1或2实际出现在硬件中,但时序实验将其定位在三个级别):数字越低,缓存认为该行会很快再次被使用。我们的缓存行位于11,刚刚被插入。 每一行都有一个“脏掩码”,指示L2是否是该行版本*唯一*的存储位置——我们的行都是完全脏的。每一行还有其最后使用和最后存储的计数器。 一个新的加载或存储到达一个行。会发生什么? 1. **在命中的情况下**:当加载到来查找我们的一行时,它找到了。该行的RRPV被设置为00。如果存储到来并命中该行,其扇区命中该行,并且其“脏掩码”被更新。 2. **在未命中的情况下**:L2需要将未命中的值带入缓存。为此,它需要找到一个牺牲者。 *替换策略*如下运作: 首先,我们扫描组中的所有16路,寻找一个RRPV=2的路:即缓存认为不会再被使用的路。如果找不到,我们递增所有RRPV值,然后再次扫描。在所有RRPV=2的值中,我们选择最近最少使用的一个。 然后我们决定如何处理我们的牺牲者。如果我们的牺牲者是*脏的*——即,L2是它唯一存在的地方,驱逐它会迫使我们立即与DRAM通信,那么我们不会立即将其从缓存中踢出,但*确实*开始清理它,通过将它的脏扇区交给内存控制器写回DRAM。该行进入组的FIFO写回缓冲区(FIFO写回缓冲区决定哪些“正在写回”的行是可读的。每两次填充后,缓冲区中最旧的行被弹出,腾出其位置。这没问题,因为该行的写回已经开始),并保持可读。然后我们再次扫描各路寻找干净的牺牲者。一旦找到,我们就将其踢出并占用它的路。 新行只需插入该路,其RRPV设置为1。 ### 保持组的清洁 (https://blog.doubleword.ai/what-happens-when-a-gpu-writes-memory#keeping-sets-clean) 我们描述的策略决定了我们如何进行驱逐以为新数据腾出空间。但如果我们一直命中脏的已存在行,我们的策略就没有办法将它们写回。 当存储到一个包含≥8(共16个)脏行的组时,在运行上述策略之前,我们首先找到该组中最近最少存储的脏行,并将其清理:将其脏扇区发送给内存控制器,并将其标记为干净。 理解为什么需要像这样的额外规则,可以想象如果没有它会发生什么。假设一个组充满脏行。想象一个未命中到来,需要从DRAM填充。根据上述策略,我们选择最旧的、脏的,所以我们将其发送到写回缓冲区。然后我们选择下一个最旧的、脏的,将其发送到写回缓冲区。然后是下一个最旧的,再下一个最旧的,都是脏的,都开始写回,都坐在写回缓冲区中。所有16行最终都同时写回,形成一次DRAM通信的突发。在缓冲区清空之前,这个组在接下来的几次未命中中几乎没有任何容量——更糟的是,这次写入的突发会在内存控制器处排队,因此该控制器上的任何其他读写操作都必须排队等待。 这条“8脏规则”就像清洁工,主动清理行,以便我们总能找到一个干净的牺牲者,并且使得流向DRAM的通信更加平滑。 一个L2组,在上述策略下运行,先是一个写入十六行输出的内核,接着是一个流式处理三十二行输入的内核。点击一行查看内容。行下方的点是其RRPV值。 ### 写回 (https://blog.doubleword.ai/what-happens-when-a-gpu-writes-memory#writing-back) 这些写入可以与发往L2的传入存储异步进行,直到某个内存控制器的[^6]写入积压队列已满,此时新的存储必须停顿[^6]。 当执行写入时,扇区到达内存控制器,然后作为**写入**到达DRAM芯片:控制器激活行,就像为我们的加载所做的那样,然后为每个扇区发出一个写入,通过相同的16个引脚以相反方向传输32字节,并带有字节掩码,因此只有写入的字节被存储[^7]。 ## 结论 (https://blog.doubleword.ai/what-happens-when-a-gpu-writes-memory#conclusion) 一个`LDG.E`花费了255纳秒,warp全程等待。`STG.E`仅花费6个周期,刚好足以启动存储操作。 存储有一个漫长的后续,因为它们经过各个单元时,warp乐在其中,除非它想读回它们。它们经过L1,然后到L2,在这一点上它们向发出它们的SM发回信号。在L2中,它们处于脏状态,随着新的存储和加载将它们推向出口而老化。最终,一个未命中将它们选为牺牲者,它们通过内存控制器被护送到DRAM,在写入它们的warp早已消失很久之后才落地。 一个内核写入128 MB输出,随后一个内核流式处理96 MB输入时的芯片状态。L2每个组一个像素,颜色表示其16行中有多少是脏的。 SMs(128个) 交叉开关 L2(36个切片,每个控制器3个),每个组一个像素:其16行中有多少是脏的 内存控制器:写入积压,读取积压 GDDR6X,12个芯片:每个芯片当前正在做什么 t0μs存储0MB脏在L2中0.0MB写入DRAM0MB SMs节流0/128 ## 附录:可见性 (https://blog.doubleword.ai/what-happens-when-a-gpu-writes-memory#appendix-visibility) ### 何时写入变得可见 (https://blog.doubleword.ai/what-happens-when-a-gpu-writes-memory#when-the-write-becomes-visible) 我们的`STG.E`是“即发即忘”的——warp发出了它,然后程序就毫不客气地退出了。这通常很有用:发出存储的内核可以在存储完成的同时继续前进并执行其他工作。但这意味着我们需要小心:因为数据不会在指令完成时就持久地写入,我们需要某种方式来知道写入何时完成。答案是屏障(fence)。 屏障是一个指令,它会保持warp暂停,直到它发出的每个存储都变得可见。有三种屏障,每种对应一个**作用域**:`membar.cta`等待存储对warp自己的块(CTA)可见,`membar.gl`等待对芯片上的每个SM可见,`membar.sys`等待对主机和其他设备也可见(这些是PTX指令,它们会展开为多条SASS指令)。 #### 对我们的块:在L1处会合 (https://blog.doubleword.ai/what-happens-when-a-gpu-writes-memory#to-our-block-rendezvous-at-l1) `membar.cta`在warp上将等待同一CTA中的任何存储变得可见。所有进出SM的内存通信的一致性点是共享的L1。因此`membar.cta`只需要等待存储在L1中可见,这是它知道的,因为一旦它成功地交付给L1,根据定义,它就是可见的。`membar.cta`为`STG.E`增加了大约1纳秒或3个周期[^8]。 #### 对所有SM:在L2处会合 (https://blog.doubleword.ai/what-happens-when-a-gpu-writes-memory#to-all-the-sms-rendezvous-at-l2) `membar.gl`需要等待存储对芯片上的每个SM都变得可见。为了实现这一点,我们不需要向这些SM广播任何东西,因为就像在SM本身一样,我们有一个所有通信都经过的瓶颈点。所以一旦我们将写入放入L2,并收到确认,我们就知道它在全局范围内可见。`membar.gl`大约在140纳秒内完成,与L2的延迟相同。 它只是*原则上*全局可见——为了让读者来获取它,读者需要从L2获取(它自己的L1可能持有过期的行)。读者必须有意这样做:像`LDG.E.STRONG.GPU`这样的指令将绕过其L1。像`ld.acquire.gpu`这样的指令——专为这种可见性协商设计,编译成的SASS包含`CCTL.IVALL`,这将使该整个SM的L1无效。 屏障本身是双向的:它们还负责确保该线程后续的加载能看到该线程已写入的数据。因此它们也包含使...

相似文章

当GPU读取内存时会发生什么

Hacker News Top

这篇博文详细介绍了NVIDIA RTX 4090上GPU内存读取指令的硬件路径,解释了CUDA内核如何通过L1缓存和DRAM等组件访问内存,以提供性能洞察。