CHAPTER 34 / 架构、性能与并行

Kernel 索引、边界与 grid-stride loop

启动十二个线程处理十个元素,剩下两个线程该做什么?

阅读与推演约 60 分钟练习时间另计

这一章要弄清楚

  • 从 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/空格操作按钮;图内方向键平移,手机可横向滑动。

正在准备默认算例。下方例题包含完整源码与逐步解释。

第 1 步

先预测,再前进一步

最后一块中,哪些线程必须跳过写入?

输入、边界、状态变化

完整文字推演与当前数据
    静态推演与完整文字(便于对照、打印)
    观察 · 推演

    n=10、block=4 的线程地图

    lane0 1 2 3global i0 1 2 301 / 03 · TILESlane0 1 2 3global i0 1 2 301 / 03 · TILES
    block 0

    四个线程都有效。

    1 / 3
    阅读完整推演文字
    1. block 0

      lane:0 1 2 3;global i:0 1 2 3

      四个线程都有效。

    2. block 1

      lane:0 1 2 3;global i:4 5 6 7

      全局索引加上偏移4。

    3. block 2

      lane:0 1 2 3;有效:8 9;无效:10 11

      同一组lane映射到两类索引:8、9可写;10、11跳过写入。两类之间不存在执行依赖。

    跟着例子,走完一遍

    例题 01C++20 · 本机可运行

    枚举十二个线程覆盖十个元素

    n=10、B=4;并检查n=0..100、B=1..32。

    1. 用除法与余数计算grid。
    2. 枚举block与lane,先算i再检查i<n。
    3. 统计每个有效元素只写一次。
    30-a.cpp
    下载
    #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";
    }

    如何编译和运行下载的 .cpp 文件 →

    结果与解释

    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
    
    例题 02C++20 · 本机可运行

    六个逻辑线程跨步覆盖十七项

    S=6,线程t访问t、t+6、t+12直到越界。

    1. 每个线程的初始下标是t。
    2. 每轮增加全网格线程数S。
    3. 遍历多组n和S,验证所有元素恰好一次。
    30-b.cpp
    下载
    #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和额外线程数是多少?空输入又该怎样处理?

    给我一点提示
    1. 向上取整得到两个block。
    2. 有效与启动总数分开算。
    查看答案与推理

    grid=2,启动512个线程,有效257,多余255。host对n=0按空结果合同直接返回,避免依赖零网格launch行为。

    练习 2

    行主序2×3矩阵误用col=3,线性下标3仍在6元素数组内,为什么结果可能错却不崩溃?

    给我一点提示
    1. 线性存储合法不等于坐标合法。
    2. 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.

    继续查证

    公开资料用于查证;本章图解和例题是独立教学内容。CPU 逻辑模型不能证明设备性能。

    接着看已有的图解

    这些资料按主题补充本章内容。