Module 4:为什么需要 GPU,什么是 Shader?
从 CPU/GPU 执行模型、内存层次、coalesced access 到 roofline model,建立写高性能 LLM kernel 的第一张地图。
学习目标
- 解释 CPU 与 GPU 的执行模型差异:低延迟 vs 高吞吐,SIMT / wavefront / occupancy。
- 理解 GPU 内存层次:register、groupshared/LDS、global VRAM、host memory。
- 说明 coalesced memory access 为什么常常比“多做一点算术”更重要。
- 理解 compute shader 的基本概念:thread、threadgroup、dispatch grid。
- 用 arithmetic intensity 与 roofline model 判断一个 kernel 是 memory-bound 还是 compute-bound。
4.1CPU vs GPU:两种不同的并行哲学
CPU 与 GPU 面向不同的执行目标。CPU 依靠少量复杂核心、乱序执行、分支预测和多级缓存降低单个任务的延迟,适合解析、调度、采样和系统调用等控制流较复杂的工作。GPU 使用大量较轻量的执行单元,把同一段程序同时作用于大量数据,以吞吐量换取单线程延迟。LLM 推理中的矩阵乘、归约、attention 和逐元素算子满足这种数据并行形式,因此适合放到 GPU 上执行。
| 维度 | CPU | GPU |
|---|---|---|
| 目标 | 降低单个任务延迟 | 提高大量相似任务的总吞吐 |
| 并行粒度 | 几十个硬件线程 | 成千上万个轻量线程 |
| 控制流 | 复杂分支也能处理 | 分支发散会浪费 lanes |
| 内存访问 | 缓存隐藏 很多随机访问 | 强依赖 coalesced access |
| 适合任务 | 调度、解析、采样、系统逻辑 | matmul、attention、归约、逐元素算子 |
SIMT(Single Instruction, Multiple Threads)指一组线程按同一指令流执行,但每个线程拥有独立的寄存器、索引和谓词。NVIDIA 通常称这组线程为 warp,AMD 常称 wavefront 或 wave;本教程统一称为 wave。分支发散并不会停止程序运行,但同一 wave 内不同分支路径需要分批执行,空闲 lane 会降低有效吞吐。
4.2GPU 内存层次:越近越快,越远越大
GPU kernel 的性能通常受数据移动限制。寄存器、groupshared/LDS、global VRAM 与 host memory 的容量、可见范围和访问延迟不同;正确的设计会把频繁复用的数据放在更近的位置,并尽量减少 CPU 与 GPU 之间的传输。简化的内存层次如下:
| 位置 | 谁能访问 | 典型用途 | 常见错误 |
|---|---|---|---|
| Register | 单个线程 | 临时累加、Q 向量片段 | 用太多导致 occupancy 降低 |
| groupshared / LDS | 同一 threadgroup | 归约、tile 缓存、共享 activation | 忘记 barrier 或 bank conflict |
| Global VRAM | 所有 GPU 线程 | 权重、KV cache、activation buffer | 非 coalesced 访问 |
| Host memory | CPU | 加载权重、最终 token id | 每 token 读回大量 logits |
4.3Coalesced memory access:GEMV 性能的生命线
显存访问以 cache line 或 memory transaction 为单位完成,而不是按单个线程的单个 float 独立计费。同一 wave 中相邻线程读取连续地址时,硬件可把访问合并为少量事务,这称为 coalesced access。若相邻线程访问相距很远的地址,同样数量的标量读取会拆成更多事务,实际带宽下降。
4.4什么是 compute shader?
Compute shader 是由 CPU 录入命令、在 GPU 上执行的程序。CPU 侧提交一次 dispatch,指定 threadgroup 的三维数量;shader 代码中的 [numthreads] 指定每个 group 内的线程数量。每个线程通过系统语义取得全局或组内编号,并据此选择要处理的数据元素。
// HLSL: 每个 thread 计算 out[i] = in[i] * 2 + 1
StructuredBuffer<float> In : register(t0);
RWStructuredBuffer<float> Out : register(u0);
cbuffer Params : register(b0) { uint count; };
[numthreads(64, 1, 1)]
void main(uint3 tid : SV_DispatchThreadID) {
uint i = tid.x;
if (i < count) {
Out[i] = In[i] * 2.0f + 1.0f;
}
}
这里 [numthreads(64,1,1)] 表示一个 threadgroup 有 64 个线程。CPU 侧如果 dispatch
个 groups,那么最多会启动 个线程。通常:
4.5Arithmetic intensity 与 roofline model
判断 kernel 瓶颈时,可先计算 arithmetic intensity(算术强度):
低表示每读取或写入一个 byte 只完成少量计算,kernel 往往受内存带宽限制,即 memory-bound。 足够高时,kernel 才可能接近计算单元的峰值吞吐。
roofline model 可以写成:
例子:decode GEMV 为什么常常 memory-bound?
一个线性层 ,其中 ,。 对每个输出元素,需要 次乘加,约 FLOPs;同时至少要读取 个权重。 如果权重是 f16,则权重读取约 bytes。因此单个输出的算术强度粗略为:
这个强度很低。decode GEMV 中每个权重通常只参与一次乘加,性能主要取决于权重读取带宽,而不是乘法器数量。f16 weights、coalesced loads、量化和 KV cache 的意义都可从这一点理解:它们减少传输量或改善访问效率。
小结
本章建立了后续编写 HLSL kernel 所需的性能模型。CPU 负责低延迟控制流,GPU 负责大量同构数据并行;GPU 线程按 SIMT/wave 执行,分支发散和低 occupancy 都会降低吞吐。内存层次决定了许多 LLM kernel 的上限,尤其是 decode 阶段的 GEMV:linear_f16.hlsl 用 64 个线程组成一个 threadgroup 计算一个输 出元素,线程沿 维做连续的 f16 读取,并在 groupshared memory 中完成树形归约。D3D12 单轴最多 65535 个 group;LM head 的输出维度超过该限制时,xinfer 使用二维 dispatch grid,并在 shader 中用 idx = gid.y * grid_x + gid.x 还原线性输出索引。
Lab 4纸上估算 matmul 带宽
本 lab 不要求编写代码,目标是用数量级估算判断性能瓶颈。
- 设 LM head 是 ,decode 时 。估算一次 forward 的 FLOPs。
- 若权重为 f32,估算至少要读取多少 MB 权重;若为 f16 呢?
- 用 估算 f32 与 f16 的 arithmetic intensity。
- 假设 GPU 有 800 GB/s 实际有效带宽, 估算这个 GEMV 的理论下限耗时。
- 解释为什么 GPU argmax 可以显著提升 decode:它减少的是计算、显存访问,还是 CPU/GPU readback?
你不需要得到完全精确的数值。系统性能分析的第一步是判断数量级:瓶颈在计算、显存带宽、同步,还是跨设备传输。
思考与练习
基础用一句话解释 thread 与 threadgroup 的区别。
thread 是执行 kernel 的最小单元(一份独立的索引与寄存器);threadgroup 是一组一起调度、可共享 groupshared(LDS)内存并通过 barrier 同步的线程集合。
基础为什么 GPU 上相邻线程读取相邻地址通常更快?
因为内存事务是按 cache line / 对齐的内存段为单位完成的。当 warp/wave 内相邻线程访问连续地址时,硬件可以把它们合并(coalesce)成少数几次宽内存事务;否则每个线程触发各自的事务,带宽利用率骤降。这正是 kernel 设计要让 W 读取连续的原因。
进阶解释为什么 decode GEMV 的 arithmetic intensity 远低于大 batch GEMM。
arithmetic intensity = FLOPs / bytes。decode 时 , 要读入整个 权重却只做 次乘加(每个权重只用一次),强度约 ,纯带宽受限。大 batch GEMM 中每个权重被 行复用,FLOPs 增长 倍而权重读取不变,强度约 ,可进入计算受限区。这就是 decode 慢、需要 f16/coalescing 优化的根因。
进阶如果一个 kernel 需要 threadgroup 内部所有线程先写 LDS 再读 LDS,为什么必须使用 barrier?
因为 threadgroup 内的线程并非严格同步执行(不同 wave 进度不同)。没有 barrier 时,某线程可能在别的线程还没写入它需要的 LDS 槽位前就去读,读到旧值/未定义值,产生竞态。GroupMemoryBarrierWithGroupSync() 保证“所有写已完成且所有线程都到达此点”后再继续读 。
挑战为 设计一个 threadgroup 布局,使得权重读取 coalesced,并说明每个线程负责哪些 维元素。
布局:一个 threadgroup 负责一个输出元素 。以 linear_f16.hlsl 为例,TG=64,线程 处理 f16 pair 索引 ,即沿 维跨步累加 。同一轮中相邻线程读取相邻权重 pair,因此 W 读取可 coalesce。每个线程得到 partial sum 后写入 groupshared float partials[64],再用 GroupMemoryBarrierWithGroupSync() 做树形归约,由 t==0 写出最终 dot product。二维 dispatch grid 只改变输出索引的生成方式,不改变组内归约结构。