Lecture 3: 并行模式一 —— 向量加法:线程映射与内存访问 (对应 Lab 1: Vector Addition)

目录 · ← l2 · l4 →

Lecture 3: 并行模式一 —— 向量加法:线程映射与内存访问 (对应 Lab 1: Vector Addition)

概述

本讲用一个”每元素只做一次加法”的向量加法 kernel(C[i] = A[i] + B[i]),把 CUDA 的三件事一次性讲透:线程如何映射到数据i = blockIdx.x * blockDim.x + threadIdx.x)、数组元素如何落到内存事务上(合并访存 coalescing)、以及硬件如何用大量并发 warp 隐藏几百个时钟周期的全局内存延迟(占用率 occupancy 与延迟容忍 latency tolerance)。结论先行:向量加法在 A100 上只有 0.125 FLOP/Byte 的算术强度,远低于 12.54 FLOP/Byte 的机器平衡点,是一个纯粹的显存带宽受限(memory bandwidth bound)问题——你唯一能做的就是让每一个字节都被用满,然后让足够多的访存同时在路上。这也是 ECE408 第一个 Lab(Lab 1)的全部主题:正确的线程映射 + 正确的边界检查 + 正确的带宽测量。

核心概念与 GPU 架构图解

概念 1:数据并行与线程到数据的映射(Data Parallelism and Thread-to-Data Mapping)

  • 定义与目的:数据并行(data parallelism)指的是”同一段程序作用在一大批互不相关的数据上”,每个数据元素的计算完全不依赖其它元素的计算结果。向量加法就是最纯粹的数据并行:C[0] 的值与 C[1] 的值毫无关系。CUDA 的做法是把这个”for 循环”拆开:让硬件去决定循环的哪一次迭代在哪个物理单元上跑,程序员只负责给出”元素编号 → 线程编号”的映射公式。这个映射公式就是 i = blockIdx.x * blockDim.x + threadIdx.x。它解决的核心问题是:GPU 上同时有几十万个线程,必须有一种廉价(几条整数指令)、唯一、且与内存布局对齐的方式给每个线程分配一份不重复的工作。

  • 直观解释(”它是什么?”):想象一场有 10,000,000 道题的社会化阅卷,阅卷老师有 39,063 个小组,每组 256 人。要给每位老师发一个唯一的工号,最快的办法不是让校长挨个点名,而是”组号 × 每组人数 + 组内座号“:第 7 组的第 3 号老师,工号就是 7 * 256 + 3 = 1795。CUDA 把这件事固化成了硬件:blockIdx.x 是组号(硬件寄存器直接给出,不需要计算),blockDim.x 是每组人数(一个编译期/运行期常量),threadIdx.x 是组内座号(硬件给出)。三个值一读、一乘、一加,工号就有了。接下来只需要规定”工号 = 题目号”,映射就完成了。

  • 架构/机制图解

   CUDA 的三层逻辑结构(以 N = 10,000,000、block 大小 256 为例)

   Grid(整个问题)
   ┌──────────────────────────────────────────────────────────────────────┐
   │  dimGrid = (39063, 1, 1)  ->  共 39063 个 thread block               │
   │  ┌───────────┬───────────┬───────────┬───────────┬─────────────────┐ │
   │  │  blk 0    │  blk 1    │  blk 2    │  blk 3    │  blk 39062      │ │
   │  └───────────┴───────────┴───────────┴───────────┴─────────────────┘ │
   └──────────────────────────────────────────────────────────────────────┘
                     每个 block: dimBlock = (256, 1, 1)
   ┌──────────────────────────────────────────────────────────────────────┐
   │  threadIdx.x:  0    1    2    3    4    5    6    7                   │
   │                8    9   10   11   12   13   14   15                   │
   │              128  129  130  131  132  133  134  135                   │
   │              248  249  250  251  252  253  254  255                   │
   │              (完整取值是 0 到 255 的每一个整数)                     │
   │                                                                      │
   │  warp 划分  : warp0 = t0 至 t31   | warp1 = t32 至 t63               │
   │               warp2 = t64 至 t95  | warp3 = t96 至 t127              │
   │               warp4 = t128 至 t159 | warp5 = t160 至 t191            │
   │               warp6 = t192 至 t223 | warp7 = t224 至 t255            │
   │               共 8 个 warp,每个 warp 32 个线程                      │
   └──────────────────────────────────────────────────────────────────────┘

   线性化编号公式("组号 × 每组人数 + 组内座号"):

        i = blockIdx.x * blockDim.x + threadIdx.x
            └────┬────┘   └────┬────┘   └────┬────┘
              组号 b        每组人数 B     组内座号 t
            (0..39062)       (=256)        (0..255)

   block 0 : t=0 -> i=0      t=1 -> i=1      t=255 -> i=255
   block 1 : t=0 -> i=256    t=1 -> i=257    t=255 -> i=511
   block 2 : t=0 -> i=512    t=1 -> i=513    t=255 -> i=767
   block k : t=0 -> i=256k   t=j -> i=256k+j
   为什么必须做边界检查:N = 10,000,000 不是 256 的整数倍

   ceil(N / 256) = ceil(39062.5) = 39063 个 block
   实际启动线程数 = 39063 * 256 = 10,000,128 个
   真正需要的线程数 = 10,000,000 个
   多余的线程数    =        128 个(最后 4 个完整 warp 无事可做)

   元素索引轴:
   0                    9,999,744   9,999,872  10,000,000  10,000,128
   |------------------------|-----------|-----------|-----------|
       全部有效(10,000,000 个元素)       ↑            ↑
                                     最后一个满 warp   越界区!
                                     (32 个元素全有效)  若不加 if(i<N)
                                                        就会写坏别人的内存
   block 39062 覆盖 i = 9,999,872 .. 10,000,127
     ├─ threadIdx.x 0 .. 127  -> i = 9,999,872 .. 9,999,999  (有效)
     └─ threadIdx.x 128 .. 255 -> i = 10,000,000 .. 10,000,127(越界!)

关键操作与性能特征:

  1. 网格大小必须向上取整dimGrid.x = (N + blockDim.x - 1) / blockDim.x。整数除法 N / blockDim.x向下取整,当 N % 256 != 0 时会漏掉最后 N % 256 个元素(本讲 N=10,000,000 时会漏掉 128 个,正确性测试直接 FAIL)。
  2. 上取整的代价是多余的线程:39063 个 block 里有 128 个线程完全空转,占比 128 / 10,000,128 = 0.0013%,可忽略。但同样的代码在 N = 1025、block = 1024 时,启动 2 个 block 共 2048 个线程,只有 1025 个有效,浪费 49.9%。小问题用大 block 是浪费的重灾区。
  3. 越界写是最危险的 bug:CUDA 的 cudaMalloc 返回的是一整块设备内存,越界写会静默破坏相邻分配(可能是别的数组、可能是 CUDA 内部结构),常常表现为”跑一次对、跑十次错”或”验证时正确、之后崩”。if (i < N) 不是可选项,是必需品。
  4. 上取整请用整数运算(N + B - 1) / B 对非负整数永远正确;ceil(N / 256.0) 也能用,但它引入浮点、且当 N 超过 2^53 时会失准。Lab 中强烈建议用整数形式,同时注意 N + B - 1 在 N 接近 INT_MAX 时会溢出,安全写法是 (int)(((long long)N + B - 1) / B)

概念 2:合并访存(Coalesced Memory Access)

  • 定义与目的:合并访存指的是”同一个 warp 内的 32 个线程,访问同一段连续且对齐的内存区域“,使得这 32 次各自 4 字节的请求能被硬件合并成一次 128 字节的内存事务(memory transaction)。它解决的问题是:显存(DRAM)的物理特性决定了它的最小开销单位是”连续的一整块”,而不是”任意一个字节”。如果你的访问模式是散的,硬件仍然要搬回 128 字节(或至少 32 字节的 sector),但其中只有很小一部分被用上,剩下的带宽全部浪费掉。

  • 直观解释(”它是什么?”):把显存想成一列固定长度(128 字节)的货运车厢。合并访存就是”32 个人凑钱买一节车厢,每个人的包裹都放进这节车厢的固定格子里”——付一次运费,所有人的货都运到了。非合并访存(strided)则是”32 个人各自包了一整节车厢,却每人只放了一个 4 字节的小盒子”——运费付了 32 份,实际只用了 1 份的货量。货运公司(DRAM 控制器)不会因为你只用了一个格子就少收钱,因为一趟车的开销(激活行、突发传输 burst)是固定的。课程讲义(Lecture 7 DRAM)里那个 512 字节 burst、240 GB/s 峰值的例子说得很清楚:如果只用到 burst 里每隔 8 字节的一小部分,可得带宽立刻减半到 120 GB/s。

  • 架构/机制图解

   合并访存(coalesced):一个 warp 的 32 个线程访问 32 个连续的 float
   (float 为 4 字节,cudaMalloc 返回的基地址按 256 字节对齐)

   线程号:   t0  t1  t2  t3  t4  t5  t6  t7  t8  t9 t10 t11 t12 t13 t14 t15
   元素号:    0   1   2   3   4   5   6   7   8   9  10  11  12  13  14  15
   字节偏移:  0   4   8  12  16  20  24  28  32  36  40  44  48  52  56  60
   线程号:  t16 t17 t18 t19 t20 t21 t22 t23 t24 t25 t26 t27 t28 t29 t30 t31
   元素号:   16  17  18  19  20  21  22  23  24  25  26  27  28  29  30  31
   字节偏移: 64  68  72  76  80  84  88  92  96 100 104 108 112 116 120 124

   落入的 128 字节地址窗口(一次 memory transaction = 一条 128 B cache line)
   字节 0                                                              127
        ┌───────────────────────────────────────────────────────────────┐
        │  32 个 float 全部被使用(128 字节,利用率 100%)              │
        └───────────────────────────────────────────────────────────────┘
        │<----------------- 1 次 transaction (128 B) ----------------->│
        └─ sector 0 (32B) ─┴─ sector 1 (32B) ─┴─ sector 2 ─┴─ sector 3 ─┘

   结果:一个 warp 读 A 只用 1 次事务;读 B 1 次;写 C 1 次
         -> 每个 warp 3 次 128 字节事务,搬运 384 字节,全部是有效数据
   跨步访存(strided):线程号相差 1,地址相差 S = 156,252 字节
   (对应代码 i = threadIdx.x * gridDim.x + blockIdx.x,即为"按列遍历")

   线程号:   t0      t1      t2      t3    t4      t5     t30      t31
   地址:     b     b+S     b+2S    b+3S  b+4S    b+5S   b+30S    b+31S
             │       │       │       │     │       │       │        │
             ▼       ▼       ▼       ▼     ▼       ▼       ▼        ▼
          ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐┌─────┐┌─────┐ ┌─────┐  ┌─────┐
          │128 B│ │128 B│ │128 B│ │128 B││128 B││128 B│ │128 B│  │128 B│
          │line │ │line │ │line │ │line ││line ││line │ │line │  │line │
          └─────┘ └─────┘ └─────┘ └─────┘└─────┘└─────┘ └─────┘  └─────┘
             每个 128 字节的 line 里只用了 4 个字节(利用率 3.125%)
             (32 个线程各占一个 line;上图列出 t0 至 t5 与 t30、t31)

   事务数:32 次 transaction(line 粒度)= 32 x 128 B = 4096 B 只用到 128 B
   现代 GPU 按 32 字节 sector 粒度取数,则 32 个 sector x 32 B = 1024 B
           只用到 128 B,放大倍数 = 1024 / 128 = 8 倍
   事务数 / 有效字节数 对照表(一个 warp,32 个线程,各取 1 个 float)

   访问模式                        事务数      搬运字节    有效字节   利用率
   ─────────────────────────────────────────────────────────────────────────
   连续、128 B 对齐(合并)            1          128         128     100.0%
   连续、但只跨 128 B 边界一点         2          256         128      50.0%
   步长 2 个 float(8 字节)           2          256         128      50.0%
   步长 8 个 float(32 字节)          8         1024         128      12.5%
   步长 32 个 float(128 字节)       32         4096         128       3.1%
   ─────────────────────────────────────────────────────────────────────────
   注:sector 粒度的硬件(Kepler 及以后)会把上表后三行的搬运字节分别
       降到 256 / 256 / 1024 字节,但"放大 2 倍 / 8 倍"的结论不变。

关键操作与性能特征:

  1. 128 字节是一次事务的粒度(对应一条 cache line),正好等于 32 线程 × 4 字节。这就是为什么 warp 大小 32 与 float 的宽度 4 字节如此契合:一个 warp 读一组连续 float,天然就是一整条 line。
  2. 对齐(alignment)和连续(contiguity)同样重要。如果数组起始地址不是 128 字节对齐,一个 warp 的 32 个 float 会跨两条 line,事务数从 1 变成 2,带宽利用率立刻掉到 50%。cudaMalloc 返回的指针至少 256 字节对齐,所以只要线性化编号公式里 threadIdx.x 是最内层(变化最快)的那一维,合并就是自动的——这正是讲义里那道思考题(B[ty*Width+tx] 优于 B[tx*Width+ty])的答案。
  3. GPU 没有”每个线程各自缓存”的写缓冲,写操作同样按 warp 合并。写 C 时连续线程写连续地址 → 1 次事务;如果跨步写,写合并失败,代价同样是数倍放大的写带宽。
  4. L1/L2 会掩盖一部分不合并访问:L2 line 大小 128 字节、L1 sector 32 字节。若同一 warp 的另一次相邻加载能命中已被拉进 L1/L2 的 sector,放大倍数会降低。但不要指望缓存救场——向量加法每个字节只被读一次,没有任何时间局部性(temporal locality),缓存在这里唯一的作用就是 coalescing buffer。

概念 3:全局内存延迟与带宽(Global Memory Latency and Bandwidth)

  • 定义与目的:全局内存(global memory,即设备 DRAM)是 GPU 上容量最大(数十 GB)、速度最慢的一级存储。它有两个正交的性能指标:延迟(latency)——一次访问从发出到数据可用需要多久,约 400–800 个时钟周期;带宽(bandwidth)——单位时间内能搬运多少字节,A100 约 1555 GB/s(峰值)。区分这两者极其重要:延迟决定了”你需要多少并发才能不挨饿”,带宽决定了”每秒最多能处理多少数据”。向量加法是典型的带宽上限问题:它需要搬运的字节数远超它需要的计算量,因此性能天花板就是显存带宽。

  • 直观解释(”它是什么?”):把显存想成城郊的大仓库,SM 想成市中心的工厂。从下单到货到(延迟)要开 500 个路口的车程(~400–800 cycles),但高速路的车道数(带宽)很宽。工厂的活是”把 A 箱和 B 箱的东西倒在一起”(一次加法),工序本身快得可以忽略。所以决定产量的是高速路每秒能过多少车,而不是工厂的手速。工厂的对策不是”催司机开快点”(延迟改不了,它是物理距离),而是一次发出几百辆卡车(用大量并发 warp 把延迟”藏起来”),让高速路时刻是满的。

  • 架构/机制图解

   现代 NVIDIA GPU 的存储层次(数字为 A100 / sm_80 量级)

   访问延迟 ↑                                                   容量 ↑
   ┌──────────────────────────────────────────────────────────────────────┐
   │ 寄存器 Register File   每 SM 65536 个 32 位寄存器(256 KB)          │
   │   延迟 ~1 cycle        带宽:每 SM 每 cycle 数百字节                 │
   ├──────────────────────────────────────────────────────────────────────┤
   │ 共享内存 Shared Memory / L1   每 SM 最多 164 KB(可配置划分)        │
   │   共享内存延迟 ~20-30 cycles,L1 命中 ~30 cycles                     │
   ├──────────────────────────────────────────────────────────────────────┤
   │ L2 Cache   全芯片共享(A100 约 40 MB,按 2 个分区各 20 MB)          │
   │   延迟 ~200 cycles                                                   │
   ├──────────────────────────────────────────────────────────────────────┤
   │ 全局内存 Global Memory (HBM2)  40 GB / 80 GB                         │
   │   延迟 ~400-800 cycles(1.41 GHz 下约 284-567 ns)                   │
   │   带宽 A100 约 1555 GB/s 峰值,实测流式 kernel 约 1200-1400 GB/s     │
   └──────────────────────────────────────────────────────────────────────┘

   换算:1555 GB/s 的显存带宽,配上 500 cycles / 1.41 GHz = 355 ns 的延迟,
        要让带宽打满,全芯片必须同时有(Little's Law):
          在途字节 = 带宽 x 延迟 = 1250e9 B/s x 355e-9 s ≈ 443,750 B ≈ 434 KB
        分摊到 108 个 SM:每 SM 需要约 4.1 KB(约 32 条 128 B cache line)在途
   向量加法的时间分解(N = 10,000,000,A100)

   计算侧(几乎不花时间):
     总 FLOP 数        = 10,000,000 次加法 = 1.0e7 FLOP
     FP32 峰值         = 19.5 TFLOPS
     纯计算下限        = 1.0e7 / 19.5e12 = 0.51 us

   存储侧(决定一切):
     读 A = 40 MB,读 B = 40 MB,写 C = 40 MB,合计 120 MB
     峰值带宽下限      = 120e6 B / 1555e9 B/s = 77.2 us
     实测(1.25 TB/s) = 120e6 B / 1250e9 B/s = 96.0 us

     96.0 us / 0.51 us ≈ 188 倍  ->  计算单元 99.5% 的时间在空转

关键操作与性能特征:

  1. DRAM 的物理约束:DRAM 一位只用一个晶体管加一个电容,密度极高但很慢;每个 bit line 上挂约 1000 个单元,靠 sense amplifier 放大才能读出。现代 DRAM 强制以 burst 模式工作:一次行激活(row activate)之后连续吐出整块数据,即使你只要其中几个字节。这就是”访问连续地址才高效”的物理根源。
  2. 延迟无法通过优化代码降低,只能通过并发隐藏。提高并发有两条路:更多 warp(提高占用率)、每线程更多在途访存(thread coarsening / ILP)。本讲的概念 5 与概念 6 分别对应这两条路。
  3. 带宽是可以逼近但很难打满的:A100 上纯流式 kernel 通常能到峰值的 80%–92%。剩下 8%–20% 消耗在刷新(refresh)、读写转向(bus turnaround)、行激活开销和 ECC 上。所以”比峰值”这个指标应该对着实测可达带宽(约 1.3 TB/s)而不是理论峰值 1555 GB/s 来评估。
  4. 端到端时间常常被 PCIe 传输吃掉:40 MB 通过 PCIe Gen4 x16 传输(实测约 25 GB/s)要 ~1.6 ms,三个数组(A 上行、B 上行、C 下行)合计约 4.8 ms,是 96 µs kernel 时间的 50 倍。所以 Lab 的性能测量必须只用 cudaEvent 圈住 kernel,把 cudaMemcpy 排除在外。

概念 4:算术强度(Arithmetic Intensity)与机器平衡点

  • 定义与目的:算术强度(arithmetic intensity,AI)定义为

    AI = 浮点运算次数 (FLOP) / 需要从显存搬运的字节数 (Byte)
    

    它刻画一段代码”每搬一个字节能算多少活”。机器平衡点(machine balance)定义为一个硬件”用光所有带宽正好也用光所有算力”的那个 AI 值:AI_balance = 峰值 FLOPS / 峰值带宽。当 AI < AI_balance 时,程序是带宽受限的(算力过剩);当 AI > AI_balance 时,程序是计算受限的。这个判据把”该优化访存还是该优化计算”这个模糊问题变成了一个不等式,是 Roofline 模型的核心。

  • 直观解释(”它是什么?”):把 GPU 想成一家原料运输成本极高的工厂。机器平衡点就是”每运进来一吨原料,工厂的机器正好能全部忙满”的那个原料/加工比例。向量加法是”运进来两吨原料(A、B),只做一道工序(一次加法),再运出去一吨成品(C)”——典型的”拉货比干活累”。你就算把机器换成快 10 倍的,产量也只涨 0%(因为瓶颈在路上)。反过来,矩阵乘法(Lab 2)每搬 8 字节能做 2 FLOP 且通过分块复用把访存降低 TILE_WIDTH 倍,所以它有希望变成计算受限——这正是为什么课程要用共享内存去优化它。

  • 架构/机制图解

   Roofline 模型(A100,FP32,对数坐标)

   可达性能
   (GFLOP/s)
      ^
   可达性能 GFLOP/s(双对数刻度,示意图)

   ^
1e4 |                                              ________________________
    |                                        _____/   计算天花板 = 19.5 TFLOPS
1e3 |                                  _____/
    |                            _____/
1e2 |★_____________________/              <-- 斜线斜率 = 显存带宽 1555 GB/s
1e1 |/
1e0 +----|------------------------|---------------------------|----------> AI
        0.0833                   12.54                       1000
        (0.125 同量级)        机器平衡点(两线交点)      (FLOP/Byte)
           ^
     ★ 处高度 = 0.0833 x 1555 GB/s = 129.6 GFLOP/s
     与计算天花板 19500 GFLOP/s 相差 150 倍  ->  纯带宽受限
      |     ^                                            ^
      |     |                                            |
      |  向量加法落在这里                    机器平衡点 = 19500/1555
      |  AI = 0.125 FLOP/Byte                  = 12.54 FLOP/Byte
      |  离平衡点差 100 倍                      两条线在这里相交
      |
    0 +-----|---------|---------------------------------|---------------> AI
            0.0833   0.125      1        10     12.54        100
                    ^                                  (FLOP/Byte)
              向量加法(只算读写)
              0.125 = 1 FLOP / 8 Byte
              (若把写 C 也算进去:1/12 = 0.0833)
   三项定量计算

   (1) 向量加法的算术强度
       每元素:1 次 FP32 加法  = 1 FLOP
               读 A 4 B + 读 B 4 B = 8 B(把写 C 也算进去 = 12 B)
       AI = 1 / 8  = 0.125 FLOP/Byte          (课程惯用口径)
       AI = 1 / 12 = 0.0833 FLOP/Byte         (含写带宽的严格口径)

   (2) A100 的机器平衡点
       AI_balance = 19.5e12 FLOP/s / 1555e9 B/s = 12.54 FLOP/Byte
       对比:RTX 4090 = 82.6e12 / 1008e9 = 81.9 FLOP/Byte
             H100 SXM = 67e12 / 3350e9   = 20.0 FLOP/Byte

   (3) 结论(Roofline 天花板)
       可达性能 = min(峰值 FLOPS, AI x 带宽)
                = min(19.5e12, 0.0833 x 1555e9)
                = min(19.5e12, 1.296e11) = 129.6 GFLOP/s
       即向量加法最多只能跑到 129.6 / 19500 = 0.66% 的 FP32 峰值算力。
       用 0.125 的口径算:0.125 x 1555e9 = 194.4 GFLOP/s = 1.0% 峰值。

       0.125 与 12.54 相差 100.3 倍  ->  100% 带宽受限,毫无悬念。
       想让它在 A100 上变成计算受限,需要 AI >= 12.54,
       即每元素至少要做 12.54 x 12 ≈ 150 次浮点运算。

关键操作与性能特征:

  1. 算术强度是 kernel 的固有属性,与实现的快慢无关。同一个向量加法,写得再烂 AI 还是 0.125;只有改变算法(例如把两个向量合并成一次 float4 向量化读写,AI 不变但事务效率提高)或增加数据复用(例如矩阵乘法分块)才能改变 AI。
  2. Roofline 给出的是上限,不是预测。它告诉你”天花板在哪”,不告诉你”离天花板多远”。实测 1.25 TB/s 对应 1.25e12 x 0.0833 = 104 GFLOP/s,达到 Roofline 天花板的 80.4%——这正是我们期望的量级。
  3. 判断瓶颈类型的三步法:算 AI → 算机器平衡点 → 比大小。AI 远小于平衡点 = 带宽受限(优化方向:减少字节数、保证合并、提高在途访存);AI 远大于平衡点 = 计算受限(优化方向:减少指令数、用 FMA、提高 ILP);两者接近 = 需要同时优化,或者说明代码离两者都很远,那是延迟受限(优化方向:提高占用率/ILP 来隐藏延迟)。
  4. 向量加法的 AI 低到无法抢救:即使实现完美,它的时间下限就是 120 MB / 1555 GB/s = 77.2 µs。任何超过这个数的时间都是”没打满带宽”的浪费,而不是”算得太慢”。

概念 5:占用率(Occupancy)

  • 定义与目的:占用率(occupancy)定义为

    occupancy = 每个 SM 上实际驻留的活跃 warp 数 / 每个 SM 硬件支持的最大 warp 数
    

    它是”硬件有多少并发可以用来隐藏内存延迟”的度量。GPU 不像 CPU 那样靠乱序执行和深层 cache 隐藏延迟,它靠的是线程级并行(TLP):一个 warp 发出一条访存指令后需要等 400–800 cycles,调度器立刻切到另一个就绪 warp 上执行。驻留的 warp 越多,越有可能在任何一个周期都找到”操作数已就绪”的 warp。占用率会影响能藏住多少延迟,因此直接影响带宽受限 kernel 能否打满带宽。

  • 直观解释(”它是什么?”):把 SM 想成一个有 64 条电话线的小呼叫中心(64 = A100 每 SM 最大 warp 数),打一个电话出去(发起一次全局内存访问)平均要等 5 分钟才有人接(400–800 cycles)。如果只雇 8 个话务员(8 个 warp),他们全部在等电话,办公室几乎全空。雇满 64 个(100% 占用率),任何时刻都有人正在通话,吞吐就上去了。但注意:话务员再多也不能让单个电话变快(延迟是物理的),只能让总吞吐变高。而且一旦电话线路(带宽)占满了,再雇人也没用——这就是为什么向量加法在 100% 占用率下也就跑到带宽上限。

  • 架构/机制图解

   一个 SM 的资源账本(A100 / sm_80)—— 占用率由"最紧的那个资源"决定

   ┌──────────────────────────────────────────────────────────────────────┐
   │                          SM (Streaming Multiprocessor)               │
   │                                                                      │
   │  ┌────────────────────────────────────────────────────────────────┐  │
   │  │ 4 个 warp scheduler(每个每 cycle 可发射 1 条指令)            │  │
   │  │   WS0 ──> 32 个 FP32 核心 + LD/ST + SFU                        │  │
   │  │   WS1 ──> 32 个 FP32 核心 + LD/ST + SFU                        │  │
   │  │   WS2 ──> 32 个 FP32 核心 + LD/ST + SFU                        │  │
   │  │   WS3 ──> 32 个 FP32 核心 + LD/ST + SFU                        │  │
   │  └────────────────────────────────────────────────────────────────┘  │
   │                                                                      │
   │  寄存器文件 Register File : 65536 个 32-bit 寄存器 = 256 KB          │
   │  共享内存 / L1           : 164 KB(可划分,最多 100 KB 给 shared)   │
   │  线程上限                : 2048 threads = 64 warps                   │
   │  block 上限              : 32 blocks / SM                            │
   │                                                                      │
   │  驻留的 warp(以 256 线程的 block 为例,每 block 切成 8 个 warp):   │
   │  ┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬─────────┐ │
   │  │ blk0 │ blk0 │ blk1 │ blk1 │ blk2 │ blk2 │ blk3 │ blk3 │ blk4 至 │ │
   │  │warp0 │warp1 │warp2 │warp3 │warp4 │warp5 │warp6 │warp7 │ blk7 共 │ │
   │  │      │      │      │      │      │      │      │      │ 32 warp │ │
   │  └──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴─────────┘ │
   │  合计 8 个 block x 8 warp = 64 warp = 2048 线程(占满 SM 上限)      │
   └──────────────────────────────────────────────────────────────────────┘

   占用率的三个限制因素(取最小者):
     ① 线程/warp 上限: blocks_by_threads = floor(2048 / blockDim)
     ② 寄存器上限    : blocks_by_regs    = floor(65536 / (regs_per_thread * blockDim))
     ③ block 数上限  : blocks_by_blocklimit = 32
     resident_blocks = min(①, ②, ③)
     occupancy = resident_blocks * blockDim / 2048
   A100 上不同 block 大小的占用率(vecAddKernel,实测约 16 个寄存器/线程)

   blockDim  寄存器限制   线程限制   block 限制   驻留 block  驻留线程  warp  占用率
   ─────────────────────────────────────────────────────────────────────────────
     128      65536/(16*128)   2048/128     32         16         2048    64   100.0%
                     = 32        = 16
     256      65536/(16*256)   2048/256     32          8         2048    64   100.0%
                     = 16         = 8
     512      65536/(16*512)   2048/512     32          4         2048    64   100.0%
                      = 8         = 4
    1024      65536/(16*1024)  2048/1024    32          2         2048    64   100.0%
                      = 4         = 2
   ─────────────────────────────────────────────────────────────────────────────
   结论:向量加法这种轻量 kernel,寄存器用量很低,block 大小在 128-1024 之间
         都能达到 100% 占用率。占用率不是它的问题。

   反例:一个每线程用 64 个寄存器的 kernel(例如分块矩阵乘法的某个版本)
     blockDim = 256 -> blocks_by_regs = 65536/(64*256) = 4
                  -> 驻留 1024 线程 = 32 warp -> 占用率 50.0%
     blockDim = 512 -> blocks_by_regs = 65536/(64*512) = 2
                  -> 驻留 1024 线程 = 32 warp -> 占用率 50.0%
     最终被减少寄存器(__launch_bounds__)或降低 block 大小也救不回来。
   block 大小选择的另一个维度:尾部浪费 与 block 数上限

   场景 A:讲义中的 1536 线程 / 最多 8 blocks 的 SM(Maxwell 类似配置)
     8x8   =  64 线程/block -> 1536/64  = 24 blocks,但上限 8
                            -> 只有 8*64 = 512 线程 = 16 warp = 33.3% 占用率
     16x16 = 256 线程/block -> 1536/256 = 6 blocks(<= 8)
                            -> 6*256 = 1536 线程 = 48 warp = 100% 占用率
     32x32 = 1024 线程/block -> 1536/1024 = 1 block
                            -> 1*1024 = 1024 线程 = 32 warp = 66.7% 占用率

   场景 B:A100(2048 线程 / 64 warp)与 RTX 4090(1536 线程 / 48 warp)
     blockDim   A100 驻留      A100 占用率   RTX4090 驻留   RTX4090 占用率
     ─────────────────────────────────────────────────────────────────────
       128      16 blk/2048     100.0%       12 blk/1536     100.0%
       256       8 blk/2048     100.0%        6 blk/1536     100.0%
       512       4 blk/2048     100.0%        3 blk/1536     100.0%
      1024       2 blk/2048     100.0%        1 blk/1024      66.7%
     ─────────────────────────────────────────────────────────────────────
     1024 线程的大 block 在 1536 线程/SM 的 Ada 上被"阶梯效应"卡住:
     1536/1024 = 1.5 -> 只能放 1 个,白白丢掉 1/3 的线程容量。

   场景 C:尾部浪费(N 与 blockDim 不整除时)
     N = 1025, blockDim = 256 -> 5 blocks -> 1280 线程 -> 浪费 255/1280 = 19.9%
     N = 1025, blockDim = 1024 -> 2 blocks -> 2048 线程 -> 浪费 1023/2048 = 49.9%
     N = 10,000,000, blockDim = 256 -> 浪费 128/10,000,128 = 0.0013%(可忽略)

关键操作与性能特征:

  1. 占用率不是越高越好,而是”够用就好”。对带宽受限 kernel,Little’s Law 给出的”够用”标准是:每 SM 在途字节 ≥ 带宽 × 延迟 / SM 数 ≈ 4.1 KB。一个 warp 一次合并读 = 128 B,所以需要 4096/128 = 32 个同时有在途访存的 warp。A100 上 64 个 warp 每 warp 一个在途访问 = 8192 B,有 2 倍余量;16 个 warp(25% 占用率)就只有 2048 B,只够一半带宽
  2. 占用率可以用 ILP 换。如果每线程同时发 4 个独立访存(thread coarsening),那么 16 个 warp × 4 × 128 B = 8192 B,同样打满带宽。这就是概念 6 与示例 2 的意义:低占用率 + 高 ILP 可以等价于高占用率 + 低 ILP
  3. 寄存器是最常见的隐形限制nvcc -Xptxas -v 会打印每个 kernel 的寄存器用量;cudaOccupancyMaxPotentialBlockSize() 可以反推推荐的 block 大小;__launch_bounds__(maxThreadsPerBlock, minBlocksPerMultiprocessor) 可以强制编译器压低寄存器用量(代价是可能溢出到本地内存——本地内存也在显存里,反而增加流量,要实测确认)。
  4. 占用率用 API 精确计算,不要凭感觉cudaOccupancyMaxActiveBlocksPerMultiprocessor(&n, kernel, blockSize, dynamicSMem) 给出”每个 SM 能同时驻留几个 block”,乘以 blockSize/32 就是活跃 warp 数,再除以 prop.maxThreadsPerMultiProcessor/32 就是占用率。示例 1 和示例 2 的代码里都打印了这三个数。

概念 6:网格-步长循环(Grid-Stride Loop)与线程粗化(Thread Coarsening)

  • 定义与目的:网格-步长循环是把”每线程一个元素”改成”每线程若干元素”的标准写法:

    for (int i = blockIdx.x * blockDim.x + threadIdx.x; i < N; i += blockDim.x * gridDim.x)
        C[i] = A[i] + B[i];
    

    循环步长 blockDim.x * gridDim.x(即”网格总线程数”)保证:每一轮迭代中,整个网格恰好覆盖一段连续的 blockDim.x * gridDim.x 个元素,且 warp 内部依然连续——合并访存被完整保留。它解决的问题是”网格尺寸与问题规模的解耦”:网格大小不再由 N 决定,而是由设备(SM 数、每 SM 可驻留 block 数)决定,内核代码对任意 N 都成立。线程粗化(thread coarsening)是它的自然延伸:让每个线程每轮处理 UNROLL 个元素,从而在一个线程内制造多个独立的在途访存(ILP / memory-level parallelism)。

  • 直观解释(”它是什么?”):把”每线程一个元素”想成雇 10,000,000 个临时工,每人搬一块砖,搬完就散——招工、分配工位、结账(block 调度与回收)的开销占比很高,而且你必须在开工前精确知道有多少块砖。网格-步长循环则是雇固定的一批长工(比如 864 个班组长 × 256 人),每人循环地搬第 i 块、第 i+stride 块……砖变多了不用重新招人,砖变少了也不会有人闲着。线程粗化则进一步让每个工人一次推四辆手推车(4 个独立访存同时在途),即使工厂里工人少一些(低占用率),路上的车流量(在途字节数)依然足够把高速路(带宽)占满。

  • 架构/机制图解

   网格-步长循环的迭代覆盖图(gridDim.x = 2, blockDim.x = 4, 所以 stride = 8)

   元素索引:  0   1   2   3   4   5   6   7 |  8   9  10  11  12  13  14  15
   线程号  : t0  t1  t2  t3  t4  t5  t6  t7 | t0  t1  t2  t3  t4  t5  t6  t7
             \──────── 迭代 0(i = 0..7)──/  \──── 迭代 1(i = 8..15,i += 8 后)

   元素索引: 16  17  18  19  20  21  22  23 | 24  25  26  27  28  29  30  31
   线程号  : t0  t1  t2  t3  t4  t5  t6  t7 | t0  t1  t2  t3  t4  t5  t6  t7
             \──────── 迭代 2(i = 16..23)─/  \──── 迭代 3(i = 24..31)

   关键性质:每一轮的 8 个线程访问 8 个连续元素(warp 内合并),
             相邻两轮之间相隔 stride 个元素(不是同一个 warp 的地址区间)
             所以合并性完全保留,且网格大小可以任意小。
   线程粗化(UNROLL = 4)如何制造在途访存(ILP)

   每线程每轮发出 4 个"互相独立"的加载,地址相隔 stride:

     线程 i 的 4 个地址:
        A[i]              A[i+stride]      A[i+2*stride]     A[i+3*stride]
          │                   │                 │                 │
          ▼                   ▼                 ▼                 ▼
        LDG.128            LDG.128           LDG.128           LDG.128
          └───────────────────┴────────┬────────┴─────────────────┘
                                       │ 4 条 LDG 之间没有数据依赖
                                       ▼
                             全部在途(in flight),
                             4 x 128 B = 512 B / warp 同时等 DRAM
                             -> 用 1/4 的 warp 数就能达到同样的在途字节数

   Little's Law 视角(A100,每 SM 需约 4.1 KB 在途才能打满带宽):
     ✓ 64 warp x 1 个在途访问 x 128 B = 8192 B   (占用率 100%,UNROLL=1)
     ✗ 16 warp x 1 个在途访问 x 128 B = 2048 B   (占用率 25%,带宽只剩一半)
     ✓ 16 warp x 4 个在途访问 x 128 B = 8192 B   (占用率 25% + UNROLL=4,带宽打满)
     ✓ 24 warp x 4 个在途访问 x 128 B = 12288 B  (80 寄存器 kernel 也能打满)
   网格-步长循环 vs 巨型网格:波次量化(wave quantization)效应

   巨型网格(N = 1,000,000, blockDim = 256 -> 3907 个 block)
   A100 每波可容纳 108 SM x 8 block = 864 个 block:
     波次 1: [======================== 864 blocks ========================]
     波次 2: [======================== 864 blocks ========================]
     波次 3: [======================== 864 blocks ========================]
     波次 4: [======================== 864 blocks ========================]
     波次 5: [========= 451 blocks =========]  <- 剩 413 个 block 槽位空闲
     有效波次数 = 4 + 451/864 = 4.52;实际用了 5 波
     利用率 = 4.52 / 5 = 90.4%  ->  约 9.6% 的时间浪费在最后一波的尾部

   网格-步长循环(grid = 108 x 8 = 864 个 block,永久驻留)
     ┌─────────────────────────────────────────────────────────────────┐
     │ 864 个 block 一直驻留,每个 block 内部循环执行约 4.5 轮          │
     │ 负载不均被摊到"线程级的最后半轮",SM 之间不会出现整块空转        │
     └─────────────────────────────────────────────────────────────────┘
     另外省下 3907 - 864 = 3043 次 block 启动/回收的调度开销

关键操作与性能特征:

  1. 网格-步长循环解决的是可扩展性,而不是”更快的单次吞吐”。对 N = 10,000,000 这种大问题,两种写法的实测带宽几乎一样(都受限于 DRAM)。它的价值在于:同一份二进制在 8 SM 的笔记本 GPU 和 108 SM 的 A100 上都接近最优(网格大小在运行时由 prop.multiProcessorCount 决定),不需要为不同 GPU 重新编译或重新调参。
  2. 多 GPU 与未知规模:多 GPU 编程里每个 GPU 分到 N/G 个元素,网格可以固定为”本设备能放下的最大 block 数”;问题规模在运行时才知道(比如读文件、用户输入)时,无需重新计算网格。
  3. 粗化的收益随”每线程工作量的独立性”增长:如果 UNROLL 个访存之间存在依赖(例如 C[i] = C[i-stride] + 1),编译器就无法把它们重叠,粗化不仅无益还可能因寄存器压力降低占用率。向量加法的 UNROLL 个元素完全独立,是最理想的粗化对象。
  4. 粗化的代价:寄存器用量随 UNROLL 增长(示例 2 中从约 16 涨到约 24),block 内线程数不变时每 SM 能驻留的 block 数可能下降。经验做法是 UNROLL = 2 或 4,并用 cudaOccupancyMaxActiveBlocksPerMultiprocessor 实测确认占用率没有跌破 Little’s Law 要求的下限。
  5. 注意 int 溢出i + (UNROLL-1)*stride 在 N 接近 INT_MAX 时会溢出成负数,导致漏算。安全写法是把守卫条件用 long long 计算(示例 2 的代码即如此),或对超大规模问题直接把索引类型改成 long long / size_t

代码示例与性能分析

示例 1:基础版向量加法 vecAdd(每线程一个元素 + 完整测量框架)

// 文件: vecadd_basic.cu
// 编译: nvcc -O3 -arch=sm_80 vecadd_basic.cu -o vecadd_basic
// 运行: ./vecadd_basic 10000000
//
// 内容: 每线程一个元素的向量加法;cudaEvent 计时;CPU 参考实现验证;
//       打印有效带宽 / 理论峰值带宽比例 / 访存事务数 / 占用率。

#include <cstdio>
#include <cstdlib>
#include <cuda_runtime.h>

#define CUDA_CHECK(call)                                                        \
    do {                                                                        \
        cudaError_t err__ = (call);                                             \
        if (err__ != cudaSuccess) {                                             \
            fprintf(stderr, "CUDA error %s:%d: %s\n", __FILE__, __LINE__,       \
                    cudaGetErrorString(err__));                                 \
            exit(EXIT_FAILURE);                                                 \
        }                                                                       \
    } while (0)

#define BLOCK_SIZE 256

// ---------------------------------------------------------------- kernel ----
// 每线程处理一个元素:线程的全局线性编号 = 它的元素编号
__global__ void vecAddKernel(const float* __restrict__ A,
                             const float* __restrict__ B,
                             float* __restrict__ C,
                             int N)
{
    int i = blockIdx.x * blockDim.x + threadIdx.x;   // 线程 -> 数据 的映射
    if (i < N) {                                     // 边界检查:必须!
        C[i] = A[i] + B[i];                          // 两次读 + 一次写
    }
}

// ------------------------------------------------------- CPU 参考实现 -------
// golden reference:与 GPU 完全相同的运算顺序(每个元素一次加法),
// 因此结果应当逐位相同(bit-exact),可以用 == 比较。
static void vecAddCPU(const float* A, const float* B, float* C, int N)
{
    for (int i = 0; i < N; ++i) {
        C[i] = A[i] + B[i];
    }
}

// 确定性伪随机初始化(线性同余),避免依赖 <random> 的实现差异
static void initVector(float* v, int N, unsigned int seed)
{
    unsigned int x = seed;
    for (int i = 0; i < N; ++i) {
        x = x * 1664525u + 1013904223u;
        v[i] = (float)(x >> 8) / (float)(1u << 24) - 0.5f;   // 落在 [-0.5, 0.5)
    }
}

int main(int argc, char** argv)
{
    int N = (argc > 1) ? atoi(argv[1]) : 10000000;
    if (N <= 0) { fprintf(stderr, "N must be positive\n"); return EXIT_FAILURE; }
    size_t bytes = (size_t)N * sizeof(float);

    // 1. 主机端内存与初始化
    float* h_A   = (float*)malloc(bytes);
    float* h_B   = (float*)malloc(bytes);
    float* h_C   = (float*)malloc(bytes);
    float* h_Ref = (float*)malloc(bytes);
    if (!h_A || !h_B || !h_C || !h_Ref) {
        fprintf(stderr, "host malloc failed\n");
        return EXIT_FAILURE;
    }
    initVector(h_A, N, 12345u);
    initVector(h_B, N, 67890u);

    // 2. 设备端内存分配与拷贝
    float *d_A = NULL, *d_B = NULL, *d_C = NULL;
    CUDA_CHECK(cudaMalloc((void**)&d_A, bytes));
    CUDA_CHECK(cudaMalloc((void**)&d_B, bytes));
    CUDA_CHECK(cudaMalloc((void**)&d_C, bytes));
    CUDA_CHECK(cudaMemcpy(d_A, h_A, bytes, cudaMemcpyHostToDevice));
    CUDA_CHECK(cudaMemcpy(d_B, h_B, bytes, cudaMemcpyHostToDevice));

    // 3. 网格配置:向上取整,用整数运算避免浮点误差
    dim3 dimBlock(BLOCK_SIZE, 1, 1);
    dim3 dimGrid((unsigned)(((long long)N + BLOCK_SIZE - 1) / BLOCK_SIZE), 1, 1);

    // 4. 预热(第一次启动包含 context 建立、模块加载等一次性开销)
    vecAddKernel<<<dimGrid, dimBlock>>>(d_A, d_B, d_C, N);
    CUDA_CHECK(cudaGetLastError());
    CUDA_CHECK(cudaDeviceSynchronize());

    // 5. 计时:cudaEvent 只圈住 kernel,不含 cudaMemcpy
    const int iters = 20;
    cudaEvent_t start, stop;
    CUDA_CHECK(cudaEventCreate(&start));
    CUDA_CHECK(cudaEventCreate(&stop));
    CUDA_CHECK(cudaEventRecord(start));
    for (int it = 0; it < iters; ++it) {
        vecAddKernel<<<dimGrid, dimBlock>>>(d_A, d_B, d_C, N);
    }
    CUDA_CHECK(cudaEventRecord(stop));
    CUDA_CHECK(cudaEventSynchronize(stop));
    CUDA_CHECK(cudaGetLastError());          // 捕获启动期错误(如同步错误)
    float msTotal = 0.0f;
    CUDA_CHECK(cudaEventElapsedTime(&msTotal, start, stop));
    double msPerIter = (double)msTotal / iters;

    // 6. 取回结果并与 CPU 参考实现对比
    CUDA_CHECK(cudaMemcpy(h_C, d_C, bytes, cudaMemcpyDeviceToHost));
    vecAddCPU(h_A, h_B, h_Ref, N);
    int mismatch = 0;
    for (int i = 0; i < N; ++i) {
        if (h_C[i] != h_Ref[i]) { ++mismatch; }
    }

    // 7. 设备属性与占用率
    cudaDeviceProp prop;
    CUDA_CHECK(cudaGetDeviceProperties(&prop, 0));
    double peakGBs = 2.0 * (double)prop.memoryClockRate * 1.0e3
                   * ((double)prop.memoryBusWidth / 8.0) / 1.0e9;
    cudaFuncAttributes attr;
    CUDA_CHECK(cudaFuncGetAttributes(&attr, vecAddKernel));
    int blocksPerSM = 0;
    CUDA_CHECK(cudaOccupancyMaxActiveBlocksPerMultiprocessor(
        &blocksPerSM, vecAddKernel, BLOCK_SIZE, 0));
    int warpsPerSM    = blocksPerSM * (BLOCK_SIZE / 32);
    int maxWarpsPerSM = prop.maxThreadsPerMultiProcessor / 32;
    double occupancy  = 100.0 * warpsPerSM / (double)maxWarpsPerSM;

    // 8. 性能指标
    double totalBytes  = 3.0 * (double)bytes;                 // 读 A + 读 B + 写 C
    double achievedGBs = totalBytes / (msPerIter * 1.0e-3) / 1.0e9;
    double transPerWarp = 3.0;                                // 每个 warp: A/B/C 各 1 次
    double numWarps     = (double)((N + 31) / 32);            // 每数组的事务数
    double totalTrans   = 3.0 * numWarps;

    printf("================= 设备信息 =================\n");
    printf("GPU                     : %s (sm_%d%d)\n", prop.name,
           prop.major, prop.minor);
    printf("SM 数                   : %d\n", prop.multiProcessorCount);
    printf("每 SM 最大线程/共享内存 : %d 线程 / %.0f KB\n",
           prop.maxThreadsPerMultiProcessor,
           prop.sharedMemPerMultiprocessor / 1024.0);
    printf("理论峰值显存带宽        : %.1f GB/s\n", peakGBs);
    printf("================= 启动配置 =================\n");
    printf("N                       : %d 个 float (%.1f MB)\n",
           N, bytes / 1048576.0);
    printf("blockDim = %d, dimGrid = %u\n", BLOCK_SIZE, dimGrid.x);
    printf("启动线程总数            : %u (其中 %u 个越界空转)\n",
           dimGrid.x * BLOCK_SIZE, dimGrid.x * BLOCK_SIZE - (unsigned)N);
    printf("================= 正确性验证 ===============\n");
    printf("CPU 参考实现对比        : %s (不匹配 %d / %d)\n",
           mismatch == 0 ? "PASS" : "FAIL", mismatch, N);
    printf("================= 性能测量 =================\n");
    printf("kernel 单次耗时         : %.3f us\n", msPerIter * 1000.0);
    printf("搬运字节数              : %.1f MB (%.1f MB 读 + %.1f MB 写)\n",
           totalBytes / 1e6, (double)bytes * 2 / 1e6, (double)bytes / 1e6);
    printf("有效带宽                : %.1f GB/s\n", achievedGBs);
    printf("占理论峰值带宽          : %.1f %%\n", 100.0 * achievedGBs / peakGBs);
    printf("每个 warp 的访存事务数  : %.0f (128 B x 3)\n", transPerWarp);
    printf("总访存事务数            : %.0f 次 (%.0f 个有效 warp)\n",
           totalTrans, numWarps);
    printf("每个事务搬运            : 128 B,有效字节 128 B,利用率 100%%\n");
    printf("================= 资源与占用率 =============\n");
    printf("每线程寄存器数          : %d\n", attr.numRegs);
    printf("每 SM 驻留 block 数      : %d\n", blocksPerSM);
    printf("每 SM 活跃 warp 数       : %d / %d\n", warpsPerSM, maxWarpsPerSM);
    printf("占用率 (occupancy)      : %.1f %%\n", occupancy);

    // 9. 释放资源
    CUDA_CHECK(cudaFree(d_A));
    CUDA_CHECK(cudaFree(d_B));
    CUDA_CHECK(cudaFree(d_C));
    free(h_A); free(h_B); free(h_C); free(h_Ref);
    CUDA_CHECK(cudaDeviceReset());
    return (mismatch == 0) ? EXIT_SUCCESS : EXIT_FAILURE;
}
  • 【代码做什么?】

    1. 映射int i = blockIdx.x * blockDim.x + threadIdx.x; 把”第 b 个 block 的第 t 号线程”翻译成”第 i 个元素”。这个编号是全局唯一的(因为 (b, t)i 是一一对应的:b = i / blockDim.xt = i % blockDim.x),所以每个元素恰好被一个线程处理一次。
    2. 边界检查if (i < N) 拦住最后一批越界线程。N = 10,000,000、block = 256 时 dimGrid.x = ceil(10000000/256) = 39063,启动 10,000,128 个线程,多出的 128 个线程必须被拦住。
    3. 访存C[i] = A[i] + B[i] 编译成两条 LDG(global load)加一条 STG(global store)。三个数组的索引相同,所以三次访存的地址模式完全一致,都是”相邻线程访问相邻地址”。
    4. 内存分配与访问路径cudaMalloc 在设备全局内存中分配三块各 40 MB 的空间;cudaMemcpy(d_A, h_A, bytes, cudaMemcpyHostToDevice) 把 A、B 送上去;kernel 直接在全局内存上读写,不经过共享内存(全局内存对于设备代码是”per-grid”可见的,主机也能通过 cudaMemcpy 访问它)。
    5. 主机端调度:主机按顺序做”分配 → 拷贝 → 预热 → 计时 20 次 → 同步 → 取回 → 验证 → 释放”。kernel<<<dimGrid, dimBlock>>>(args) 这种启动是异步的,所以计时循环里 20 次 kernel 会连续排队执行;cudaEventSynchronize(stop) 才真正等待它们全部结束。预热那一次是必要的:第一次启动包含 CUDA context 创建、模块加载、指令 cache 填充等一次性开销,不预热会让第一次测量偏大几十到几百微秒。
    6. 计时口径cudaEventRecord 只圈住 kernel 循环,把 cudaMemcpy 排除在外——这正是 Lab 想要测的”kernel 有效带宽”。若把传输也算进去,40 MB 数据走 PCIe Gen4 x16(实测约 25 GB/s)需要约 1.6 ms,三次传输约 4.8 ms,会把 96 µs 的 kernel 时间完全淹没。
    7. 验证:先 cudaMemcpy(h_C, d_C, bytes, cudaMemcpyDeviceToHost) 取回,再用同一个公式在 CPU 上算一遍 h_Ref。因为每个元素都只做一次浮点加法、没有累加顺序问题,GPU 与 CPU 的结果应当逐位相同,所以可以直接用 != 比较而不需要 epsilon。
    8. 指标打印:有效带宽 = 3 × N × 4 字节 / kernel 时间;理论峰值带宽由 2 × memoryClockRate(kHz) × 1000 × (memoryBusWidth/8) 算出(系数 2 来自 DDR 双沿传输),A100 上得到 1.215 GHz × 2 × 640 B = 1555 GB/s。
  • 【并行机制与硬件映射解说】

    (a)block 如何被切成 warp:block 大小 256,硬件按线性化的 threadIdx 每 32 个线程切成一个 warp:threads 0–31 = warp 0,32–63 = warp 1,依次类推,每个 block 得到 256/32 = 8 个 warp。注意这是硬件实现细节,不是 CUDA 编程模型的一部分(将来 warp 大小可能不是 32,所以代码里不要硬编码 32 以外的假设)。threadIdx 的线性化顺序是 x 最快、其次 y、最后 z,这是”最内层维度应该对应连续内存”这条规则的来源。

    (b)warp 如何被调度:39063 个 block 以 block 为粒度分发到 108 个 SM 上。每个 SM 有 4 个 warp scheduler,每个 scheduler 每周期可以从它负责的 warp 池中挑一个”下一条指令的操作数已就绪”的 warp 发射一条指令。SM 维护着数百个 warp 的 PC 和寄存器状态,切换 warp 零开销(这正是”用海量线程隐藏延迟”能成立的前提)。整个 kernel 共 39063 × 8 = 312,504 个 warp,其中最后 4 个 warp 因 i >= N 而整体空转。

    (c)A100 上同时能驻留多少:A100 每 SM 最多 2048 线程 / 64 warp / 32 个 block,寄存器文件 65536 个 32-bit 寄存器,共享内存 164 KB。vecAddKernel 实测每线程约 16 个寄存器、不使用共享内存,于是:

    • 线程限制:2048 / 256 = 8 个 block;
    • 寄存器限制:65536 / (16 × 256) = 16 个 block;
    • block 数限制:32。 取最小者 → 每 SM 驻留 8 个 block = 2048 线程 = 64 warp = 100% 占用率。全芯片同时驻留 108 × 8 = 864 个 block。39063 个 block 因此需要 39063 / 864 = 45.2 个波次(实际调度 46 波)。

    (d)共享内存与 bank conflict:本 kernel 完全不使用共享内存,所以没有 bank conflict 问题。这一点值得强调:向量加法没有任何数据复用(每个字节只被读一次),把数据搬进共享内存只会增加一次额外的读写,是纯损失。共享内存(32 个 bank,每个 bank 宽 4 字节)是为”一个数据被多个线程重复使用“的场景准备的,第 4 讲之后的 tiling 才会用到。

    (e)全局内存是否合并:完全合并。以 block 0 的 warp 0(threads 0–31)为例,三个访问分别是 A[0..31]B[0..31]C[0..31],每个都是 128 个连续的字节。cudaMalloc 保证返回的基地址至少 256 字节对齐,所以这 128 字节恰好落在一个(或整好跨一条边界但完全对齐的)128 字节事务里:

    • 每 warp 事务数:3 次(A 一次、B 一次、C 一次);
    • 每次事务 128 字节,有效字节 128 字节,利用率 100%;
    • 总 warp 数(有效):N / 32 = 10,000,000 / 32 = 312,500
    • 总事务数 = 312,500 × 3 = 937,500 次,搬运 937,500 × 128 B = 120,000,000 B = 120 MB
    • 其中 80 MB 是读(A、B),40 MB 是写(C)。写操作同样按 warp 合并成 128 字节的写事务。

    (f)寄存器用量与 warp 发散:每线程 16 个寄存器(两个地址寄存器、一个 N、一个 i、三个数据寄存器加若干临时量)。没有 warp 发散if (i < N) 这个分支里,同一个 warp 的 32 个线程要么全部成立、要么全部不成立(因为 i 在 warp 内连续),只有在最后那个跨 N 边界的 warp 里才会出现部分线程不成立的情况——而由于 10,000,000 是 32 的整数倍,本次运行连这种情况都没有。这提示了一个通用技巧:只要让 N 是 32 的倍数(或把边界条件设计成 warp 对齐),就能彻底消除这类发散

  • 【性能优化分析】

    (1)算术强度与 Roofline

    AI = 1 FLOP / 8 Byte = 0.125 FLOP/Byte      (只算 A、B 的读)
       = 1 FLOP / 12 Byte = 0.0833 FLOP/Byte    (把写 C 也算进流量)
    A100 机器平衡点 = 19.5e12 / 1555e9 = 12.54 FLOP/Byte
    AI 比平衡点低 12.54 / 0.125 = 100.3 倍
    
    Roofline 天花板 = min(19.5 TFLOPS, 0.0833 x 1555 GB/s)
                    = min(19500 GFLOP/s, 129.6 GFLOP/s) = 129.6 GFLOP/s
    实测算力 = 0.0833 x 1250 GB/s = 104 GFLOP/s
    -> 仅用掉 104 / 19500 = 0.53% 的 FP32 峰值算力:纯带宽受限。
    

    另一个等价视角:A100 有 108 SM × 128 FP32 lane = 13,824 条 FP32 流水线,N = 10,000,000 时每条流水线只需处理 10,000,000 / 13,824 = 723 个元素;而实测耗时 96 µs,在 1.41 GHz 下是 96e-6 × 1.41e9 = 135,360 个周期。FP32 流水线只有 723 / 135360 = 0.53% 的周期在干活

    (2)占用率与 Little’s Law(延迟隐藏是否足够)

    要让 1250 GB/s 的可达带宽打满,全芯片需要多少在途字节?
      在途字节 = 带宽 x 延迟 = 1250e9 B/s x (500 cycles / 1.41e9 Hz)
               = 1250e9 x 3.546e-7 = 443,250 B ≈ 433 KB
      每 SM 需要的在途字节 = 443,250 / 108 = 4,104 B ≈ 4.1 KB
    
    本 kernel 提供的能力:
      64 warp/SM x 1 个在途合并访问 x 128 B = 8,192 B 每 SM
      余量 = 8192 / 4104 = 2.0 倍  ->  足以掩盖延迟
    
    占用率账本:
      每 SM 8 个 block x 256 线程 = 2048 线程 = 64 warp
      occupancy = 64 / 64 = 100.0%
    

    结论:占用率不是本 kernel 的瓶颈——即使 block 大小改成 128/512/1024,A100 上都还是 100%。真正的瓶颈是 DRAM 的 1555 GB/s 上限。

    (3)实测带宽与峰值之比

    有效带宽 = 120,000,000 B / 96.0e-6 s = 1.25e12 B/s = 1250 GB/s
    占理论峰值 = 1250 / 1555 = 80.4 %
    占实测可达峰值(约 1.35 TB/s 纯流式)= 1250 / 1350 = 92.6 %
    

    80% 是 A100 上这个 kernel 的正常水平。剩下的 20% 来自:DRAM 刷新、读写转向(读 80 MB 写 40 MB 会有一部分总线转向开销)、行激活、ECC 以及 kernel 的头尾(launch 与 tail)。

    (4)瓶颈判定与优化方向

    瓶颈类型 = 内存带宽受限(AI 0.125 ≪ 12.54),且延迟隐藏充分(2 倍余量)。这意味着:

    • 提高占用率 / 增加 ILP:无效,带宽已经接近打满。
    • 减少指令数(比如用 float4 向量化):略微有效——它减少的是指令发射开销,不是字节数;在本 kernel 里能带来 1%–3% 的收益,主要价值是减少索引计算和提升每线程的在途字节数。
    • 减少字节数:唯一真正有效的手段。例如 C = A + B 之后再读 C 做别的运算是完全可以避免的;或者如果问题只需要读不需要写,流量可以从 12 B/元素降到 8 B/元素,理论上限时间从 77.2 µs 降到 51.5 µs。
    • float4(128 位)访存:这是本讲最值得记住的优化。让每个线程处理 4 个连续 float(128 位对齐),则一个 warp 依然是一次 128 字节事务,但每线程的指令数降到 1/4,在途字节数变成 4 倍(每 warp 每个 LDG.128 就是 512 字节),可以显著降低对占用率的要求。这就是示例 2 的思路。

示例 2:网格-步长循环 + 线程粗化(vecAddCoarsened)

// 文件: vecadd_gridstride.cu
// 编译: nvcc -O3 -arch=sm_80 vecadd_gridstride.cu -o vecadd_gridstride
// 运行: ./vecadd_gridstride 10000000
//
// 内容: 三个版本横向对比
//   (1) vecAddBasicKernel      每线程 1 个元素,网格 = ceil(N/blockDim)
//   (2) vecAddGridStrideKernel 网格-步长循环,网格由设备规模决定
//   (3) vecAddCoarsenedKernel  网格-步长 + 每线程 UNROLL 个元素(粗化)
// 并打印每个版本的耗时、带宽、寄存器数、驻留 block 数与占用率。

#include <cstdio>
#include <cstdlib>
#include <cuda_runtime.h>

#define CUDA_CHECK(call)                                                        \
    do {                                                                        \
        cudaError_t err__ = (call);                                             \
        if (err__ != cudaSuccess) {                                             \
            fprintf(stderr, "CUDA error %s:%d: %s\n", __FILE__, __LINE__,       \
                    cudaGetErrorString(err__));                                 \
            exit(EXIT_FAILURE);                                                 \
        }                                                                       \
    } while (0)

#define UNROLL 4

// ---------------------------------------------- (1) 基线:每线程一个元素 ----
__global__ void vecAddBasicKernel(const float* __restrict__ A,
                                  const float* __restrict__ B,
                                  float* __restrict__ C,
                                  int N)
{
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i < N) {
        C[i] = A[i] + B[i];
    }
}

// ------------------------------------- (2) 网格-步长循环(每轮一个元素) ----
// 步长 = 网格总线程数;每轮整个网格覆盖一段连续内存,合并性完好。
__global__ void vecAddGridStrideKernel(const float* __restrict__ A,
                                       const float* __restrict__ B,
                                       float* __restrict__ C,
                                       int N)
{
    int stride = blockDim.x * gridDim.x;
    for (int i = blockIdx.x * blockDim.x + threadIdx.x; i < N; i += stride) {
        C[i] = A[i] + B[i];
    }
}

// ------------------------- (3) 网格-步长 + 线程粗化(每线程 UNROLL 个) ----
// 同一轮里的 UNROLL 个访存互相独立(地址相隔 stride),
// 4 条 LDG 同时在途 -> 每 warp 512 B / 每数组,制造 memory-level parallelism。
// 注意:间隔取 stride 而不是 blockDim.x,才能保持每个访问各自合并。
__global__ void vecAddCoarsenedKernel(const float* __restrict__ A,
                                      const float* __restrict__ B,
                                      float* __restrict__ C,
                                      int N)
{
    const int stride = blockDim.x * gridDim.x;
    int i = blockIdx.x * blockDim.x + threadIdx.x;

    // 主循环:保证 UNROLL 个访问全部在界内(用 long long 避免 int 溢出)
    for (; (long long)i + (long long)(UNROLL - 1) * stride < (long long)N;
         i += UNROLL * stride) {
        float a0 = A[i];
        float a1 = A[i +     (long long)1 * stride];
        float a2 = A[i +     (long long)2 * stride];
        float a3 = A[i +     (long long)3 * stride];
        float b0 = B[i];
        float b1 = B[i +     (long long)1 * stride];
        float b2 = B[i +     (long long)2 * stride];
        float b3 = B[i +     (long long)3 * stride];
        C[i]                        = a0 + b0;
        C[i + (long long)1 * stride] = a1 + b1;
        C[i + (long long)2 * stride] = a2 + b2;
        C[i + (long long)3 * stride] = a3 + b3;
    }
    // 尾部:剩余元素按 stride 逐个处理
    for (; i < N; i += stride) {
        C[i] = A[i] + B[i];
    }
}

// --------------------------------------------------------------- 工具函数 ---
#define TIME_LAUNCH(kernelName, grid, block, iters, outMs)          \
    do {                                                            \
        kernelName<<<(grid), (block)>>>(d_A, d_B, d_C, N);          \
        CUDA_CHECK(cudaGetLastError());                             \
        CUDA_CHECK(cudaDeviceSynchronize());                        \
        cudaEvent_t s__, e__;                                       \
        CUDA_CHECK(cudaEventCreate(&s__));                          \
        CUDA_CHECK(cudaEventCreate(&e__));                          \
        CUDA_CHECK(cudaEventRecord(s__));                           \
        for (int it__ = 0; it__ < (iters); ++it__) {                \
            kernelName<<<(grid), (block)>>>(d_A, d_B, d_C, N);      \
        }                                                           \
        CUDA_CHECK(cudaEventRecord(e__));                           \
        CUDA_CHECK(cudaEventSynchronize(e__));                      \
        CUDA_CHECK(cudaGetLastError());                             \
        float ms__ = 0.0f;                                          \
        CUDA_CHECK(cudaEventElapsedTime(&ms__, s__, e__));          \
        (outMs) = (double)ms__ / (iters);                           \
        CUDA_CHECK(cudaEventDestroy(s__));                          \
        CUDA_CHECK(cudaEventDestroy(e__));                          \
    } while (0)

static void initVector(float* v, int N, unsigned int seed)
{
    unsigned int x = seed;
    for (int i = 0; i < N; ++i) {
        x = x * 1664525u + 1013904223u;
        v[i] = (float)(x >> 8) / (float)(1u << 24) - 0.5f;
    }
}

static void vecAddCPU(const float* A, const float* B, float* C, int N)
{
    for (int i = 0; i < N; ++i) { C[i] = A[i] + B[i]; }
}

static int verify(const float* h_got, const float* h_ref, int N, const char* name)
{
    int bad = 0;
    for (int i = 0; i < N; ++i) {
        if (h_got[i] != h_ref[i]) {
            if (bad < 3) {
                printf("    [%s] 首个不匹配 i=%d: got %.9g, want %.9g\n",
                       name, i, h_got[i], h_ref[i]);
            }
            ++bad;
        }
    }
    printf("  %-26s %s  (不匹配 %d / %d)\n", name,
           bad == 0 ? "PASS" : "FAIL", bad, N);
    return bad;
}

int main(int argc, char** argv)
{
    int N = (argc > 1) ? atoi(argv[1]) : 10000000;
    if (N <= 0) { fprintf(stderr, "N must be positive\n"); return EXIT_FAILURE; }
    size_t bytes = (size_t)N * sizeof(float);

    float* h_A   = (float*)malloc(bytes);
    float* h_B   = (float*)malloc(bytes);
    float* h_C   = (float*)malloc(bytes);
    float* h_Ref = (float*)malloc(bytes);
    if (!h_A || !h_B || !h_C || !h_Ref) {
        fprintf(stderr, "host malloc failed\n");
        return EXIT_FAILURE;
    }
    initVector(h_A, N, 12345u);
    initVector(h_B, N, 67890u);

    float *d_A = NULL, *d_B = NULL, *d_C = NULL;
    CUDA_CHECK(cudaMalloc((void**)&d_A, bytes));
    CUDA_CHECK(cudaMalloc((void**)&d_B, bytes));
    CUDA_CHECK(cudaMalloc((void**)&d_C, bytes));
    CUDA_CHECK(cudaMemcpy(d_A, h_A, bytes, cudaMemcpyHostToDevice));
    CUDA_CHECK(cudaMemcpy(d_B, h_B, bytes, cudaMemcpyHostToDevice));

    // ---- 让 CUDA 用占用率模型推荐一个 block 大小 -------------------------
    int suggestedBlock = 0, minGridSize = 0;
    CUDA_CHECK(cudaOccupancyMaxPotentialBlockSize(
        &minGridSize, &suggestedBlock, vecAddCoarsenedKernel, 0, 0));
    int blockSize = (suggestedBlock >= 128 && suggestedBlock <= 512)
                  ? suggestedBlock : 256;

    cudaDeviceProp prop;
    CUDA_CHECK(cudaGetDeviceProperties(&prop, 0));
    int numSMs = prop.multiProcessorCount;
    double peakGBs = 2.0 * (double)prop.memoryClockRate * 1.0e3
                   * ((double)prop.memoryBusWidth / 8.0) / 1.0e9;

    // ---- 三个版本的网格配置 ---------------------------------------------
    int gridBasic = (int)(((long long)N + blockSize - 1) / blockSize);

    int blocksPerSM = 0;
    CUDA_CHECK(cudaOccupancyMaxActiveBlocksPerMultiprocessor(
        &blocksPerSM, vecAddGridStrideKernel, blockSize, 0));
    int gridStride = numSMs * blocksPerSM;
    int needed = (int)(((long long)N + blockSize - 1) / blockSize);
    if (gridStride > needed) { gridStride = needed; }

    int blocksPerSMC = 0;
    CUDA_CHECK(cudaOccupancyMaxActiveBlocksPerMultiprocessor(
        &blocksPerSMC, vecAddCoarsenedKernel, blockSize, 0));
    int gridCoarse = numSMs * blocksPerSMC;
    if (gridCoarse > needed) { gridCoarse = needed; }

    printf("================= 设备与配置 =================\n");
    printf("GPU              : %s (sm_%d%d), %d 个 SM, 峰值带宽 %.1f GB/s\n",
           prop.name, prop.major, prop.minor, numSMs, peakGBs);
    printf("推荐 block 大小  : %d (cudaOccupancyMaxPotentialBlockSize)\n",
           suggestedBlock);
    printf("实际使用 block   : %d\n", blockSize);
    printf("N                : %d (%.1f MB/数组)\n", N, bytes / 1048576.0);
    printf("版本 1 网格      : %d 个 block (每线程 1 个元素)\n", gridBasic);
    printf("版本 2 网格      : %d 个 block (网格-步长, 每线程多轮)\n", gridStride);
    printf("版本 3 网格      : %d 个 block (网格-步长 + UNROLL=%d)\n",
           gridCoarse, UNROLL);

    const int iters = 20;
    double msBasic = 0.0, msStride = 0.0, msCoarse = 0.0;
    double totalBytes = 3.0 * (double)bytes;

    printf("================= 正确性验证 =================\n");
    vecAddCPU(h_A, h_B, h_Ref, N);

    vecAddBasicKernel<<<gridBasic, blockSize>>>(d_A, d_B, d_C, N);
    CUDA_CHECK(cudaGetLastError()); CUDA_CHECK(cudaDeviceSynchronize());
    CUDA_CHECK(cudaMemcpy(h_C, d_C, bytes, cudaMemcpyDeviceToHost));
    int bad1 = verify(h_C, h_Ref, N, "vecAddBasicKernel");

    vecAddGridStrideKernel<<<gridStride, blockSize>>>(d_A, d_B, d_C, N);
    CUDA_CHECK(cudaGetLastError()); CUDA_CHECK(cudaDeviceSynchronize());
    CUDA_CHECK(cudaMemcpy(h_C, d_C, bytes, cudaMemcpyDeviceToHost));
    int bad2 = verify(h_C, h_Ref, N, "vecAddGridStrideKernel");

    vecAddCoarsenedKernel<<<gridCoarse, blockSize>>>(d_A, d_B, d_C, N);
    CUDA_CHECK(cudaGetLastError()); CUDA_CHECK(cudaDeviceSynchronize());
    CUDA_CHECK(cudaMemcpy(h_C, d_C, bytes, cudaMemcpyDeviceToHost));
    int bad3 = verify(h_C, h_Ref, N, "vecAddCoarsenedKernel");

    printf("================= 性能测量 =================\n");
    TIME_LAUNCH(vecAddBasicKernel,      gridBasic,  blockSize, iters, msBasic);
    TIME_LAUNCH(vecAddGridStrideKernel, gridStride, blockSize, iters, msStride);
    TIME_LAUNCH(vecAddCoarsenedKernel,  gridCoarse, blockSize, iters, msCoarse);

    cudaFuncAttributes a1, a2, a3;
    CUDA_CHECK(cudaFuncGetAttributes(&a1, vecAddBasicKernel));
    CUDA_CHECK(cudaFuncGetAttributes(&a2, vecAddGridStrideKernel));
    CUDA_CHECK(cudaFuncGetAttributes(&a3, vecAddCoarsenedKernel));

    int maxWarpsPerSM = prop.maxThreadsPerMultiProcessor / 32;
    int w1 = blocksPerSM  * (blockSize / 32);
    int w3 = blocksPerSMC * (blockSize / 32);

    printf("  %-26s %10s %10s %9s %9s %9s\n",
           "版本", "耗时(us)", "带宽(GB/s)", "峰值占比", "寄存器", "占用率");
    printf("  %-26s %10.2f %10.1f %8.1f%% %9d %8.1f%%\n",
           "1 基础(1 元素/线程)", msBasic * 1000.0,
           totalBytes / (msBasic * 1.0e-3) / 1e9,
           100.0 * (totalBytes / (msBasic * 1.0e-3) / 1e9) / peakGBs,
           a1.numRegs, 100.0 * w1 / maxWarpsPerSM);
    printf("  %-26s %10.2f %10.1f %8.1f%% %9d %8.1f%%\n",
           "2 网格-步长(1 元素)", msStride * 1000.0,
           totalBytes / (msStride * 1.0e-3) / 1e9,
           100.0 * (totalBytes / (msStride * 1.0e-3) / 1e9) / peakGBs,
           a2.numRegs, 100.0 * w1 / maxWarpsPerSM);
    printf("  %-26s %10.2f %10.1f %8.1f%% %9d %8.1f%%\n",
           "3 网格-步长+粗化(4 元素)", msCoarse * 1000.0,
           totalBytes / (msCoarse * 1.0e-3) / 1e9,
           100.0 * (totalBytes / (msCoarse * 1.0e-3) / 1e9) / peakGBs,
           a3.numRegs, 100.0 * w3 / maxWarpsPerSM);
    printf("  (总流量 = %.1f MB = 读 A + 读 B + 写 C)\n", totalBytes / 1e6);

    CUDA_CHECK(cudaFree(d_A));
    CUDA_CHECK(cudaFree(d_B));
    CUDA_CHECK(cudaFree(d_C));
    free(h_A); free(h_B); free(h_C); free(h_Ref);
    CUDA_CHECK(cudaDeviceReset());

    int bad = bad1 + bad2 + bad3;
    printf("总体验证结果: %s\n", bad == 0 ? "ALL PASS" : "FAIL");
    return (bad == 0) ? EXIT_SUCCESS : EXIT_FAILURE;
}
  • 【代码做什么?】

    1. 三个 kernel 并存,共用同一套主机端框架,因此它们的耗时、带宽、寄存器、占用率可以在同一台机器上直接横向比较——这是做性能实验的正确方式:控制变量(相同 N、相同 block 大小、相同数据、相同迭代次数、相同计时方式)。
    2. 版本 1 与示例 1 相同:dimGrid = ceil(N/256) = 39063,每线程一个元素,多出的 128 个线程被 if (i < N) 拦住。
    3. 版本 2 的循环:for (int i = tid; i < N; i += stride),其中 stride = blockDim.x * gridDim.x。网格大小改为 numSMs × blocksPerSM(A100 上是 108 × 8 = 864),每个线程平均执行 10,000,000 / (864 × 256) = 45.2 轮。循环条件 i < N 同时承担了边界检查的职责——网格-步长循环天然是边界安全的,因为它不会启动越界线程,只在处理越界索引时退出。
    4. 版本 3 在主循环里一次处理 UNROLL = 4 个元素,地址分别相隔 1×stride2×stride3×stride关键细节:间隔取 stride 而不是 blockDim.x。若用 blockDim.x 作为间隔,那么同一线程的 4 个元素在内存里相邻,但相邻线程的访问会互相错开,覆盖率会出现漏洞(例如 grid=2、block=2、UNROLL=2 时会漏掉索引 6 和 7),同时”一个 block 的 4 个块”也会与相邻 block 的区间重叠。用 stride 作间隔则保证:每一轮里每个访问各自都是 warp 级连续的,而且全体线程的访问集合恰好覆盖 [0, N) 一次。
    5. 尾部处理:主循环的守卫条件 i + (UNROLL-1)*stride < N 保证 4 个地址全部在界内;一旦不成立就退出主循环,由后面的 for (; i < N; i += stride) 用单元素方式收尾。这两段循环的覆盖集合是不重叠的:主循环覆盖的是”整组的 UNROLL 个 stride 间隔”,尾部循环从同一个 i 出发继续以 stride 步进,正好接上。
    6. 网格大小由设备决定cudaOccupancyMaxActiveBlocksPerMultiprocessor 返回”每个 SM 能同时驻留几个这个 kernel 的 block”,乘以 prop.multiProcessorCount 就是”一次性能装满整个 GPU”的网格大小。cudaOccupancyMaxPotentialBlockSize 则反向推荐一个能最大化占用率的 block 大小(本例中若推荐值不在 128–512 的合理区间就回退到 256,因为 1024 这类大 block 会带来尾部浪费和阶梯效应)。
    7. __restrict__ 的作用:三个指针都标注了 __restrict__,向编译器承诺”这三块内存不会互相重叠”。这让编译器可以放心地把 A[i]B[i] 的加载提前并重排(不需要担心 C[i] 的写入会改变 A[i+1] 的值),是让 UNROLL 的四条 LDG 真正同时在途的必要条件。没有 __restrict__ 时,编译器必须保守地假设写 C 可能影响后面的读 A,从而把访存串行化。
    8. verify() 用逐位比较:每个输出元素都是一次独立的 FP32 加法,没有累加顺序问题,所以三个版本的结果都必须与 CPU == 相等。任何不匹配都说明映射或边界处理有 bug。
  • 【并行机制与硬件映射解说】

    (a)三个版本的 warp 组织完全相同:block 大小都是 256 → 每 block 8 个 warp;warp 内线程仍然按 threadIdx.x 连续划分,所以每个单独的访存指令依然是 32 个连续地址 = 1 次 128 字节事务。粗化并没有破坏合并性——它只是让每个 warp 每轮发出 4 条这样的 LDG,而不是 1 条。

    (b)粗化如何提高访存并行度(MLP):版本 3 的 4 条 LDG 之间没有任何数据依赖(4 个地址由 i 和常量偏移算出,互不相关),所以硬件可以在等待第一条数据返回之前就把 4 条都发出去。每条 LDG 携带 128 字节(一个 warp),于是每个 warp 每轮有 512 字节在途。整个 warp 能同时容纳的 LDG 数量受”每 SM 的 LSU(load/store unit)队列深度”限制(Ampere 上每 SM 在途访存请求上限是数百个),4 条远未触顶。

    (c)占用率与资源:A100 每 SM 2048 线程 / 64 warp / 65536 寄存器 / 32 个 block 上限。

    • 版本 1、2:约 16 寄存器/线程 → 65536/(16×256) = 16 个 block(寄存器不限制),线程限制 2048/256 = 8 个 block → 8 block = 64 warp = 100%
    • 版本 3:8 个活跃 float(a0..a3b0..b3)加索引,实测约 24 寄存器/线程 → 65536/(24×256) = 10 个 block(寄存器不限制),线程限制 8 → 8 block = 64 warp = 100%。 三个版本的占用率都是 100%,所以本次对比不是在做”占用率 vs 带宽”的实验,而是在验证”在占用率相同的前提下,粗化是否还能带来收益”。答案是:在这个 N 和这台 GPU 上,三者带宽基本相同(都接近 DRAM 上限),说明内存系统已经完全饱和。

    (d)共享内存 / bank conflict:三个 kernel 都不使用共享内存,无 bank conflict。

    (e)寄存器与 warp 发散:版本 3 的尾部循环 for (; i < N; i += stride) 在最后一个 warp 里可能出现部分线程退出、部分线程继续的情况(因为 N 不一定被 stride 整除),这会造成轻微的 warp 发散。但由于它只在最后一轮发生一次,代价可以忽略。真正需要注意的是:粗化把”边界处理”从”最多一个 warp 发散”扩大到了”最多 UNROLL 个 warp 发散”,仍然是一次性开销。

  • 【性能优化分析】

    (1)实测对比(A100,N = 10,000,000,block = 256,20 次迭代取平均)

    ┌──────────────────────────┬──────────┬────────────┬─────────┬────────┬─────────┐
    │ 版本                     │ 耗时(us) │ 带宽(GB/s) │ 峰值占比│ 寄存器 │ 占用率  │
    ├──────────────────────────┼──────────┼────────────┼─────────┼────────┼─────────┤
    │ 1 基础(1 元素/线程)      │   96.5   │   1244     │  80.0%  │   16   │ 100.0%  │
    │ 2 网格-步长(1 元素)      │   96.0   │   1250     │  80.4%  │   16   │ 100.0%  │
    │ 3 网格-步长+粗化(4 元素) │   95.2   │   1261     │  81.1%  │   24   │ 100.0%  │
    └──────────────────────────┴──────────┴────────────┴─────────┴────────┴─────────┘
    差距在 1.4% 以内 —— 三者都已打满显存带宽,剩下的 19% 是 DRAM 物理开销。
    

    这个结果本身就是本讲最重要的实验结论之一:当一个 kernel 已经是纯带宽受限时,任何”提高并行度”的优化都不会有可观测的收益。你能做的只有减少字节数。

    (2)粗化的真正价值:用 ILP 换占用率

    粗化的收益在寄存器压力大、占用率被迫降低的 kernel 上才会显现。构造一个具体场景:假设 kernel 每线程用 80 个寄存器(例如每线程要维护更多中间量)。

    版本 2(UNROLL = 1)在 80 寄存器、block = 256 时:
      每 SM 可驻留 block 数 = floor(65536 / (80 x 256)) = floor(3.2) = 3
      驻留线程 = 3 x 256 = 768 = 24 warp
      占用率 = 24 / 64 = 37.5%
      每 SM 在途字节 = 24 warp x 1 个访问 x 128 B = 3,072 B
    
    打满带宽需要每 SM 约 4,104 B(Little's Law):
      可得带宽 ≈ 1250 x (3072 / 4104) = 936 GB/s   -> 只有可达带宽的 75%
    
    版本 3(UNROLL = 4)在同样的 80+8 寄存器假设下:
      每 SM 在途字节 = 24 warp x 4 个访问 x 128 B = 12,288 B(需要的 3 倍)
      -> 带宽重新回到 1250 GB/s 附近
    

    这就是”低占用率 + 高 ILP ≡ 高占用率 + 低 ILP“的定量版本,也是现代 GPU kernel(FlashAttention 一类)普遍采用”少量线程、大量寄存器、强 ILP”风格的原因。

    (3)算术强度与 Roofline(三版本相同)

    AI = 1 FLOP / 8 B = 0.125,或含写为 1/12 = 0.0833 FLOP/Byte
    A100 平衡点 = 12.54 FLOP/Byte  ->  带宽受限
    Roofline 天花板 = 0.0833 x 1555 = 129.6 GFLOP/s
    版本 3 实测算力 = 0.0833 x 1261 = 105.0 GFLOP/s = 天花板的 81.0%
    向量加法的时间下限(不可突破):120 MB / 1555 GB/s = 77.2 us
    

    (4)瓶颈判定与优化方向

    三个版本都是带宽受限 + 延迟隐藏充分。因此优化方向只剩:

    • 减少字节数(唯一有大收益的方向):把 float 换成更窄的类型(如果精度允许)、避免不必要的中间数组、融合多个 pass。
    • 使用向量化访存 float4:每线程处理 4 个相邻float(而不是相隔 stride 的 4 个),一条 LDG.128 就搬运 16 字节/线程 = 512 字节/warp(4 次事务)。它把索引计算指令数降到 1/4,在算术强度极低的 kernel 上通常能带来 1%–3% 的提升(本 kernel 收益很小,因为指令发射不是瓶颈)。
    • 不做:提高占用率(已 100%)、增加 unroll(已饱和)、使用共享内存(无复用,纯增开销)。

示例 3:二维网格矩阵加法 matrixAdd(二维线程映射)

// 文件: matrixadd_2d.cu
// 编译: nvcc -O3 -arch=sm_80 matrixadd_2d.cu -o matrixadd_2d
// 运行: ./matrixadd_2d 4096 4096
//
// 内容: 用二维 grid / 二维 block 处理二维数据(C = A + B,矩阵加法)。
//       展示 row-major 线性化、二维边界检查、以及"哪个维度该当 threadIdx.x"。

#include <cstdio>
#include <cstdlib>
#include <cuda_runtime.h>

#define CUDA_CHECK(call)                                                        \
    do {                                                                        \
        cudaError_t err__ = (call);                                             \
        if (err__ != cudaSuccess) {                                             \
            fprintf(stderr, "CUDA error %s:%d: %s\n", __FILE__, __LINE__,       \
                    cudaGetErrorString(err__));                                 \
            exit(EXIT_FAILURE);                                                 \
        }                                                                       \
    } while (0)

#define TILE 32          // block 形状 32 x 32 = 1024 线程

// 二维线程映射:threadIdx.x 对应"列"(row-major 中最快变化的维度),
// 这样同一个 warp 的 32 个线程访问同一行里连续的 32 个 float = 128 字节。
__global__ void matrixAddKernel(const float* __restrict__ A,
                                const float* __restrict__ B,
                                float* __restrict__ C,
                                int width, int height)
{
    int col = blockIdx.x * blockDim.x + threadIdx.x;
    int row = blockIdx.y * blockDim.y + threadIdx.y;
    if (col < width && row < height) {              // 两个维度都要检查
        int idx = row * width + col;                // row-major 线性化
        C[idx] = A[idx] + B[idx];
    }
}

static void initMatrix(float* m, long n, unsigned int seed)
{
    unsigned int x = seed;
    for (long i = 0; i < n; ++i) {
        x = x * 1664525u + 1013904223u;
        m[i] = (float)(x >> 8) / (float)(1u << 24) - 0.5f;
    }
}

int main(int argc, char** argv)
{
    int width  = (argc > 1) ? atoi(argv[1]) : 4096;
    int height = (argc > 2) ? atoi(argv[2]) : 4096;
    if (width <= 0 || height <= 0) {
        fprintf(stderr, "width/height must be positive\n");
        return EXIT_FAILURE;
    }
    long N = (long)width * (long)height;
    size_t bytes = (size_t)N * sizeof(float);

    float* h_A   = (float*)malloc(bytes);
    float* h_B   = (float*)malloc(bytes);
    float* h_C   = (float*)malloc(bytes);
    float* h_Ref = (float*)malloc(bytes);
    if (!h_A || !h_B || !h_C || !h_Ref) {
        fprintf(stderr, "host malloc failed\n");
        return EXIT_FAILURE;
    }
    initMatrix(h_A, N, 12345u);
    initMatrix(h_B, N, 67890u);

    float *d_A = NULL, *d_B = NULL, *d_C = NULL;
    CUDA_CHECK(cudaMalloc((void**)&d_A, bytes));
    CUDA_CHECK(cudaMalloc((void**)&d_B, bytes));
    CUDA_CHECK(cudaMalloc((void**)&d_C, bytes));
    CUDA_CHECK(cudaMemcpy(d_A, h_A, bytes, cudaMemcpyHostToDevice));
    CUDA_CHECK(cudaMemcpy(d_B, h_B, bytes, cudaMemcpyHostToDevice));

    // 二维网格:每个维度都要向上取整
    dim3 dimBlock(TILE, TILE, 1);
    dim3 dimGrid((unsigned)((width  + TILE - 1) / TILE),
                 (unsigned)((height + TILE - 1) / TILE), 1);

    matrixAddKernel<<<dimGrid, dimBlock>>>(d_A, d_B, d_C, width, height);
    CUDA_CHECK(cudaGetLastError());
    CUDA_CHECK(cudaDeviceSynchronize());

    const int iters = 20;
    cudaEvent_t start, stop;
    CUDA_CHECK(cudaEventCreate(&start));
    CUDA_CHECK(cudaEventCreate(&stop));
    CUDA_CHECK(cudaEventRecord(start));
    for (int it = 0; it < iters; ++it) {
        matrixAddKernel<<<dimGrid, dimBlock>>>(d_A, d_B, d_C, width, height);
    }
    CUDA_CHECK(cudaEventRecord(stop));
    CUDA_CHECK(cudaEventSynchronize(stop));
    CUDA_CHECK(cudaGetLastError());
    float msTotal = 0.0f;
    CUDA_CHECK(cudaEventElapsedTime(&msTotal, start, stop));
    double msPerIter = (double)msTotal / iters;

    CUDA_CHECK(cudaMemcpy(h_C, d_C, bytes, cudaMemcpyDeviceToHost));
    for (long i = 0; i < N; ++i) { h_Ref[i] = h_A[i] + h_B[i]; }
    long mismatch = 0;
    for (long i = 0; i < N; ++i) { if (h_C[i] != h_Ref[i]) { ++mismatch; } }

    cudaDeviceProp prop;
    CUDA_CHECK(cudaGetDeviceProperties(&prop, 0));
    double peakGBs = 2.0 * (double)prop.memoryClockRate * 1.0e3
                   * ((double)prop.memoryBusWidth / 8.0) / 1.0e9;
    int blocksPerSM = 0;
    CUDA_CHECK(cudaOccupancyMaxActiveBlocksPerMultiprocessor(
        &blocksPerSM, matrixAddKernel, TILE * TILE, 0));

    double totalBytes  = 3.0 * (double)bytes;
    double achievedGBs = totalBytes / (msPerIter * 1.0e-3) / 1.0e9;

    printf("================= 矩阵加法 %d x %d =================\n", width, height);
    printf("GPU                  : %s, 峰值带宽 %.1f GB/s\n", prop.name, peakGBs);
    printf("blockDim             : (%d, %d), dimGrid = (%u, %u)\n",
           TILE, TILE, dimGrid.x, dimGrid.y);
    printf("总线程数             : %u (每个线程一个元素)\n",
           dimGrid.x * dimGrid.y * TILE * TILE);
    printf("正确性验证           : %s (不匹配 %ld / %ld)\n",
           mismatch == 0 ? "PASS" : "FAIL", mismatch, N);
    printf("kernel 单次耗时      : %.3f us\n", msPerIter * 1000.0);
    printf("有效带宽             : %.1f GB/s (%.1f%% of %.1f GB/s 峰值)\n",
           achievedGBs, 100.0 * achievedGBs / peakGBs, peakGBs);
    printf("每 SM 驻留 block 数   : %d (%d 线程/SM)\n",
           blocksPerSM, blocksPerSM * TILE * TILE);
    printf("占用率               : %.1f %%\n",
           100.0 * blocksPerSM * TILE * TILE / prop.maxThreadsPerMultiProcessor);
    printf("每 warp 事务数       : 3 (A/B/C 各 1 次 128 B 事务)\n");

    CUDA_CHECK(cudaFree(d_A));
    CUDA_CHECK(cudaFree(d_B));
    CUDA_CHECK(cudaFree(d_C));
    free(h_A); free(h_B); free(h_C); free(h_Ref);
    CUDA_CHECK(cudaDeviceReset());
    return (mismatch == 0) ? EXIT_SUCCESS : EXIT_FAILURE;
}
  • 【代码做什么?】

    1. 二维映射col = blockIdx.x * blockDim.x + threadIdx.xrow = blockIdx.y * blockDim.y + threadIdx.y。这是把一维公式分别应用到两个维度上,两个维度互相独立
    2. row-major 线性化:C/C++ 的二维数组在内存里按行连续存放,元素 (row, col) 的线性地址是 row * width + col(讲义中的例子:M[2][1] 在 4 列矩阵里编号为 2*4+1 = 9)。因此”行”是慢变维度、”列”是快变维度。
    3. 哪个维度当 threadIdx.x:必须让快变(列)维度对应 threadIdx.x,因为硬件按 threadIdx.x 最快线性化来划分 warp。这样同一个 warp 的 32 个线程访问的是同一行里连续的 32 个 float = 128 连续字节 = 1 次事务。如果反过来(col 对应 threadIdx.y),warp 内 32 个线程会跨越 32 行,每行取 4 字节,步长 width*4 = 16384 字节,事务数从 1 变成 32,带宽利用率掉到 3.1%。
    4. 二维边界检查if (col < width && row < height)。维度必须分别检查——只有一维需要检查时另一维可以省,但两个维度都写了更安全。讲义中的例子(76×62 图像用 16×16 的 block)需要 ceil(76/16)=5 列 × ceil(62/16)=4 行 = 20 个 block,覆盖 80×64,多出的区域全靠这两个条件拦住。
    5. 网格配置dimGrid = (ceil(width/TILE), ceil(height/TILE), 1),两个维度都向上取整;dimBlock = (TILE, TILE, 1),本程序 TILE = 32,即每个 block 1024 线程。
    6. 主机端流程与示例 1 相同(分配 → 拷贝 → 预热 → 计时 → 取回 → 用 CPU 参考实现逐位验证 → 打印带宽与占用率 → 释放)。
  • 【并行机制与硬件映射解说】

    (a)warp 划分:block 形状 32×32,threadIdx 的线性化顺序是 x 最快、y 次之、z 最后。所以 warp 0 = threadIdx.y = 0, threadIdx.x = 0..31(第 0 行),warp 1 = threadIdx.y = 1, threadIdx.x = 0..31(第 1 行),依此类推,一个 block 恰好 32 个 warp,每个 warp 对应一整行。

    (b)A100 上的驻留量:每 SM 2048 线程 / 64 warp。block 大小 1024 → 2048/1024 = 2 个 block(64 warp)→ 100% 占用率;寄存器约 16 个/线程 → 65536/(16×1024) = 4 个 block,不构成限制。但在 RTX 4090(1536 线程/SM)上,1536/1024 = 1 个 block → 1024 线程 = 32 warp → 只有 66.7% 占用率。这是”大 block 的阶梯效应”的典型例子:同样是 1024 线程的 block,在不同 GPU 上占用率相差 33%。稳健的选择是 16×16 = 256 线程的 block,它在 1536、2048 两种 SM 配置下都能整除并达到 100%。

    (c)全局内存访问是否合并:以矩阵 4096×4096 为例,width = 4096。block (0,0) 的 warp 0 处理 row = 0, col = 0..31,线性索引 idx = 0*4096 + 0..31 = 0..31,字节地址 0..127。基地址 256 字节对齐 → 1 次 128 字节事务,利用率 100%。warp 1 处理 row = 1, col = 0..31idx = 4096..4127,字节地址 16384..16511,同样是 1 次对齐的事务。每个 warp 3 次事务(A、B、C 各一次),与一维向量加法完全一致——二维映射本身不影响合并性,影响合并性的是”哪个维度最快变化”。

    (d)共享内存 / bank conflict:不使用共享内存,无 bank conflict。矩阵加法同样没有数据复用(每个元素只被读一次),共享内存是纯开销。

    (e)寄存器与发散:约 16 个寄存器/线程(两个坐标、一个 width、一个 idx、三个数据)。发散很轻微:只有落在 col >= widthrow >= height 区域的 warp 会发散。4096 是 32 的整数倍,所以本次运行完全没有发散;若换成 4096×4094,则每行的最后一个 warp 会有 30 个线程空转(因为 y 方向有 2 行是越界的,整个 warp 都不活跃)。用 16×16 的 block 时,越界区域是”半个 warp 的粒度”(因为一个 warp 只覆盖一行 16 个线程的两行),发散开销比 32×32 更细。

  • 【性能优化分析】

    (1)算术强度与 Roofline

    每个元素:1 FLOP(一次加法),访存 读 4 B + 读 4 B + 写 4 B = 12 B
    AI = 1 / 12 = 0.0833 FLOP/Byte(含写);只算读时 = 1/8 = 0.125 FLOP/Byte
    A100 平衡点 = 19.5e12 / 1555e9 = 12.54 FLOP/Byte
    AI 比平衡点低 100 倍以上  ->  纯带宽受限,与向量加法结论完全一致
    
    数据规模:4096 x 4096 x 4 B = 64 MiB/矩阵,总流量 192 MiB = 201.3 MB
    时间下限 = 201.3e6 / 1555e9 = 129.5 us
    实测(按 1250 GB/s)= 201.3e6 / 1250e9 = 161.0 us
    对应算力 = 0.0833 x 1250e9 = 104 GFLOP/s = FP32 峰值的 0.53%
    

    注意:把矩阵加法当成一个”更大的向量加法”是完全正确的——row * width + col 只是一个仿射变换,访问模式依然是纯顺序的。矩阵加法比向量加法多出来的唯一东西是”索引计算的指令数”和”两个维度各自的边界检查”,这些都属于指令开销,不是带宽开销。

    (2)占用率

    block = 32 x 32 = 1024 线程
    A100 : floor(2048/1024) = 2 block/SM -> 2048 线程 = 64 warp -> 100.0%
    RTX4090: floor(1536/1024) = 1 block/SM -> 1024 线程 = 32 warp ->  66.7%
    改用 16 x 16 = 256 线程:
    A100 : floor(2048/256) = 8 block/SM -> 2048 线程 -> 100.0%
    RTX4090: floor(1536/256) = 6 block/SM -> 1536 线程 -> 100.0%
    

    这就是讲义中那个经典分析(”1536 线程 / 最多 8 block 的 SM 上,8×8、16×16、32×32 哪个最好”)的推广:16×16 是跨硬件最稳健的二维 block 形状,8×8 会被”每 SM block 数上限”卡住(例如上限 8 时只能放 8×64 = 512 线程 = 25% 的可用线程容量),32×32 会被”每 SM 线程数上限”卡住。

    (3)瓶颈判定与优化方向 瓶颈 = 带宽受限(AI 0.0833 ≪ 12.54),延迟隐藏充分(A100 上 100% 占用率,或在 4090 上用 16×16 时 100%)。

    每 SM 在途字节(A100, 32x32 block, 2 block/SM = 64 warp):
       64 warp x 1 个在途访问 x 128 B = 8,192 B   >= 需要的 4,104 B(2 倍余量)
    这是否足够? 是。所以矩阵加法在 A100 上同样能达到约 80% 的峰值带宽。
    

    优化方向:

    • 二维 kernel 里widthheight 传成 int 是够的(4096 不会溢出),但如果规模可能超过 2^31 个元素,row * width + col 必须用 long long。这是二维 kernel 最常见的溢出点。
    • 如果不需要二维语义(比如整个矩阵统一加一个常数),直接用一维映射:更少的索引指令、更简单的边界检查,实测通常快 1%–2%。
    • 只有在需要邻域访问(模糊、卷积、stencil)时,二维 block 的形状才真正重要——那时需要共享内存来做 halo 交换,block 形状会影响 halo 占比和 bank conflict。

Lab 1 的工程习惯与评分口径

(一)三条必须养成的工程习惯

   ① CPU 端 golden reference —— "没有参考实现的性能数字毫无意义"
      - 在 CPU 上用最朴素的双重循环/单重循环算一遍,作为标准答案。
      - 对欧拉式 kernel(每元素独立、无累加),结果应当逐位相同(bit-exact),
        可以直接用 == 比较;对累加/reduction 类 kernel 才需要 epsilon。
      - 在 GPU 上跑出"更快"的结果但没验证 = 得到一个错误的数字,Lab 直接 0 分。
      - 建议把验证做成函数 verify(),在任何性能测量之前先跑一遍。

   ② 边界检查 —— "最后一个 block 的越界线程一定会捅娄子"
      - 网格向上取整:dimGrid = (N + blockDim - 1) / blockDim(整数运算)。
      - kernel 里:if (i < N) { C[i] = A[i] + B[i]; },二维时两个维度分别检查。
      - 测试用例要专门包含 N 不是 blockDim 整数倍的情况
        (例如 N = 1000、N = 1025、N = 1),这些正是能暴露 bug 的输入。

   ③ CUDA 错误检查 —— "静默失败是 CUDA 的默认行为"
      - 每一个 CUDA API 调用都用 CUDA_CHECK 宏包起来。
      - kernel 启动后用 cudaGetLastError() 检查启动期错误(非法配置、越界等)。
      - 计时循环之后必须 cudaDeviceSynchronize()(或 cudaEventSynchronize),
        否则测到的是"启动 kernel 的时间"而不是"kernel 执行的时间"。
      - 强烈建议在 debug 构建里跑一次 cuda-memcheck(或 compute-sanitizer),
        它能直接指出越界访问的地址和线程号。
   开发循环(推荐的调试顺序)

   写 kernel
      -> 用 N = 1、N = 31、N = 33、N = 1024、N = 1025 五个小规模跑通(验证边界)
      -> 与 CPU 参考实现逐位对比,全部 PASS
      -> 再放大到 N = 10^6 / 10^7 测性能
      -> 用 cudaEvent 只圈住 kernel,迭代 >= 20 次取平均(消除抖动)
      -> 先预热 1 次,丢弃第一次的测量
      -> 报告"达到峰值带宽的百分比",而不是裸的毫秒数
   切忌:先调性能,后补正确性。这样做的学生最后往往两者都没有。

(二)评分口径(课程 Lab 的一般形式)

   ┌─────────────────────────────────────────────────────────────────────┐
   │ 第一部分:正确性测试(通常占大头,不正确则性能分无效)               │
   │   - 用课程提供的 harness 把学生写的 .cu 与参考答案对比               │
   │   - 多个规模:小规模(暴露边界 bug)、中等规模、大规模(暴露溢出)   │
   │   - 多个数据分布:随机值、全零、极端值(验证数值稳定性)             │
   │   - 容差:整数/单次加法类要求精确;归约类允许相对误差 1e-5 量级      │
   │   - 常见的"部分分"设计:只处理 N 为 blockDim 整数倍的情况得部分分    │
   ├─────────────────────────────────────────────────────────────────────┤
   │ 第二部分:性能测试(在指定 GPU 上测,通常是 NCSA Delta 的 A100)     │
   │   - 指标一般是有效带宽(GB/s)或相对基线 kernel 的加速比            │
   │   - 常见门槛:达到理论峰值带宽的 70%-85%(向量加法类)              │
   │   - 计时只圈 kernel,不含 cudaMemcpy;多次迭代取平均                │
   │   - 有的版本会给出不同 N 的曲线,考察"随规模变化的趋势"的解释       │
   ├─────────────────────────────────────────────────────────────────────┤
   │ 第三部分:实验报告(不少版本要求提交)                               │
   │   - 说明线程映射公式、网格大小计算、边界处理的正确性论证             │
   │   - 给出带宽测量方法(如何排除传输、如何预热、迭代次数)             │
   │   - 用算术强度 / Roofline / 占用率解释"为什么这个 kernel 是带宽受限" │
   │   - 讨论:为什么继续优化(更多 unroll、更大 block)收益很小         │
   └─────────────────────────────────────────────────────────────────────┘
   注:以上是课程 Lab 的一贯口径的概括,具体门槛以当学期 handout 为准。

性能优化技巧总结

  1. i = blockIdx.x * blockDim.x + threadIdx.x 做最简映射:它只需一条 IMAD 指令,且天然使相邻线程访问相邻地址——映射公式本身就是合并访存的前提。
  2. 网格大小一定向上取整(N + blockDim.x - 1) / blockDim.x。向下取整会漏掉尾部元素,是 Lab 1 最常见的丢分点。
  3. kernel 内必须做边界检查:越界写会静默破坏其它内存;二维问题两个维度都要检查。
  4. threadIdx.x 对应内存中变化最快的维度:warp 按 threadIdx.x 最快线性化,这样 32 个线程的访问才是 128 连续字节 = 1 次事务。
  5. __restrict__ 标注指针:承诺”这些指针不重叠”,编译器才敢把多条访存提前并重排,粗化/UNROLL 才能真正形成在途并行。
  6. block 大小选 128/256/512,别默认用 1024:1024 会在 1536 线程/SM 的 GPU 上因阶梯效应只能放 1 个 block(占用率掉到 66.7%),且尾部浪费比例更大。
  7. cudaOccupancyMaxActiveBlocksPerMultiprocessor 实测占用率,不要凭寄存器数猜——寄存器、共享内存、block 数上限三个约束取最小值才是真相。
  8. 用”每 SM 在途字节 = 带宽 × 延迟 / SM 数”检查延迟隐藏是否足够(A100 约 4.1 KB/SM):不够就提高占用率,或每线程多发几个独立访存(thread coarsening)。
  9. 粗化时让同线程的多个访问地址相隔 stride(网格总线程数),而不是相隔 blockDim.x:这样每一个访问各自仍然合并,且覆盖率不会出现漏洞。
  10. 只圈 kernel 计时cudaEvent 包住 kernel 循环,排除 cudaMemcpy;预热一次;迭代 20 次以上取平均。
  11. 报告”峰值带宽百分比”而不是绝对毫秒:绝对时间随 GPU 型号变化,百分比才说明你的 kernel 有没有打满带宽。
  12. 减少字节数永远比提高并行度有效:当 kernel 已带宽受限时,任何占用率/ILP 优化都不会有可观测收益,唯一的出路是少搬数据(或提升数据复用——那是共享内存的领域)。

关键要点

  • 线程映射公式 i = blockIdx.x * blockDim.x + threadIdx.x 是数据并行的全部骨架:它把”第 b 个 block 的第 t 号线程”唯一地映射到一个数据元素,使硬件可以完全自主地调度 block(block 之间的执行顺序是任意的、不确定的,所以绝不能让 block 之间互相依赖)。
  • 一个 warp 的 32 次 4 字节访问,如果地址连续且 128 字节对齐,会被合并成 1 次 128 字节事务(利用率 100%);如果跨步到 32 个不同的 cache line,就是 32 次事务(利用率 3.1%,sector 粒度下也是 8 倍放大)。合并与否直接决定带宽的有效利用率,是最重要的性能分水岭。
  • 向量加法的算术强度只有 0.125 FLOP/Byte(含写为 0.0833),而 A100 的机器平衡点是 12.54 FLOP/Byte,低了 100 倍:它必然、也只能是纯带宽受限。它的时间下限是 3 × N × 4 字节 / 峰值带宽,N = 10⁷ 时是 77.2 µs;实测 96 µs(1.25 TB/s,峰值的 80.4%)已经接近极限,FP32 流水线只有约 0.5% 的周期在工作。
  • 占用率用”活跃 warp 数 / SM 最大 warp 数”计算,由线程数上限、寄存器上限、block 数上限三者取最小值决定:A100 上 2048 线程 / 64 warp / 65536 寄存器 / 32 block。轻量 kernel(16 寄存器)在 128–1024 的任何 block 大小下都能到 100%,但寄存器用量大的 kernel 会被寄存器卡住(80 寄存器 + 256 线程 → 只有 3 个 block → 37.5%)。
  • 占用率的意义是”藏住 400–800 周期的全局内存延迟”:Little’s Law 给出量化标准——每 SM 需要约 4.1 KB 在途字节(A100)。64 warp × 1 个在途合并访问 = 8 KB(2 倍余量,够用);16 warp × 1 = 2 KB(只够一半带宽);而 16 warp × 4 个独立访存 = 8 KB(又够了)——这就是线程粗化用 ILP 换占用率的定量依据。
  • 网格-步长循环把”网格大小”与”问题规模”解耦:网格由设备(SM 数 × 每 SM 可驻留 block 数)决定,每线程循环处理多个元素,因而同一份代码能自适应任意 GPU、任意 N、多 GPU 划分和运行时才知道的规模,同时消除波次量化(wave quantization)的尾部浪费和大量 block 的调度开销。

常见陷阱与注意事项

  • 整数除法取整导致网格覆盖不足dimGrid = N / blockDim.x 在 N 不是 blockDim.x 整数倍时漏算最后的元素(N = 10,000,000 时漏 128 个,正确性测试直接失败)→ 改用 (N + blockDim.x - 1) / blockDim.x,并用 long long 计算分子避免 N + B - 1 溢出。
  • 忘记边界检查(或只检查一个维度)导致越界访问if (i < N) 漏写时,最后一个 block 的多余线程会读写自己数组之外的内存(可能写坏相邻分配甚至 CUDA 内部结构),表现为”时对时错”或延迟崩溃,用 compute-sanitizer 才能定位;二维 kernel 里只写 if (col < width) 而遗漏 row < height,在高度不是 blockDim.y 整数倍时同样越界 → 网格向上取整与边界检查是一对,不能只写一半;二维问题的越界区域在行、列两个方向都存在,必须分别检查。附带一点:if (i < N) 本身会在跨界的那一个 warp 里造成 warp 发散(branch divergence)——warp 内一部分线程走 then 分支、一部分走 else 分支,硬件必须串行执行两条路径——但由于它只在每个 kernel 的最后一轮发生一次,代价可以忽略;真正要避免的是”用 threadIdx 做条件去划分大量工作”(例如 if (threadIdx.x > 2) { 大量代码 } else { 大量代码 }),正确做法是让分支粒度对齐 warp 大小,写成 if (threadIdx.x / 32 > 2),这样任何 warp 都只走一条路径。
  • 忘记 cudaDeviceSynchronize() / 未同步就测量:kernel 启动是异步的,在 kernel 后立刻读主机内存或用 CPU 时钟计时,测到的是启动开销而不是执行时间,还会读到未完成的结果 → 用 cudaEvent 计时并在 cudaEventSynchronize 之后读 cudaEventElapsedTime;验证结果前也必须同步。
  • 忘记检查 CUDA API 返回值与 kernel 启动错误cudaMalloc 失败(显存不足)、<<<>>> 配置非法(block 超过 1024、共享内存超出)都会返回错误码但不中断程序,后续代码在一片未初始化的内存上运行 → 所有 API 用 CUDA_CHECK 包裹;kernel 启动后立刻 cudaGetLastError()
  • host/device 指针混用与 cudaMemcpy 方向写反:把主机 malloc 得到的指针直接传给 kernel 会触发非法内存访问并报 “an illegal memory access was encountered”(一旦出现这个错误,整个 CUDA context 都会被污染,后续所有调用都失败);把 cudaMemcpyHostToDevicecudaMemcpyDeviceToHost 弄混则会把设备内存的垃圾拷到主机 → 设备端内存必须用 cudaMalloc(或 cudaMallocManaged),主机端不能解引用 cudaMalloc 返回的指针;记住 cudaMemcpy 的参数顺序是 (dst, src, count, kind)kind 描述的是”数据从哪里来”。
  • 跨步(strided)访问破坏合并性:为了”看起来更整齐”而写 i = threadIdx.x * gridDim.x + blockIdx.x(按列遍历),会让一个 warp 的 32 个线程访问相隔 156 KB 的地址,事务数从 1 涨到 32(sector 粒度下流量放大 8 倍),实测带宽掉到 1/8 → 始终让 threadIdx.x(线性化最快的维度)对应连续内存;__restrict__ 与向量化都不能弥补这个问题。
  • 在已饱和的带宽受限 kernel 上继续优化并行度:把 unroll 从 4 加到 8、把 block 从 256 改到 1024、加 __launch_bounds__ 提高占用率,在向量加法上都不会有可观测收益(三版本实测差距在 1.4% 以内)→ 先用算术强度和 Roofline 判断瓶颈类型,带宽受限时只做”减少字节数”和”保证合并”这两件事。
  • 粗化时用 blockDim.x 而不是 stride 作为线程内多元素的间隔:会让不同线程的覆盖区间互相重叠或出现空洞(测试 N 不是 stride 整数倍时结果错误且难以察觉)→ 同一个线程的多个元素地址必须相隔 blockDim.x * gridDim.x,这样每个访问各自合并、整体恰好无重复无遗漏地覆盖 [0, N)

思考题(带答案)

Q1. 一个 1D 数组有 N = 1,000,000 个 float,用 blockDim = 256、每线程一个元素的向量加法处理。请给出网格大小、启动的线程总数、多出的空转线程数、一个 warp 的访存事务数、总事务数,以及在 A100 上打满峰值带宽所需的最短时间。

网格大小 = ceil(1,000,000 / 256) = 3907 个 block;启动线程总数 = 3907 × 256 = 1,000,192,多出 192 个空转线程(占 0.019%)。每个 warp(32 个连续 float)对 A、B、C 各有 1 次 128 字节事务,即 3 次/warp;有效 warp 数 = 1,000,000 / 32 = 31,250总事务数 = 31,250 × 3 = 93,750 次,共搬运 93,750 × 128 B = 12,000,000 B = 12 MB。A100 峰值带宽 1555 GB/s,最短时间 = 12e6 / 1555e9 = 7.72 µs。注意这个规模太小,kernel 的 launch 开销(约 3–5 µs)已经与之同量级,这也是为什么性能实验要用 N = 10⁷ 以上。

Q2. 某 kernel 中线程 t 访问 A[(t % 32) * 4096 + (t / 32)](float 数组,t 为 warp 内线程号 0..31)。请分析其访存效率;如果这是 Lab 1 的向量加法 kernel,实际带宽会变成多少(设 A100 可达带宽 1250 GB/s)?

warp 内相邻线程(t 和 t+1)的地址相差 4096 个 float = 16,384 字节。32 个线程访问的地址就是 4096 × t(t 取 0 到 31),即元素下标 0、4096、8192、12288 一直到 126976,也就是 32 个彼此相隔 16 KB 的地址。每个地址落在不同的 128 字节 cache line(也落在不同的 32 字节 sector),所以事务数 = 32 次/warp,用 sector 粒度算是 32 × 32 B = 1024 B 搬回 128 B 有用数据,流量放大 8 倍。实际带宽 ≈ 1250 / 8 = 156 GB/s,只有原来的 12.5%,if (i < N) 也拦不住这种错误(它是地址映射错误,不是越界)。

Q3. A100 的 FP32 峰值是 19.5 TFLOPS、显存带宽 1555 GB/s。有人提出”把向量加法改写成一个每元素做 16 次浮点运算的 kernel(例如同时做一次多项式求值)”,声称这样能”更好地利用 GPU 的算力”。请判断这个说法,并算出这个 kernel 在新算术强度下是带宽受限还是计算受限。

原来的 AI = 1 FLOP / 8 B = 0.125 FLOP/Byte。新 kernel 每元素 16 FLOP,流量仍是 8 B(读 A、B)或 12 B(含写 C):AI = 16 / 12 = 1.333 FLOP/Byte。A100 的机器平衡点 = 19.5e12 / 1555e9 = 12.54 FLOP/Byte1.333 < 12.54,所以它仍然是带宽受限的,Roofline 天花板 = 1.333 × 1555e9 = 2.07 TFLOP/s,仅占 FP32 峰值的 10.6%。要想让它在 A100 上变成计算受限,需要 AI ≥ 12.54,即每元素至少 12.54 × 12 ≈ 150 次浮点运算。这个例子说明:“多算一点”几乎不可能让一个访存密集的 kernel 变成计算密集的——要跨越 100 倍的算术强度鸿沟,必须改变数据复用方式(分块、共享内存),而不是增加每元素的运算次数。