ARTICLE DETAIL

资讯详情

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

深入GPU SIMT数据依赖:从Stall Wait到指令级并行优化

深入GPU SIMT数据依赖:从Stall Wait到指令级并行优化 做GPU性能优化的人迟早会撞上“这条指令为什么要等我上一条”的问题。打开Nsight Compute看到Scheduler Stats里那一排Stall Wait你其实已经在跟SIMT指令流中的数据依赖处理打交道了。这个问题不算新却是理解GPU底层执行效率绕不开的核心SIMT模型让一个线程束warp里的32个线程锁步执行同一条指令而指令之间的读写依赖决定了硬件什么时候才能把下一条指令发下去。搞清楚依赖怎么被识别、怎么被等待、怎么被编译器绕开你就不只是会“调参数”而是真正能看懂Profiler上那些等待周期的来龙去脉。这篇文章写给正在做GPU kernel优化、跑大规模并行程序想榨干硬件性能、或者单纯想搞明白GPU流水线工作原理的同行们。1. 先搞清楚SIMT指令流里“依赖”为什么是个麻烦1.1 从SIMT执行模型说起一条指令32个线程SIMTSingle Instruction, Multiple Thread是NVIDIA GPU最核心的执行模型。你写一个kernelGPU会把它编译成很多条指令这些指令以线程束为单位发射。一个线程束包含32个线程硬件每次从指令流里取出一条指令广播给这32个线程让它们在同一拍里执行。注意这里和SIMD有本质区别。SIMD是单条指令操作一个向量寄存器里的多个数据元素本质上还是一份数据通路而SIMT里每个线程有自己独立的寄存器、独立的地址计算、独立的执行状态。广播的只是指令数据完全是各算各的。打个比方SIMD像一个班级统一做同一页口算题所有学生同步动笔答案格式一样SIMT是全班收到同一道题的指令但每个学生用自己桌上的草稿纸、自己的思路写只是“在同一个时间点开始做题”这个约束是一样的。正因为每个线程有自己的寄存器文件和程序状态依赖问题就变得很微妙一个线程束里的指令流是共享的但每条指令执行时32个线程各自访问自己的寄存器。硬件需要在“共享指令流”和“独立寄存器状态”之间做出仲裁判断某条指令能不能发射不仅要看它在指令流里的位置还要看32个线程里每一个线程的数据是否就绪。任何一个线程的数据没准备好整个线程束都得等着。1.2 依赖的本质指令之间在抢什么资源数据依赖的本质其实很朴素一条指令要读的数据恰好是另一条指令要写的数据。如果你把指令流看成一条时间线后一条指令必须等前一条指令把结果写进寄存器之后才能读这个“必须等”就是依赖。但GPU里的情况比单核CPU复杂。CPU是单指令流多数据流的一两个核心在乱序执行依赖关系只在一个线程内部存在GPU则是在一个SM里同时跑着几十个线程束每个线程束又有32个线程在跑。这样依赖就好比一个食堂里几十个窗口同时开火每个窗口的厨师都有自己的菜谱和灶台但菜谱里“等水烧开再下锅”这样的步骤约束和隔壁窗口完全没有关系。硬件要做的就是既要保证每个窗口内部的做菜顺序不能乱又要在某个窗口等水烧开时让其他窗口继续做菜不能让整个食堂停下来。所以SIMT指令流里的依赖处理核心矛盾就是如何在不违背每个线程内部语义的前提下让线程束之间的执行尽量并行把等待时间藏起来。2. 数据依赖在SIMT里的真面目三类冲突与两种应对思路2.1 三类经典依赖RAW、WAR、WAW教材里讲的依赖类型放到SIMT里一样适用只是危害程度和应对方式不同。依赖类型英文名含义在SIMT指令流中的典型表现读后写RAW (Read After Write)一条指令读寄存器另一条指令写同一个寄存器最常见的依赖比如load的结果被下一条ALU指令使用写后读WAR (Write After Read)一条指令写寄存器但它之前有一条指令正在读同一个寄存器乱序执行或流水线深度交错时容易出现写后写WAW (Write After Write)两条指令写同一个寄存器后一条写入会覆盖前一条必须保证最终顺序照理说一个严格顺序发射的处理器只会有RAW依赖——指令按顺序执行前一条写完了后一条才发出WAR和WAW根本不会出现。但现代GPU为了提高吞吐指令在流水线里会有重叠发射一条指令后不会等到它完成就发射下一条于是WAR和WAW就出现了。举个例子指令A执行“add.r.f32 %f0, %f1, %f2”指令B执行“mov.b32 %f1, 0”。如果按顺序执行A先读%f1再算出结果B随后把%f1清零没问题。但如果在流水线里A还没读到%f1的时候B就完成了写入B就把A的输入数据改了这就是WAR。WAW更直接两条指令都写%f0后发的先完成最终寄存器里留下的是早发那条的结果语义就错了。2.2 SIMT环境下的特殊依赖形态除了经典的三类依赖SIMT里还有两种容易忽略的依赖形态跨线程束的显式同步依赖以及内存别名带来的隐式依赖。先说跨线程束。同一个线程束内部线程之间并不能直接访问彼此的寄存器。线程0的寄存器%f0线程1根本看不到。所以线程束内不存在“你的结果给我用”这种横向依赖。但当一个线程束通过共享内存或全局内存交换数据时依赖关系就变成了“你写入共享内存之后我才能读取”。这种依赖不是硬件自动追踪的而是靠__syncthreads()这类屏障指令显式建立的。编译器面对syncthreads会把它当作一条特殊指令前后所有共享内存访问都不能跨越它重排。再说内存别名。寄存器依赖硬件看得一清二楚但内存依赖需要猜测别名。比如一条指令写全局内存地址A另一条指令读全局内存地址B编译器如果不知道A和B是否重叠就只能最保守地假设它们重叠强行保持顺序。这种保守处理会挡住很多指令级并行也是为什么有些手写代码看起来明明没依赖实测却不断stall的原因之一。2.3 硬件流水线为什么不能“跳过”依赖很多人刚接触时会有个疑问反正GPU线程那么多硬件等一条指令的同时可以跑别的线程束为什么还要专门处理依赖答案是依赖处理不是“要不要”的问题而是“怎么判断该等多久”的问题。硬件必须知道哪些指令可以重叠、哪些必须等才能决定调度策略。如果完全不做依赖检查直接把下一条指令发下去遇到RAW依赖时计算结果就是错的。那种“赌它已经完成”的做法在CPU上有叫预测执行但需要专门的回滚机制成本极高。GPU选择了更轻的路线发现依赖就停不猜、不赌。代价是当前线程束停一个周期收益是硬件逻辑简单、面积小、功耗低可以把更多晶体管花在计算单元和寄存器文件上。这个取舍背后是GPU的定位用大规模线程并行来抵消单线程等待而不是像CPU那样用复杂的乱序执行引擎去压榨单线程性能。3. 硬件怎么判读依赖Scoreboard、Stall与延迟隐藏3.1 Scoreboard背后的工作原理NVIDIA GPU从很早就开始使用scoreboard机制来管理寄存器依赖。所谓scoreboard可以理解成一张“寄存器就绪表”硬件为每个线程的每个寄存器维护一个状态位告诉调度器这个寄存器里的数据是否已经有效。当一条指令发射后它要写的目的寄存器会被标记为“未就绪”。这条指令从执行单元写回结果的那一刻scoreboard把对应寄存器标记为“就绪”。调度器每周期检查下一条要发射的指令读它的源寄存器列表只要发现其中任何一个寄存器仍未就绪就判定存在依赖当前线程束停在这个阶段不发射。你可能想问一个warp里的32个线程各自有独立的寄存器scoreboard是查一份还是32份答案是按字节宽度的寄存器文件管但依赖检查必须覆盖所有线程。任何一个线程的源寄存器没就绪整个warp就不能发射。这种“一票否决”的机制让单个线程的延迟会拖累整个线程束所以寄存器访问模式是否规整、指令序列的依赖链长度会直接影响吞吐。3.2 固定延迟与可变延迟两种等待策略依赖等待有两种明显不同的时间尺度固定延迟和可变延迟。固定延迟出现在ALU类指令之间。比如add.f32需要约4个周期mul.f32差不多也是这个量级fma稍微多一点。这类延迟几乎是确定的编译器可以精确算出两条指令之间要隔多少周期才能不冲突。NVIDIA在较新的架构里通过所谓“确定性执行”的方式让编译器知道这些固定的latency尽力安排指令来避免stall。可变延迟则来自访存。一条load指令从全局内存取数延迟取决于数据是否命中L1、L2还是直接落到DRAM。命中L1大概三四十个周期L2可能要两百周期主存更是高达数百周期。这种延迟在运行时才确定编译器没法提前计算只能由硬件scoreboard在数据真正写回后广播信号。所以load-use依赖是SIMT指令流里最需要关注、也最常出现在Profiler里的瓶颈。3.3 一个PTX例子看依赖等待下面用一段PTXNVIDIA的虚拟汇编来说明问题。假设我们读取一个全局数组元素然后累加ld.global.f32 %f1, [%rd1]; // L1: 加载全局内存数据到 %f1 add.f32 %f2, %f0, %f1; // L2: 使用 %f1RAW依赖 st.global.f32 [%rd2], %f2; // L3: 存储结果如果%f1没从内存返回第二条add.f32就必须等待。在这段代码里即使L2和L3之间没有依赖两者也绑在同一条load-use链上。要改善可以拆成多个独立load再一起计算ld.global.f32 %f1, [%rd1]; // 第一个 load ld.global.f32 %f3, [%rd14]; // 第二个独立 load add.f32 %f2, %f0, %f1; // 使用 %f1 add.f32 %f4, %f2, %f3; // 使用 %f3两条load的延迟重叠第二条load和第一条之间没有数据依赖它们可以在同一个窗口内一起发射。等到第一个add需要%f1时时间已经过去了几个周期实际等待时间被摊薄。这就是所谓的“提高指令级并行ILP”。3.4 为什么寄存器重命名在SIMT中没那么吃香CPU的乱序执行核心普遍采用寄存器重命名来消除WAR和WAW但GPU没有大范围采用原因有三。首先是成本。重命名需要物理寄存器文件比架构寄存器大很多还要维护一张映射表。GPU一个SM里同时跑上千个线程每个线程动辄几十个寄存器重映射表的存储和读取开销大到不现实。其次是语义。SIMT为了支持分支掩码和线程级独立状态寄存器文件本来就被切成大量小块重命名这种“偷寄存器名”的玩法和这种布局天然冲突。最后是编译器策略。GPU的编译器本来就是针对自家架构定制的它在静态编译阶段就用寄存器分配和指令调度尽量避免了WAR和WAW。比如两条指令写同一个寄存器编译器会让后一条换个寄存器或者干脆重排顺序让依赖窗口错开。硬件只需要处理剪不掉的RAW依赖设计的校验逻辑就简单很多。这也是理解数据依赖处理的一个关键思路软件能消的依赖绝不让硬件硬扛。GPU把省下来的晶体管都用在了增大吞吐上。4. 编译器在依赖处理中的角色静态调度与指令重排4.1 编译器如何分析依赖从nvcc到最终SASS中间要经过多层依赖分析。PTX层面编译器维护每个寄存器的def-use链——哪个指令定义写入了它哪些后续指令使用了它。这个信息构成数据流图DAG节点是指令边是依赖关系。有了DAG编译器才能决定指令的发射顺序。这个过程看起来像排课表先把所有课程列出来标清谁是谁的先修课然后排出时间槽。性能优化时编译器会尽量把没有先修关系的课排进同一时间槽让多个执行单元都能忙起来。你可以用nvcc -Xptxas -v看到编译后寄存器使用情况但依赖分析的具体输出不直接可见更多是通过SASS指令顺序来体现。实际操作中一个常见的现象是同是两段逻辑写法不同编译器生成的SASS排列顺序完全不同。下面这段代码float sum 0.0f; for (int i 0; i 64; i) { sum data[i]; }编译器会识别出一条很长的累加依赖链因为每次sum data[i]都必须等上一次写回。比较聪明的做法是把循环展开并拆成多个累加器float s0 0.0f, s1 0.0f, s2 0.0f, s3 0.0f; for (int i 0; i 64; i 4) { s0 data[i]; s1 data[i 1]; s2 data[i 2]; s3 data[i 3]; } float sum (s0 s1) (s2 s3);四条独立的累加链互不依赖可以并行推进最后再合并。这就是“打破依赖链”的典型手法。4.2 指令级并行与填充策略编译器的另一个职责是用不相关的指令填满依赖等待的槽位。比如面对一条load-use依赖编译器会在load和use之间插入几条和这条链无关的算术指令让等待时间被有用计算覆盖。填充策略做得好的时候SASS里会看到密集排列的FMA、整数运算、地址计算交错在一起看起来几乎每个周期都在发射指令。做得不好时会出现大片NOP或者连续几条指令都在等同一个寄存器。后者在Nsight Compute的“Avg. Warps Issue Stalled”里会有明显体现。我踩过的一个坑是手动把kernel里所有的pow()调用换成了exp2f(y * log2f(x))以为运算量大了会更慢。结果因为原来的pow()是一个比较长的库函数调用依赖链很长换成两条独立的MUFU指令之后依赖链变短指令数反而减少了最终性能提升了约两成。这说明依赖的长度往往比指令的“数量”更影响性能。4.3 编译器调度的局限性编译器虽然很强但不是万能的。第一个局限是内存别名。前面提到两个指针可能指向同一块内存时编译器默认它们有依赖就不会重排。比如output[i] a[i] * b[i];编译器不知道output会不会和a重叠只能保守地把“写output”排在“读a之后”。你可以在代码里用const __restrict__告诉编译器这些指针不重叠它会立刻放开手脚做更多重排。第二个局限是动态分支。GPU的warp是锁步执行的遇到分支时一个warp只会走其中一个方向另一个方向被掩蔽。编译器遇到分支后会做“分支之后无需为未执行线程负责”的假设但这也意味着分支内部的依赖结构会动态变化静态调度只能按最坏情况来。第三个局限是寄存器压力。编译器为了消除依赖会把很多中间结果放在寄存器里但寄存器总数是有限的。一旦超出编译器只能把中间结果“溢出”到局部内存这反而会引入新的访存依赖。一个典型信号是你手动展开循环之后寄存器占用暴增性能反而下降。这时候要把展开因子调小或者拆分kernel让编译器在“更多ILP”和“不溢出”之间找到平衡点。5. 实操性能剖析中如何定位依赖瓶颈5.1 从Nsight Compute看调度器的等待Nsight Compute是排查依赖问题最顺手的工具。打开一个kernel的分析报告重点看Warp State部分。它会列出调度器因哪些原因没有发射warp等待原因含义常见场景Short Scoreboard等待短延迟指令写回ALU依赖、寄存器RAWLong Scoreboard等待访存指令写回load-use链、全局内存延迟Wait等待固定延迟周期算术指令的latencyBarrier等待同步屏障__syncthreads()、协作组同步Branch Resolving等待分支判定复杂分支条件如果Long Scoreboard占大头说明瓶颈在访存类依赖。如果Short Scoreboard或Wait占大头说明算术指令之间的RAW依赖是主因。排查时先看这两种基本能定位大方向。用一个小规模的测试kernel去跑把block数降到一个让SM里只有一个线程束这样可以看到最纯粹的依赖链行为。在这种配置下调度器没有别的线程束可选任何依赖都直接暴露成stall。跑完之后对比单warp和多warp的吞吐差异就能评估“是延迟问题还是带宽问题”。5.2 排查依赖问题的几条经验路径依赖问题虽多但在实际项目里通常逃不出下面几个模式。第一条load-use链过长。特征是kernel访存密集Profiler显示Long Scoreboard比例很高但DRAM吞吐并没有打满。这种情况不要急着优化指令数先看指令序列里每一条load是不是立刻被使用。如果是尝试在循环开头预取一批数据或者用cp.async做异步拷贝把访存和计算重叠起来。第二条归约类kernel的累加依赖。特征是“每线程一个串行累加循环”随着数据量增大延迟完全暴露。解决办法就是前面提过的多累加器或者改用树形归约。实测中我见过一个double类型的大数组归约改成四路累加后性能提升了1.8倍原因是double的加法延迟比float更长串行链副作用被放大了。第三条分支过多导致的保守依赖。特征是代码里有大量if-else而且分支体内有对同一数组的读写。编译器在分支边界会保守保持顺序。这种情况下优先考虑把热点路径里的分支提出来用三步运算符或查表法替代。不过要注意过早做过度的分支消除会严重降低可读性建议先用Profiler确认分支确实是瓶颈再动手。下面是一张我在项目中常用的问题速查表症状可能原因建议手段高stall且低DRAM利用率load-use链过长未隐藏延迟增加每线程独立load使用异步拷贝高stall且ALU吞吐低串行依赖链多累加指令排队拆多条独立计算链循环展开寄存器溢出过度展开或过度优化减少展开因子限制最大寄存器数低占用率寄存器分配过多用maxrregcount平衡拆分kernelsyncthreads频繁线程间共享数据粒度太小改用warp shuffle增大每线程私有工作5.3 实际调优案例从stall中抠出两倍性能最后分享一个我最近处理的例子。一个模拟计算kernel主体是一个循环每个线程处理一串网格点循环体内有三次连续的共享内存读取然后做一次较复杂的浮点运算。原始版本跑在A100上占用率是满的但Profiler显示Long Scoreboard接近百分之六十。我先在PTX/SASS层面检查发现共享内存load之后的第一条浮点指令立刻消费了load结果中间没有任何填充指令。当时我做了三件事第一把共享内存load从三次分散读取改成一次float4向量读取让一个load喂四条指令第二在load和第一条浮点指令之间插入几次不依赖load结果的整数计算比如下一个索引的计算第三把循环里的累加拆成两个独立变量延迟链减半。改完之后核心里Long Scoreboard占比降到了百分之三十以下整体时间从3.1毫秒降到了1.6毫秒左右。这个案例给我的教训是即便占用率再高依赖造成的调度空隙依然存在只有让每个线程束内部有足够的并行指令才能真正把SM的每个周期填满。6. 一点个人体会写了这么多其实想表达的核心就是一句话SIMT指令流中的数据依赖处理是GPU并行体系里软件和硬件的一次长期配合。硬件用scoreboard守住正确性编译器用静态调度挤出性能而我们做优化的就是在这两者之间找到那个最合适的平衡点。每当你看到Profiler里某个stall指标特别高时先别急着调占用率或者改显存访问花点时间把指令流的依赖链画出来往往能找到更本质的瓶颈。最后再分享一个小技巧拿到一个陌生kernel第一件不是去看Profiler而是用cuobjdump把SASS导出来人工浏览一遍指令序列。如果整个序列里到处都是连续的同类型指令且每条指令之间都能看到清晰的RAW关系那依赖问题基本可以板上钉钉。SASS虽然看起来啰嗦但它是理解GPU行为最直接的一手资料。把这条基本功练好再回头处理任何并行性能问题都会比单纯试参数可靠得多。
返回列表