在现代异构计算的宏大舞台上,CPU与GPU的协同工作已经成为了算力爆发的绝对基石。不论你是沉浸在深度学习框架底层进行算子开发的算法工程师,还是在HPC(高性能计算)领域榨干每一滴浮点运算能力的极客,理解GPU内核(Kernel)的交互机制都是一门必修课。

NVIDIA的CUDA(Compute Unified Device Architecture)作为行业的老大哥,构建了极为成熟且封闭的软硬件生态城墙;而AMD的ROCm(Radeon Open Compute)则高举开源大旗,试图通过HSA(异构系统架构)标准和HIP语言在红海中杀出一条血路。这两者在最底层的Kernel交互、调度与执行上,既有着“殊途同归”的并行计算哲学,又在硬件映射、队列管理和指令集架构上存在着深刻的分歧。

异构世界的“邮局模型”:Kernel是如何被唤醒的?

在我们的代码中,无论是CUDA的 kernel<<<grid, block>>>(args) 还是HIP的 hipLaunchKernelGGL(...),看起来都像是一个普通的异步函数调用。但在操作系统和硬件眼中,这是一次跨越了系统总线(通常是PCIe或高速互联总线如NVLink/Infinity Fabric)的复杂“邮包投递”过程。

CPU(Host)是一位极度聪明的指挥官,但他手下只有寥寥几个精锐士兵;GPU(Device)则是一个拥有数以万计流水线工人的超级工厂,但这些工人只能接受极其简单的死板指令。Kernel的交互机制,就是指挥官向超级工厂下达生产任务的通讯协议。

1: 准备命令包(Command Packet Generation)

CPU并不会直接把代码塞给GPU。无论是CUDA Runtime还是ROCm Runtime,第一步都是将Kernel的函数指针、网格/线程块维度(Grid/Block Dimensions)、共享内存大小以及参数列表打包成一个特定格式的命令包。在ROCm的HSA架构中,这被称为AQL(Architectural Queueing Language)Packet,它有着严格的64字节标准结构。

2: 环形缓冲区投递(Ring Buffer / Command Queue)

打包好的命令包会被放入驻留在系统内存(或映射的设备内存)中的命令队列。这是一个典型的生产者-消费者模型。CPU作为生产者,将Packet推入队列。在CUDA中,这通常表现为CUDA Stream的底层操作;而在ROCm中,这是一个用户态可见的HSA Queue。

3: 敲响门铃(Ringing the Doorbell)

GPU处于被动等待状态,CPU需要通过写PCIe的特定内存映射寄存器(Memory-Mapped IO, MMIO)来通知GPU“有新活儿了”。这个动作在硬件术语中极其形象地被称为“Doorbell”。CPU往Doorbell寄存器写入更新后的队列写指针(Write Pointer)。

4: 硬件命令调度器接管(Hardware Command Scheduler)

GPU的前端(Front-End)硬件调度器(在NVIDIA中通常是GigaThread Engine,在AMD中是Asynchronous Compute Engine, ACE)察觉到Doorbell的响动,利用DMA(直接内存访问)引擎从系统内存的队列中拉取(Fetch)刚才放入的命令包。至此,Kernel的控制权正式从主机端交接到设备端。

NVIDIA CUDA内核交互机制:GigaThread的精密统治

当我们深入绿厂(NVIDIA)的领地,我们需要理解CUDA是如何将一个抽象的Kernel分解并映射到硅片上的。NVIDIA的软硬件协同经过了从Tesla架构到Hopper架构的十几年打磨,其Kernel的生命周期管理已经变成了一台极其精密的仪器。

两级编译与PTX的中间态艺术

CUDA的Kernel在编译时,并不是直接被翻译成GPU的机器码,而是采用了两级编译策略。

1: NVCC编译生成PTX(Parallel Thread Execution)

PTX是一种伪汇编语言,是NVIDIA定义的一种虚拟指令集架构(ISA)。它不依赖于任何具体的NVIDIA微架构。这就是为什么你编译的CUDA程序往往能在跨代GPU上运行的原因。

2: JIT编译与SASS(Shader Assembly)

当CUDA程序在目标机器上运行时,显卡驱动中的JIT(Just-In-Time)编译器会将PTX代码实时编译成当前GPU真正能听懂的硬件指令——SASS。SASS是与特定架构(如Ampere, Hopper)深度绑定的。驱动程序会缓存这些SASS代码(Compute Cache),以加速后续的Kernel启动。

线程层级与硬件映射

CUDA的Kernel执行模型采用了SIMT(Single Instruction, Multiple Threads,单指令多线程)架构。在这个模型中,交互的基本单元被严格分级。

1: Thread(线程)与 SP(流处理器)

最基本的执行单元。每个Thread拥有自己的程序计数器(PC)和寄存器状态。在硬件上,它映射到SM(Streaming Multiprocessor)内的一个CUDA Core(SP)。

2: Warp(线程束)与 Warp Scheduler

这是CUDA调度和交互的真正最小硬件单元。32个连续的Thread组成一个Warp。Warp内的所有线程在同一时刻必须执行完全相同的指令(即Lock-step执行)。如果遇到 if-else 分支导致线程需要执行不同的路径,Warp会发生“线程发散”(Warp Divergence),此时硬件会使用掩码(Masking)机制,让不同路径的线程串行执行,这会导致严重的性能损耗。

3: Thread Block(线程块)与 SM(流多处理器)

多个Warp组成一个Block。Kernel交互的一个核心原则是:一个Block一旦被调度到一个SM上,在它执行完毕之前,绝对不会被迁移到其他SM,也不会被换出(Preempted,除非发生极其特殊的中断)。 这保证了Block内的线程可以通过Shared Memory(共享内存)进行极低延迟的通信和同步(__syncthreads())。

4: Grid(网格)与 GPU Device

一个Kernel启动产生的所有Block统称为一个Grid。GigaThread Engine负责将Grid中的Block分发到GPU全局的各个SM中去。

在复杂的数学建模中,为了衡量Kernel在CUDA架构下的执行效率,我们常引入Little’s Law(利特尔法则)在并发计算中的变体。为了掩盖指令的延迟(Latency),我们需要足够的并发线程数(Concurrency):

Concurrency=Latency×ThroughputConcurrency = Latency \times ThroughputConcurrency=Latency×Throughput

这意味着,在CUDA Kernel的交互设计中,程序员必须向GPU提供海量的线程,以确保当某些Warp因为等待内存数据而停滞时,Warp Scheduler能以零时钟周期的开销瞬间切换到另一个准备就绪的Warp上继续压榨计算单元(ALU)。这种利用海量线程切换来掩盖延迟的机制,是CUDA Kernel区别于CPU多线程调度的核心灵魂。

AMD ROCm内核交互机制:HSA与Wavefront的野望

跨入红厂(AMD)的阵营,ROCm的底层架构思想与CUDA既有相似的镜像,又有截然不同的底层哲学。AMD长期坚持的APU(加速处理器)战略深刻影响了其软件栈的设计,使得ROCm在诞生之初就带有强烈的“CPU与GPU平权”的HSA(异构系统架构)色彩。

开源软件栈与AOMP编译器

与NVIDIA封闭的NVCC和闭源驱动不同,ROCm的软件栈几乎是全开源的,重度依赖LLVM编译器基础设施。

1: HIP(Heterogeneous-Compute Interface for Portability)

AMD为了降低CUDA开发者的迁移成本,推出了HIP语言。它在API层面长得和CUDA几乎一模一样(比如用 hipMalloc 代替 cudaMalloc)。在编译层面,HIP代码会通过基于Clang/LLVM的HIPCC编译器进行处理。

2: LLVM IR 与 AMDGPU ISA

与CUDA的PTX虚拟汇编不同,HIP/C++代码会被编译为LLVM IR(中间表示),然后再由LLVM后端的AMDGPU目标代码生成器直接生成底层的GCN(Graphics Core Next)或CDNA(Compute DNA)架构的机器码(ISA)。

3: ROCr与HSA用户态队列

在CUDA中,Kernel的启动需要通过闭源的CUDA驱动进行复杂的内核态上下文切换。而在ROCm中,ROCr(ROCm Runtime)遵循HSA标准,允许用户空间进程直接向GPU的硬件队列(Command Queues)中写入AQL数据包,直接绕过了操作系统的内核态驱动。这种User-Mode Queue机制极大地降低了Kernel启动的延迟(Dispatch Latency),使得高频、小任务量的Kernel交互变得更加高效。

Wavefront:AMD的“Warp”变体

在执行模型上,AMD采用了与SIMT非常相似的模型,但在术语和具体实现上有自己的考量。

1: Work-item(工作项)与 ALU

等价于CUDA中的Thread。

2: Wavefront(波前)与 SIMD Unit

这是AMD的精髓所在,等价于CUDA的Warp。但关键的区别在于大小:CUDA的Warp大小永远是32;而AMD的Wavefront大小在早期的GCN架构中是64,在最新的CDNA(针对计算)和RDNA(针对图形)架构中,为了提升执行效率和降低分支预测失败的惩罚,可以配置为Wave32或Wave64。Wave64在处理大量连续数据时能摊薄指令获取的开销,而Wave32则在处理逻辑复杂、分支较多的Kernel时能显著减少Divergence带来的性能浪费。

3: Workgroup(工作组)与 CU/WGP

等价于CUDA的Thread Block。在AMD的硬件中,Workgroup被调度到CU(Compute Unit)或更高级的WGP(Workgroup Processor)上执行。每个CU内部包含自己的L1缓存和LDS(Local Data Share,等价于CUDA的Shared Memory)。

跨越红绿阵营:Kernel调度的深层瓶颈与交互介质

无论是CUDA还是ROCm,当Kernel一旦被成功抛入GPU内部,它的生命周期就完全交由硬件调度器掌控了。但在这期间,Host(CPU)与Device(GPU)并不是完全失联的。内存机制和同步原语构成了它们交互的生命线。

显式同步与隐式屏障

异构计算最痛苦的事情莫过于等待。CPU把Kernel发射出去后,默认情况下会直接执行下一行代码(异步执行)。如果你需要CPU等待GPU计算完成,就需要进行同步。

1: Stream/Queue 同步

在CUDA中,我们可以使用 cudaStreamSynchronize(stream);在ROCm中,对应 hipStreamSynchronize(stream)。这将阻塞CPU当前的线程,直到指定的流/队列中所有的Kernel和内存拷贝操作全部完成。这种重量级的交互意味着CPU计算资源的闲置。

2: Event(事件)机制

为了实现更细粒度的交互,系统引入了Event。在Kernel队列中插入一个Event,当硬件执行到这个Event时,会向系统记录一个时间戳,或者改变一个状态标志。CPU可以非阻塞地查询(Query)这个Event的状态,从而实现计算与内存拷贝的极致重叠(Overlap)。

统一内存(Unified Memory)与缺页异常的交互

在早期的异构编程中,开发者需要手动使用 cudaMemcpy 搬运数据。这是一种显式的、基于DMA的交互。而如今,无论是NVIDIA的Unified Memory还是AMD基于HSA的Fine-Grained Memory,都引入了一种更为极其隐蔽的Kernel交互机制:基于缺页异常(Page Fault)的按需迁移。

当你在CPU上分配了一块统一内存(如 cudaMallocManaged),然后直接让GPU的Kernel去访问这块内存的指针时:

1: 首次访问触发缺页(Page Fault)

Kernel中的某个Warp/Wavefront尝试读取该虚拟地址,发现该地址对应的物理页并不在GPU显存(VRAM)中,MMU(内存管理单元)会触发一个缺页中断。

2: 硬件停顿与跨总线请求

触发缺页的Warp会被硬件挂起。GPU的缺页处理引擎会通过PCIe向CPU的操作系统内核发送请求。

3: 页面迁移(Page Migration)

操作系统的驱动程序介入,将数据从系统主存(Host RAM)拷贝到GPU显存中,然后更新GPU的页表(Page Table),建立虚拟地址到新显存物理地址的映射。

4: 指令重放(Instruction Replay)

缺页处理完成后,被挂起的Warp被唤醒,重新执行刚才失败的那条访存指令。

这种隐式交互极大地降低了编程门槛,但也使得Kernel的执行时间变得极不可控。频繁的缺页异常(Thrashing)会导致总线带宽被大量细碎的数据包填满,让性能跌入深渊。

在评估这种因为显存交互导致的性能瓶颈时,经典的Roofline模型(屋顶线模型)是我们最重要的理论武器。Roofline模型通过计算强度(Operational Intensity,即每字节内存传输所进行的浮点运算次数)来判断一个Kernel是处于内存受限(Memory Bound)还是计算受限(Compute Bound)。

P=min⁡(Ppeak,I×βpeak)P = \min(P_{peak}, I \times \beta_{peak})P=min(Ppeak,I×βpeak)

其中,PPP 是Kernel的实际可达性能,PpeakP_{peak}Ppeak 是GPU的理论峰值算力,III 是Kernel的计算强度(FLOPs/Byte),βpeak\beta_{peak}βpeak 是GPU的理论内存带宽。

只有当Kernel的计算强度 III 越过屋顶的转折点(Ridge Point)时,GPU-Kernel的算力才能被真正释放。这就引出了我们在设计Kernel时必须面对的残酷现实:如何通过优化寄存器分配、极致利用Shared Memory/LDS、以及对齐全局内存访问,来减少Kernel与外部存储的交互开销。这些关乎生死的调优细节,正是区分“能写出CUDA/HIP代码的人”与“真正的异构计算工程师”的分水岭。

GPU-Kernel的协同优化,本质上是一场与物理定律抗争的游戏:如何对抗光速带来的内存延迟?如何避免硅片上成千上万个计算单元相互踩踏?如何在CUDA和ROCm不同的底层架构中,找到那把解锁性能巅峰的通用钥匙?

接下来,我们将戴上显微镜,深入探究Kernel在执行态的极限优化策略。

内存墙的突围:从Global Memory到Registers的极限榨取

无论NVIDIA的Hopper架构还是AMD的CDNA架构,算力的增长速度永远远远甩开内存带宽的增长速度。数据搬运的代价往往比计算本身高昂几个数量级。因此,Kernel优化的第一要义,就是建立绝对严密的内存分级访问策略。

1: 显存合并访问(Coalesced Memory Access)

这是全局内存(Global Memory)优化的灵魂。当NVIDIA的一个Warp(32个线程)或AMD的一个Wavefront(64/32个工作项)尝试从全局内存读取数据时,硬件并不是让每个线程单独去要数据。内存控制器会试图将这几十个访存请求“合并”成一到两次大粒度的内存事务(Memory Transaction,通常是32字节、64字节或128字节对齐)。如果你的线程访问的内存地址是连续且对齐的,带宽利用率就能达到100%。反之,如果线程访问的地址是随机跳跃的(例如稀疏矩阵的不规则访问),这被称为非合并访问(Uncoalesced Access),会导致总线上传输了大量无用数据,实际有效带宽可能暴跌至理论值的十分之一。

2: 共享内存与Bank Conflicts(Shared Memory & LDS)

为了缓解全局内存的极高延迟,NVIDIA引入了Shared Memory,AMD称之为LDS(Local Data Share)。这是一块位于SM/CU内部、极低延迟的SRAM芯片。它是Block/Workgroup内所有线程共享的黑板。但这个黑板并不是一整块板子,而是被划分为32个(或更多)独立的存储体(Memory Banks)。

这里隐藏着异构编程中最经典的陷阱:Bank Conflict(存储体冲突)。当多个线程在同一时钟周期内试图访问同一个Bank的不同地址时,硬件无法同时处理这些请求,只能将它们串行化(Serialize)。

我们可以用一个简单的数论公式来表达地址到Bank的映射机制。假设每个Bank的字长为4字节(32位):

Bank_Index=(Address4)(modNUM_BANKS)Bank\_Index = \left( \frac{Address}{4} \right) \pmod{NUM\_BANKS}Bank_Index=(4Address)(modNUM_BANKS)

如果你的数据结构是一个巨大的结构体数组(AoS),极易引发严重的Bank Conflict。经验丰富的开发者会通过“内存填充(Padding)”或者改变数据排布为数组的结构体(SoA)来打破这种致命的访问步长(Stride)。

延迟掩盖艺术:并发、占用率与寄存器压力

在上篇中我们提到了Little’s Law。GPU没有像CPU那样庞大的分支预测器和乱序执行引擎,它对抗延迟的唯一武器就是绝对的暴力并发。当一个Warp/Wavefront因为等待内存数据而卡住时,调度器必须立刻找到另一个Warp切进去执行。

1: 占用率(Occupancy)的几何学

占用率定义为当前驻留在SM/CU上的活跃Warp数量与硬件支持的最大Warp数量的比值。直觉告诉我们,占用率越高越好,这意味着有充足的备用Warp来掩盖延迟。但残酷的现实是,占用率是一个多变量博弈的结果。SM内部的资源是固定的:最大寄存器数量、最大共享内存容量。如果你在一个Kernel中申请了海量的共享内存,那么这块SM能同时容纳的Block数量就会锐减,占用率自然暴跌。

2: 寄存器溢出(Register Spilling)的惩罚

寄存器是GPU中最快的存储介质,但也是极其稀缺的。如果你的Kernel写得过于复杂,局部变量漫天飞舞,每个线程需要的寄存器数量超过了硬件分配的上限,编译器就会被迫将多余的数据“溢出”到本地内存(Local Memory)中。

请注意,“Local Memory”是一个极具欺骗性的名字,它在物理上实际上位于慢速的Global Memory中!一旦发生Register Spilling,你的Kernel性能会被瞬间打骨折。在CUDA和HIP中,我们可以分别使用 __launch_bounds____attribute__((amdgpu_waves_per_eu)) 等编译器指令,来强制编译器限制寄存器使用,哪怕这会增加指令数,也要死保占用率和避免溢出。

线程束内的悄悄话:Warp/Wavefront级原语

早期的GPU编程中,线程之间交换数据必须通过Shared Memory,并伴随昂贵的 __syncthreads() 屏障同步。但随着硬件的进化,无论是绿厂还是红厂,都开放了更底层的微架构交互能力。

1: 寄存器级数据交换(Shuffle Instructions)

在NVIDIA的Kepler架构之后,CUDA引入了极其强大的Warp Shuffle指令(如 __shfl_sync)。它允许同一个Warp内的32个线程直接读取其他线程的寄存器值,完全不需要经过任何内存,也不需要显式的Block级同步。这在进行树状规约(Reduction)、前缀和(Scan)以及矩阵乘法的底层Tile计算时,堪称降维打击级别的优化手段。

2: AMD的DPP(Data Parallel Primitives)

不甘示弱的AMD在其GCN及后续架构中,提供了类似甚至在某些场景下更灵活的机制。DPP允许Wavefront内的工作项跨通道(Cross-Lane)直接获取相邻工作项的数据。更进一步,AMD还提供了基于LDS的快速广播指令。在ROCm生态中,充分利用HIP底层的 __shfl 宏映射或者直接嵌入AMDGPU内联汇编,是榨干CDNA架构浮点吞吐量的杀手锏。

降维打击:CUDA Graphs与HIP Graphs的静态调度

当我们把Kernel内部的逻辑优化到极致,单次执行只需要两三微秒时,一个意想不到的瓶颈出现了:CPU指挥官的语速太慢了。

1: 传统Stream的调度瓶颈

传统的Kernel启动是一个高度动态的过程。CPU需要准备参数、通过PCIe调用驱动、验证状态、再发射到队列。这个CPU端的发射开销(Launch Overhead)通常在5到10微秒之间。如果你的Kernel本身只跑2微秒,那就意味着大部分时间GPU都在无所事事地等待CPU下达命令。这种现象在深度学习推理(Inference)和具有大量小规模算子的物理模拟中尤为致命。

2: Graph的捕获与重播(Capture and Instantiation)

为了打破这个僵局,NVIDIA推出了CUDA Graphs,AMD紧随其后在ROCm中实现了HIP Graphs。这种机制的本质是执行拓扑的静态化

开发者可以开启一个“录制(Capture)”模式,像往常一样向Stream中提交成百上千个Kernel、内存拷贝和Event。但此时驱动并不会执行它们,而是将这些操作及其依赖关系编译成一张有向无环图(DAG)。这张图被一次性实例化并保存在GPU硬件内部。

之后,CPU只需要发出一条“重播(Launch Graph)”指令。GPU内部的硬件调度器会直接接管整个复杂的执行流,以纳秒级的极低延迟自动触发后续Kernel,彻底将CPU从繁琐的调度泥潭中解放出来。

多卡纪元:超越PCIe的节点间内核交互

单芯片的摩尔定律正在放缓,大模型时代的算力缺口只能用算力集群来填补。当一个Kernel的计算图被切分到多个GPU上时,跨设备的交互机制就成了新的战场。

1: NVLink与Infinity Fabric的硬核互联

无论是NVIDIA的NVLink还是AMD的Infinity Fabric,它们的核心目标都是绕过拥挤且高延迟的CPU PCIe总线。在多卡环境下,GPU A的Kernel可以通过这些高速互联通道,像访问本地显存一样直接通过Load/Store指令读取GPU B的显存,这被称为P2P(Peer-to-Peer)Direct Access。

2: 通信原语库的底层融合(NCCL & RCCL)

为了管理多卡之间复杂的All-Reduce、All-Gather等集体通信(Collective Communication)操作,NVIDIA开发了NCCL,AMD对应的开源版本则是RCCL。现代的高阶优化中,通信逻辑不再局限于CPU端调用,而是直接融合到GPU Kernel内部(例如NVIDIA在Hopper架构引入的TMA和异步组播机制)。这意味着Kernel在进行矩阵乘法的同时,硬件级的DMA引擎已经在后台默默地通过NVLink将边界数据交换到了另一张卡上,实现了计算与通信的完美掩盖。

工具链与探针:白盒剖析的黄金法则

在异构优化的世界里,凭直觉修改代码无异于蒙眼狂奔。无论是CUDA还是ROCm,都提供了深入到寄存器级别的性能剖析(Profiling)工具。

1: 瓶颈定位的黄金法制(Nsight Compute / RocProfiler)

永远不要猜测瓶颈在哪里。NVIDIA的Nsight Compute和AMD的RocProfiler能够以硬件性能计数器(Performance Counters)为基准,为你提供最真实的运行时数据。

当你跑完一个Profile后,你需要关注几个核心指标:SM活跃度(SM Activity)、内存带宽利用率(Memory Throughput)、指令发射停顿原因(Warp State / Stall Reasons)。如果停顿原因显示为 Stall_WaitMemory,那么你的重心就是优化访存模式;如果显示为 Stall_InstFetchStall_ExecutionDependency,你需要检查指令级并行度(ILP)并尝试展开循环(Loop Unrolling)。通过这些探针,黑盒的GPU变成了一个透明的玻璃箱,每一滴算力的流失都无所遁形。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐