ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从SIMD到SIMT:CPU向量化与GPU并行优化的关键

从SIMD到SIMT:CPU向量化与GPU并行优化的关键 相信不少搞过程序优化的人都有过这种困惑CPU的主频十年间从4GHz冲到顶后又退回来多核数从2核一路涨到64核可我们写的程序有时候还是慢得让人抓狂。直到有一天我把一个图像处理算法从循环逐像素改成SIMD向量化再后来扔到GPU上跑才真正理解了计算机体系结构中这两个关键词的分量——GPU和SIMD其实是一枚硬币的两面都在榨干“并行”这桶水只是桶的大小和取水的方式完全不同。这篇文章我会从指令级并行讲起拆开SIMD的寄存器与指令语义解释为什么CPU靠它撑起了日常软件的性能然后再进入GPU的世界看看它是怎样把SIMD的思想规模化变成几千个线程同时跑的SIMT架构。过程中会穿插真实的代码片段、性能数据和踩坑记录也顺便聊聊大家关心的驱动、调度、深度学习环境配置这类一线问题。适合正在学体系结构的学生、写图像/音频/数值计算的工程师以及刚准备入坑GPU编程的朋友。1. 为什么CPU这么多年还在原地踏步从单核频率到指令级并行1.1 多核之外的另一条路超标量与乱序执行过去二十年CPU单核性能的提升主要靠的不是频率而是“指令级并行”ILP。什么意思呢CPU执行指令不是一条一条顺序走完的现代处理器内部有多个执行单元——整数ALU、浮点单元、访存单元、分支预测单元它们可以同时工作。乱序执行引擎会把一条指令流拆开找到没有数据依赖的指令让它们并行地塞进不同的执行单元里。这就是所谓的“超标量”superscalar设计。但这条路走到今天已经很难再拓宽了。原因很朴素程序里真正能在相邻几条指令间找到的并行度是有限的。一个加法算完才能算下一个这种串行依赖链到处都是。你给CPU再多的执行单元它也找不到那么多可以同时执行的独立指令。我记得有一门体系结构课上老师打过一个比方——超标量内核就像一家餐厅里一个动作极快的厨师他可以同时炒三个菜但菜的工序之间有严格的先后顺序他再快也只能卡在切洋葱等热油这些环节上。于是芯片厂商开始想另一条路既然单条指令流里挖不出并行度那能不能让一条指令本身就携带多个数据的操作这就是SIMD的出发点。1.2 SIMD的登场一条指令同时处理多个数据SIMD是Single Instruction Multiple Data的缩写中文叫单指令多数据流。它的核心思想极其简单粗暴一条加法指令不再只算一对数而是同时算四对、八对、十六对数。x86平台上是这样演进的最早是MMX接着是SSE系列SSE一次处理128位数据也就是4个32位浮点数再后来是AVX把寄存器宽度翻到256位8个floatAVX-512则是512位16个float。ARM那边对应的叫NEON128位起步新一代的SVE则允许可变向量长度可以扩展到2048位。指令集名字不重要关键数字在这里指令集寄存器宽度单条指令可处理的float个数典型场景SSE128位4老代码兼容日常图像处理AVX2256位8视频编解码、矩阵运算AVX-512512位16科学计算、AI推理、数据库NEON128位4移动端、嵌入式SVE128~2048位可变4~64超算、ARM服务器你可以把SIMD理解成给厨师配了一把“能同时切八根黄瓜的刀”。CPU还是那个CPU时钟周期还是那么多但每一条指令干的活变多了吞吐量自然就上去了。1.3 用AVX2做一次最简单的向量化实验光讲概念不过瘾我拿一段真实代码演示一下。假设我们要把两个float数组逐元素相加数组长度是1024// 标量版本 void add_scalar(float* a, float* b, float* c, int n) { for (int i 0; i n; i) { c[i] a[i] b[i]; } } // 使用AVX2指令的向量化版本 #include immintrin.h void add_avx2(float* a, float* b, float* c, int n) { int i 0; for (; i 8 n; i 8) { __m256 va _mm256_loadu_ps(a i); __m256 vb _mm256_loadu_ps(b i); __m256 vc _mm256_add_ps(va, vb); _mm256_storeu_ps(c i, vc); } // 处理剩余不足8个的元素 for (; i n; i) { c[i] a[i] b[i]; } }第一段代码在编译器开了-O3 -mavx2之后其实也会被自动向量化效果差不多但如果你遇到的是复杂一些的循环编译器不敢自动向量化手写intrinsic就派上用场了。我实测过这个简单加法在开启AVX2后大概有4到6倍的加速——注意不是8倍因为访存带宽会拖后腿。理论加速和实际加速之间的差距正是我们接下来要聊的重点。2. SIMD的四堵墙为什么CPU靠指令级并行走不了太远我一开始天真的以为CPU有了AVX-512单核性能问题就彻底解决了。直到我把各种算法往SIMD上搬才发现这玩意儿有四堵墙每一堵都结结实实挡在“理论加速比”和“实测加速比”之间。2.1 第一堵墙向量寄存器宽度和ISA设计AVX-512听起来一次能处理16个float但问题在于你要同时准备好16个数据它们得在内存里是连续的得对齐还得保证这16个数的计算路径完全一样。现实中很多算法根本不是这种整齐的结构。比如处理稀疏矩阵非零元素散布在内存里你没法直接加载一段连续的16个数比如查表操作每个元素的索引都不一样SIMD指令根本没法做到“每个通道查各自的表”你得上_mm256_i32gather_ps这种gather指令而gather在大部分微架构上都会拆成多个周期慢得你怀疑人生。还有指令集本身的兼容性问题。AVX-512在一些CPU上是降频的元凶功耗和发热让它在笔记本上经常被禁用ARM的NEON和SVE之间又完全不兼容代码写死了NEON换到SVE平台就要重写。SIMD指令集生态长期处于碎片化状态这是绕不过去的现实。2.2 第二堵墙分支与串行逻辑的惩罚SIMD最怕的是什么是分支。想象你有一段代码for (int i 0; i n; i) { if (a[i] 0) { c[i] b[i] * 2; } else { c[i] b[i] * 0.5; } }在标量代码里每个元素各走各的分支很自然。但在SIMD里一条指令同时处理8个元素它们必须走同一条路。编译器遇到这种循环一般直接放弃向量化。就算你写成_mm256_blendv_ps之类的混合指令逻辑上也是“8条路都算一遍然后根据mask挑结果”相当于无效分支的那部分计算白干了效率直接对半砍甚至更低。这就是SIMD的硬伤它擅长的是“整齐的重复劳动”最怕的是“每个数据有自己的想法”。而现实世界的算法恰恰充满了这种情况。2.3 第三堵墙访存带宽与数据搬运SIMD把计算变快了但数据还得从内存搬进寄存器。一块双通道DDR4的内存理论带宽也就是25GB/s左右如果你的算法对每8个float只做一次加法那访存和计算的时间比大概是2比1向量化再猛总时间也压不下来瓶颈在内存而不是CPU执行单元。像图像处理里常见的滤波操作每个像素要读相邻像素数据复用率低SIMD加速的效果天然就有限。这其实暴露了一个更普遍的原理任何并行优化最后都受限于系统里最短的那块板。计算再快喂不饱数据都是白搭。2.4 GPU把墙推倒的方式用海量线程掩盖一切延迟CPU面对这几堵墙的做法是继续加缓存、加分支预测器、加乱序窗口试图把单条指令流的效率再挤一挤。GPU则完全不同它选择了一个近乎偏执的路线——我不追求单条指令跑得多快也不费劲去预测分支而是同时跑成千上万个线程。我直接放弃复杂的乱序执行和超深流水线把节省下来的晶体管全部拿去做计算单元和线程上下文。GPU用最朴实的方式绕开了SIMD的三堵墙分支多每个线程各有各的程序计数器可以独立走自己的分支代价只是同组线程的执行得串行化访存慢当一批线程在等内存数据回来时调度器立刻切到另一批就绪的线程上去算用线程间的切换掩盖内存延迟。GPU放弃了“单线程的智能”换取了“整个处理器的吞吐量”这套设计在体系结构里叫“延迟隐藏”latency hiding。这就要引出一个关键概念GPU不是把SIMD做得更宽而是把SIMD做成了成千上万个独立的小任务再在硬件层面把它们组织成SIMD式的大批量执行。这个模式才是理解GPU的核心钥匙。3. GPU的计算引擎SIMT与线程束3.1 WarpGPU里的SIMD但又不完全一样NVIDIA给出的答案叫做SIMT——Single Instruction, Multiple Threads单指令多线程。它和SIMD最本质的区别在于SIMD是在一条指令里显式地打包多个数据程序员要自己操纵向量寄存器而SIMT中程序员写的是普通的标量代码每个线程处理一个数据看起来就像写CPU线程一样自由。但实际上GPU的硬件执行单元并不是真正让每个线程都独立取指执行的。GPU把32个线程绑成一个线程束warp这32个线程在硬件上共享一个程序计数器执行同一条指令。换句话说一个warp内的32个线程在某一个时刻执行的是同一条指令只是各自操作自己的数据——本质上就是32通道的SIMD。只是这个SIMD的“寄存器”分布在不同线程的上下文中NVIDIA管它叫作“SIMT的执行模型”把SIMD的“数据并行”和线程的“独立调度”柔和地结合在一起。你可以这样理解SIMD是你在一个核心里手动展开的并行SIMT是硬件帮你把32个标量线程伪装成一场整齐划一的集体操。程序员写起来像多线程硬件跑起来像SIMD中间这层翻译由编译器nvcc和硬件调度器共同完成。3.2 SM内部结构从SIMD到SIMT的硬件组织GPU里最基本的计算单元叫SMStreaming Multiprocessor。一块现代GPU有几十到上百个SM。每个SM内部又分成若干组每组有一个warp调度器下面挂着几十个CUDA coreNVIDIA的叫法本质是简单的浮点/整数ALU。调度器每次给一个warp发一条指令这条指令会被分发给32个CUDA core同时执行。举个例子RTX 4090有128个SM每个SM里头有128个FP32 CUDA core总共就是16384个core。A100是108个SM每个SM里64个FP32 core总共6912个。SM数量、核心数这些参数决定了GPU的“理论并行度”但实际跑满与否取决于你能不能把足够多的warp喂给调度器。每个SM能同时容纳多少个warp是有限制的——一般有最大线程数上限比如1024或2048个线程。这个上限非常重要如果你的kernel每个线程只干很少的活你启动的线程数再多SM能同时“在飞”的线程也就那么多多余的全在排队。这也是为什么GPU适合“大量简单任务”而不是“少数复杂任务”——每个线程占用的寄存器太多会直接压降SM里能同时驻留的线程数进而影响调度器用线程切换掩盖延迟的能力。3.3 访存合并GPU性能的隐形天花板讲了半天硬件线程GPU编程里最经典的那句话又要搬出来了“写代码的人觉得自己在写并行程序实际上决定性能的是访存模式。”GPU访问显存有个硬规则一个warp的32个线程同时访问显存硬件会把它们的访问请求合并成尽量少的“缓存行”事务。如果32个线程访问的地址是连续的硬件只需要发1次128字节的内存请求全部命中如果地址是随机散乱的就要拆成几十次事务带宽浪费十倍以上。这个机制叫访存合并memory coalescing。我自己的体验是两个逻辑一模一样的kernel一个按连续地址访问一个按奇偶跳着访问性能差距轻松超过5倍。GPU的ALU浮点运算能力已经强到很多时候我们不是在“算不过来”而是在“取不过来”。很多深度学习框架里的算子在GPU上慢八成不是计算的问题而是访存pattern的问题。看一下下面的CPU代码风格和GPU代码风格对照就很清楚了// GPU kernel: 连续的线程访问连续的地址 —— 合并访存 __global__ void copy_coalesced(float* in, float* out, int n) { int idx blockIdx.x * blockDim.x threadIdx.x; if (idx n) { out[idx] in[idx]; } } // GPU kernel: 线程按stride跳着访问 —— 访存发散性能灾难 __global__ void copy_strided(float* in, float* out, int n) { int idx blockIdx.x * blockDim.x threadIdx.x; if (idx n) { out[idx * 2] in[idx * 2]; // 相邻线程地址不连续 } }第一段代码中相邻的threadIdx.x对应相邻的数组下标这是GPU最欢迎的访问模式第二段代码乘以2之后一个warp的32个线程访问的地址横跨256个float硬件得拆成多次事务才能完成。我给学员做培训时经常用这两段代码做实验同一块GPU上coalesced版本比strided版本快5到8倍。3.4 分支发散同一个warp里不同线程的代价第三个GPU特色问题分支发散divergence我前面在SIMD里提过它的表亲。在GPU上一个warp的32个线程执行同一条指令如果碰到if-else而且32个线程的判断结果不一致会发生什么硬件会先执行所有满足条件1的线程屏蔽掉不满足的再反过来执行条件2的线程屏蔽掉另一批。两边的时间是叠加的一个warp里分化出k条不同的分支路径这段代码的实际执行时间就翻k倍。所以在GPU编程里面试官和书上都爱考一句话尽量让同一个warp里的线程走相同的控制流分支判断尽量基于block级别的条件而不是thread级别的随机条件。当然现代GPU的分支预测已经能在一定程度上减轻这种惩罚——当32个线程全都走同一分支时只需要一个周期只有当分叉真的发生时才会出现代价。但这也意味着你的性能稳定性很大程度取决于数据是否“幸运”。4. GPU上的程序是怎么跑起来的调度、驱动与并行模型4.1 线程的调度GPU不是多核CPU是核多CPU很多第一次接触GPU编程的人会把GPU想象成“超多核CPU”这其实是个误会。多核CPU的每个核心都是完整的独立处理器有自己的缓存、乱序执行、分支预测可以各自跑完全独立的程序。GPU的SM则是个“集体农庄”同一个SM里的warp一起排队、被调度、共享寄存器堆每个warp没有独立的缓存和独立的指令流控制权。GPU的调度也和CPU完全不同。CPU线程调度由操作系统内核负责时间片轮转可以随时抢占GPU的warp调度是纯硬件机制warp一旦被选中执行就一直运行到遇到访存等待或被显式同步中间不被打断。GPU也不存在“优先级”和“中断”这些概念它想的只有一件事保持所有执行单元尽量忙碌。所以GPU程序设计的最大法则是你要生成足够的并行任务让每个SM都有活干。这就是GPU上“线程数要远大于SM数”的原因——不是希望它们同时执行而是为了给硬件调度器提供足够的候选warp好让它能在一个warp卡在访存上的瞬间换上另一个warp接着算。这个机制就是前面说的“延迟隐藏”的工程实现。4.2 驱动与运行时从驱动开发到“GPU被物理移除”说到GPU很多人只关注硬件和CUDA代码但我在一线折腾GPU服务器时最常被坑的其实不是kernel性能而是驱动和运行时。整个GPU软件栈大致分成三层最底下是内核态驱动负责管理GPU设备的DMA、显存映射、中断处理中间是用户态运行时库CUDA Runtime或者OpenCL负责把kernel编译产物加载进GPU、分配显存、管理命令队列最上层才是你写的核函数和框架代码。驱动开发属于操作系统和硬件结合最紧密的领域之一常见的坑包括不同版本GPU固件和驱动的配套关系、SR-IOV虚拟化环境里的显存直通、MIG切分后的显存隔离等任何一个在驱动层没对上上层应用就各种花式报错。热词里有个很有意思的“电脑经常提示GPU被物理移除”。这个我熟。它通常不是GPU真的被拔走了而是GPU和主机之间的PCIe链路发生了异常——可能是供电不稳导致设备掉卡、驱动崩溃后没有恢复、或者是PCIe链路训练失败。GPU设备热插拔机制本来就有不少边缘情况系统在设备短暂失联后会把整条PCIe总线上设备的状态全部重设如果你在虚拟机里透传了GPUhost端一次dmesg刷屏后guest端就再也看不到这个设备了。排查思路也很模板化先看dmesg | grep -i nvidia里有没有NVRM报错再看nvidia-smi -a能不能读到设备最后检查PCIe链路是否降级。我建议所有用GPU做生产的人都养成习惯定期记录nvidia-smi输出的GPU温度、显存、PCIe速率有异常早发现早处理。4.3 从体系结构到工具链CUDA、PyTorch GPU版与整套软硬件配合现在我们回到普通开发者最常接触的那一层——深度学习环境配置。热词里出现了“pytorch安装教程gpu”“深度学习环境配置gpu版”“英特尔显卡怎么使用gpu版本的pytorch”这类高频问题说到底都逃不过软硬件版本匹配的魔咒。PyTorch的GPU版之所以是“安装”而不像普通Python包一样“pip install”就完事是因为它需要与CUDA运行时、cuDNN、显卡驱动的版本严格对应。PyTorch编译时绑定了特定CUDA版本运行时再通过驱动里的用户态库与GPU通信所以驱动版本过低、CUDA版本不匹配、cuDNN缺失都会导致import torch时直接报错或者运行到一半才炸出来。我的建议是别去追求最新版CUDA先查清楚你的GPU驱动支持哪个CUDA版本再选对应版本的PyTorch轮子。具体来说nvidia-smi右上角显示的CUDA Version是驱动支持的最高版本只要PyTorch要求的CUDA小于等于这个数字基本就能跑。英特尔显卡那边要装的是oneAPI工具链加PyTorch的IPEX扩展和NVIDIA的CUDA生态是另一套体系别混着来。另外还有“GPU租用”和“GPU微调大模型”——这里面最容易被忽略的是多租户GPU的调度问题。云上的GPU实例有的走MIG切分有的走vGPU虚拟化有的干脆整卡独占三种方式的性能隔离程度完全不同。我租GPU跑微调时必看的一个指标是实例是否允许我用nvidia-smi -lgc锁定GPU时钟频率因为共享卡上的内存带宽波动可能让你的训练步长时间从1秒跳到20秒这种不稳定通常比绝对速度更让人头疼。昇腾系列GPU那边生态是类似的思路——昇腾有自己的驱动、CANN运行时和MindSpore框架适配的模型也要用昇腾的工具做转换。现在很多国产GPU包括昇腾、寒武纪等都在做“兼容CUDA”的迁移层但要注意兼容层在性能上往往达不到原生水平跑A100上同样的模型换成国产卡之后如果只追求“能跑”那结果往往差强人意。选平台前先想清楚你的应用是IO密集还是计算密集再决定要不要为“兼容性”买单。最后说个真实经历吧。我之前写过一个图像锐化算法先是在CPU上用AVX2优化从每秒处理30帧提到100帧沾沾自喜后来移植到CUDA上第一次跑出来只有45帧比AVX2还慢。查了很久才发现问题出在两处一是kernel里每个线程处理的像素数太少线程调度和访存开销压过了计算收益二是我把图像数据按行存储直接读没有考虑GPU的合并访存模式导致显存带宽利用率极低。改成每线程连续处理4个像素、并确保同一warp访问连续显存地址之后直接跳到每秒400多帧。这段经历让我彻底记住了体系结构课上那句话“并行优化的本质是让硬件处于它最舒服的工作状态。”CPU的SIMD舒服在整齐的短向量运算GPU的SIMT舒服在海量整齐的长向量吞吐。你手里有什么硬件代码就得顺着它的脾气来而不是靠蛮力堆线程或者堆指令。搞清楚了这一点再回头看GPU驱动报错、PyTorch装不上、训练时快时慢这些幺蛾子其实都是相同的逻辑——软硬件栈每一层都有它的脾气顺着来就能省下大把的排查时间。
返回列表