CHAPTER 34 / 架构、性能与并行
Kernel 索引、边界与 grid-stride loop
启动十二个线程处理十个元素,剩下两个线程该做什么?
这一章要弄清楚
- 从 block 与 thread 坐标推导全局索引
- 正确计算网格规模与空输入
- 证明 grid-stride 访问恰好覆盖输入
先备知识:拥有一组数据:array、vector与string / Host/device、传输与异步生命周期
C++20 / macOS 与 Linux;本章的硬件模型只推演逻辑,不代表设备性能。
启动形状和问题形状不同
GPU kernel 的启动配置描述多少个 block、每个 block 多少线程;问题本身描述有多少有效元素。两者不必完全相等。设 n=10、block 大小 B=4,需要三个 block,共启动十二个线程。全局下标 i=block_id*B+thread_id 会得到 0 到11,只有前十个可访问数组。i=10、11 的线程不能越界写入,它们仍可能需要参与后续 block 内同步,取决于 kernel 的结构。
需要的 block 数是向上取整 n/B。常见 (n+B-1)/B 在 n 接近整数上限时会先溢出;更稳妥的写法是 n/B + (n%B != 0),并拒绝 B=0。n=0 时不必发起无效的零网格 launch,host 可以直接按空输入合同返回。真实启动前还要查询设备的每 block 线程数、各维限制和可用资源,不能把教学配置当作通用硬件上限。
一维索引是一种映射合同
第一个例子用嵌套 CPU 循环枚举 block 和 lane,记录每个元素被访问的次数。它不执行 GPU 指令,也不模拟调度;它验证的是索引映射。对一组 n 和 B,检查每个有效元素访问一次,额外线程只执行边界检查。这样的逻辑模型能在没有设备时发现 off-by-one,却无法证明真实 kernel 无 race 或同步错误。
选择整数类型时不要让乘法在窄类型中发生后才转换。应先保证 block_id、B 与问题大小使用足够宽的类型,或先转换再相乘。字节数 n*sizeof(T) 也需检查溢出。64位 host 上的 size_t 与 device 参数布局、API维度类型的关系,要根据具体工具链核实。
grid-stride 改变每线程的工作量
不一定要一个元素对应一个线程。grid-stride loop 让线程先处理自己的初始 i,然后每次增加全网格线程数 S。若 n=17、S=6,线程0处理0、6、12;线程5处理5、11;其余同理。初始余数决定线程归属,因此每个下标恰好归属于一个线程。网格大小可以按设备需要设置,而循环处理更大输入。
这不意味着网格越小越好。线程太少可能不足以隐藏延迟;每线程工作太多可能增加寄存器压力与关键路径。也不能仅因相邻线程的第一次访问连续,就忽略后续循环的依赖和写入冲突。第二例同样是CPU逻辑覆盖检查,没有提供最优block大小建议。
二维与三维要写明布局
行步长与有效坐标(补充,另估15分钟)
先把逻辑列数与行步长分开:row、col是从0起的行列坐标;行主序把一行的有效列依次存放。这里的leading_dimension或行stride表示相邻两行起点相差多少个元素,不是字节数。行尾可以有padding,所以它可以大于逻辑列数。
给定2行×3列、行步长4,并为两行准备8个元素槽:第一行有效偏移0、1、2,偏移3是行尾填充;第二行从4开始,有效偏移4、5、6,偏移7是填充。于是(row=1,col=2)先通过row小于2、col小于3的检查,再算1×4+2=6。坐标(0,3)虽然线性偏移3落在分配范围里,仍不是合法矩阵元素;不能把“地址在缓冲区内”代替“坐标在shape内”。
空的0行或0列形状没有有效坐标,不计算元素访问。对这份非空行主序合同,要保证行步长足够容纳有效列、存储确实覆盖访问偏移,且乘加不溢出;本例的小整数满足这些前提。这里是布局推演,没有执行设备访存;更完整的tensor与转置布局留在第32章。
对于行主序矩阵,元素 (row,col) 对应 row*leading_dimension+col。启动时通常让 x 坐标对应连续的列,y 对应行,但这是一种约定,不是数学强制。二维边界需要分别检查 row 与 col;只比较线性地址可能让越界列落入下一行的合法内存,从而产生“没有崩溃但结果错位”的错误。
向面试官解释索引时先拿一个非常小的非整除尺寸画格子,标出有效线程与多余线程,再写公式。测试包括0、1、B-1、B、B+1和多维边缘。后续共享内存章节会继续说明:没有有效元素的lane可以把单位元写入共享区并参加barrier,不能一律在函数开头return。
动手改变 · 观察因果
让线程坐标落到有效元素上
最后一块中,哪些线程必须跳过写入?
CPU 索引逻辑模型:检查唯一覆盖和尾部谓词,不启动 kernel。N=0 时不发起设备工作。 对应 例题 30-a;图中的代码行是步骤提示,完整可编译源码见例题。
改输入后从第一步重新推演。Tab 选择控件,Enter/空格操作按钮;图内方向键平移,手机可横向滑动。
先预测,再前进一步
最后一块中,哪些线程必须跳过写入?
输入、边界、状态变化完整文字推演与当前数据
静态推演与完整文字(便于对照、打印)
n=10、block=4 的线程地图
四个线程都有效。
全局索引加上偏移4。
同一组lane映射到两类索引:8、9可写;10、11跳过写入。两类之间不存在执行依赖。
阅读完整推演文字
- block 0
lane:0 1 2 3;global i:0 1 2 3
四个线程都有效。
- block 1
lane:0 1 2 3;global i:4 5 6 7
全局索引加上偏移4。
- block 2
lane:0 1 2 3;有效:8 9;无效:10 11
同一组lane映射到两类索引:8、9可写;10、11跳过写入。两类之间不存在执行依赖。
跟着例子,走完一遍
枚举十二个线程覆盖十个元素
n=10、B=4;并检查n=0..100、B=1..32。
- 用除法与余数计算grid。
- 枚举block与lane,先算i再检查i<n。
- 统计每个有效元素只写一次。
#include <cstddef>
#include <iostream>
#include <stdexcept>
#include <vector>
std::size_t verify(std::size_t n,std::size_t block) {
if(block==0) throw std::invalid_argument("block");
const auto grid=n/block+(n%block!=0?1:0);
std::vector<int> visits(n,0);
for(std::size_t b=0;b<grid;++b) for(std::size_t lane=0;lane<block;++lane) {
const auto i=b*block+lane;if(i<n) ++visits[i];
}
for(int count:visits) if(count!=1) throw std::runtime_error("coverage");
return grid;
}
int main() {
for(std::size_t n=0;n<=100;++n) for(std::size_t b=1;b<=32;++b) {
const auto grid=verify(n,b);
if(grid*b<n || (grid>0 && (grid-1)*b>=n))
throw std::runtime_error("minimal covering grid");
}
bool rejected=false;
try {
const auto unexpected=verify(1,0);
std::cout<<"unexpected zero-block grid="<<unexpected<<'\n';
return 1;
} catch(const std::invalid_argument&){rejected=true;}
if(!rejected || verify(10,4)!=3) throw std::runtime_error("launch");
std::cout<<"grid=3 launched=12 active=10\n";
}grid=3 launched=12 active=10
网格向上取整产生额外线程,边界谓词决定合法访问。
在本机运行这个例子
下载后,在文件所在目录执行。需要支持 C++20 的编译器;POSIX 示例还需要章节说明中的系统条件。
clang++ -std=c++20 -Wall -Wextra -Wpedantic -Werror -pthread 30-a.cpp -o example && ./example预期标准输出:
grid=3 launched=12 active=10
六个逻辑线程跨步覆盖十七项
S=6,线程t访问t、t+6、t+12直到越界。
- 每个线程的初始下标是t。
- 每轮增加全网格线程数S。
- 遍历多组n和S,验证所有元素恰好一次。
#include <cstddef>
#include <iostream>
#include <stdexcept>
#include <vector>
void verify(std::size_t n,std::size_t threads) {
if(threads==0) throw std::invalid_argument("threads");
std::vector<int> visits(n,0);
for(std::size_t t=0;t<threads;++t) for(std::size_t i=t;i<n;) {
++visits[i];if(n-i<=threads) break;i+=threads;
}
for(int count:visits) if(count!=1) throw std::runtime_error("grid stride");
}
int main() {
for(std::size_t n=0;n<=100;++n) for(std::size_t t=1;t<=20;++t) verify(n,t);
verify(17,6);std::cout<<"thread0=0,6,12 coverage=17\n";
}thread0=0,6,12 coverage=17
按模S分组使每个下标有唯一所有者;这里只检查CPU索引逻辑。
在本机运行这个例子
下载后,在文件所在目录执行。需要支持 C++20 的编译器;POSIX 示例还需要章节说明中的系统条件。
clang++ -std=c++20 -Wall -Wextra -Wpedantic -Werror -pthread 30-b.cpp -o example && ./example预期标准输出:
thread0=0,6,12 coverage=17
边界线程一律 return
简单elementwise kernel可以这样做,但block内稍后有barrier时,提前返回可能破坏参与合同。
修正思路:先区分索引有效性与同步参与;无效lane使用单位元或掩码访问,仍按要求经过barrier。
轮到你动手
先写预测或代码,再按需打开提示。完整答案用于对照自己的推理。
练习 1
n=257、B=256,grid和额外线程数是多少?空输入又该怎样处理?
给我一点提示
- 向上取整得到两个block。
- 有效与启动总数分开算。
查看答案与推理
grid=2,启动512个线程,有效257,多余255。host对n=0按空结果合同直接返回,避免依赖零网格launch行为。
练习 2
行主序2×3矩阵误用col=3,线性下标3仍在6元素数组内,为什么结果可能错却不崩溃?
给我一点提示
- 线性存储合法不等于坐标合法。
- row=0,col=3映到下一行开头。
查看答案与推理
必须分别检查row<2、col<3。只检查row*3+col<6会允许非法列覆盖row=1,col=0的位置,造成别名写与错误结果。
把理解说出来
先用中文讲清因果,再用英文回答。问题依据技能主题编写,并非公司内部题库。
block大小与问题长度必须整除吗?
参考回答 / English answer
不必,向上取整启动并对有效元素做边界检查;同步参与另行遵守kernel合同。
The input need not be divisible by the block size. Extra threads must guard accesses while respecting synchronization requirements.(n+B-1)/B有什么整数风险?
参考回答 / English answer
加法可能先溢出且B可能为零。验证B后用商加非零余数标志,并检查API尺寸上限。
The addition can overflow before division. I validate the divisor and use quotient plus a remainder test.S=6时线程2覆盖n=17的哪些元素?
参考回答 / English answer
2、8、14,下一项20已越界。
Thread two visits indices two, eight, and fourteen. The next index is outside the range.grid-stride为什么不会重复覆盖?
参考回答 / English answer
每个下标除以S的余数确定唯一初始线程,商确定循环次数;前提是起点唯一且步长为全网格线程数。
Each index has one remainder modulo the total thread count. That remainder identifies its unique starting thread.CPU索引模型通过之后还缺哪些验证?
参考回答 / English answer
真实工具链编译、launch配置、地址空间、数据移动、并发与同步、数值结果和设备运行检查。
The model validates index arithmetic only. Device compilation, memory access, synchronization, and output correctness remain to be tested.二维kernel只检查线性下标够吗?
参考回答 / English answer
不够,非法列可能映到下一行有效内存;分别约束每一坐标并使用正确leading dimension。
An invalid column may map into a valid address on the next row. I validate each coordinate independently.继续查证
- AMD HIP:C++ language extensions ↗
threadIdx、blockIdx、blockDim、gridDim
- AMD HIP:Programming model ↗
线程组织与kernel启动
公开资料用于查证;本章图解和例题是独立教学内容。CPU 逻辑模型不能证明设备性能。
接着看已有的图解
- GPU 执行层级与 wave 图解 ↗
辅助对照软件线程与硬件执行单位;旧调试题中的最后一段 wave 隐式同步不能作为正确实现,按本书第 31/36 章证明同步。
- HIP 基础:Grid、Block 与 Thread ↗
复习线程索引与 host/device 调用顺序,运行时与驱动的职责以本书第 35 章及修正版 Atlas 为准。
这些资料按主题补充本章内容。