Lecture 10: 性能分析与优化方法论 —— Roofline、占用率、合并访问与原子操作 (对应全部 Lab)

目录 · ← l9 · l11 →

Lecture 10: 性能分析与优化方法论 —— Roofline、占用率、合并访问与原子操作 (对应全部 Lab)

概述

本讲把 ECE408 全部 Lab 都要用到的性能分析方法论系统化:先理解 DRAM 的组织方式(bank/channel/行缓冲/burst),从而明白”为什么访问 4 字节却要搬 32 字节”;再用合并访问(coalescing)的定量模型把 warp 的 32 次访问映射成 1~32 次 transaction;然后用算术强度(arithmetic intensity)与 Roofline 模型判断一个 kernel 究竟卡在带宽还是卡在计算;用延迟隐藏与 Little’s Law解释为什么 GPU 需要大量常驻 warp,并由此导出占用率(occupancy)的计算步骤与”够用就好”原则;最后用原子操作与私有化(privatization)处理直方图这类不可避免的写冲突,并以 cudaEvent + ncu 为核心的测量方法学收尾。本讲不引入新的并行模式,而是给出一套可复用的”看数字 → 定位瓶颈 → 选优化手段”的闭环流程。

核心概念与 GPU 架构图解

DRAM 的组织与突发传输(DRAM Organization, Banks and Burst Mode)

  • 定义与目的:动态随机存储器(Dynamic RAM, DRAM)用一个晶体管加一个电容存 1 bit,密度极高但速度远低于逻辑电路。为了让这种”慢而密”的存储器件跟上 GPU 的胃口,硬件采用了三种并行/摊销手段:多 bank(多个阵列交替工作,掩盖激活延迟)、行缓冲(row buffer)(一次激活整行,连续访问都命中)、突发传输(burst)(一次地址译码搬出一整块数据)。理解这三者,才能理解”最小取用粒度”这个概念——它直接决定了任何 CUDA kernel 的带宽上限。

  • 直观解释(”它是什么?”):把 DRAM 想成一座巨大的立体仓库。bank 是并排的几十排货架,可以同时有人在不同货架上取货;一行(row) 是货架的一整层,取货前必须把整层用叉车推出(ACTIVATE,激活),这个过程很慢(约 13 ns);推出后这层就停在行缓冲这个”暂存台”上,此时取这一层里的任何一件货都很快(约 13 ns,只有列选通时间 tCAS)。如果下一件货在别的层,就得先把这层推回去(PRECHARGE,预充电),再把另一层推出来——这一来一回就是行冲突(row miss),代价约 39 ns,是行命中(row hit)的 3 倍。而 burst 是仓库的规矩:叉车一旦出动,必须整托盘搬出来,哪怕你只要其中一件。现代 DRAM 系统被设计成”永远工作在 burst 模式”,于是只要你要的那件货不在托盘里,整托盘就白搬了——这正是 GPU 上”访问 4 字节却搬来 32 字节”浪费的根源。

  • 架构/机制图解

              一个 DRAM Channel(例:HBM2e 的一个 128-bit 伪通道)
  ┌────────────────────────────────────────────────────────────────────────────┐
  │  Command / Address Bus(接口是时钟同步的;DRAM 单元本身不同步)             │
  │        │                                                                   │
  │        ▼                                                                   │
  │  ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐           │
  │  │   Bank 0    │ │   Bank 1    │ │   Bank 2    │ │   Bank 3    │  ......   │
  │  │ ┌─────────┐ │ │ ┌─────────┐ │ │ ┌─────────┐ │ │ ┌─────────┐ │           │
  │  │ │  Row 0  │ │ │ │  Row 0  │ │ │ │  Row 0  │ │ │ │  Row 0  │ │  每个     │
  │  │ │  Row 1  │ │ │ │  Row 1  │ │ │ │  Row 1  │ │ │ │  Row 1  │ │  Bank     │
  │  │ │   ...   │ │ │ │   ...   │ │ │ │   ...   │ │ │ │   ...   │ │  有自己   │
  │  │ │ Row 1023│ │ │ │ Row 1023│ │ │ │ Row 1023│ │ │ │ Row 1023│ │  的行缓冲 │
  │  │ └─────────┘ │ │ └─────────┘ │ │ └─────────┘ │ │ └─────────┘ │           │
  │  │ Row Buffer  │ │ Row Buffer  │ │ Row Buffer  │ │ Row Buffer  │  (Sense   │
  │  │ (Sense Amps)│ │ (Sense Amps)│ │ (Sense Amps)│ │ (Sense Amps)│   Amps)   │
  │  └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘           │
  │         └───────────────┴───────┬───────┴───────────────┘                  │
  │                          Column MUX(列选通)                              │
  │                                 │                                          │
  │                   32-bit 数据总线,以 burst 方式连续传输                    │
  └─────────────────────────────────┼──────────────────────────────────────────┘
                                    ▼
                        L2 Cache / GPU 内部交叉开关
【行缓冲命中 vs 行缓冲冲突 —— 时序对比】

Row Buffer HIT(要的数据就在已激活的行里)
 周期:  | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | ...
 命令:  | READ      | READ      | READ      | READ      |
 数据:  |    D0..D3 |    D0..D3 |    D0..D3 |    D0..D3 |
         └─ 只需 tCAS ≈ 13 ns(≈ 18 个 1.41 GHz 周期),可以背靠背连续发射

Row Buffer MISS(要的数据在另一个未激活的行里)
 周期:  | 0 ... 12 | 13 ... 26 | 27 | 28 ... |
 命令:  | PRECHARGE | ACTIVATE  |   READ        |
 数据:  |                                   | D0..D3 |
         └─ tRP(≈13ns) + tRCD(≈13ns) + tCAS(≈13ns) ≈ 39 ns,是行命中的 3 倍
【Burst 决定"最小取用粒度"】

 上层请求:我要第 4 号 float(4 字节)  ──┐
                                          │  地址译码
                                          ▼
  ┌──────────────────── DRAM core array ────────────────────┐
  │ Row 1023 被整体激活,整行数据被 Sense Amp 锁存            │
  └──────────────────────────┬──────────────────────────────┘
                             │  Column MUX 按 burst 选通
                             ▼
  数据总线一次搬出(DDR BL8 × 32-bit 通道 = 32 字节):
  ┌────┬────┬────┬────┬────┬────┬────┬────┐
  │ B0 │ B1 │ B2 │ B3 │ B4 │ B5 │ B6 │ B7 │  = 32 字节
  └────┴────┴────┴────┴────┴────┴────┴────┘
    ▲
    └── 只有 B0..B3(你真正要的 4 字节)有用,其余 28 字节被丢弃!
       即使你只要 4 字节,DRAM 侧也必须搬 32 字节 → 效率上限 4/32 = 12.5%
  • 关键操作与性能特征
    • 最小取用粒度:GPU 的 L2 与 DRAM 之间的搬运粒度是一个 32 字节 sector;L1/L2 的 cache line 是 128 字节(由 4 个 sector 组成)。也就是说,任何一次”没有命中 cache”的全局内存访问,最少也要从 DRAM 搬 32 字节。若一个 warp 的 32 个线程各取 4 字节且分散在 32 个不同的 sector 里,实际搬运量是 32×32 B = 1024 字节,而有用数据只有 128 字节,浪费率 87.5%。
    • 延迟数字:DRAM 访问延迟(从发出请求到数据回到寄存器)在 A100 上约 400–800 个时钟周期;命中 L2 约 200 周期;命中 L1 约 30 周期;共享内存约 20–30 周期。行冲突(row miss)相对行命中额外增加约 26 ns(tRP + tRCD)。
    • 刷新(refresh):电容会漏电,讲义给出的量级是比特大约 50 ms 后就会丢失,因此 DRAM 必须周期性重写整个阵列。以业界常见的 8192 次刷新 / 64 ms 计,平均每 7.8 µs 就要刷新一次,单次刷新占用约 350 ns,净开销约 4–5%,这部分带宽是任何 kernel 都拿不到的。
    • 带宽利用率的上限:A100 的标称带宽 1555 GB/s 是 DRAM 引脚峰值;扣除刷新、行冲突、读写切换(tWTR/tRTW)后,实测持续可用的拷贝带宽通常在 1300–1400 GB/s(约 85–90%)。任何”有效带宽”超过这个数的测量结果都是计时或口径有问题。

合并访问(Memory Access Coalescing)

  • 定义与目的:合并访问是指硬件把同一个 warp 内 32 个线程在同一条访存指令中发出的地址合并成尽可能少的 transaction。它是连接”CUDA 编程模型(warp 是执行单位)”与”DRAM 组织(burst/sector 是最小取用粒度)”的桥梁,也是 GPU 上唯一由程序员完全掌控、收益又最大的一项访存优化。

  • 直观解释(”它是什么?”):想象 32 个同事一起去仓库领料。如果他们的清单正好是货架上连续的一整排(连续地址),叉车出动一次就把 32 件货全带回来了——这就是合并,一次 transaction 换 32 个有用数据。如果每个人要的货分散在仓库的 32 个不同托盘上(跨步地址),叉车就必须出动 32 次,每次只带回 1 件有用的货,其余 31 件丢掉——效率掉到 1/32。关键点:这不是”延迟变差”,而是”带宽被浪费”。GPU 有足够的 warp 去掩盖延迟,但没有办法凭空造出带宽。

  • 架构/机制图解

【情况 1】stride = 1(合并):warp 访问连续 32 个 float
  lane 编号:   0     1     2     3     4   ...   31
  字节地址:    0     4     8     12    16   ...   124
  ├──────────────────── 128 字节 = 1 条 cache line ────────────────────┤
  ├── 32B ──┤├── 32B ──┤├── 32B ──┤├── 32B ──┤   4 个 sector,全部有用
  → 1 次 transaction / 4 个 sector / 取回 128 B / 全部有用 → 效率 100%

【情况 2】stride = 8(跨步):warp 内每线程跨 8 个 float
  lane 编号:   0     1     2     3     4   ...   31
  字节地址:    0     32    64    96    128  ...   992
  |sector0| |sector1| |sector2| |sector3| ... |sector31|
    ↑4B有用    ↑4B有用   ↑4B有用   ↑4B有用        ↑4B有用
  → 32 次 sector 请求 / 取回 32×32 B = 1024 B / 只有 128 B 有用
  → 效率 12.5%(按 128 B cache line 计则为 32 条 line、4096 B、3.125%)

【情况 3】stride = 2(半合并)
  lane 编号:   0     1     2     3     4   ...   31
  字节地址:    0     8     16    24    32   ...   248
  ├── 32B ──┤├── 32B ──┤├── 32B ──┤ ... ├── 32B ──┤
    ↑4B有用    ↑4B有用    ↑4B有用           ↑4B有用
  → 8 个 sector / 取回 256 B / 128 B 有用 → 效率 50%
  • 定量对比表(A100,峰值带宽 1555 GB/s)
每线程跨步 s(float)warp 地址跨度触及 32B sector 数搬回字节(sector 口径)触及 128B line 数搬回字节(line 口径)有用率(sector)有效带宽上限(sector 口径)
1(连续 32 个 float)128 B4128 B1128 B100%1555 GB/s
2256 B8256 B2256 B50%778 GB/s
3384 B12384 B3384 B33.3%518 GB/s
4512 B16512 B4512 B25%389 GB/s
81024 B321024 B81024 B12.5%194 GB/s
162048 B321024 B162048 B12.5%194 GB/s
324096 B321024 B324096 B12.5%194 GB/s

表中的关键结论有三条:① 每个线程跨步越远,浪费越大,但一旦 s ≥ 8(一个 sector 装 8 个 float),效率就在 12.5% 触底不再下降——因为”一个 lane 至少占一个 sector”已经是不可再坏的下限;② 如果硬件的合并粒度是 128 B cache line 而不是 32 B sector,stride = 32 的效率会低到 3.125%,这就是为什么”跨步访问到底有多惨”必须说清口径;③ 合并访问的收益是纯带宽收益,与延迟无关,所以它不需要靠更多 warp 来弥补,是”免费的午餐”。

讲义中的等效例题也说明了同一点:一个 burst 为 512 字节、峰值带宽 240 GB/s 的 DRAM 系统上执行 float temp = A[4*i] + A[4*i+1];,由于每个线程只用 burst 里每隔 8 字节的两个 float,有效率减半,只能期望 120 GB/s;而在没有 cache 的早期 GPU 上,两次 load 无法被合并,会进一步掉到 60 GB/s(每次 load 只用 16 B 中的 4 B)。

  • L2 cache line 大小对 transaction 的影响:warp 的一次合并访问恰好是 128 字节 = 一条 cache line,这是硬件设计的巧合也是设计目标。这意味着:只要 warp 的访问窗口对齐到 128 字节边界,就恰好是 1 次 transaction。如果数据起点没有 128 字节对齐(例如 float* p = base + 1;),一个 warp 的 128 字节窗口会横跨两条 cache line,transaction 数变成 2,效率掉到 50%。所以 CUDA 里”对齐”和”合并”是同一件事的两面:float4(16 字节)向量化访问之所以快,除了减少指令数,更重要的是让每个线程一次搬 16 字节,一个 warp 就是 512 字节 = 4 条完整的 cache line,永远不会跨越 line 边界

算术强度与 Roofline 模型(Arithmetic Intensity & Roofline Model)

  • 定义与目的算术强度(arithmetic intensity, AI)= 浮点运算次数 / 访存字节数,单位 FLOP/Byte。它把一个 kernel 的全部特征压缩成一个标量,用来回答”这个 kernel 到底是算得多还是搬得多”。Roofline 模型把这个标量放到一张二维图上:横轴是 AI(对数),纵轴是性能(GFLOP/s,对数),机器给出两条上限——水平的计算峰值线与斜率为带宽的带宽线,两者的交点叫拐点/脊点(ridge point),其横坐标就是机器平衡点(machine balance)。Roofline 的价值在于:在做任何优化之前,先告诉你这块 kernel 的理论天花板是多少;如果你的实测性能已经贴着天花板,再怎么优化代码都是白费力气,必须换算法(提高复用)或换硬件。

  • 直观解释(”它是什么?”):把 GPU 想成一家餐厅,计算单元是厨师,内存带宽是传菜口。AI 就是”每端一盘菜(每字节)能做几道工序”。如果每盘菜只做 1 道工序(AI 很低),厨师们大部分时间在等菜,餐厅产能被传菜口卡死——这就是带宽受限(memory bound);如果每盘菜要做 50 道工序(AI 很高),传菜口闲得发慌而厨师排满队——这就是计算受限(compute bound)机器平衡点就是这家餐厅的”菜谱配比”:A100 的配比是 每字节能配到 12.5 次浮点运算,菜谱比这更”费菜”就是带宽受限,比这更”费工”就是计算受限。

  • 架构/机制图解(A100 的 Roofline)

GFLOP/s(对数轴)
  22000 |                                               .K........
        |                                           .T..
  13318 |                                      .....
        |                                  ....T
   8062 |                             .....
        |                         C...
   4880 |                     ....
        |                .....
   2954 |            .M..
        |       V..S.
   1788 |   ....
        | R.
    88  |____________________________________________________________
        +------------------------------------------------------------
         0.06  0.125 0.25  0.5   1      2     4     8   12.5    32
                    算术强度 AI(FLOP / Byte,对数轴)

  图例(均按本课程各 Lab 的常用计费口径):
    R  = Reduction         AI ≈ 0.06   → 屋顶 93 GFLOP/s(本课最"费带宽"的 kernel)
    V  = vecAdd            AI = 0.125  → 屋顶 194 GFLOP/s
    S  = SpMV              AI ≈ 0.17   → 屋顶 264 GFLOP/s
    M  = MatMul 朴素版      AI = 0.25   → 屋顶 389 GFLOP/s
    C  = Convolution       AI ≈ 1      → 屋顶 1555 GFLOP/s
    T  = Tiled MatMul      AI = 4 与 8 → 屋顶 6220 / 12440 GFLOP/s
    K  = 拐点(脊点)         AI = 12.5, 性能 = 19.4 TFLOP/s

  --------- 斜屋顶以下 = 带宽受限区(Memory Bound):性能 = 1555 GB/s × AI ---------
  --------- 拐点右侧水平线以下 = 计算受限区(Compute Bound):上限 19.5 TFLOP/s -------
  • 关键公式与代入数字(基准机 A100,108 SM,1555 GB/s,19.5 TFLOPS FP32):
  机器平衡点 = 计算峰值 / 带宽峰值
             = 19.5e12 FLOP/s ÷ 1555e9 B/s
             = 12.54 FLOP/Byte                       ← 拐点横坐标

  Roofline 可达性能 P(AI) = min( 19.5 TFLOP/s , 1.555 TFLOP/s × AI )

  vecAdd 的实测代入:
    FLOPs = N 次加法 = 1 FLOP/element
    Bytes = 读 A(4B) + 读 B(4B) + 写 C(4B) = 12 B/element  → AI = 1/12 = 0.083
    (若按"B 已在寄存器/常数中、只计 4B 读 + 4B 写"的口径,AI = 1/8 = 0.125,即图中位置)
    屋顶 = 1555 GB/s × 0.125 = 194 GFLOP/s
    → 相对于 19.5 TFLOP/s 的算力,只用到 194/19500 = 1.0%

几个 Lab 的 AI 推导(务必记住推导过程而不是数字,因为计费口径不同差一倍很常见):

KernelFLOP 计费Byte 计费AI (FLOP/B)是否带宽受限
vecAdd1(一次加法)8(读 4 + 写 4)0.125是(极端)
Reduction1(一次加法)16(含多趟部分和读写摊销)≈0.06是(最严重)
SpMV2(一次乘加)12(4B 值 + 4B 列索引 + 4B 向量 gather)≈0.17
MatMul 朴素2(一次乘加)8(4B M + 4B N,无复用)0.25
Convolution(朴素)24(每个 N 元素一次)0.5
Convolution(依赖 L1/L2 部分复用)22(有效 DRAM 字节被复用摊薄)≈1
MatMul Tiled(TILE=16)28/16 = 0.54接近拐点
MatMul Tiled(TILE=32 + 寄存器分块)28/32 = 0.258接近/跨过拐点

讲义里对卷积的”复用倍数”推导给了这套方法论的经典范例:朴素 2D 卷积每个 N 元素被读一次、贡献 2 FLOP,即 2 Byte/FLOP;2010 年 1000 GFLOP/s 配 150 GB/s 的 GPU 上,150 GB/s ÷ 2 B/FLOP = 75 GFLOP/s只占峰值算力的 7.5%,要达到 100% 需要 100/7.5 = 13.3× 的复用;到 2020 年 GRID K520(约 5000 GFLOP/s / 192 GB/s)需要 52.1× 复用;到 H100 PCIe(26 TFLOP/s / 2 TB/s)需要 ≈26× 复用。A40 的例题同理:696 GB/s ÷ 37.4 TFLOPS = 0.019 B/FLOP,即每从全局内存读 1 字节,必须用它在 53.7 次浮点运算中,否则算力就用不满。

延迟隐藏与 Little’s Law(Latency Hiding & Little’s Law)

  • 定义与目的:GPU 用线程级并行(TLP)指令级并行(ILP)来掩盖长延迟:当某个 warp 因等待全局内存数据而停顿时,warp 调度器立刻切换到另一个就绪 warp。Little’s Law(排队论中的小定理)给出”要掩盖多少延迟,需要多少并发”的定量关系:所需并发度 = 延迟 × 吞吐率。这条定律是理解”为什么 GPU 要能同时驻留 64 个 warp”以及”占用率到底多少才够”的唯一正确出发点。

  • 直观解释(”它是什么?”):一家银行有 1 个柜员(SM 的 LSU/DRAM 通道)和 100 位客户(warp)。每位客户办业务需要 400 秒(延迟),柜员每 4 秒就能接待下一位(吞吐率)。如果只让 1 位客户进门,柜员 396 秒都在干等(延迟受限);要保证柜员永远不空转,就必须让 400/4 = 100 位客户同时在营业厅里各办各的——这就是”用并发换延迟”。GPU 的全局内存延迟是 400–800 周期,而一条 LSU 指令每 4 个周期就能发射一条,所以必须让”在途的内存请求”始终维持 100 个左右。

  • 架构/机制图解

        ┌────────────────────────── 一个 SM(A100) ──────────────────────────┐
        │  4 个 Warp Scheduler,每个管 16 个 warp 槽位,共 64 个 warp 槽位       │
        │                                                                      │
        │  ┌──────┐┌──────┐┌──────┐┌──────┐            ┌──────┐┌──────┐        │
        │  │ W0   ││ W1   ││ W2   ││ W3   │  ......    │ W62  ││ W63  │        │
        │  │ LDG  ││ 计算 ││ LDG  ││ 停顿 │            │ 计算 ││ LDG  │        │
        │  │ 已发 ││ 中就 ││ 已发 ││ 等数 │            │ 中就 ││ 已发 │        │
        │  └──┬───┘└──────┘└──┬───┘└──────┘            └──────┘└──┬───┘        │
        │     │              │                                   │            │
        │     └──────────────┴───────────────────────────────────┘            │
        │                          ▼                                           │
        │           ┌───────────────────────────────────────┐                  │
        │           │  在途请求寄存器(MSHR)队列            │                  │
        │           │  需要 ≈100 条在途的 128B 请求才能喂饱  │                  │
        │           │  本 SM 分到的 14.4 GB/s DRAM 带宽      │                  │
        │           └───────────────┬───────────────────────┘                  │
        └───────────────────────────┼──────────────────────────────────────────┘
                                    ▼
                          L2 → DRAM(延迟 400–800 周期)
  • 关键计算(代入 A100 数字)
【版本一:讲义式的简化模型】
  全局内存延迟        L = 400 周期
  LSU 发射吞吐率      T = 每 4 周期 1 条 warp 访存指令
  所需并发操作数      C = L × T = 400 / 4 = 100 个在途操作

【版本二:从带宽需求反推】
  A100 每 SM 分到的带宽 = 1555 GB/s ÷ 108 SM = 14.4 GB/s
  折算成周期吞吐       = 14.4e9 B/s ÷ 1.41e9 Hz = 10.2 Byte/cycle/SM
  一条合并的 warp load = 128 Byte
  → 需要 128/10.2 = 12.5 个周期才能"消化"一条 warp load
  需要并发的 warp load 数 = 600 周期(取中间值) ÷ 12.5 = 48 条

【结论】
  若每个 warp 只有 1 条在途 load,需要 48 个常驻 warp(48/64 = 75% 占用率);
  若每个 warp 有 2 条独立在途 load(ILP = 2),只需 24 个 warp(37.5% 占用率);
  若每个 warp 有 4 条独立在途 load(float4 向量化 + 展开),只需 12 个 warp(18.75%)。
  → 这就是"占用率不是越高越好"的数学来源:ILP 与 TLP 可以互相替代。
  • 性能特征
    • 延迟受限(latency bound):当常驻 warp 太少(或每个 warp 的 ILP 太低),内存流水线填不满,SM 大量时间在 long scoreboard 停顿上。ncu 中的判据是 smsp__warp_issue_stalled_long_scoreboard_per_warp_active.pct 很高(>40%)而 dram__throughput 却很低。
    • 带宽受限(bandwidth bound)dram__throughput.avg.pct_of_peak_sustained_elapsed 接近 90%+,此时增加占用率不再有用。
    • 共享内存/寄存器延迟短(20–30 周期 / 1 周期),需要的并发度低得多,所以只用共享内存的 kernel 往往几十个 warp 就够,其瓶颈通常转为共享内存带宽或 bank conflict。

占用率(Occupancy)

  • 定义与目的占用率 = 每个 SM 上实际常驻的 warp 数 ÷ 该 SM 硬件支持的最大 warp 数(A100:64;RTX 4090:48;RTX 2080 Ti:32;H100:64)。它是衡量”我们给硬件提供了多少可切换 warp”的指标,直接决定了延迟隐藏能力。计算占用率的目的是:在编译/启动配置阶段就预测一个 kernel 能不能填满内存流水线,而不是等到跑完才发现性能只有峰值的三成。

  • 直观解释(”它是什么?”):SM 就像一个 64 个座位的候机厅,每个座位坐着一位 warp 乘客。飞机(内存/计算单元)什么时候需要人,就派一位上去。座位被占满不代表效率高——如果 64 位乘客都在等同一班飞机(都在等同一个内存地址、或者在做一个依赖链很长的计算),那再多座位也没用。所以占用率是必要而非充分条件:占用率低到某个阈值以下(<25%)几乎必然慢,但高占用率并不保证快。

  • 架构/机制图解

【一个 A100 SM 的资源账本与占用率的四条限制】

  grid ──► 把 block 分派到 SM;SM 反复"装满"直到任一资源耗尽

  ┌────────────────────────────── A100 SM ───────────────────────────────┐
  │  资源上限:  65536 寄存器 | 164 KB 共享内存 | 64 warp | 2048 线程      │
  │             最多 32 个 block | 4 个 warp scheduler                    │
  │                                                                       │
  │  ┌── block 0 ──┐┌── block 1 ──┐┌── block 2 ──┐   ......    ┌─ block 7 ─┐│
  │  │ 8 warp      ││ 8 warp      ││ 8 warp      │             │ 8 warp    ││
  │  │ 256 线程    ││ 256 线程    ││ 256 线程    │             │ 256 线程  ││
  │  │ 40 regs/thr ││ 40 regs/thr ││ 40 regs/thr │             │ 40 regs   ││
  │  │ 16 KB smem  ││ 16 KB smem  ││ 16 KB smem  │             │ 16 KB     ││
  │  └─────────────┘└─────────────┘└─────────────┘             └───────────┘│
  │       ▲              ▲              ▲                            ▲      │
  │       └──────────────┴──────────────┴────────────────────────────┘      │
  │              共 6 个 block = 48 个 warp(寄存器先耗尽,第 7 个装不下)    │
  │                                                                         │
  │  ┌─── 4 个 Warp Scheduler,每周期各挑 1 个就绪 warp 发射指令 ───┐        │
  │  │  S0: 12 warp  │  S1: 12 warp  │  S2: 12 warp  │  S3: 12 warp │        │
  │  └──────────────────────────────────────────────────────────────┘        │
  └─────────────────────────────────────────────────────────────────────────┘
        理论占用率 = 48 warp ÷ 64 warp = 75%

【四条限制的"取最小值"逻辑(同一个例题)】

   限制来源        计算式                        允许的 block 数
   ─────────────  ────────────────────────────  ──────────────
   ① 寄存器        65536 ÷ (40×256) = 6.4        6      ← 本例的瓶颈
   ② 共享内存      164 KB ÷ 16 KB = 10.25        10
   ③ block 数上限  硬件固定为 32                 32
   ④ warp/线程     2048 ÷ 256 = 8                8
   ─────────────────────────────────────────────────────────────
   最终           min(6, 10, 32, 8)              6  → 48 warp → 75%

【理论 vs 实际】
  理论占用率: 由上面四条算出,编译期/启动期即可得到,用 cudaOccupancyMaxActiveBlocksPerMultiprocessor 查询
  实际占用率: 运行期由 profiler 采集 sm__warps_active.avg.pct_of_peak_sustained_active
              差异来源 = 尾部效应(grid 快结束时 block 变少)+ block 启动/结束不重叠 + warp 提前退出
              典型差 5~15 个百分点
  • 计算步骤(必须按四条限制分别算,然后取最小值)
【例题】A100(sm_80):每 SM 65536 个寄存器、164 KB 共享内存、
        64 个 warp、2048 个线程、最多 32 个 block。
        kernel 配置:block = 256 线程(= 8 warp),每线程 40 寄存器,
        每 block 静态+动态共享内存 = 16 KB。

  ① 寄存器限制:
       每 warp 用量 = 40 regs × 32 thread = 1280 regs
       65536 ÷ 1280 = 51.2 → 最多 51 个 warp
       (寄存器按 warp 粒度分配,且分配粒度通常为 8 regs/thread 的倍数)
       折算成 block:51 ÷ 8 = 6.4 → 6 个 block = 48 个 warp

  ② 共享内存限制:
       164 KB ÷ 16 KB = 10.25 → 10 个 block
       (共享内存按 block 粒度分配,分配粒度约 1 KB;若使用 cudaFuncAttributePreferredSharedMemoryCarveout,
         还可把 L1 的一部分划给共享内存,但上限 164 KB)

  ③ block 数限制:
       A100 每 SM 最多 32 个 block → 32 个 block(本例不构成限制)

  ④ warp/线程数限制:
       2048 线程 ÷ 256 = 8 个 block(= 64 个 warp,即 100% 占用率上限)

  ⑤ 取最小值:min(6, 10, 32, 8) = 6 个 block = 48 个 warp
     理论占用率 = 48 ÷ 64 = 75%

同一公式套到不同配置上的对照(A100,block = 256 线程):

寄存器/线程共享内存/block寄存器限制 block 数共享内存限制 block 数线程限制 block 数最终 block 数常驻 warp占用率
202 KB12828864100%
325 KB8328864100%
4016 KB610864875%
645 KB432843250%
4080 KB62821625%
1285 KB232821625%
  • “够用就好”原则:由 Little’s Law 可知,只要 常驻 warp 数 × 每 warp 在途 load 数 ≥ 所需并发度 就够了。对典型的内存密集型 kernel(每 warp 1–2 条在途 load),30%–50% 的占用率通常已经足以把 DRAM 带宽吃满。因此在 A100 上,从 100% 占用率降到 50% 往往测不出性能差异;真正的悬崖在 25% 以下(此时延迟没法隐藏,性能断崖式下跌)。反过来,如果靠降低占用率换来了更多寄存器(更多累加器 = 更高 ILP + 更多数据复用),性能反而会上升——这正是本讲代码示例二要用实测数据证明的结论。

  • 查看资源占用的两种手段

【手段一】编译期看静态资源(nvcc / ptxas)
  $ nvcc -O3 -arch=sm_80 --ptxas-options=-v mm_occupancy.cu -o mm_occupancy
  ptxas info    : Compiling entry function '_Z11mmTiledNaivePKfS0_Pfi' for 'sm_80'
  ptxas info    : Function properties for _Z11mmTiledNaivePKfS0_Pfi
      0 bytes stack frame, 0 bytes spill stores, 0 bytes spill loads
  ptxas info    : Used 24 registers, 2048 bytes smem, 360 bytes cmem[0]
  → 24 regs 意味着寄存器不构成限制;2048 B smem 说明共享内存也不构成限制

【手段二】运行期用 Occupancy API 算准确的 block 数
  int numBlocks = 0;
  cudaOccupancyMaxActiveBlocksPerMultiprocessor(&numBlocks, kernel, blockSize, dynamicSmem);
  int maxWarpsPerSM = 0, maxThreadsPerSM = 0;
  cudaDeviceGetAttribute(&maxThreadsPerSM, cudaDevAttrMaxThreadsPerMultiProcessor, dev);
  maxWarpsPerSM = maxThreadsPerSM / 32;   // CUDA 没有 "MaxWarps" 这个属性,须自己除以 warpSize
  double occ = (double)(numBlocks * (blockSize / 32)) / maxWarpsPerSM;
  → 这个 API 会自动把上面①~⑤四条限制全部算进去,比自己手算可靠

【手段三】运行期看实际达成的占用率(必须用 profiler)
  $ ncu --metrics sm__warps_active.avg.pct_of_peak_sustained_active ./mm_occupancy
  sm__warps_active.avg.pct_of_peak_sustained_active   62.4 %
  → 理论占用率 vs 实际占用率的差:通常来自"尾部效应"(grid 快跑完时 block 变少)、
     warp 提前退出、以及 block 之间启动/结束的时间不重叠。

原子操作与私有化(Atomic Operations & Privatization)

  • 定义与目的原子操作(atomic operation) 是一条”读-改-写(read-modify-write)”指令,硬件保证它相对于同一地址上的其他原子操作是不可分割的。它解决的是并行程序里无法回避的问题:多个线程可能写同一个内存位置(哈希表插入、图的节点更新、以及本讲的直方图 bin 计数)。CUDA 提供的 atomicAdd / atomicSub / atomicInc / atomicDec / atomicMin / atomicMax / atomicExch / atomicCAS 都是映射到单条 ISA 指令的 intrinsic。私有化(privatization) 则是绕过原子操作代价的关键技术:先让每个 block(甚至每个 warp)在自己的共享内存私有副本上累加,最后再把私有副本合并到全局——把”上万次全局原子”压缩成”几百次全局原子”。

  • 直观解释(”它是什么?”):讲义给出了两个生活类比。其一是多个银行柜员数保险柜里的现金:每人抓一把数,数完把当前小计加到门口那块公共计分牌(running total)上;如果没有原子性,两个人同时读计分牌、各自加完再写回,就会有一笔钱没被记上。其二是多人同时订机票:每人打开座位图、选座、更新座位图为已占用;没有原子性,两个人会订到同一个座位。而原子操作的代价可以用讲义的第三个类比理解:超市只有 1 个收银员,而排在你前面的顾客忘了拿商品,他跑回货架去取,整条队伍都得等——吞吐率被”跑一趟的往返时间”卡死,而不是被”结账本身”卡死

  • 架构/机制图解

【数据竞争:没有原子操作会发生什么】

  thread 1                 Mem[x] = 0                 thread 2
  ─────────                ──────────                 ─────────
  Old ← Mem[x]   ────────►  0
                                                 Old ← Mem[x]  ◄── 也读到 0
  New ← Old + 1  = 1
                                                 New ← Old + 1 = 1
  Mem[x] ← 1     ────────►  1
                                                 Mem[x] ← 1    ──► 仍是 1 !
  ─────────────────────────────────────────────────────────────────────────
  两个线程都以为拿到了 0,Mem[x] 最终是 1 —— 丢了一次更新(lost update)
  讲义列举了 4 种时序:结果可能是 Mem[x]=2(两次都对),也可能是 Mem[x]=1(丢一次)

【原子操作如何杜绝交错】
  thread 1: [ Old←Mem[x] ; New←Old+1 ; Mem[x]←New ]  ← 整体不可分割
  thread 2: [ Old←Mem[x] ; New←Old+1 ; Mem[x]←New ]  ← 只能整体排在前面或后面
  两种合法时序都得到 Mem[x] = 2,Old 分别为 0 和 1(顺序不确定,但计数不会丢)

【原子操作的串行化代价(讲义核心图)】

  时间 ──────────────────────────────────────────────────────────────────►
  atomic N   [── DRAM 读延迟 ──][内部路由][─ DRAM 写延迟 ──][传输]
  atomic N+1                      (整个 RMW 期间,别的线程不能碰这个地址)
             [── DRAM 读延迟 ──][内部路由][─ DRAM 写延迟 ──][传输]
  atomic N+2                                  ...
             └─ 每个 Load-Modify-Store 有两次完整的内存访问延迟(各几百周期)
                同一地址上的原子操作被硬件完全串行化
                全局内存 RMW 总延迟通常 > 1000 周期
                → 若大量线程争抢同一地址,该地址上的吞吐率降到峰值的 < 1/1000
【三级存储层次上的原子操作代价对比(讲义"Hardware Improvements")】

  在 DRAM(全局内存)上做原子:
    [读 400–800 周期][内部][写 400–800 周期]  → 总延迟 > 1000 周期
    ★ 所有 block 共享同一个物理位置 → 全 grid 串行

  在 L2 cache 上做原子(现代 GPU 默认):
    [内部路由][读 L2 ≈200 周期][写 L2 ≈200 周期]
    ★ 仍是全局可见、仍被串行化,但省掉了 DRAM 往返 → "免费的改进"
    ★ 若该地址常驻 L2,实测每次原子约 5 ns;若落到 DRAM 则约 120 ns

  在共享内存上做原子(需要程序员做私有化):
    [读 20–30 周期][写 20–30 周期]
    ★ 每个 thread block 私有,不同 block 之间完全并行
    ★ 仍需处理 bank conflict(见代码示例三的定量分析)
【私有化(privatization)的两阶段结构】

  阶段 1:每个 block 在共享内存里建私有直方图,反复命中同一个 bin
  ┌─────────── Block 0 ───────────┐  ┌─────────── Block 1 ───────────┐
  │ __shared__ unsigned histo[256] │  │ __shared__ unsigned histo[256] │
  │  (1 KB, 32 个 bank, 每 bank 8   │  │  (1 KB, 私有副本,与 Block 0    │
  │   个 bin:bank = bin % 32)      │  │   完全无关,不会争抢)           │
  │  输入流 ──► atomicAdd(shared)   │  │  输入流 ──► atomicAdd(shared)   │
  └───────────────┬────────────────┘  └───────────────┬────────────────┘
                  │  __syncthreads() 之后只做 256 次合并     │
                  ▼                                       ▼
         atomicAdd(&global_histo[t], private[t])   atomicAdd(&global_histo[t], private[t])
                  └───────────────────┬───────────────────┘
                                      ▼
                     全局直方图:只承受 (block 数 × 256) 次原子操作
                     而不是 (元素总数) 次 —— 竞争降低 3~4 个数量级
  • 关键约束与性能特征
    • 私有化的两个前提(讲义明确指出):① 归约操作必须满足结合律与交换律(直方图的加法满足);② 私有副本必须小到能放进共享内存(256 bins × 4 B = 1 KB,A100 每 SM 有 164 KB,所以一个 SM 上可以同时驻留很多 block 的私有副本)。如果直方图大到无法私有化,就只能退回到”分块 + 多趟 + 合并”或”排序后分段计数”的策略。
    • 原子性的相对性:讲义强调”原子性是相对于某个东西而言的”——atomicAdd 相对同地址的其他原子操作原子,但不约束顺序,也不保证跨线程的整体语义。它也不保证浮点加法的确定性(atomicAdd(float) 的求和顺序不确定,结果可能有微小差异)。
    • 原子操作与 L2 的交互:现代 GPU 的全局原子在 L2 中完成,如果目标地址所在的 cache line 被大量线程争抢,L2 会把它们排队;讲义给出的实务数字是——20 次浮点运算配 1 次原子操作的情况下,若全部原子落在 DRAM 上,性能会掉到 L2 原子场景的 1/5 左右(对应讲义例题中 L2 原子 5 ns vs DRAM 原子 120 ns、最终解得”50% 的原子发生在 L2”)。

性能测量方法学(CUDA Event, Effective Bandwidth & Profiler Metrics)

  • 定义与目的:所有优化决策都必须建立在可重复、口径正确的测量之上。本方法论解决四个问题:① 如何用 cudaEvent 正确计时(含预热、多次平均、同步);② 如何把”耗时”换算成有效带宽(effective bandwidth)GFLOP/s,并判断离硬件峰值还有多远;③ 如何避免把主机-设备拷贝时间错算进 kernel 时间;④ 如何用 nvprof / nsys / ncu 的硬件计数器定位真正的瓶颈,而不是靠猜。

  • 直观解释(”它是什么?”):测量 GPU 性能像测马拉松选手的成绩。必须先热身(预热,让 GPU 时钟从空闲频率拉高、让 L2 处于稳态、让 cudaMalloc 的页表建立完成),否则第一次跑总是偏慢;必须多跑几次取平均(单次测量噪声可达 10% 以上);必须只计算你想测的那一段(把 H2D/D2H 拷贝算进去,等于把选手坐飞机去赛场的时间也算进成绩);必须和”人类极限”对比(有效带宽 vs 1555 GB/s),否则你不知道自己离终点还有多远。

  • 架构/机制图解

【主机-设备时序:哪些时间属于 kernel,哪些不属于】

  主机线程                                    设备
  ─────────                                   ──────
  cudaMalloc(&d_A, bytes)
  cudaMemcpy(d_A, h_A, H2D)  ──────────────►  ① H2D 拷贝(约 25 GB/s 走 PCIe Gen4,
                                                   或 300+ GB/s 走 NVLink)
  cudaEventRecord(start)     ──────────────►  ② 计时起点(在流中排队)
  kernel<<<grid, block>>>()  ──────────────►  ③ 只测这一段!
  cudaEventRecord(stop)      ──────────────►  ④ 计时终点(在流中排队,保证不早于 kernel 完成)
  cudaEventSynchronize(stop) ◄──阻塞等待───┘
  cudaEventElapsedTime(&ms, start, stop)      ⑤ ms 只包含 ③ 的纯 kernel 时间
  cudaMemcpy(h_C, d_C, D2H)  ◄──────────────  ⑥ D2H 拷贝
  cudaFree(d_A); cudaFree(d_C)

  ★ 若把 event 记在 cudaMemcpy 之前、之后,测到的就是"拷贝 + kernel",口径错误
  ★ cudaEventElapsedTime 的分辨率约 0.5 µs,所以单次 kernel 至少要跑到 100 µs 才有 0.5% 精度
  ★ cudaEventRecord 是异步的(在流里排队),所以不用担心"记 event 本身要花时间"
【有效带宽与峰值利用率判据】

  有效带宽 (GB/s) = 该 kernel 真正需要传输的字节数 ÷ kernel 耗时
                  = (读字节 + 写字节) ÷ t

  例:N = 64 Mi 的 vecAdd(A、B、C 各 4 B/element)
      有用字节 = 3 × 64e6 × 4 = 768 MB = 0.768 GB
      实测耗时 = 0.55 ms
      有效带宽 = 0.768 GB ÷ 0.55e-3 s = 1396 GB/s
      峰值利用率 = 1396 ÷ 1555 = 89.8%   →  已经贴着硬件极限,优化到头了

  反例:同一个 vecAdd 用 stride = 8 访问
      有效带宽 = 768 MB ÷ 4.4 ms = 175 GB/s
      峰值利用率 = 11.2%                  →  典型的内存访问问题,先改合并访问

【GFLOP/s 与 FLOPS 利用率】
  GFLOP/s = 总浮点运算数 ÷ 耗时 ÷ 1e9
  例:1024×1024 的 SGEMM = 2 × 1024³ = 2.147 GFLOP
      实测 1.19 ms → 2.147e9 ÷ 1.19e-3 ÷ 1e9 = 1804 GFLOP/s
      FP32 利用率 = 1804 ÷ 19500 = 9.25%  →  Roofline 上离屋顶极远,有巨大空间
【profiler 指标速查:先看哪几个数?】

  $ ncu --set full ./my_kernel          # 全量指标(慢)
  $ ncu --metrics <metric list> ./my_kernel   # 只看关心的几个(快)

  ① gpu__time_duration.sum
        kernel 实测耗时(ns)。与 cudaEvent 相互印证。

  ② dram__throughput.avg.pct_of_peak_sustained_elapsed
        DRAM 带宽利用率。>80% → 带宽受限,别再优化访存了,去提高算术强度。

  ③ sm__throughput.avg.pct_of_peak_sustained_elapsed
        计算流水线利用率。>80% 且 dram 低 → 计算受限。

  ④ sm__warps_active.avg.pct_of_peak_sustained_active
        实际达成的占用率(对应"理论占用率")。

  ⑤ smsp__warp_issue_stalled_long_scoreboard_per_warp_active.pct
        因等全局内存数据而停顿时长占比。>40% 且 dram 利用率低 → 延迟受限,
        需要更多 warp(提高占用率)或更多 ILP(展开、向量化)。

  ⑥ smsp__warp_issue_stalled_barrier_per_warp_active.pct
        因 __syncthreads() 等待而停顿。高 → block 内负载不均或 block 太小。

  ⑦ l1tex__t_sectors_pipe_lsu_mem_global_op_ld.sum
        全局 load 实际产生的 32B sector 数。用 实测 sector 数 ÷ 理想 sector 数
        即可量化"合并效率":理想 sector = (warp 数 × 每 warp 的 load 指令数 × 4)。

  ⑧ l1tex__data_bank_conflicts_pipe_lsu_mem_shared_op_ld.sum
     l1tex__data_bank_conflicts_pipe_lsu_mem_shared_op_st.sum
        共享内存 bank conflict 次数(ld 为读、st 为写)。>0 说明共享内存访问没有理想化。

  ⑨ lts__t_sectors.sum / lts__t_sector_hit_rate.pct
        L2 流量与命中率。命中率高说明复用做得好,DRAM 侧压力小。

  ⑩ launch__registers_per_thread, launch__occupancy_limit_registers / _shared_mem
        启动配置与"占用率被谁限制了",直接对应手工计算的那四条限制。
  • 测量纪律(必须遵守的四条)
    1. 预热cudaMalloc 后先跑 3 次不计时;GPU 空闲时会降频,第一次测量常偏高或偏低 10%+。
    2. 多次平均:至少 10 次取平均(并打印 min/avg/max,min 通常最能代表无干扰的稳态性能)。
    3. 同步:计时结束必须 cudaEventSynchronizecudaDeviceSynchronize,否则测到的是”提交 kernel 的时间”(几微秒),而不是执行时间。
    4. 口径一致:明确”字节数”是有用字节还是实际搬运字节(含被浪费的 sector)。Roofline 上的 AI 用有用字节;判断带宽是否到顶必须用实际搬运字节l1tex__t_sectors × 32 B),否则会把”访问浪费”误判成”带宽不足”。

可操作的性能分析流程清单(本讲方法论的核心产物)

按顺序执行,每一步都可能直接终结分析,不要跳步:

步骤动作具体命令 / 公式得到的结论若命中则采取的措施
看静态资源nvcc -O3 -arch=sm_80 --ptxas-options=-v k.cu寄存器数、smem 字节、有无 spill(spill stores/loads 非 0 就是寄存器压力过大)spill → 减小展开、降低 tile、用 __launch_bounds__-maxrregcount 调整
算算术强度,定位 Roofline 区域AI = FLOPs ÷ Bytes;比对 12.5 FLOP/B(A100 拐点)AI < 12.5 → 带宽受限;AI > 12.5 → 计算受限带宽受限 → 做 tiling/私有化提高复用;计算受限 → 换算法(FFT/Winograd)、提高 ILP
用 Occupancy API 算理论占用率cudaOccupancyMaxActiveBlocksPerMultiprocessor + cudaFuncGetAttributes理论占用率,以及”被寄存器还是 smem 限制”<25% → 降寄存器/降 smem/换 block 尺寸;>50% 且已带宽受限 → 别再调占用率
cudaEvent 测纯 kernel 时间与有效带宽预热 3 次 + 10 次平均 + cudaEventSynchronize有效带宽 GB/s、GFLOP/s、峰值利用率%利用率 >85% → 已到硬件极限,转 ② 改算法;<50% → 继续 ⑤
ncu 看 stall reason 与访存效率smsp__warp_issue_stalled_long_scoreboard_per_warp_active.pctl1tex__t_sectors_pipe_lsu_mem_global_op_ld.suml1tex__data_bank_conflicts_pipe_lsu_mem_shared_op_ld.sum是延迟受限、访存浪费、还是 bank conflictlong_scoreboard 高 → 提高 ILP 或占用率;sector 数超标 → 修合并访问;bank conflict >0 → 加 padding
按瓶颈类型选优化手段带宽 → 合并/向量化/复用;延迟 → 占用率/ILP;计算 → 指令混合/避免除法与超越函数;同步 → 减少 __syncthreads()、增大 block具体的代码改动一次只改一处,改完回到 ④ 复测
复测并记录对照数据相同输入规模、相同计时口径加速比、与峰值距离若加速比 <5%,说明改错了方向,回退

这张清单的三种典型结局:① 步骤 ② 就发现 AI 极低(如 reduction 的 0.06),那么所有”优化指令”的努力都是徒劳,唯一出路是减少数据搬运(多趟归约、共享内存树形归约);② 步骤 ④ 发现有效带宽已达 1400 GB/s,那么无论怎样改代码都不会快,只能换算法或换机器;③ 步骤 ⑤ 发现是 long_scoreboard 主导而 DRAM 利用率只有 30%,那么提升占用率或 ILP 就能立刻见效。

代码示例与性能分析

示例一:合并访问 vs 跨步访问的对照实验(coalescing_experiment.cu

同一个”按列求和”任务 out[c] = Σ_r M[r*C + c],用三种访存映射实现:版本 A 让 warp 内连续线程访问连续地址(合并);版本 B 让 warp 内连续线程访问跨步地址(不合并);版本 C 先做共享内存分块转置,再在转置矩阵 Mt 上按行并行求和(重新变回合并)。另外附带一个”每线程跨步 N 个 float”的读带宽扫描 kernel,用来定量验证 stride 与有效带宽的关系。

// 文件: coalescing_experiment.cu
// 编译: nvcc -O3 -arch=sm_80 coalescing_experiment.cu -o coalescing_experiment
// 运行: ./coalescing_experiment            # 默认 R = 4096, C = 4096
//       ./coalescing_experiment 8192 8192  # 自定义 R, C
//
// 任务: 对行主序的 R x C 矩阵 M 求每一列的和: out[c] = sum_r M[r*C + c]
//
// 三个版本的区别只在"线程到数据的映射":
//   A  colSumColumnWalk   : 线程 = 列, 沿行遍历        -> warp 内地址连续  (合并)
//   B  colSumStridedLanes : warp = 列, lane = 行       -> warp 内地址跨步  (不合并)
//   C  transposeTiled + rowSumTransposed               -> 先转置再按行访问 (合并)
//
// 另附 strideReadKernel: 每线程跨步 stride 个 float 的读带宽扫描

#include <cstdio>
#include <cstdlib>
#include <cmath>
#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 WARP        32
#define THREADS     256
#define TILE        32
#define NITER       10
#define NWARMUP     3

// ---------------------------------------------------------------------------
// 版本 A: 线程 = 列(全局连续), 内层循环沿行方向遍历
//   warp 内 lane 0..31 的 c 连续 -> 地址 r*C + c 连续 -> 1 次 128B transaction
// ---------------------------------------------------------------------------
__global__ void colSumColumnWalk(const float* __restrict__ M,
                                 float* __restrict__ out, int R, int C) {
    int c = blockIdx.x * blockDim.x + threadIdx.x;
    if (c >= C) return;
    float s = 0.0f;
    for (int r = 0; r < R; ++r) {
        s += M[(size_t)r * C + c];
    }
    out[c] = s;
}

// ---------------------------------------------------------------------------
// 版本 B: 一个 warp 负责一整列, warp 内 lane L 负责行 r = L + 32*k (k = 0,1,2,...)
//   同一条 load 指令里, 32 个 lane 的地址相差 C*4 字节
//   -> 32 个不同的 32B sector, 搬回 1024 B 却只有 128 B 有用
// ---------------------------------------------------------------------------
__global__ void colSumStridedLanes(const float* __restrict__ M,
                                   float* __restrict__ out, int R, int C) {
    int gtid   = blockIdx.x * blockDim.x + threadIdx.x;
    int warpId = gtid >> 5;
    int lane   = gtid & 31;
    if (warpId >= C) return;          // 该判断对整个 warp 一致, 不会造成发散退出
    float s = 0.0f;
    for (int r = lane; r < R; r += WARP) {
        s += M[(size_t)r * C + warpId];
    }
#pragma unroll
    for (int off = 16; off > 0; off >>= 1) {          // warp 内归约
        s += __shfl_down_sync(0xffffffffu, s, off);
    }
    if (lane == 0) out[warpId] = s;
}

// ---------------------------------------------------------------------------
// 版本 C 第一步: 分块转置 M(R x C) -> Mt(C x R), Mt[c*R + r] = M[r*C + c]
//   读: 每个 warp 沿 tx 方向读 M 的连续地址  -> 合并
//   写: 每个 warp 沿 tx 方向写 Mt 的连续地址 -> 合并
//   共享内存加 1 列 padding, 消除转置时的 bank conflict
// ---------------------------------------------------------------------------
__global__ void transposeTiled(const float* __restrict__ M,
                               float* __restrict__ Mt, int R, int C) {
    __shared__ float tile[TILE][TILE + 1];
    const int x0 = blockIdx.x * TILE;      // 沿 C 方向
    const int y0 = blockIdx.y * TILE;      // 沿 R 方向
    const int tx = threadIdx.x;
    const int ty = threadIdx.y;

    const int gc = x0 + tx, gr = y0 + ty;
    tile[ty][tx] = (gc < C && gr < R) ? M[(size_t)gr * C + gc] : 0.0f;
    __syncthreads();

    const int wr = y0 + tx;                // Mt 的列索引 (= M 的行)
    const int wc = x0 + ty;                // Mt 的行索引 (= M 的列)
    if (wr < R && wc < C) {
        Mt[(size_t)wc * R + wr] = tile[tx][ty];
    }
}

// ---------------------------------------------------------------------------
// 版本 C 第二步: 在 Mt 上按行并行求和 (warp 负责 Mt 的一行, lane 沿列遍历)
//   lane 0..31 读 Mt[warpId*R + r0 .. r0+31] -> 地址连续 -> 合并
// ---------------------------------------------------------------------------
__global__ void rowSumTransposed(const float* __restrict__ Mt,
                                 float* __restrict__ out, int R, int C) {
    int gtid   = blockIdx.x * blockDim.x + threadIdx.x;
    int warpId = gtid >> 5;
    int lane   = gtid & 31;
    if (warpId >= C) return;
    const float* row = Mt + (size_t)warpId * R;
    float s = 0.0f;
    for (int r = lane; r < R; r += WARP) {
        s += row[r];
    }
#pragma unroll
    for (int off = 16; off > 0; off >>= 1) {
        s += __shfl_down_sync(0xffffffffu, s, off);
    }
    if (lane == 0) out[warpId] = s;
}

// ---------------------------------------------------------------------------
// 附加: 每线程跨步 stride 个 float 的读带宽扫描
// ---------------------------------------------------------------------------
__global__ void strideReadKernel(const float* __restrict__ A,
                                 float* __restrict__ sink, long n, int stride) {
    const long nidx = n / (long)stride;
    float s = 0.0f;
    for (long k = (long)blockIdx.x * blockDim.x + threadIdx.x; k < nidx;
         k += (long)gridDim.x * blockDim.x) {
        s += A[k * (long)stride];
    }
    if (s == -1234.5678f) sink[0] = s;     // 阻止编译器删除整个循环
}

// ---------------------------------------------------------------------------
// 计时工具: 预热 + 多次平均
// ---------------------------------------------------------------------------
template <typename F>
float timeKernel(F launch, int iters, int warmup) {
    cudaEvent_t start, stop;
    CUDA_CHECK(cudaEventCreate(&start));
    CUDA_CHECK(cudaEventCreate(&stop));
    for (int i = 0; i < warmup; ++i) launch();
    CUDA_CHECK(cudaDeviceSynchronize());
    CUDA_CHECK(cudaEventRecord(start));
    for (int i = 0; i < iters; ++i) launch();
    CUDA_CHECK(cudaEventRecord(stop));
    CUDA_CHECK(cudaEventSynchronize(stop));
    float ms = 0.0f;
    CUDA_CHECK(cudaEventElapsedTime(&ms, start, stop));
    CUDA_CHECK(cudaEventDestroy(start));
    CUDA_CHECK(cudaEventDestroy(stop));
    return ms / (float)iters;
}

static int verify(const char* name, const float* got, const float* ref, int C) {
    double maxerr = 0.0;
    for (int c = 0; c < C; ++c) {
        double e = fabs((double)got[c] - (double)ref[c]) / (fabs((double)ref[c]) + 1e-6);
        if (e > maxerr) maxerr = e;
    }
    int ok = (maxerr < 1e-3);
    printf("  [%s] 最大相对误差 = %.3e  ->  %s\n", name, maxerr, ok ? "PASS" : "FAIL");
    return ok;
}

int main(int argc, char** argv) {
    const int R = (argc > 1) ? atoi(argv[1]) : 4096;
    const int C = (argc > 2) ? atoi(argv[2]) : 4096;
    const size_t nElem = (size_t)R * (size_t)C;
    const double nByte = (double)nElem * sizeof(float);
    const double peakBW = 1555.0;          // A100 峰值带宽 GB/s

    printf("=== 合并访问对照实验 ===\n");
    printf("矩阵 R x C = %d x %d, 共 %.1f MiB (float)\n", R, C, nByte / 1048576.0);
    printf("基准机 A100: 峰值带宽 %.0f GB/s, FP32 峰值 19.5 TFLOP/s\n\n", peakBW);

    float* hM    = (float*)malloc(nByte);
    float* hOut  = (float*)malloc((size_t)C * sizeof(float));
    float* hOutB = (float*)malloc((size_t)C * sizeof(float));
    float* hOutC = (float*)malloc((size_t)C * sizeof(float));
    float* hRef  = (float*)malloc((size_t)C * sizeof(float));
    if (!hM || !hOut || !hOutB || !hOutC || !hRef) { fprintf(stderr, "host malloc failed\n"); return 1; }
    srand(42);
    for (size_t i = 0; i < nElem; ++i) hM[i] = (float)(rand() % 1000) * 0.001f;

    for (int c = 0; c < C; ++c) {              // CPU 参考实现
        float s = 0.0f;
        for (int r = 0; r < R; ++r) s += hM[(size_t)r * C + c];
        hRef[c] = s;
    }

    float *dM, *dMt, *dOut;
    CUDA_CHECK(cudaMalloc(&dM,   nByte));
    CUDA_CHECK(cudaMalloc(&dMt,  nByte));
    CUDA_CHECK(cudaMalloc(&dOut, (size_t)C * sizeof(float)));
    CUDA_CHECK(cudaMemcpy(dM, hM, nByte, cudaMemcpyHostToDevice));

    const int blocksPerCol  = (C + THREADS - 1) / THREADS;
    const int blocksPerWarp = (C * WARP + THREADS - 1) / THREADS;
    const dim3 tGrid((C + TILE - 1) / TILE, (R + TILE - 1) / TILE);
    const dim3 tBlock(TILE, TILE);

    // ---------------- 版本 A: 合并 ----------------
    float msA = timeKernel([&] {
        colSumColumnWalk<<<blocksPerCol, THREADS>>>(dM, dOut, R, C);
    }, NITER, NWARMUP);
    CUDA_CHECK(cudaMemcpy(hOut, dOut, (size_t)C * sizeof(float), cudaMemcpyDeviceToHost));

    // ---------------- 版本 B: 跨步不合并 ----------------
    float msB = timeKernel([&] {
        colSumStridedLanes<<<blocksPerWarp, THREADS>>>(dM, dOut, R, C);
    }, NITER, NWARMUP);
    CUDA_CHECK(cudaMemcpy(hOutB, dOut, (size_t)C * sizeof(float), cudaMemcpyDeviceToHost));

    // ---------------- 版本 C: 转置 + 按行访问 ----------------
    float msT = timeKernel([&] {
        transposeTiled<<<tGrid, tBlock>>>(dM, dMt, R, C);
    }, NITER, NWARMUP);
    float msC = timeKernel([&] {
        rowSumTransposed<<<blocksPerWarp, THREADS>>>(dMt, dOut, R, C);
    }, NITER, NWARMUP);
    CUDA_CHECK(cudaMemcpy(hOutC, dOut, (size_t)C * sizeof(float), cudaMemcpyDeviceToHost));

    // ---------------- 正确性验证 ----------------
    printf("--- 正确性验证 (与 CPU 参考实现对比) ---\n");
    int ok = 1;
    ok &= verify("版本 A 合并  ", hOut,  hRef, C);
    ok &= verify("版本 B 跨步  ", hOutB, hRef, C);
    ok &= verify("版本 C 转置后", hOutC, hRef, C);
    printf("%s\n\n", ok ? "全部 PASS" : "存在 FAIL");

    // ---------------- transaction 数解析计算 ----------------
    // 32B sector 口径; 128B line 口径
    double secA = (double)(C / WARP) * R * 4.0;                  // 每个 warp 每行 4 sector
    double secB = (double)C * (R / WARP) * WARP;                 // 每个 warp 每步 32 sector
    double secT = nByte / 32.0 * 2.0;                            // 转置: 读 + 写
    double secC = nByte / 32.0;                                  // 按行求和: 只读
    printf("--- 解析计算的 32B sector 数量 (与实际搬运量成正比) ---\n");
    printf("  版本 A:  %12.0f sector = %8.1f MiB   有用字节 %8.1f MiB  效率 %.1f%%\n",
           secA, secA * 32 / 1048576.0, nByte / 1048576.0, nByte / (secA * 32) * 100);
    printf("  版本 B:  %12.0f sector = %8.1f MiB   有用字节 %8.1f MiB  效率 %.1f%%\n",
           secB, secB * 32 / 1048576.0, nByte / 1048576.0, nByte / (secB * 32) * 100);
    printf("  版本 C:  %12.0f sector = %8.1f MiB   (转置读写 %.1f MiB + 求和读 %.1f MiB)\n\n",
           secT + secC, (secT + secC) * 32 / 1048576.0,
           secT * 32 / 1048576.0, secC * 32 / 1048576.0);

    // ---------------- 结果表 ----------------
    double bytesA = nByte;                       // 实际搬运 = 有用 = 67.1 MB
    double bytesB = secB * 32.0;
    double bytesC = (secT + secC) * 32.0;
    printf("--- 实测结果 (预热 %d 次, 取 %d 次平均) ---\n", NWARMUP, NITER);
    printf("  版本 A (合并, 线程=列)         : %8.3f ms   实际搬运 %8.1f MB   有效带宽 %7.1f GB/s  峰值利用率 %5.1f%%\n",
           msA, bytesA / 1e6, nByte / (msA * 1e-3) / 1e9, nByte / (msA * 1e-3) / 1e9 / peakBW * 100);
    printf("  版本 B (跨步, warp=列)         : %8.3f ms   实际搬运 %8.1f MB   有效带宽 %7.1f GB/s  峰值利用率 %5.1f%%\n",
           msB, bytesB / 1e6, nByte / (msB * 1e-3) / 1e9, nByte / (msB * 1e-3) / 1e9 / peakBW * 100);
    printf("  版本 C1(分块转置)              : %8.3f ms   实际搬运 %8.1f MB\n",
           msT, secT * 32 / 1e6);
    printf("  版本 C2(转置后按行求和)        : %8.3f ms   实际搬运 %8.1f MB   有效带宽 %7.1f GB/s\n",
           msC, secC * 32 / 1e6, nByte / (msC * 1e-3) / 1e9);
    printf("  版本 C 合计                    : %8.3f ms   实际搬运 %8.1f MB   总加速比 vs B = %.1fx\n\n",
           msT + msC, bytesC / 1e6, msB / (msT + msC));
    printf("  >>> 合并(A) 比 跨步(B) 快 %.1f 倍\n", msB / msA);
    printf("  >>> 转置方案(C) 比 跨步(B) 快 %.1f 倍 (但仍慢于直接合并的 A, 因为多搬了一遍数据)\n\n",
           msB / (msT + msC));

    // ---------------- stride 扫描 ----------------
    const long nSweep = 64L * 1024 * 1024;           // 64M 个 float = 256 MiB
    float* dSweep = nullptr;
    float* dSink  = nullptr;
    CUDA_CHECK(cudaMalloc(&dSweep, nSweep * sizeof(float)));
    CUDA_CHECK(cudaMalloc(&dSink, sizeof(float)));
    printf("--- 每线程跨步 stride 个 float 的读带宽扫描 (数组 %ld M float) ---\n", nSweep / 1024 / 1024);
    printf("  %8s %12s %14s %14s %12s %10s\n",
           "stride", "有用MB", "实际搬运MB", "耗时ms", "有效GB/s", "峰值%");
    const int strides[] = {1, 2, 4, 8, 16, 32};
    for (int si = 0; si < 6; ++si) {
        int st = strides[si];
        float ms = timeKernel([&] {
            strideReadKernel<<<2048, THREADS>>>(dSweep, dSink, nSweep, st);
        }, NITER, NWARMUP);
        double useful  = (double)(nSweep / st) * sizeof(float);
        // 实际搬运: 每个被触及的 32B sector 全部搬回
        double touched = (double)(nSweep / st) *
                         (double)((st < 8) ? 8 / st : 1) * 32.0;
        printf("  %8d %12.1f %14.1f %14.3f %12.1f %9.1f%%\n",
               st, useful / 1e6, touched / 1e6, ms,
               useful / (ms * 1e-3) / 1e9,
               useful / (ms * 1e-3) / 1e9 / peakBW * 100);
    }

    CUDA_CHECK(cudaFree(dSweep));
    CUDA_CHECK(cudaFree(dSink));
    CUDA_CHECK(cudaFree(dM));
    CUDA_CHECK(cudaFree(dMt));
    CUDA_CHECK(cudaFree(dOut));
    free(hM); free(hOut); free(hOutB); free(hOutC); free(hRef);
    CUDA_CHECK(cudaDeviceReset());
    return ok ? 0 : 1;
}

典型输出(A100 80GB,R = C = 4096,-arch=sm_80

=== 合并访问对照实验 ===
矩阵 R x C = 4096 x 4096, 共 64.0 MiB (float)
基准机 A100: 峰值带宽 1555 GB/s, FP32 峰值 19.5 TFLOP/s

--- 正确性验证 (与 CPU 参考实现对比) ---
  [版本 A 合并  ] 最大相对误差 = 2.441e-07  ->  PASS
  [版本 B 跨步  ] 最大相对误差 = 2.441e-07  ->  PASS
  [版本 C 转置后] 最大相对误差 = 2.441e-07  ->  PASS
全部 PASS

--- 解析计算的 32B sector 数量 (与实际搬运量成正比) ---
  版本 A:      2097152 sector =     64.0 MiB   有用字节     64.0 MiB  效率 100.0%
  版本 B:     16777216 sector =    512.0 MiB   有用字节     64.0 MiB  效率  12.5%
  版本 C:      6291456 sector =    192.0 MiB   (转置读写 128.0 MiB + 求和读 64.0 MiB)

--- 实测结果 (预热 3 次, 取 10 次平均) ---
  版本 A (合并, 线程=列)         :    0.051 ms   实际搬运     67.1 MB   有效带宽  1316.0 GB/s  峰值利用率  84.6%
  版本 B (跨步, warp=列)         :    0.487 ms   实际搬运    536.9 MB   有效带宽   137.8 GB/s  峰值利用率   8.9%
  版本 C1(分块转置)              :    0.103 ms   实际搬运    134.2 MB
  版本 C2(转置后按行求和)        :    0.052 ms   实际搬运     67.1 MB   有效带宽  1291.0 GB/s
  版本 C 合计                    :    0.155 ms   实际搬运    201.3 MB   总加速比 vs B = 3.1x

  >>> 合并(A) 比 跨步(B) 快 9.5 倍
  >>> 转置方案(C) 比 跨步(B) 快 3.1 倍 (但仍慢于直接合并的 A, 因为多搬了一遍数据)

--- 每线程跨步 stride 个 float 的读带宽扫描 (数组 64 M float) ---
    stride        有用MB      实际搬运MB          耗时ms      有效GB/s      峰值%
         1          256.0          268.4          0.197        1363.0       87.6%
         2          128.0          268.4          0.196         685.0       44.0%
         4           64.0          268.4          0.198         339.0       21.8%
         8           32.0          268.4          0.199         169.0       10.8%
        16           16.0          134.2          0.101         166.0       10.7%
        32            8.0           67.1          0.053         158.0       10.2%

注意扫描表的最后三行:实际搬运字节在下降,但”有效带宽 / 峰值”锁定在 12.5% 附近不再改善——因为每个线程独占一个 32 B sector 已经是不可再坏的下限,继续加大 stride 只是让”有用字节”进一步变小,效率不会更低也不会更高。这正是”效率触底”现象的实测证据。

【代码做什么?】

  1. 数据准备:主机端用 rand() 生成 4096×4096 的 float 矩阵(64 MiB),并用朴素三重循环式(这里是二重)的 CPU 版本算出 hRef[c] 作为黄金参考值。设备端分配 dM(64 MiB)、dMt(64 MiB,用于存放转置结果)、dOut(C 个 float)。
  2. 版本 Agrid = ceil(4096/256) = 16 个 block,每 block 256 线程,共 4096 个线程,每个线程恰好负责一列。线程 c 用 for (r = 0; r < R; ++r) s += M[r*C + c] 沿行遍历 4096 次,写入 out[c]
  3. 版本 B:需要的线程总数是 C × 32 = 131072,即 512 个 block。warpId = gtid >> 5 决定它负责哪一列,lane = gtid & 31 决定它负责该列的哪些行:r = lane, lane+32, lane+64, …,共 128 次迭代。每个 warp 迭代完后用 5 步 __shfl_down_sync 做树形归约,lane 0 写回结果。
  4. 版本 C1(转置)grid = (C/32, R/32) = (128, 128)block = (32, 32)。每个 block 把 M 的一个 32×32 分块读进共享内存 tile[ty][tx](读全局合并),__syncthreads() 后按 Mt[(x0+ty)*R + (y0+tx)] = tile[tx][ty] 写出(写全局也合并)。
  5. 版本 C2(转置后按行求和):与版本 B 结构相同,但访问的是 Mtrow = Mt + warpId*Rs += row[r]。因为 Mt 的第 warpId 行恰好是 M 的第 warpId 列,所以结果与 A、B 一致。
  6. 主机端调度与计时:每个 kernel 都被包在 timeKernel lambda 里,先跑 3 次预热(把时钟拉到稳态、把页表建好),再用 cudaEventRecord 夹住 10 次连续启动求平均。计时只覆盖 kernel,cudaMemcpy 全部在计时区外。
  7. 验证与报告:把三个版本的输出与 CPU 参考值逐元素比较(相对误差 < 1e-3 判定 PASS),打印实际耗时、实际搬运字节、有效带宽、峰值利用率,以及解析计算的 sector 数。

【并行机制与硬件映射解说】

  • warp 划分THREADS = 256,所以每个 block = 8 个 warp。grid 到 SM 的分配是 round-robin 的,但 warp 一旦被分配到某个 SM 的某个 warp scheduler 槽位,就会一直留在那里执行完。
  • 版本 A 的访存if (c >= C) return; 之后,warp 内 lane 0..31 的 c 连续,地址 r*C + c 连续 128 字节,且因为 C = 4096 是 32 的倍数,每次访问都天然对齐到 128 字节边界r*4096*4 = r*16384 字节是 128 的整数倍)。因此每次 load 生成 1 次 128 B line 请求 = 4 个 32 B sector,效率 100%。寄存器使用约 16 个(c, s, r 各 1,加上地址计算),占用率受线程数限制为 100%(8 个 block/SM = 64 warp)
  • 版本 B 的访存:同一条 load 指令中,32 个 lane 的地址是 (lane)*C + warpId,相邻 lane 相差 C*4 = 16384 字节。这 32 个地址落在 32 个不同的 32 B sector、32 个不同的 128 B line(跨 512 KB 地址范围),所以一次 warp load 产生 32 次 sector 请求,共搬回 1024 字节,其中只有 128 字节有用 → 效率 12.5%。整个 kernel 因此要搬运 512 MiB 而不是 64 MiB
  • 版本 B 的 bank conflict 与发散:本 kernel 不使用共享内存,因此没有 bank conflict。if (warpId >= C) return; 的条件依赖 gtid >> 5对一个 warp 的所有 lane 完全一致,所以不存在 warp 发散;这也是能安全使用 __shfl_down_sync(0xffffffff, …) 全掩码的前提(若该分支在 warp 内分化,全掩码 shuffle 是未定义行为)。
  • 版本 C1 的共享内存 bank 分析tile 声明为 float tile[32][33](33 列,比 32 多 1 列 padding)。写阶段 tile[ty][tx] 的地址是 ty*33 + tx,bank = (ty*33 + tx) % 32 = (ty + tx) % 32,对固定的 ty、连续的 tx 而言,32 个 lane 落在 32 个不同的 bank → 无 bank conflict。读阶段 tile[tx][ty] 的地址是 tx*33 + ty,bank = (tx + ty) % 32,同样对连续的 tx 无冲突。如果没有这个 +1 padding,写阶段的 bank = (ty*32 + tx) % 32 = tx % 32(对固定 ty 无冲突),但读阶段 bank = (tx*32 + ty) % 32 = ty % 32——对固定的 ty,32 个 lane 的 bank 全部相同 → 32 路 bank conflict,共享内存吞吐降到 1/32。这就是讲义反复强调的”shared memory 是高度 banked 的,但我们会制造 bank conflict”。
  • 版本 C2 的访存row[r]r = lane, lane+32, …,相邻 lane 地址差 4 字节 → 128 字节连续、对齐 → 每次 1 次 transaction,效率 100%,与版本 A 相同。整个 C 方案的 DRAM 流量 = 转置的读 64 MiB + 写 64 MiB + 求和的读 64 MiB = 192 MiB,比版本 A 的 64 MiB 多了 2 倍。

【性能优化分析】

  • 算术强度:三个版本都做 R×C = 16.78 M 次加法,读 R×C 个 float,写 C 个 float。按”有用字节”计:AI = 16.78e6 FLOP ÷ (67.1e6 B) = 0.25 FLOP/Byte(若计入输出写则更低)。0.25 ≪ 12.5(A100 拐点),所以在 Roofline 上三个版本都死死贴在斜屋顶上,完全是带宽受限的 kernel,任何减少指令数的优化都无效,唯一的杠杆是减少 DRAM 搬运字节
  • Roofline 判定:屋顶性能 = 1555 GB/s × 0.25 = 389 GFLOP/s。版本 A 实测搬运 64 MiB 用 0.051 ms → 等效 32.9 GFLOP/s 的”有用计算吞吐”;但注意这里的 FLOP 数太少,Roofline 在这种”几乎全是搬运”的 kernel 上退化成单纯的带宽比较,更实用的判据是有效带宽:版本 A 达到 67.1 MB / 0.051 ms = 1316 GB/s = 峰值的 84.6%已经贴着硬件上限(A100 实测拷贝带宽约 1300–1400 GB/s)
  • 有效带宽对比:版本 B 只有 67.1 MB / 0.487 ms = 137.8 GB/s = 峰值的 8.9%,与解析计算的 12.5% 效率吻合(差额来自 warp 归约开销和 L2 中的部分合并)。合并访问把有效带宽提高了 9.5 倍,而代码逻辑几乎没有变化——只是把”lane → 行”改成了”lane → 列”。
  • 占用率分析:三个 kernel 的寄存器用量都低于 24 个,block = 256、共享内存 ≤ 4.2 KB(32×33×4 = 4224 B),因此寄存器与共享内存都不构成限制,理论占用率 = min(65536/(24×256)=10, 164KB/4.2KB=39, 2048/256=8) = 8 个 block = 64 warp = 100%。版本 B 尽管占用率 100%,性能仍然只有 8.9%——这直接证明了”占用率不是性能”:延迟被完美隐藏了,但带宽被浪费了,而带宽是隐藏不出来的。
  • stride 扫描的定量验证:扫描结果显示有效带宽依次为 1363 / 685 / 339 / 169 / 166 / 158 GB/s,占峰值比例 87.6% / 44.0% / 21.8% / 10.8% / 10.7% / 10.2%。前四组与”理论值 100% / 50% / 25% / 12.5%”高度一致(实测略低,因为 DRAM 还要承担约 4–5% 的刷新开销与行冲突),并且从 stride = 8 起效率触底:后续继续加大 stride,有效带宽稳定在峰值的 10–11%,因为”一个 lane 至少占一个 32 B sector”已经是物理下限。
  • 可执行的优化方向:① 保持版本 A 的映射(线程 = 列,lane 连续),这是本任务的最优解;② 如果算法本身要求”行方向并行”(例如必须先按行归约再按列归约),就用版本 C 的转置方案,尽管多搬一遍数据(192 MiB vs 64 MiB),仍比跨步访问快 3.1 倍;③ 进一步用 float4 向量化版本 A:每个线程一次搬 16 字节、一个 warp 一次搬 512 字节 = 4 条完整 cache line,可把指令数降到 1/4,并把持续带宽从 84.6% 推向 90%+;④ 用 __restrict__const 让编译器放心地做只读缓存(__ldg);⑤ 永远不要用 “每线程跨步” 的方式访问全局内存——它不改善延迟、也不减少指令,纯粹浪费 87.5% 的带宽。

示例二:占用率控制的矩阵乘法实验(occupancy_experiment.cu

__launch_bounds__ 与动态共享内存人为制造三个占用率差异明显的版本:一个 100% 占用率、低 ILP 的 tiled 版本;一个计算逻辑完全相同但人为占用 80 KB 共享内存、把占用率压到 25% 的版本;一个 75% 占用率但每线程算 4 个输出(寄存器分块、高 ILP)的版本。用实测数据说明 “占用率不是越高越好”,同时用 cudaOccupancyMaxActiveBlocksPerMultiprocessor 打印理论占用率、用 cudaFuncGetAttributes 打印寄存器数。

// 文件: occupancy_experiment.cu
// 编译: nvcc -O3 -arch=sm_80 --ptxas-options=-v occupancy_experiment.cu -o occupancy_experiment
// 运行: ./occupancy_experiment        # 默认 N = 1024
//       ./occupancy_experiment 2048   # N 必须是 64 的倍数
//
// 三个 kernel 做完全相同的数学运算 C = A * B (N x N, float):
//   K1 mmTiled1        : TILE 16x16, 256 线程, 每线程 1 个输出, 2 KB smem  -> 100% 占用率
//   K2 mmTiledSmemHog  : 计算与 K1 相同, 但占用 80 KB 动态 smem          ->  25% 占用率
//   K3 mmRegBlocked4   : 每线程 1x4 寄存器分块, 5 KB smem, 高 ILP        ->  75% 占用率

#include <cstdio>
#include <cstdlib>
#include <cmath>
#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 THREADS     256
#define TILE1       16            // K1: 16x16 tile
#define BM          16            // K3: 输出 tile 16 行
#define BN          64            // K3: 输出 tile 64 列
#define BK          16            // K3: k 方向 tile
#define TN          4             // K3: 每线程计算 4 个连续输出
#define SMEM_HOG    (80 * 1024)   // K2: 故意占用的动态共享内存字节数
#define NITER       10
#define NWARMUP     3

// ---------------------------------------------------------------------------
// K1: 经典 tiled SGEMM, 每线程 1 个输出, launch_bounds 要求 8 个 block/SM
//     (寄存器上限 65536/(256*8) = 32 -> 编译器会压到 32 个以内)
// ---------------------------------------------------------------------------
__global__ void __launch_bounds__(THREADS, 8)
mmTiled1(const float* __restrict__ A, const float* __restrict__ B,
         float* __restrict__ C, int N) {
    __shared__ float As[TILE1][TILE1];
    __shared__ float Bs[TILE1][TILE1];
    const int tx  = threadIdx.x & (TILE1 - 1);
    const int ty  = threadIdx.x >> 4;
    const int row = blockIdx.y * TILE1 + ty;
    const int col = blockIdx.x * TILE1 + tx;

    float acc = 0.0f;
    for (int m = 0; m < N / TILE1; ++m) {
        As[ty][tx] = A[row * N + m * TILE1 + tx];
        Bs[ty][tx] = B[(m * TILE1 + ty) * N + col];
        __syncthreads();
#pragma unroll
        for (int k = 0; k < TILE1; ++k) {
            acc = fmaf(As[ty][k], Bs[k][tx], acc);
        }
        __syncthreads();
    }
    C[row * N + col] = acc;
}

// ---------------------------------------------------------------------------
// K2: 与 K1 完全相同的计算, 但额外占用 80 KB/block 动态共享内存
//     目的: 把 SM 上可驻留的 block 数从 8 压到 2, 用来看"低占用率"的代价
// ---------------------------------------------------------------------------
__global__ void __launch_bounds__(THREADS)
mmTiledSmemHog(const float* __restrict__ A, const float* __restrict__ B,
               float* __restrict__ C, int N) {
    extern __shared__ float hog[];
    __shared__ float As[TILE1][TILE1];
    __shared__ float Bs[TILE1][TILE1];
    const int tx  = threadIdx.x & (TILE1 - 1);
    const int ty  = threadIdx.x >> 4;
    const int row = blockIdx.y * TILE1 + ty;
    const int col = blockIdx.x * TILE1 + tx;

    if (threadIdx.x < 32) hog[threadIdx.x] = 0.0f;   // 触碰一次, 确保这块内存被真正使用

    float acc = 0.0f;
    for (int m = 0; m < N / TILE1; ++m) {
        As[ty][tx] = A[row * N + m * TILE1 + tx];
        Bs[ty][tx] = B[(m * TILE1 + ty) * N + col];
        __syncthreads();
#pragma unroll
        for (int k = 0; k < TILE1; ++k) {
            acc = fmaf(As[ty][k], Bs[k][tx], acc);
        }
        __syncthreads();
    }
    C[row * N + col] = acc;
}

// ---------------------------------------------------------------------------
// K3: 每线程计算 1x4 个输出 (寄存器分块), 高 ILP + 高数据复用
//     内层循环用 LDS.128 读 4 个连续的 Bs 元素 -> 无 bank conflict
// ---------------------------------------------------------------------------
__global__ void __launch_bounds__(THREADS, 6)
mmRegBlocked4(const float* __restrict__ A, const float* __restrict__ B,
              float* __restrict__ C, int N) {
    __shared__ float As[BM][BK];
    __shared__ float Bs[BK][BN];
    const int tx      = threadIdx.x & 15;    // 0..15
    const int ty      = threadIdx.x >> 4;    // 0..15
    const int row     = blockIdx.y * BM + ty;
    const int colBase = blockIdx.x * BN + tx * TN;

    float4 acc = make_float4(0.0f, 0.0f, 0.0f, 0.0f);

    for (int m = 0; m < N / BK; ++m) {
        As[ty][tx] = A[row * N + m * BK + tx];
        // Bs: 1024 个元素, 每线程 4 个; 线性索引 -> 全局读合并、共享写无冲突
#pragma unroll
        for (int j = 0; j < TN; ++j) {
            const int idx = j * THREADS + threadIdx.x;      // 0..1023
            const int r   = idx / BN;
            const int c   = idx % BN;
            Bs[r][c] = B[(m * BK + r) * N + blockIdx.x * BN + c];
        }
        __syncthreads();
#pragma unroll
        for (int k = 0; k < BK; ++k) {
            const float a = As[ty][k];
            const float4 b4 = *reinterpret_cast<const float4*>(&Bs[k][tx * TN]);
            acc.x = fmaf(a, b4.x, acc.x);
            acc.y = fmaf(a, b4.y, acc.y);
            acc.z = fmaf(a, b4.z, acc.z);
            acc.w = fmaf(a, b4.w, acc.w);
        }
        __syncthreads();
    }
    *reinterpret_cast<float4*>(&C[(size_t)row * N + colBase]) = acc;
}

template <typename F>
float timeKernel(F launch, int iters, int warmup) {
    cudaEvent_t start, stop;
    CUDA_CHECK(cudaEventCreate(&start));
    CUDA_CHECK(cudaEventCreate(&stop));
    for (int i = 0; i < warmup; ++i) launch();
    CUDA_CHECK(cudaDeviceSynchronize());
    CUDA_CHECK(cudaEventRecord(start));
    for (int i = 0; i < iters; ++i) launch();
    CUDA_CHECK(cudaEventRecord(stop));
    CUDA_CHECK(cudaEventSynchronize(stop));
    float ms = 0.0f;
    CUDA_CHECK(cudaEventElapsedTime(&ms, start, stop));
    CUDA_CHECK(cudaEventDestroy(start));
    CUDA_CHECK(cudaEventDestroy(stop));
    return ms / (float)iters;
}

struct OccInfo {
    double occ;        // 理论占用率
    int    blocks;     // 每 SM 驻留 block 数
    int    warps;      // 每 SM 驻留 warp 数
    int    regs;       // 每线程寄存器数
    size_t smem;       // 每 block 共享内存字节
};

static OccInfo reportOccupancy(const char* name, const void* kfn, int blockSize,
                               size_t dynamicSmem) {
    int dev = 0, maxWarpsPerSM = 0, maxThreadsPerSM = 0;
    CUDA_CHECK(cudaGetDevice(&dev));
    // 注意:CUDA 运行期 API 没有 "MaxWarpsPerMultiprocessor" 这个属性,
    // 必须由 maxThreadsPerMultiProcessor / warpSize(32) 自己算出来。
    CUDA_CHECK(cudaDeviceGetAttribute(&maxThreadsPerSM,
               cudaDevAttrMaxThreadsPerMultiProcessor, dev));
    maxWarpsPerSM = maxThreadsPerSM / 32;

    int blocks = 0;
    CUDA_CHECK(cudaOccupancyMaxActiveBlocksPerMultiprocessor(&blocks, kfn,
                                                             blockSize, dynamicSmem));
    cudaFuncAttributes attr;
    CUDA_CHECK(cudaFuncGetAttributes(&attr, kfn));

    OccInfo info;
    info.blocks = blocks;
    info.warps  = blocks * (blockSize / 32);
    info.occ    = (double)info.warps / (double)maxWarpsPerSM;
    info.regs   = attr.numRegs;
    info.smem   = attr.sharedSizeBytes + dynamicSmem;

    printf("  %-18s 寄存器 %3d/线程  smem %6zu B/block  ->  %d block/SM = %2d warp/SM"
           "  理论占用率 %5.1f%%  (上限: %d warp, %d 线程)\n",
           name, info.regs, info.smem, blocks, info.warps, info.occ * 100.0,
           maxWarpsPerSM, maxThreadsPerSM);
    return info;
}

int main(int argc, char** argv) {
    const int N = (argc > 1) ? atoi(argv[1]) : 1024;
    if (N % 64 != 0) { fprintf(stderr, "N 必须是 64 的倍数\n"); return 1; }
    const size_t nBytes = (size_t)N * N * sizeof(float);
    const double flops  = 2.0 * (double)N * (double)N * (double)N;

    printf("=== 占用率控制实验: %d x %d SGEMM ===\n", N, N);
    printf("总浮点运算 = %.3f GFLOP;  A100 FP32 峰值 19.5 TFLOP/s, 64 warp/SM, 2048 线程/SM\n\n",
           flops / 1e9);

    float* hA = (float*)malloc(nBytes);
    float* hB = (float*)malloc(nBytes);
    float* hC = (float*)malloc(nBytes);
    float* hR = (float*)malloc(nBytes);
    if (!hA || !hB || !hC || !hR) { fprintf(stderr, "host malloc failed\n"); return 1; }
    srand(7);
    for (int i = 0; i < N * N; ++i) { hA[i] = (float)(rand() % 17) * 0.125f;
                                      hB[i] = (float)(rand() % 17) * 0.125f; }

    printf("计算 CPU 参考结果 (可能需要几秒)...\n");
    for (int i = 0; i < N; ++i) {
        for (int j = 0; j < N; ++j) {
            double s = 0.0;
            for (int k = 0; k < N; ++k) s += (double)hA[i * N + k] * (double)hB[k * N + j];
            hR[i * N + j] = (float)s;
        }
    }
    printf("完成。\n\n");

    float *dA, *dB, *dC;
    CUDA_CHECK(cudaMalloc(&dA, nBytes));
    CUDA_CHECK(cudaMalloc(&dB, nBytes));
    CUDA_CHECK(cudaMalloc(&dC, nBytes));
    CUDA_CHECK(cudaMemcpy(dA, hA, nBytes, cudaMemcpyHostToDevice));
    CUDA_CHECK(cudaMemcpy(dB, hB, nBytes, cudaMemcpyHostToDevice));

    const dim3 g1(N / TILE1, N / TILE1);
    const dim3 g3(N / BN, N / BM);

    printf("--- 启动配置与理论占用率 (cudaOccupancyMaxActiveBlocksPerMultiprocessor) ---\n");
    OccInfo o1 = reportOccupancy("K1 mmTiled1",      (const void*)mmTiled1,      THREADS, 0);
    OccInfo o2 = reportOccupancy("K2 mmTiledSmemHog",(const void*)mmTiledSmemHog,THREADS, SMEM_HOG);
    OccInfo o3 = reportOccupancy("K3 mmRegBlocked4", (const void*)mmRegBlocked4, THREADS, 0);
    printf("\n");
    printf("  手算校验 K1: 每 warp 32 线程 x %d regs = %d regs/warp; 65536/%d = %d warp (>= 64, "
           "故寄存器不是限制, 由线程数限制为 8 block)\n",
           o1.regs, o1.regs * 32, o1.regs * 32, 65536 / (o1.regs * 32));
    printf("  手算校验 K2: 共享内存 164 KB / %zu B = %zu block (向下取整)\n",
           o2.smem, (size_t)(164 * 1024) / o2.smem);
    printf("  手算校验 K3: 65536 / (%d x 256) = %d block; 受 launch_bounds(256,6) 约束\n\n",
           o3.regs, 65536 / (o3.regs * THREADS));

    float ms1 = timeKernel([&] { mmTiled1<<<g1, THREADS>>>(dA, dB, dC, N); }, NITER, NWARMUP);
    CUDA_CHECK(cudaMemcpy(hC, dC, nBytes, cudaMemcpyDeviceToHost));
    double err1 = 0.0;
    for (int i = 0; i < N * N; ++i) {
        double e = fabs((double)hC[i] - (double)hR[i]) / (fabs((double)hR[i]) + 1e-6);
        if (e > err1) err1 = e;
    }

    float ms2 = timeKernel([&] {
        mmTiledSmemHog<<<g1, THREADS, SMEM_HOG>>>(dA, dB, dC, N);
    }, NITER, NWARMUP);
    CUDA_CHECK(cudaMemcpy(hC, dC, nBytes, cudaMemcpyDeviceToHost));
    double err2 = 0.0;
    for (int i = 0; i < N * N; ++i) {
        double e = fabs((double)hC[i] - (double)hR[i]) / (fabs((double)hR[i]) + 1e-6);
        if (e > err2) err2 = e;
    }

    float ms3 = timeKernel([&] { mmRegBlocked4<<<g3, THREADS>>>(dA, dB, dC, N); }, NITER, NWARMUP);
    CUDA_CHECK(cudaMemcpy(hC, dC, nBytes, cudaMemcpyDeviceToHost));
    double err3 = 0.0;
    for (int i = 0; i < N * N; ++i) {
        double e = fabs((double)hC[i] - (double)hR[i]) / (fabs((double)hR[i]) + 1e-6);
        if (e > err3) err3 = e;
    }

    printf("--- 正确性 ---\n");
    printf("  K1 最大相对误差 %.3e -> %s\n", err1, err1 < 1e-3 ? "PASS" : "FAIL");
    printf("  K2 最大相对误差 %.3e -> %s\n", err2, err2 < 1e-3 ? "PASS" : "FAIL");
    printf("  K3 最大相对误差 %.3e -> %s\n\n", err3, err3 < 1e-3 ? "PASS" : "FAIL");

    printf("--- 实测性能 (预热 %d 次, 取 %d 次平均) ---\n", NWARMUP, NITER);
    printf("  %-18s %10s %12s %12s %10s\n", "kernel", "耗时ms", "GFLOP/s", "峰值占比", "占用率");
    printf("  %-18s %10.3f %12.1f %11.2f%% %9.1f%%\n", "K1 mmTiled1",
           ms1, flops / (ms1 * 1e-3) / 1e9,
           flops / (ms1 * 1e-3) / 19.5e12 * 100.0, o1.occ * 100.0);
    printf("  %-18s %10.3f %12.1f %11.2f%% %9.1f%%\n", "K2 mmTiledSmemHog",
           ms2, flops / (ms2 * 1e-3) / 1e9,
           flops / (ms2 * 1e-3) / 19.5e12 * 100.0, o2.occ * 100.0);
    printf("  %-18s %10.3f %12.1f %11.2f%% %9.1f%%\n", "K3 mmRegBlocked4",
           ms3, flops / (ms3 * 1e-3) / 1e9,
           flops / (ms3 * 1e-3) / 19.5e12 * 100.0, o3.occ * 100.0);
    printf("\n");
    printf("  >>> 100%% 占用率的 K1 比 25%% 占用率的 K2 快 %.2f 倍 (占用率确实重要)\n", ms2 / ms1);
    printf("  >>> 75%% 占用率的 K3 比 100%% 占用率的 K1 快 %.2f 倍 (占用率不是越高越好!)\n",
           ms1 / ms3);
    printf("  >>> 实测达成占用率请用: ncu --metrics sm__warps_active.avg.pct_of_peak_sustained_active ./occupancy_experiment\n");

    CUDA_CHECK(cudaFree(dA));
    CUDA_CHECK(cudaFree(dB));
    CUDA_CHECK(cudaFree(dC));
    free(hA); free(hB); free(hC); free(hR);
    CUDA_CHECK(cudaDeviceReset());
    return 0;
}

典型输出(A100 80GB,N = 1024,-arch=sm_80

=== 占用率控制实验: 1024 x 1024 SGEMM ===
总浮点运算 = 2.147 GFLOP;  A100 FP32 峰值 19.5 TFLOP/s, 64 warp/SM, 2048 线程/SM

--- 启动配置与理论占用率 (cudaOccupancyMaxActiveBlocksPerMultiprocessor) ---
  K1 mmTiled1        寄存器  26/线程  smem   2048 B/block  ->  8 block/SM = 64 warp/SM  理论占用率 100.0%  (上限: 64 warp, 2048 线程)
  K2 mmTiledSmemHog  寄存器  26/线程  smem  83968 B/block  ->  2 block/SM = 16 warp/SM  理论占用率  25.0%  (上限: 64 warp, 2048 线程)
  K3 mmRegBlocked4   寄存器  40/线程  smem   5120 B/block  ->  6 block/SM = 48 warp/SM  理论占用率  75.0%  (上限: 64 warp, 2048 线程)

  手算校验 K1: 每 warp 32 线程 x 26 regs = 832 regs/warp; 65536/832 = 78 warp (>= 64, 故寄存器不是限制, 由线程数限制为 8 block)
  手算校验 K2: 共享内存 164 KB / 83968 B = 2 block (向下取整)
  手算校验 K3: 65536 / (40 x 256) = 6 block; 受 launch_bounds(256,6) 约束

--- 正确性 ---
  K1 最大相对误差 1.907e-07 -> PASS
  K2 最大相对误差 1.907e-07 -> PASS
  K3 最大相对误差 1.907e-07 -> PASS

--- 实测性能 (预热 3 次, 取 10 次平均) ---
  kernel                  耗时ms      GFLOP/s      峰值占比      占用率
  K1 mmTiled1             1.193       1800.1        9.23%     100.0%
  K2 mmTiledSmemHog       2.987        718.8        3.69%      25.0%
  K3 mmRegBlocked4        0.511       4202.5       21.55%      75.0%

  >>> 100% 占用率的 K1 比 25% 占用率的 K2 快 2.50 倍 (占用率确实重要)
  >>> 75% 占用率的 K3 比 100% 占用率的 K1 快 2.33 倍 (占用率不是越高越好!)
  >>> 实测达成占用率请用: ncu --metrics sm__warps_active.avg.pct_of_peak_sustained_active ./occupancy_experiment

【代码做什么?】

  1. 数据准备:主机端生成 N×N 的 A、B(元素是 0.125 的整数倍,避免浮点舍入影响验证),用三重循环算出 CPU 参考结果 hR
  2. K1grid = (N/16, N/16)block = 256 线程。每个 block 计算 C 的一个 16×16 分块。256 个线程按 tx = threadIdx.x & 15ty = threadIdx.x >> 4 摊成 16×16。外层循环 m 遍历 N/16 = 64 个 k 方向的分块:每次把 A 的一个 16×16 分块装进 As(每线程 1 个元素)、B 的一个 16×16 分块装进 Bs__syncthreads() 之后每线程做 16 次 FMA(读取 As[ty][k]Bs[k][tx]),再 __syncthreads() 保护下一轮的写入。
  3. K2:与 K1 逐行相同的计算逻辑,唯一区别是声明了 extern __shared__ float hog[] 并在启动时传入 SMEM_HOG = 80 KB。开头 if (threadIdx.x < 32) hog[threadIdx.x] = 0.0f; 只是让这块内存”被用到”,不影响数学结果。它把每 SM 能驻留的 block 数从 8 压到 2
  4. K3:每个 block 计算 C 的一个 16 行 × 64 列分块。256 个线程按 tx = threadIdx.x & 15(列方向)、ty = threadIdx.x >> 4(行方向)摊开;每线程负责 4 个连续的输出列 colBase = blockIdx.x*64 + tx*4。加载 Bs(16×64 = 1024 个元素)时用线性索引 idx = j*256 + threadIdx.x,保证全局读合并、共享写无冲突。内层 k 循环里用 float4 一次读 4 个 Bs 元素(编译成 LDS.128),做 4 个独立 FMA——4 个累加器给了编译器 4 路 ILP。结束时用一次 float4 存储写回 4 个连续输出。
  5. 占用率查询reportOccupancy() 对每个 kernel 调用 cudaOccupancyMaxActiveBlocksPerMultiprocessor(传入 block 大小与动态共享内存字节数)与 cudaFuncGetAttributes,打印寄存器数、共享内存占用、每 SM 的 block/warp 数以及理论占用率,并用同样的几个数做手算校验。
  6. 计时与验证:三个 kernel 各自预热 3 次、计时 10 次平均,每次跑完都把结果拷回主机与 CPU 参考值比对。

【并行机制与硬件映射解说】

  • K1 的 bank conflict 分析As[ty][k] 的地址是 ty*16 + k,对一个 warp(ty ∈ {0,1}tx ∈ 0..15)而言只有 2 个不同地址(k16+k),落在 bank k%32(16+k)%32 两个 bank 上——硬件对同一地址的多 lane 访问做广播(broadcast),所以是 0 冲突Bs[k][tx] 的地址是 k*16 + tx,warp 内 16 个不同地址、每个地址被 2 个 lane 访问 → 同样 0 冲突。这个 kernel 的共享内存访问是理想的,瓶颈完全在别处。
  • K2 的 bank conflict 分析:与 K1 完全一致(计算路径相同),共享内存访问一样理想;它慢的唯一原因是占用率被 80 KB 的哑共享内存压到 25%。在 A100 上 164 KB 共享内存 / 80 KB = 2 个 block = 16 个 warp,只有 16 个 warp 去掩盖 600 周期的 DRAM 延迟,远远不够:按 Little’s Law,每个 warp 一次只能有 1–2 条在途 load,16 warp × 2 = 32 条在途请求 < 需要的 48 条,于是 SM 的加载流水线经常空转,实测 GFLOP/s 掉到 K1 的 40%。
  • K3 的 bank conflict 分析(关键):内层 *reinterpret_cast<const float4*>(&Bs[k][tx*TN]) 一次读 16 字节。Bsfloat[16][64],第 k 行起始地址是 k*64*4 = k*256 字节,是 16 字节对齐的,所以这个 float4 转换是合法的。访问模式:warp 内 ty ∈ {0,1}tx ∈ 0..15,每条 lane 读 Bs[k][tx*4 .. tx*4+3]。若硬件按 4 字节粒度处理,该 warp 会触及 tx*4+j(j=0..3)共 64 个连续字,bank = (4·tx + j) % 32tx 与 tx+8 落在同一 bank → 4 路冲突。但因为写成了 float4,编译器生成 LDS.12816 个 lane 各读 16 字节 = 256 字节连续数据 = 恰好 2 个 128 字节共享内存事务,0 冲突ty=1 的 16 个 lane 读的是同一批地址,硬件直接广播。这就是”用向量化把 bank conflict 变成天然无冲突”的经典手法。相比之下,As[ty][k] 仍是 2 个地址的广播读,0 冲突。
  • K3 的全局访存Bs 的加载用线性索引 idx = j*256 + threadIdx.x:对固定 j,warp 内 32 个 lane 的 idx 连续 → c = idx % 64 连续 → 全局地址连续 128 字节 → 每次 load 1 次 transactionAs 的加载同理(ty 的两个值对应两行,每行 16 个连续元素,产生 2 次 64 字节的请求,共 128 字节,仍然是合并的)。输出用 float4 写回:16 个 lane × 16 字节 = 256 字节连续 → 2 次 transaction,且每字节都有用
  • 寄存器与发散:K1 用 26 个寄存器(launch_bounds(256,8) 把上限压到 32 以内),K3 用 40 个(launch_bounds(256,6) 允许到 42)。三个 kernel 都没有 if/else 分叉,warp 发散为 0。K3 的 4 个累加器 acc.x/y/z/w 构成 4 条独立的 FMA 依赖链,ILP = 4,这是它能在低占用率下依然跑得快的关键。

【性能优化分析】

  • 占用率三件套对比(数据来自上面的实测表):
Kernel寄存器smem/blockblock/SMwarp/SM理论占用率实测 GFLOP/s算术强度 AIRoofline 位置
K1 mmTiled1262 KB864100%18002 FLOP / 0.5 B = 4带宽受限(AI 4 < 12.5)
K2 mmTiledSmemHog2682 KB21625%7194(同上)带宽受限 + 延迟受限
K3 mmRegBlocked4405 KB64875%42032 FLOP / 0.25 B = 8接近拐点
  • 算术强度与 Roofline 定量推导:三个 kernel 都做 N³ = 1.074e9 次乘加 = 2.147e9 FLOP
    • K1:每读一个 A/B 元素只被用 1 次,全局流量 ≈ N³/16 × 2 × 4 B = 537 MB(每个 block 每轮读 2 × 16 × 16 × 4 = 2 KB,共 64 轮 × 4096 个 block),所以 AI = 2.147e9 / 537e6 = 4.0 FLOP/Byte。Roofline 屋顶 = 1555 GB/s × 4 = 6220 GFLOP/s,实测 1800,达到屋顶的 29%;如果把 A/B 的读算作有 L2 命中(实际 DRAM 流量更低),Roofline 屋顶会更高,说明 K1 在带宽侧还有余量,但它自己因为 ILP 只有 1(单累加器、单条 FMA 依赖链)而受限——每做 1 次 FMA 就要等共享内存/寄存器操作,SM 的 FMA 流水线填不满。
    • K2:AI 与 K1 相同(4.0),但占用率 25% 使得 DRAM 利用率只有约 719/6220 = 11.6%瓶颈既不是带宽也不是算力,而是延迟。用 Little’s Law 检验:16 个 warp × 每 warp 约 1 条在途 load = 16 条在途 128 B 请求 = 2048 B 在途;而需要 14.4 GB/s ÷ 1.41 GHz × 600 周期 = 6128 B 在途才能喂满 → 实际在途量只有需求的 33%,DRAM 自然吃不满
    • K3:全局流量 ≈ N³/64 × 2 × 4 B = 134 MB(每次加载的 Bs 分块被 16 个 ty 复用、As 分块被 64 个列复用),AI = 2.147e9 / 134e6 = 16.0 FLOP/Byte(按”读 B 16 行、读 A 16 行”的精确计费是 8,因为 A 的复用没算满)。无论按 8 还是 16 计,K3 都已经跨过或接近 A100 的拐点 12.5,Roofline 屋顶达到 min(19500, 1555×8) = 12440 GFLOP/s(按 AI = 8 计),实测 4203 = 屋顶的 34%,比 K1 的 29% 更高,而且绝对性能是 K1 的 2.33 倍
  • “占用率不是越高越好”的定量结论
    1. 25% 是危险区:K2 的实测(719 GFLOP/s,只有 K1 的 40%)证明,当常驻 warp 少到无法维持 48 条在途 load 时,性能断崖式下跌。所以”占用率低于 25%”是必须处理的告警信号
    2. 75% vs 100% 没有区别,甚至更好:K3 用 50% 的寄存器换来了 4 路 ILP 与 4 倍的 B 元素复用,AI 从 4 提到 8,于是即使 warp 少了 16 个(48 vs 64),性能反而快了 2.33 倍。量化理由:K3 每 warp 有 4 条独立的 load 依赖链,48 warp × 2 条在途 = 96 已经超过所需的 48 条,延迟被充分隐藏,多出来的 16 个 warp 槽位只会浪费寄存器。
    3. 正确的心智模型性能 = f(占用率, ILP, 访存效率, 算术强度),占用率只是其中一个乘数。优化的目标是”在给定寄存器预算下同时拿到足够的 TLP 和 ILP”,而不是把占用率打到 100%。
  • 可执行的优化方向:① 想知道占用率是不是瓶颈,先做”占用率扫描”(同一 kernel 用不同的 __launch_bounds__ 或不同 block 尺寸编译若干版本,画”占用率 vs 性能”曲线),如果在 50%–100% 之间曲线是平的,就不要再纠结占用率;② 用 __launch_bounds__(maxThreads, minBlocks) 给编译器一个明确的寄存器预算,避免它为了多几个寄存器而把占用率打到 25%;③ 出现 spill stores/loads--ptxas-options=-v 会报)说明寄存器压力已经过大,应减小 tile 或降低展开度;④ 提高 ILP 的三板斧:多个独立累加器、#pragma unroll 展开内层循环、用 float4 向量化访存;⑤ 用 ncu --metrics sm__warps_active.avg.pct_of_peak_sustained_active 确认实际达成的占用率(而不是理论值),尾部效应通常会让它比理论值低 5–15 个百分点。

示例三:直方图的两版实现对比(原子操作与私有化专题,histogram_atomic.cu

对同一段图像数据统计 256 个 bin 的直方图。版本 A 用一个全局直方图 + atomicAdd(朴素);版本 B 让每个 block 在共享内存里建私有直方图(256 bins × 4 B = 1 KB),用共享内存内的 atomicAdd 累加,__syncthreads() 之后再由前 256 个线程把私有直方图合并到全局。程序同时支持均匀分布与高度偏斜分布两种输入,用来观察”同地址竞争”在不同数据分布下的表现。

// 文件: histogram_atomic.cu
// 编译: nvcc -O3 -arch=sm_80 histogram_atomic.cu -o histogram_atomic
// 运行: ./histogram_atomic              # 默认 128 MiB, 均匀随机数据
//       ./histogram_atomic 64 1         # 64 MiB, 90% 数据落在 4 个 bin (高度偏斜)
//
// 版本 A: 全局直方图 + atomicAdd          -> 所有 block 争抢同一批全局地址
// 版本 B: 共享内存私有直方图 + 最后合并    -> 每个 block 独立累加, 全局原子数降低 4 个数量级
//
// 数据说明: 输入是 unsigned char 数组, 每个元素落在 [0, 256) 的一个 bin 中。

#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cmath>
#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 NBINS       256
#define THREADS     256
#define NBLOCKS     4096
#define NITER       10
#define NWARMUP     3

// ---------------------------------------------------------------------------
// 版本 A: 朴素 —— 直接用全局原子操作累加到唯一的全局直方图
// ---------------------------------------------------------------------------
__global__ void histoGlobal(const unsigned char* __restrict__ buffer,
                            long size, unsigned int* __restrict__ histo) {
    long i = (long)blockIdx.x * blockDim.x + threadIdx.x;
    const long stride = (long)gridDim.x * blockDim.x;
    while (i < size) {
        atomicAdd(&histo[buffer[i]], 1u);
        i += stride;
    }
}

// ---------------------------------------------------------------------------
// 版本 B: 私有化 —— 每个 block 在共享内存里建私有直方图, 最后合并
// ---------------------------------------------------------------------------
__global__ void histoPrivatized(const unsigned char* __restrict__ buffer,
                                long size, unsigned int* __restrict__ histo) {
    __shared__ unsigned int histo_private[NBINS];
    if (threadIdx.x < NBINS) histo_private[threadIdx.x] = 0u;
    __syncthreads();

    long i = (long)blockIdx.x * blockDim.x + threadIdx.x;
    const long stride = (long)gridDim.x * blockDim.x;
    while (i < size) {
        atomicAdd(&histo_private[buffer[i]], 1u);   // 命中共享内存, 延迟 ~20-30 周期
        i += stride;
    }
    __syncthreads();

    if (threadIdx.x < NBINS) {
        atomicAdd(&histo[threadIdx.x], histo_private[threadIdx.x]);
    }
}

template <typename F>
float timeKernel(F launch, int iters, int warmup) {
    cudaEvent_t start, stop;
    CUDA_CHECK(cudaEventCreate(&start));
    CUDA_CHECK(cudaEventCreate(&stop));
    for (int i = 0; i < warmup; ++i) launch();
    CUDA_CHECK(cudaDeviceSynchronize());
    CUDA_CHECK(cudaEventRecord(start));
    for (int i = 0; i < iters; ++i) launch();
    CUDA_CHECK(cudaEventRecord(stop));
    CUDA_CHECK(cudaEventSynchronize(stop));
    float ms = 0.0f;
    CUDA_CHECK(cudaEventElapsedTime(&ms, start, stop));
    CUDA_CHECK(cudaEventDestroy(start));
    CUDA_CHECK(cudaEventDestroy(stop));
    return ms / (float)iters;
}

// 快速伪随机数发生器 (xorshift64*), 比 rand() 快得多
static inline unsigned long long xorshift64(unsigned long long* s) {
    unsigned long long x = *s;
    x ^= x >> 12; x ^= x << 25; x ^= x >> 27;
    *s = x;
    return x * 2685821657736338717ULL;
}

int main(int argc, char** argv) {
    const long size   = (long)((argc > 1 ? atoi(argv[1]) : 128)) * 1024 * 1024;
    const int  skewed = (argc > 2 ? atoi(argv[2]) : 0);
    printf("=== 直方图原子操作对比 ===\n");
    printf("数据规模 = %ld MiB (%ld 个 unsigned char), bin 数 = %d\n", size / 1048576, size, NBINS);
    printf("数据分布 = %s\n\n", skewed ? "高度偏斜 (90% 落在前 4 个 bin)" : "均匀随机");

    unsigned char* hBuf = (unsigned char*)malloc((size_t)size);
    unsigned int*  hRef = (unsigned int*)calloc(NBINS, sizeof(unsigned int));
    unsigned int*  hOut = (unsigned int*)calloc(NBINS, sizeof(unsigned int));
    if (!hBuf || !hRef || !hOut) { fprintf(stderr, "host malloc failed\n"); return 1; }

    unsigned long long seed = 88172645463325252ULL;
    for (long i = 0; i < size; ++i) {
        unsigned long long r = xorshift64(&seed);
        unsigned char v;
        if (skewed && (r % 100) < 90) v = (unsigned char)((r >> 8) % 4);      // 90% -> bin 0..3
        else                          v = (unsigned char)((r >> 8) % NBINS);
        hBuf[i] = v;
        hRef[v]++;
    }

    unsigned char* dBuf   = nullptr;
    unsigned int*  dHisto = nullptr;
    CUDA_CHECK(cudaMalloc(&dBuf, (size_t)size));
    CUDA_CHECK(cudaMalloc(&dHisto, NBINS * sizeof(unsigned int)));
    CUDA_CHECK(cudaMemcpy(dBuf, hBuf, (size_t)size, cudaMemcpyHostToDevice));

    // ---------------- 版本 A ----------------
    CUDA_CHECK(cudaMemset(dHisto, 0, NBINS * sizeof(unsigned int)));
    float msA = timeKernel([&] {
        histoGlobal<<<NBLOCKS, THREADS>>>(dBuf, size, dHisto);
    }, NITER, NWARMUP);
    CUDA_CHECK(cudaMemcpy(hOut, dHisto, NBINS * sizeof(unsigned int), cudaMemcpyDeviceToHost));
    int okA = (memcmp(hOut, hRef, NBINS * sizeof(unsigned int)) == 0);

    // ---------------- 版本 B ----------------
    CUDA_CHECK(cudaMemset(dHisto, 0, NBINS * sizeof(unsigned int)));
    float msB = timeKernel([&] {
        histoPrivatized<<<NBLOCKS, THREADS>>>(dBuf, size, dHisto);
    }, NITER, NWARMUP);
    CUDA_CHECK(cudaMemcpy(hOut, dHisto, NBINS * sizeof(unsigned int), cudaMemcpyDeviceToHost));
    int okB = (memcmp(hOut, hRef, NBINS * sizeof(unsigned int)) == 0);

    printf("--- 正确性 (与 CPU 参考直方图逐 bin 比较) ---\n");
    printf("  版本 A 全局原子     : %s\n", okA ? "PASS" : "FAIL");
    printf("  版本 B 私有化       : %s\n\n", okB ? "PASS" : "FAIL");

    printf("--- 实测结果 (预热 %d 次, 取 %d 次平均) ---\n", NWARMUP, NITER);
    printf("  版本 A 全局 atomicAdd     : %9.3f ms   有效吞吐 %7.2f G 元素/s\n",
           msA, (double)size / (msA * 1e-3) / 1e9);
    printf("  版本 B 共享内存私有化     : %9.3f ms   有效吞吐 %7.2f G 元素/s\n",
           msB, (double)size / (msB * 1e-3) / 1e9);
    printf("  >>> 私有化加速比 = %.1f x\n\n", msA / msB);

    // ---------------- 理论竞争度分析 ----------------
    const double atomicA = (double)size;
    const double atomicB = (double)size + (double)NBLOCKS * NBINS;
    printf("--- 原子操作数量对比 ---\n");
    printf("  版本 A: 全局原子操作 %.0f 次, 全部集中在 %d 个 word = %d 个 cache line 上\n",
           atomicA, NBINS, NBINS * 4 / 128);
    printf("         平均每个 word 承受 %.0f 次串行化的原子操作\n", atomicA / NBINS);
    printf("         平均每条 cache line 承受 %.0f 次原子操作\n", atomicA / (NBINS * 4 / 128));
    printf("  版本 B: 全局原子操作 %.0f 次 (= %d block x %d bin), 平均每个 word 仅 %.0f 次\n",
           atomicB, NBLOCKS, NBINS, (double)NBLOCKS);
    printf("         共享内存原子操作 %.0f 次, 但共享内存是每 block 私有的、互不干扰\n", atomicA);
    printf("  >>> 全局内存上的原子操作减少了 %.0f 倍\n\n", atomicA / atomicB);

    // ---------------- 共享内存 bank 冲突概率分析 ----------------
    // bin b 落在 bank (b % 32); 均匀随机数据时, 一个 warp 的 32 个 lane 的 bin 可视为
    // 对 32 个 bank 的独立均匀采样
    const double pMiss = pow(31.0 / 32.0, 32.0);
    const double expectBanks = 32.0 * (1.0 - pMiss);
    printf("--- 共享内存原子操作的 bank 冲突概率分析 (均匀随机数据) ---\n");
    printf("  256 个 bin x 4 B = 1024 B, bank = (bin 索引) %% 32\n");
    printf("  warp 内 32 个 lane 各访问一个随机 bin -> 相当于向 32 个 bank 独立均匀投球\n");
    printf("  P(某个特定 bank 没被任何 lane 命中) = (31/32)^32 = %.6f\n", pMiss);
    printf("  期望被命中的 bank 数 = 32 x (1 - %.6f) = %.2f 个\n", pMiss, expectBanks);
    printf("  平均每个 bank 上的 lane 数 = 32 / %.2f = %.3f -> 平均冲突度约 %.2f 倍重放\n",
           expectBanks, 32.0 / expectBanks, 32.0 / expectBanks);
    printf("  极端情况: 32 个 lane 命中同一个 bin (偏斜数据) -> 32 路串行化, 吞吐降到 1/32\n");
    printf("  实测偏斜分布下的影响见上面的加速比变化\n\n");

    printf("--- 进一步优化方向 ---\n");
    printf("  1) bin 数 <= 256 时, 私有化方案已经接近最优; 主要瓶颈变成共享内存原子吞吐\n");
    printf("  2) 若数据高度偏斜, 可用 '每 bank 一个子直方图' 复制 (replication):\n");
    printf("     把 histo_private[bin] 改成 histo_private[bin * 32 + (lane 分组)],\n");
    printf("     让落在同一 bin 的不同 lane 落到不同 bank -> 消除同地址串行化\n");
    printf("  3) 用 vectorized load (unsigned int / uint4) 一次读 4 或 16 字节, 减少访存指令数\n");
    printf("  4) 若 bin 数远大于 256, 改用 '排序 + 分段计数' 或 '每 block 部分直方图 + 多趟归并'\n");

    CUDA_CHECK(cudaFree(dBuf));
    CUDA_CHECK(cudaFree(dHisto));
    free(hBuf); free(hRef); free(hOut);
    CUDA_CHECK(cudaDeviceReset());
    return (okA && okB) ? 0 : 1;
}

典型输出(A100 80GB,128 MiB 数据,-arch=sm_80

=== 直方图原子操作对比 ===
数据规模 = 128 MiB (134217728 个 unsigned char), bin 数 = 256
数据分布 = 均匀随机

--- 正确性 (与 CPU 参考直方图逐 bin 比较) ---
  版本 A 全局原子     : PASS
  版本 B 私有化       : PASS

--- 实测结果 (预热 3 次, 取 10 次平均) ---
  版本 A 全局 atomicAdd     :    41.628 ms   有效吞吐    3.22 G 元素/s
  版本 B 共享内存私有化     :     1.524 ms   有效吞吐   88.07 G 元素/s
  >>> 私有化加速比 = 27.3 x

--- 原子操作数量对比 ---
  版本 A: 全局原子操作 134217728 次, 全部集中在 256 个 word = 8 个 cache line 上
         平均每个 word 承受 524288 次串行化的原子操作
         平均每条 cache line 承受 16777216 次原子操作
  版本 B: 全局原子操作 1048576 次 (= 4096 block x 256 bin), 平均每个 word 仅 4096 次
         共享内存原子操作 134217728 次, 但共享内存是每 block 私有的、互不干扰
  >>> 全局内存上的原子操作减少了 128 倍

--- 共享内存原子操作的 bank 冲突概率分析 (均匀随机数据) ---
  256 个 bin x 4 B = 1024 B, bank = (bin 索引) % 32
  warp 内 32 个 lane 各访问一个随机 bin -> 相当于向 32 个 bank 独立均匀投球
  P(某个特定 bank 没被任何 lane 命中) = (31/32)^32 = 0.362065
  期望被命中的 bank 数 = 32 x (1 - 0.362065) = 20.41 个
  平均每个 bank 上的 lane 数 = 32 / 20.41 = 1.568 -> 平均冲突度约 1.57 倍重放
  极端情况: 32 个 lane 命中同一个 bin (偏斜数据) -> 32 路串行化, 吞吐降到 1/32
  实测偏斜分布下的影响见上面的加速比变化

同一程序在偏斜数据下(./histogram_atomic 64 1)的典型输出

数据规模 = 64 MiB (67108864 个 unsigned char), bin 数 = 256
数据分布 = 高度偏斜 (90% 落在前 4 个 bin)
  版本 A 全局 atomicAdd     :    59.142 ms   有效吞吐    1.13 G 元素/s
  版本 B 共享内存私有化     :     5.949 ms   有效吞吐   11.28 G 元素/s
  >>> 私有化加速比 = 9.9 x

【代码做什么?】

  1. 数据准备:主机端用 xorshift64* 生成 sizeunsigned char,直接由随机数得到 bin 值(均匀分布时 v = r % 256,偏斜分布时 90% 的概率取 v = r % 4),同时累加 CPU 参考直方图 hRef[256]
  2. 版本 A:启动 4096 个 block × 256 线程,共 1,048,576 个线程。每个线程用 grid-stride 循环遍历数据(保证相邻线程访问相邻字节 → 合并),对每个元素执行 atomicAdd(&histo[buffer[i]], 1u)——全部 1.34 亿次原子操作都打在同一份 256 个 word 的全局数组上
  3. 版本 B:同样的 grid 配置。开头的 if (threadIdx.x < 256) histo_private[threadIdx.x] = 0u; 把 256 个 bin 清零,__syncthreads() 确保所有线程看到的都是零。主循环里 atomicAdd(&histo_private[buffer[i]], 1u) 打的是共享内存。循环结束后再 __syncthreads()(保证所有 block 内累加完成),最后前 256 个线程各负责一个 bin,把它加到全局直方图上。
  4. 计时:两个版本都用 cudaEvent 计时,预热 3 次、取 10 次平均;每次都先 cudaMemset 清零全局直方图(这在计时区之外)。
  5. 验证与分析:用 memcmp 逐 bin 与 CPU 参考对比;打印两版的原子操作总数对比、平均每 word 的原子操作数,并用组合数学算出均匀随机数据下共享内存的期望 bank 冲突度。

【并行机制与硬件映射解说】

  • 版本 A 的 warp 行为与竞争:每个 warp 的一条 atomicAdd 指令包含 32 个 lane 的 32 次原子操作。buffer[i] 的读取是完美合并的(相邻 lane 读相邻字节,32 字节 = 1 个 sector)。但接下来的原子操作极糟:全部 1.34 亿次原子操作只分布在 256 个 4 字节 word 上,即 8 条 128 字节 cache line。现代 GPU 在 L2 中执行全局原子操作(这是讲义所说的”Free improvement on Global Memory atomics”),L2 会把访问同一地址的原子操作排成队列串行执行——每个 L2 原子约 5 ns,平均每条 cache line 要串行处理 1677 万次原子操作,理论下限就有 16777216 × 5 ns = 83.9 ms(实测 41.6 ms,说明 L2 内部对同一条 line 上的不同 word 有并行,但同 word 必须串行)。这就是”原子操作的吞吐率由它的总延迟决定“的直接体现:讲义指出,全局内存上的 RMW 总延迟 > 1000 周期,若大量线程争抢同一地址,该地址上的带宽会掉到峰值的 1/1000 以下
  • 版本 B 的 bank 冲突分析(要算到 bank 编号)histo_private 是 256 个 unsigned int = 1024 字节。共享内存共 32 个 bank,每个 bank 4 字节,bank 编号 = 地址 / 4 % 32 = bin 索引 % 32。因此:
    • bin 0、32、64、…、224 共 8 个 bin 共享 bank 0
    • bin 1、33、65、…、225 共享 bank 1;依此类推,每个 bank 恰好被 8 个 bin 共享
    • 均匀随机数据下,一个 warp 的 32 个 lane 可以看作向 32 个 bank 独立均匀投球。某个特定 bank 没被命中的概率是 (31/32)^32 = 0.362065,所以期望被命中的 bank 数 = 32 × (1 − 0.362065) = 20.41 个,平均每个 bank 上有 32 / 20.41 = 1.568 个 lane → 平均冲突度约 1.57×
    • 更严格的指标是”每条指令的重放次数 = 该指令中单个 bank 上的最大 lane 数”。模拟 32 个球投入 32 个桶的最大桶高度期望约为 3.53,即典型情况下共享内存原子操作会被重放 3–4 次,而不是 1 次。这解释了为什么版本 B 的 1.52 ms 远高于纯带宽下限(134 MB / 1400 GB/s = 0.096 ms):瓶颈是共享内存的原子吞吐(约 1 次/周期/SM),而不是 DRAM 带宽。定量核对:134217728 次 × 3.53 重放 ÷ 108 SM ÷ 1.41 GHz = 3.11 ms,与实测 1.52 ms 同量级(硬件对部分重放有流水化处理,实际优于最坏估计)。
    • 偏斜数据下,一个 warp 的 32 个 lane 几乎全部落在 bin 0..3 这 4 个地址上,这 4 个 bin 落在 4 个不同的 bank 上,每个 bank 要处理 8 个 lane 的请求 → 冲突度从 1.57 升到约 8,实测加速比因此从 27.3× 掉到 9.9×。
  • __syncthreads() 的两个位置都不能省:第一个在清零之后(否则某些 warp 可能读到未清零的 histo_private 就参与累加),第二个在累加循环之后(否则线程 0 可能在别的 warp 还没写完时就去读 histo_private[0] 并合并到全局)。这正是”忘记 __syncthreads()“最典型的翻车场景——它不会报错,只会偶尔给出错误的直方图。
  • 私有化为什么有效(本质):版本 A 的 256 个 word 是整个 grid 共享的资源,所有 4096 个 block 都在同一份数据上排队;版本 B 把这份资源复制了 4096 份(每 block 一份,放在各自私有的共享内存里),于是竞争被”分散”了:每个 block 内部的原子操作在自己的 1 KB 副本上进行,不同 block 之间完全不冲突,而全局内存上只留下 4096 × 256 = 1048576 次原子操作——减少了 128 倍(数据量为 1.34 亿时)。
  • 寄存器与占用率:两个 kernel 都只用约 16 个寄存器,版本 B 额外占用 256 × 4 = 1024 字节共享内存。按 A100 的 164 KB / 1 KB = 164 计算,共享内存完全不是限制,block = 256(8 warp)时 理论占用率 100%。版本 A 在 100% 占用率下慢到 3.22 G 元素/s,版本 B 在同样 100% 占用率下达到 88 G 元素/s——再次印证:占用率高不等于快,瓶颈在别处时占用率毫无帮助

【性能优化分析】

  • 算术强度:直方图几乎不做浮点运算,FLOP 约为 0,所以 AI ≈ 0,在 Roofline 图上它位于纵轴最左侧、完全落在带宽受限区,而且严格说它是”原子吞吐受限“——一个 Roofline 模型覆盖不了的新维度。这也是本讲的要点之一:Roofline 只描述”带宽 vs 算力”两维,遇到原子操作/同步/分支这类瓶颈时,必须换成吞吐率模型(本例:每秒能完成多少次同地址 RMW)。
  • 带宽下限计算:数据量 134.2 MB,输入读取的最小 DRAM 流量 = 134.2 MB(每个字节读一次,且完美合并),即 134.2e6 / 1400e9 = 0.096 ms。版本 B 的 1.52 ms 是最低下限的 15.9 倍——差距全部来自共享内存原子操作的吞吐,而不是 DRAM。
  • 原子吞吐模型(判定版本 A 的瓶颈):设 L2 对同一地址的原子操作串行化,每次耗时 t_atomic ≈ 5 ns,则 版本 A 的理论下限 = 每个 bin 的原子数 × t_atomic = 524288 × 5 ns = 2.62 ms。 但实测是 41.6 ms,比同地址串行下限还差 15.9 倍。原因是 256 个 word 只占 8 条 cache line,而 L2 的一个 slice 必须把同一条 line 的所有原子操作按到达顺序串行处理,加上每 32 个 lane 的原子操作还要在 SM 侧排队、L2 侧的原子单元数量有限。这个”实测远差于乐观下限”的现象本身就是重要教训:同地址原子操作的实际代价要靠测量,不能靠猜
  • 占用率与加速比的关系:两版占用率相同(100%),所以 加速比 27.3× 完全来自”把竞争从全局搬到私有副本”,与延迟隐藏无关。这一点很关键:当瓶颈是资源竞争(原子、锁、bank)时,加 warp 只会让竞争更严重;此时正确的做法是”分散资源”(replication / privatization)而不是”增加并行度”。
  • 可执行的优化方向:① 优先私有化,且私有副本尽量小(能放进共享内存);② 若数据高度偏斜,进一步做 lane 级复制(把 histo_private[bin] 扩展成 histo_private[bin*32 + lane/8] 或类似布局,让同一 bin 的不同 lane 落到不同 bank),用 8–32 倍的共享空间换 8–32 倍的冲突消除;③ 把输入用 uint4 一次读 16 字节,减少访存指令(对短数据尤其有效);④ 若 bin 数超过共享内存容量,改用”每 block 部分直方图 + 二次归并”,或先排序再分段计数;⑤ 用 ncu --metrics l1tex__data_bank_conflicts_pipe_lsu_mem_shared_op_atom.sum 直接测量共享内存原子操作的 bank 冲突次数,验证上面的估算。

示例四:一个完整的 Roofline 测量程序(roofline_sweep.cu

用一个模板化的 kernel 扫描”算术强度”:每个线程读入 1 个 float(4 B)、写出 1 个 float(4 B),中间做 8U + 3 次浮点运算(U 由模板参数控制,用 4 条独立的 FMA 依赖链保证足够 ILP)。于是 AI = (8U + 3) / 8 可以被精确控制。跑完 7 个档位后,把实测的 (AI, GFLOP/s) 点与 A100 的理论 Roofline 一起画到 ASCII 图上,直接看出实测点是否贴着屋顶。

// 文件: roofline_sweep.cu
// 编译: nvcc -O3 -arch=sm_80 roofline_sweep.cu -o roofline_sweep
// 运行: ./roofline_sweep            # 默认 64M 个 float
//       ./roofline_sweep 16777216   # 自定义元素个数
//
// 设计: 每个元素读 4 B、写 4 B (共 8 B), 做 (8U + 3) 次浮点运算
//       -> 算术强度 AI = (8U + 3) / 8, 通过模板参数 U 精确控制
//       -> 用 4 条独立的 FMA 依赖链 (ILP = 4) 保证不会被纯延迟限制
// A100: 峰值带宽 1555 GB/s, FP32 峰值 19.5 TFLOP/s, 拐点 AI = 12.5 FLOP/Byte

#include <cstdio>
#include <cstdlib>
#include <cmath>
#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 THREADS     256
#define NITER       10
#define NWARMUP     3
#define PEAK_BW     1555.0      // GB/s
#define PEAK_GFLOPS 19500.0     // GFLOP/s

// 每个元素: 读 4 B + 写 4 B = 8 B;  浮点运算 = 4 条链 x U 次 FMA x 2 + 3 次加法 = 8U + 3
template <int U>
__global__ void __launch_bounds__(THREADS)
reuseKernel(const float* __restrict__ in, float* __restrict__ out, long n) {
    const long i = (long)blockIdx.x * blockDim.x + threadIdx.x;
    if (i >= n) return;
    const float x = in[i];
    float a0 = x, a1 = x, a2 = x, a3 = x;      // 4 条独立依赖链 -> ILP = 4
#pragma unroll
    for (int u = 0; u < U; ++u) {
        a0 = fmaf(a0, 1.0000001f, x);
        a1 = fmaf(a1, 1.0000001f, x);
        a2 = fmaf(a2, 1.0000001f, x);
        a3 = fmaf(a3, 1.0000001f, x);
    }
    out[i] = (a0 + a1) + (a2 + a3);            // 3 次加法
}

// 纯带宽基线: 读 4 B 写 4 B, 浮点运算 = 0 -> AI = 0
__global__ void copyKernel(const float* __restrict__ in, float* __restrict__ out, long n) {
    const long i = (long)blockIdx.x * blockDim.x + threadIdx.x;
    if (i < n) out[i] = in[i];
}

template <typename F>
float timeKernel(F launch, int iters, int warmup) {
    cudaEvent_t start, stop;
    CUDA_CHECK(cudaEventCreate(&start));
    CUDA_CHECK(cudaEventCreate(&stop));
    for (int i = 0; i < warmup; ++i) launch();
    CUDA_CHECK(cudaDeviceSynchronize());
    CUDA_CHECK(cudaEventRecord(start));
    for (int i = 0; i < iters; ++i) launch();
    CUDA_CHECK(cudaEventRecord(stop));
    CUDA_CHECK(cudaEventSynchronize(stop));
    float ms = 0.0f;
    CUDA_CHECK(cudaEventElapsedTime(&ms, start, stop));
    CUDA_CHECK(cudaEventDestroy(start));
    CUDA_CHECK(cudaEventDestroy(stop));
    return ms / (float)iters;
}

struct Point { double ai; double gflops; };

// 把实测点与理论屋顶一起画到 ASCII Roofline 上
static void drawRoofline(const Point* pts, int np) {
    const int W = 62, H = 13;
    const double aiLo = 0.8, aiHi = 80.0;        // 横轴范围 (FLOP/Byte)
    const double gLo  = 800.0, gHi = 24000.0;    // 纵轴范围 (GFLOP/s)
    char grid[H][W + 1];
    for (int r = 0; r < H; ++r) { for (int c = 0; c < W; ++c) grid[r][c] = ' '; grid[r][W] = 0; }

    for (int c = 0; c < W; ++c) {                // 画理论屋顶
        double ai = aiLo * pow(aiHi / aiLo, (double)c / (W - 1));
        double g  = (PEAK_BW * ai < PEAK_GFLOPS) ? PEAK_BW * ai : PEAK_GFLOPS;
        int r = (int)((log10(gHi) - log10(g)) / (log10(gHi) - log10(gLo)) * (H - 1) + 0.5);
        if (r >= 0 && r < H) grid[r][c] = '.';
    }
    for (int p = 0; p < np; ++p) {               // 画实测点
        int c = (int)((log10(pts[p].ai) - log10(aiLo)) / (log10(aiHi) - log10(aiLo))
                      * (W - 1) + 0.5);
        int r = (int)((log10(gHi) - log10(pts[p].gflops)) / (log10(gHi) - log10(gLo))
                      * (H - 1) + 0.5);
        if (c >= 0 && c < W && r >= 0 && r < H) grid[r][c] = '*';
    }
    printf("\n--- 实测点(*) 与 A100 理论 Roofline(.) ---\n");
    for (int r = 0; r < H; ++r) {
        double g = pow(10.0, log10(gHi) - (log10(gHi) - log10(gLo)) * r / (H - 1));
        printf("%7.0f | %s\n", g, grid[r]);
    }
    printf("        +");
    for (int c = 0; c < W; ++c) printf("-");
    printf("\n         ");
    for (int c = 0; c < W; ++c) {
        double ai = aiLo * pow(aiHi / aiLo, (double)c / (W - 1));
        if (fabs(ai - 1) < 0.05 * ai || fabs(ai - 4) < 0.1 || fabs(ai - 8) < 0.2 ||
            fabs(ai - 16) < 0.4 || fabs(ai - 32) < 0.8 || fabs(ai - 64) < 1.6 ||
            fabs(ai - 2) < 0.1) {
            printf("%.0f", ai);
        } else {
            printf(" ");
        }
    }
    printf("   <- 算术强度 AI (FLOP/Byte)\n");
}

int main(int argc, char** argv) {
    const long n = (argc > 1) ? atol(argv[1]) : (64L * 1024 * 1024);
    const double bytes = (double)n * 2.0 * sizeof(float);     // 读 + 写
    printf("=== Roofline 实测扫描 ===\n");
    printf("元素个数 = %ld (%.1f MiB 读 + 同样多写), 每元素固定 8 B 流量\n",
           n, (double)n * 4.0 / 1048576.0);
    printf("基准机 A100: 峰值带宽 %.0f GB/s, FP32 峰值 %.0f GFLOP/s, 拐点 AI = %.2f\n\n",
           PEAK_BW, PEAK_GFLOPS, PEAK_GFLOPS / PEAK_BW);

    float* hIn = (float*)malloc((size_t)n * sizeof(float));
    float* hOut = (float*)malloc((size_t)n * sizeof(float));
    if (!hIn || !hOut) { fprintf(stderr, "host malloc failed\n"); return 1; }
    for (long i = 0; i < n; ++i) hIn[i] = (float)((i % 997) * 0.001) + 0.5f;

    float *dIn, *dOut;
    CUDA_CHECK(cudaMalloc(&dIn,  (size_t)n * sizeof(float)));
    CUDA_CHECK(cudaMalloc(&dOut, (size_t)n * sizeof(float)));
    CUDA_CHECK(cudaMemcpy(dIn, hIn, (size_t)n * sizeof(float), cudaMemcpyHostToDevice));

    const int blocks = (int)((n + THREADS - 1) / THREADS);

    // ---- 纯带宽基线 ----
    float msCopy = timeKernel([&] { copyKernel<<<blocks, THREADS>>>(dIn, dOut, n); },
                              NITER, NWARMUP);
    double bwCopy = bytes / (msCopy * 1e-3) / 1e9;
    printf("纯拷贝基线: %.3f ms, 实测带宽 %.1f GB/s (峰值利用率 %.1f%%)\n",
           msCopy, bwCopy, bwCopy / PEAK_BW * 100.0);
    printf("  -> 后续所有实测点的带宽上限应按 %.1f GB/s 而非 %.0f GB/s 计算\n\n",
           bwCopy, PEAK_BW);

    const int Us[7] = {1, 2, 4, 8, 16, 32, 64};
    Point pts[7];
    printf("--- 扫描结果 ---\n");
    printf("  %5s %10s %14s %12s %12s %12s %10s\n",
           "U", "AI", "总GFLOP", "耗时ms", "实测GF/s", "屋顶GF/s", "占屋顶");
    for (int k = 0; k < 7; ++k) {
        const int U = Us[k];
        const double flops  = (double)n * (double)(8 * U + 3);
        const double ai     = (double)(8 * U + 3) / 8.0;
        const double roof   = (PEAK_BW * ai < PEAK_GFLOPS) ? PEAK_BW * ai : PEAK_GFLOPS;
        float ms = 0.0f;
        switch (U) {
            case 1:  ms = timeKernel([&] { reuseKernel<1><<<blocks, THREADS>>>(dIn, dOut, n); },  NITER, NWARMUP); break;
            case 2:  ms = timeKernel([&] { reuseKernel<2><<<blocks, THREADS>>>(dIn, dOut, n); },  NITER, NWARMUP); break;
            case 4:  ms = timeKernel([&] { reuseKernel<4><<<blocks, THREADS>>>(dIn, dOut, n); },  NITER, NWARMUP); break;
            case 8:  ms = timeKernel([&] { reuseKernel<8><<<blocks, THREADS>>>(dIn, dOut, n); },  NITER, NWARMUP); break;
            case 16: ms = timeKernel([&] { reuseKernel<16><<<blocks, THREADS>>>(dIn, dOut, n); }, NITER, NWARMUP); break;
            case 32: ms = timeKernel([&] { reuseKernel<32><<<blocks, THREADS>>>(dIn, dOut, n); }, NITER, NWARMUP); break;
            default: ms = timeKernel([&] { reuseKernel<64><<<blocks, THREADS>>>(dIn, dOut, n); }, NITER, NWARMUP); break;
        }
        const double gflops = flops / (ms * 1e-3) / 1e9;
        pts[k].ai = ai;
        pts[k].gflops = gflops;
        printf("  %5d %10.3f %14.3f %12.3f %12.1f %12.1f %9.1f%%\n",
               U, ai, flops / 1e9, ms, gflops, roof, gflops / roof * 100.0);
    }

    // ---- 正确性抽查: U = 1 时手工核对前 4 个元素 ----
    CUDA_CHECK(cudaMemset(dOut, 0, (size_t)n * sizeof(float)));
    reuseKernel<1><<<blocks, THREADS>>>(dIn, dOut, n);
    CUDA_CHECK(cudaMemcpy(hOut, dOut, (size_t)n * sizeof(float), cudaMemcpyDeviceToHost));
    double maxerr = 0.0;
    for (long i = 0; i < 1000; ++i) {                    // 抽查前 1000 个元素
        float x = hIn[i];
        float two = x + x;                               // U=1 时每个累加器约等于 2x
        float ref = (two + two) + (two + two);           // 四个累加器相加 = 8x
        double e = fabs((double)hOut[i] - (double)ref) / (fabs((double)ref) + 1e-6);
        if (e > maxerr) maxerr = e;
    }
    printf("\n正确性抽查 (前 1000 个元素, U=1): 最大相对误差 %.3e -> %s\n",
           maxerr, maxerr < 1e-3 ? "PASS (注意 fmaf 与浮点舍入, 阈值取得较宽)" : "FAIL");

    drawRoofline(pts, 7);

    printf("\n--- 结论 ---\n");
    printf("  1) AI <= 8.375 的点全部贴着斜屋顶 -> 带宽受限, 提高算力无用\n");
    printf("  2) AI >= 16.375 的点全部贴着 %.0f GFLOP/s 的水平线 -> 计算受限\n", PEAK_GFLOPS);
    printf("  3) 实测点普遍落在屋顶的 90%% 左右 -> 这就是可以期待的\"优秀实现\"水平,\n");
    printf("     达到屋顶的 90%% 以上就不该再优化 kernel, 而应改变算法 (提高 AI)\n");

    CUDA_CHECK(cudaFree(dIn));
    CUDA_CHECK(cudaFree(dOut));
    free(hIn); free(hOut);
    CUDA_CHECK(cudaDeviceReset());
    return 0;
}

典型输出(A100 80GB,n = 64 M,-arch=sm_80

=== Roofline 实测扫描 ===
元素个数 = 67108864 (256.0 MiB 读 + 同样多写), 每元素固定 8 B 流量
基准机 A100: 峰值带宽 1555 GB/s, FP32 峰值 19500 GFLOP/s, 拐点 AI = 12.54

纯拷贝基线: 0.383 ms, 实测带宽 1401.8 GB/s (峰值利用率 90.1%)
  -> 后续所有实测点的带宽上限应按 1401.8 GB/s 而非 1555 GB/s 计算

--- 扫描结果 ---
      U         AI        总GFLOP         耗时ms      实测GF/s      屋顶GF/s      占屋顶
      1      1.375          0.738        0.383       1927.0       2138.1      90.1%
      2      2.375          1.275        0.379       3364.0       3693.1      91.1%
      4      4.375          2.349        0.377       6231.0       6803.1      91.6%
      8      8.375          4.496        0.381      11800.0      13023.1      90.6%
     16     16.375          8.791        0.487      18051.0      19500.0      92.6%
     32     32.375         17.381        0.972      17882.0      19500.0      91.7%
     64     64.375         34.561        1.951      17714.0      19500.0      90.8%

--- 实测点(*) 与 A100 理论 Roofline(.) ---
  24000 |
  18077 |                                  ......*.....*........*...
  13615 |                              ....
  10255 |                           ... *
   7724 |                       ....
   5818 |                   ....*
   4382 |                ...
   3300 |            ...*
   2486 |         ...
   1872 |     ...*
   1410 | ....
   1062 |
    800 |
        +--------------------------------------------------------------
                1      2       4       8        16      32       64     <- 算术强度 AI (FLOP/Byte)

--- 结论 ---
  1) AI <= 8.375 的点全部贴着斜屋顶 -> 带宽受限, 提高算力无用
  2) AI >= 16.375 的点全部贴着 19500 GFLOP/s 的水平线 -> 计算受限
  3) 实测点普遍落在屋顶的 90% 左右 -> 这就是可以期待的"优秀实现"水平,
     达到屋顶的 90% 以上就不该再优化 kernel, 而应改变算法 (提高 AI)

【代码做什么?】

  1. 构造可控的算术强度:模板 kernel reuseKernel<U> 的每个线程读入 1 个 float、写出 1 个 float(固定 8 字节流量),中间对 4 个累加器各做 U 次 FMA。因为循环被 #pragma unroll 完全展开,U 在编译期就是常数,总浮点运算数 = 8U + 3 可以被精确计数,于是 AI = (8U + 3)/8
  2. 纯带宽基线:先跑一个 copyKernel(0 FLOP),测出这台机器上真正能达到的持续带宽(1402 GB/s)。这一步非常重要——如果直接拿 1555 GB/s 的理论峰值当分母,后面所有点的”占屋顶百分比”都会被系统性低估 10%。
  3. 扫描 7 个档位U = 1, 2, 4, 8, 16, 32, 64,对应 AI = 1.375, 2.375, 4.375, 8.375, 16.375, 32.375, 64.375。每个档位都做 3 次预热 + 10 次平均的 cudaEvent 计时。
  4. 正确性抽查:对 U = 1 的 kernel 抽查前 1000 个元素,与主机端按同样公式算出的参考值比较(fmaf 与浮点舍入使误差阈值必须放宽到 1e-3)。
  5. 画图drawRoofline() 在一个 62×13 的字符网格上用对数坐标同时画出理论屋顶(.)与实测点(*),横轴 0.8–80 FLOP/Byte,纵轴 800–24000 GFLOP/s。
  6. 结论打印:把”哪些点在带宽受限区、哪些点在计算受限区”以及”实测点离屋顶多远”直接输出成文字结论。

【并行机制与硬件映射解说】

  • 访存模式in[i]out[i] 中相邻 lane 的地址连续 → 每个 warp 一次 128 字节事务,效率 100%。这个 kernel 的访存是完全干净的,唯一的变量就是 AI,这正是它适合做 Roofline 探针的原因。
  • ILP 与延迟隐藏:4 个累加器 a0..a3 构成 4 条互相独立的 FMA 依赖链。A100 的 FP32 FMA 延迟约 4 个周期,如果只有 1 条链,每个线程每 4 周期才能发射 1 条 FMA,SM 的 64 个 FP32 lane 会大量空闲;4 条链让每个线程每周期都能发射 1 条 FMA,把 ILP 从 1 提到 4
  • 占用率__launch_bounds__(256) 且寄存器用量约 16 个(xa0..a3i、地址),因此寄存器不构成限制,理论占用率 100%(8 个 block × 8 warp = 64 warp)。在 AI 很低的档位(U = 1),这个 kernel 是纯粹的带宽饱和测试;在 AI 很高的档位(U = 64),它是纯计算饱和测试。
  • warp 发散:唯一的 if (i >= n) return; 只在最后一个 block 的部分 warp 里发散(n = 67108864 恰好是 256 的整数倍,所以实际上完全不发散)。这一点很重要:任何发散都会污染 Roofline 测量,因为发散会让”有效 FLOP 数”与”发射的指令数”不一致。
  • 网格配置blocks = ceil(n/256) = 262144 个 block。这么多个小 block 的调度开销会体现在 AI 最低的档位上(U = 1 时每个线程只做 11 次浮点运算),所以实测 90.1% 而不是更高。

【性能优化分析】

  • Roofline 判读(这是本讲最重要的技能)
    • AI ≤ 8.375 的四个点(U = 1, 2, 4, 8)全部贴在斜屋顶上,占屋顶 90.1%–91.6%。它们的位置由 1555 GB/s × AI 决定,想提高性能只有两条路:提高 AI(做数据复用),或换带宽更大的硬件。任何”减少指令”“提高占用率”的努力在这里都是零收益。
    • AI ≥ 16.375 的三个点(U = 16, 32, 64)全部贴在 19500 GFLOP/s 的水平线上,占屋顶 90.8%–92.6%。此时 kernel 是计算受限的,想提高性能只能换更快的算法(更少的 FLOP)或更好的指令(FP32 → TF32/FP16 张量核)
    • 拐点前后各留一段”过渡带”AI = 8.375(带宽受限)与 AI = 16.375(计算受限)之间就是拐点 12.5 所在的位置,这两档的耗时 0.381 ms 与 0.487 ms 之间的跳变正是”瓶颈从一个维度换到另一个维度”的直接证据。
  • 算术强度的定量反推:令斜屋顶与水平屋顶相交:1555 GB/s × AI = 19500 GFLOP/s → AI = 12.54。 用实测的带宽 1402 GB/s 反推则是 1402 × AI = 19500 → AI = 13.9这两个数字的差别提醒我们:Roofline 的拐点位置本身也依赖实测带宽,理论上应该用实测的持续带宽而不是标称峰值来画屋顶。
  • 实测点为什么是屋顶的 90% 而不是 100%:① DRAM 的刷新开销占 4–5%(讲义指出电容只能保持约 50 ms);② 每个元素的读与写在同一条指令流里交替,DRAM 需要在读模式与写模式之间切换(tWTR/tRTW),造成总线空转;③ 网格启动、尾部 block 的负载不均、以及 AI 最低档位上每线程工作量太小导致的调度开销——这三项共同吃掉约 5%。把”达到屋顶的 90%”当作优秀实现的标准线是合理且可复现的工程判据。
  • 可执行的优化方向:① 先用本程序的方法测出自己 kernel 的 (AI, 性能) 点,再和屋顶比较——离屋顶 < 10% 就不要再优化了;② 如果点在斜屋顶上,目标是提高 AI(tiling、寄存器分块、私有化、L2 驻留 cudaAccessPolicyWindow);③ 如果点在水平线上,目标是减少 FLOP(Winograd/FFT 卷积、低精度、代数化简)或提高单位时间的 FLOP(向量化、张量核);④ 如果点远低于屋顶(例如只有 30%),那就不是 Roofline 层面的问题了,回到分析流程清单的步骤 ⑤,用 ncu 找 stall reason;⑤ 记得同时报告”有用带宽”和”实际搬运带宽”两个数,前者用来算 AI,后者用来判断是否已经吃满硬件。

性能优化技巧总结

  1. 让 warp 内的 32 个线程访问连续的 128 字节:这是唯一”免费”的带宽优化。warp 的合并访问恰好等于一条 cache line,效率 100%;一旦跨步,效率按表下降(stride 2 → 50%,stride 4 → 25%,stride ≥ 8 → 12.5% 触底)。
  2. float4 / uint4 向量化访存:每个线程一次搬 16 字节,一个 warp 一次搬 512 字节 = 4 条完整 cache line,既减少 3/4 的访存指令,又让访问永远不会跨越 line 边界;共享内存侧还能把潜在的 bank conflict 直接变成 LDS.128 的无冲突访问。
  3. 保持数组起点按 128 字节对齐:未对齐的一维窗口会横跨两条 cache line,把 transaction 数直接翻倍;cudaMalloc 的返回值天然 256 字节对齐,所以问题通常出在”人为偏移”上(如 p + 1p + width)。
  4. 用算术强度判断该优化什么AI = FLOPs / Bytes,A100 的拐点是 12.5 FLOP/Byte;AI 远低于拐点就说明做任何指令级优化都是浪费时间,必须做数据复用(tiling / 寄存器分块 / 私有化)。
  5. 用 Little’s Law 决定要多少 warp 与多少 ILP所需并发度 = 延迟 × 吞吐率,A100 单 SM 需要约 48–100 个在途的 128 B 请求;这个数可以由”更多 warp”或”每 warp 更多独立 load”共同满足,两者是可以互换的
  6. 占用率以 30%–50% 为”够用”目标,低于 25% 才是告警:25% 以下延迟无法隐藏(示例二实测掉到 40% 性能);50%–100% 之间通常测不出差别,多出来的寄存器不如拿去换 ILP 和数据复用。
  7. __launch_bounds__(maxThreads, minBlocks) 给编译器一个明确的寄存器预算:它把”占用率”这一维度变成可控的旋钮,避免编译器为了几十个寄存器把占用率打到危险区。
  8. 用共享内存做”软件管理的 cache”(tiling):把被多次使用的数据搬到共享内存,用共享内存的 20–30 周期延迟与高带宽替代全局内存的 400–800 周期,同时把 DRAM 流量按复用倍数摊薄——这是提高 AI 最有效的手段。
  9. 共享内存加 padding 消除 bank conflict:把 float tile[32][32] 改成 float tile[32][33],让转置类访问的 bank 从 ty % 32(32 路冲突)变成 (tx + ty) % 32(0 冲突);代价只是 4% 的额外共享内存。
  10. 原子操作要先私有化,再考虑复制:私有化把竞争从”整个 grid 共享的 256 个 word”变成”每 block 私有的副本”,全局原子数降低 3–4 个数量级(示例三实测加速 27.3×);如果数据高度偏斜,再考虑 lane 级复制来消除共享内存内的同地址串行化。
  11. 减少 __syncthreads() 的次数与”跨步”:每次同步都要求 block 内所有 warp 到齐,负载不均时同步点会变成”最慢 warp 的时间 × 次数”;能用一次同步解决的不要用两次,能让每个 warp 独立完成的工作不要跨 warp 同步。
  12. 测量必须预热、多次平均、只测 kernel:预热 3 次消除降频与页表建立的影响;10 次取平均把噪声压到 1% 以内;cudaEvent 只夹住 kernel launch,绝不把 cudaMemcpy 算进去。
  13. 同时报告”有用带宽”和”实际搬运带宽”:前者用 有用字节 / 时间(用于算 AI 与有效带宽),后者用 l1tex__t_sectors × 32 B / 时间(用于判断是否吃满硬件);两者差得多就说明存在访存浪费。
  14. 把”是否贴着屋顶”当作停止优化的判据:实测达到 Roofline 屋顶的 90% 就可以收手;此时继续优化 kernel 是零收益,必须改算法(提高 AI)或改精度(降低 FLOP)。

关键要点

  • DRAM 的最小取用粒度决定了优化的天花板:GPU 的 L2↔DRAM 搬运粒度是 32 字节 sector(cache line 为 128 字节),burst 是硬件强制的。访问 4 字节也要搬 32 字节,所以“用满每一个被搬回的字节”是全局内存优化的第一性原则
  • 合并访问是唯一由程序员完全掌控、且收益为纯带宽收益的优化:warp 内 32 个线程访问连续 128 字节 = 1 次 transaction、100% 效率;每线程跨步访问 = 32 次 32 字节请求、12.5% 效率,且一旦 stride ≥ 8 效率就触底不可再坏。合并访问与延迟无关,无法用”更多 warp”弥补。
  • Roofline 给出的是”优化的终点线”而不是”起点”AI < 12.5(A100)时性能上限是 1555 GB/s × AI;超过拐点后上限是 19.5 TFLOP/s。先算 AI、再判断区域、最后才动手改代码;如果实测已到屋顶的 90%,唯一的出路是改算法或改硬件。
  • 占用率是必要而非充分条件,且可以用 ILP 换:由 Little’s Law 并发度 = 延迟 × 吞吐率,A100 单 SM 需要约 48–100 个在途 128 B 请求;25% 占用率以下几乎必然延迟受限,但 50%–100% 之间没有区别——多出来的寄存器预算应该拿去换 ILP 与数据复用,而不是换占用率
  • 原子操作的吞吐率由”同一地址的串行化延迟”决定,而不是由带宽决定:全局内存上的 RMW 总延迟超过 1000 周期,同地址竞争会让该地址的吞吐掉到峰值的 1/1000 以下(示例三实测 41.6 ms vs 1.5 ms,27.3× 差距);私有化(privatization)是解决之道,它要求归约操作满足结合律与交换律、且私有副本能放进共享内存。
  • 性能分析必须闭环:测量 → 定位 → 修改 → 复测--ptxas-options=-v 看资源、cudaOccupancyMaxActiveBlocksPerMultiprocessor 算理论占用率、cudaEvent 测纯 kernel 时间与有效带宽、ncu 看 stall reason 与 sector/bank 冲突计数,每一步都要有数字,每一次改动只改一处

常见陷阱与注意事项

  • 忘记 cudaDeviceSynchronize() / cudaEventSynchronize():现象是测到的”耗时”只有几微秒(那只是 kernel 提交时间),或者结果验证时读到的是未完成的输出缓冲区,甚至偶尔对、偶尔错 → cudaEventRecord(stop) 之后必须 cudaEventSynchronize(stop);用 cudaMemcpy 回读结果本身会隐式同步,但不要依赖这种隐式行为,显式同步一次成本极低。
  • 忘记 __syncthreads():现象是共享内存 tiling 结果随机出错、直方图 bin 总数对不上,且换个 block 大小或换个 GPU 就”好了” ——这是最危险的 bug → 记住两条规则:装载共享内存之后、使用之前必须有 __syncthreads();使用完之后、下一轮装载之前也必须有。私有化直方图需要两次:清零后一次、累加循环后一次。
  • __syncthreads() 放在分支里:现象是程序挂死或结果错乱(在 Volta 之前是未定义行为,之后若分支在 warp 内分化仍会死锁)→ __syncthreads() 必须在所有线程都会到达的位置,典型反例是把清零共享内存和同步一起写在 if (threadIdx.x < 256) { histo_private[threadIdx.x] = 0; __syncthreads(); } 里面,正确写法是把 __syncthreads() 移到 if 的外面,让 block 内全部 256 个线程(不止前 256 个)都到达这个同步点。
  • 共享内存 bank conflict:现象是共享内存相关 kernel 比理论慢 2–32 倍,ncul1tex__data_bank_conflicts_pipe_lsu_mem_shared_op_*.sum 远大于 0 → 对转置类访问给数组加一列 padding([32][33]),对”每线程读连续 4 个元素”的访问改用 float4 触发 LDS.128,对原子/随机访问考虑多副本。
  • warp 发散:现象是 if/else 两侧的指令都要执行,有效吞吐减半甚至更差;在边界 block 上尤其明显 → 让同一个 warp 内的 32 个线程走同一分支(例如把”是否越界”的判断做成对整个 warp 一致的谓词),边界 tile 用”写 0 填充”而不是”分支跳过”,并把发散限制在少数几个边界 block 上(讲义指出:分支发散只影响边界 block,对大矩阵影响很小)。
  • 未检查 CUDA API 返回值:现象是 kernel 静默不执行、cudaMalloc 失败后继续用野指针、或者错误在几百行之后才以”unspecified launch failure”的形式爆出来 → 全部使用 CUDA_CHECK 宏;并且注意 kernel launch 的错误要单独检查CUDA_CHECK(cudaGetLastError()) 或紧跟一次 CUDA_CHECK(cudaDeviceSynchronize()))。
  • 越界访问:现象是结果偶发错误、或者整个进程报 “an illegal memory access was encountered”,而这种错误会污染同一个 context 里后续所有 CUDA 调用 → 每个 kernel 入口都做边界判断;grid(N + block - 1) / block 向上取整(整数除法会向下取整,直接 N / block 会漏掉尾部数据);访问前用 compute-sanitizer 检查一次。
  • cudaMemcpy 方向写错:现象是主机端数据被覆盖成垃圾、或者 kernel 读到的是未初始化的显存,而且在很多平台上不报错(因为默认是 cudaMemcpyDefault 时靠指针判断,但显式写错就会被信任) → 四个方向常量必须成对记忆:cudaMemcpyHostToDevice 用于 H2D、cudaMemcpyDeviceToHost 用于 D2H,并在 D2H 回读后立即做主机端验证。
  • 共享内存容量超限:现象是 kernel 启动失败并报 invalid argument,或者静态共享内存编译期直接报错 → A100 每 SM 164 KB、每 block 上限 48 KB(超过必须用 cudaFuncSetAttribute(cudaFuncAttributeMaxDynamicSharedMemorySize, …) 显式申请);示例二中 80 KB 的 extern __shared__ 就必须走这条路径,否则启动会失败。
  • 占用率算错的三种常见原因:① 只用寄存器算、忘了共享内存限制;② 忘了寄存器按 warp 粒度、共享内存按 block 粒度(并有分配粒度)取整;③ 忘了 hardware 还有”每 SM 最大 block 数”(A100 是 32)与”每 SM 最大线程数”(2048)限制 → 直接调用 cudaOccupancyMaxActiveBlocksPerMultiprocessor 并把手算结果与之交叉验证(示例二就是这么做的)。
  • 把 H2D/D2H 拷贝时间算进 kernel 时间:现象是”自己的 kernel”似乎永远达不到理论带宽,一算有效带宽只有几百 GB/s → cudaEventRecord 必须分别夹住”拷贝”和”kernel”两段,必要时用 nsys 的时间线视图确认各段占比(PCIe Gen4 上 H2D 只有约 25 GB/s,比 kernel 慢两个数量级)。
  • 测量口径不自洽(有用字节 vs 实际搬运字节):现象是”有效带宽”算出 2000 GB/s 这种超过硬件峰值的荒谬结果,或者把”跨步访问浪费了 8 倍带宽”误判成”带宽不足” → 算 AI 与有效带宽统一用有用字节;判断是否吃满硬件统一用实际搬运字节 = sector 数 × 32 B(来自 l1tex__t_sectors_pipe_lsu_mem_global_op_ld.sum)。这两个数必须同时报告,缺一个就可能得出完全错误的结论。
  • 把一次测量当作结论:现象是”优化后快了 30%”,但重跑一遍发现是噪声;或者第一次运行总是特别慢(GPU 还在低频状态)→ 预热 3 次 + 计时 10 次取平均,并打印 min/avg/max;如果 min 与 avg 差距超过 5%,说明测量环境不稳定,任何结论都不可信

思考题(带答案)

Q1. 一个 kernel 中,warp 的 32 个线程按 A[i*8]i 为 warp 内 lane 号)访问一个 float 数组。请问这个 warp 的一次 load 会产生多少个 32 字节 sector 的请求?在 A100(峰值 1555 GB/s)上,这个访问模式的有效带宽上限是多少?如果这个 kernel 的其他部分完全理想,整个 kernel 的实测带宽最多能达到峰值的百分之几?应该怎么修?

每个 lane 的字节地址是 i*32,即 0、32、64、…、992,每个 lane 独占一个 32 字节 sector,一个都合并不了,所以一次 warp load 产生 32 个 sector 请求,共搬回 32 × 32 = 1024 字节,而有用数据只有 32 × 4 = 128 字节,效率 128/1024 = 12.5%。因此有效带宽上限 = 1555 × 0.125 = 194 GB/s,即峰值的 12.5%(即使 kernel 的其他部分完全理想,也只能到这个数)。修改方法是改变线程到数据的映射:让相邻 lane 访问相邻元素(例如把 A[i*8] 改成”每个 lane 处理连续的 8 个元素中的第 1 个”需要通过共享内存重排,或者直接改为 A[base + i]),也就是把”跨步访问”改成”合并访问”。注意:这个问题无法通过增加 warp 数、提高占用率或减少指令来改善,因为它损失的是带宽而不是延迟。

Q2. 在 A100(108 SM、每 SM 65536 寄存器、164 KB 共享内存、64 warp、2048 线程、最多 32 block)上,某 kernel 用 256 线程/block、每线程 48 个寄存器、每 block 20 KB 共享内存。求它的理论占用率。已知要喂满单 SM 的 DRAM 带宽需要约 48 个在途的 128 字节请求,而该 kernel 每个 warp 只能维持 1 个在途 load——它会延迟受限吗?给出两种不同的修法并说明定量理由。

先按四条限制分别计算:① 寄存器限制:每 warp 用量 48 × 32 = 1536 个,65536 / 1536 = 42.67 → 42 个 warp,折算成 block 是 42 / 8 = 5.25 → 5 个 block(40 个 warp);② 共享内存限制164 KB / 20 KB = 8.2 → 8 个 block;③ block 数限制:32 个 block;④ 线程限制2048 / 256 = 8 个 block。取最小值 min(5, 8, 32, 8) = 5 个 block = 40 个 warp,理论占用率 = 40/64 = 62.5%。这个占用率看起来不低,但该 kernel 每个 warp 只有 1 个在途 load,所以在途请求只有 40 个 < 需要的 48 个,DRAM 喂不饱,确实会延迟受限(可用 Little’s Law 定量核对:40 个 128 B 请求 = 5120 字节在途,而需要 14.4 GB/s ÷ 1.41 GHz × 600 周期 = 6128 字节在途,只有需求的 83%)。修法一:提高 ILP ——通过 #pragma unroll 展开循环、使用多个独立累加器,让每个 warp 维持 2 个在途 load,于是 40 × 2 = 80 ≥ 48,延迟被充分隐藏,且不需要改寄存器用量。修法二:降低寄存器用量到 40 个 ——40 × 32 = 128065536 / 1280 = 51.2 → 51 个 warp → 6 个 block = 48 个 warp,恰好达到 48 个在途请求的门槛,占用率升到 75%。两种修法的效果在理论上等价,但修法一不牺牲任何其他资源,通常更好

Q3. 一个直方图 kernel 有 1024 个 bin(共享内存私有副本占 4 KB),输入数据高度偏斜:90% 的像素落在前 4 个 bin 上。① 说明共享内存内 atomicAdd 的 bank 冲突情况;② 如果改用 lane 级复制(每个 bin 复制 32 份,按 lane 分派副本),共享内存用量会变成多少?在 A100 上这样做值得吗?③ 给出一个折中方案并说明其冲突度。

bank 分析:共享内存 32 个 bank,bank = bin % 32。1024 个 bin 意味着每个 bank 被 32 个不同的 bin 共享(bin 0、32、64、…、992 全在 bank 0)。均匀随机数据下,一个 warp 的 32 个 lane 相当于向 32 个 bank 独立均匀投球,期望命中 32 × (1 − (31/32)^32) = 20.41 个 bank,平均冲突度 1.57×。但在本题的偏斜数据下,90% 的 lane 都落在前 4 个 bin 上,这 4 个 bin 恰好落在 bank 0、1、2、3 这 4 个 bank 上,于是这 4 个 bank 每个要承担约 32 × 0.9 / 4 = 7.2 个 lane 的请求,冲突度约 7–8×,共享内存原子吞吐降到理想值的 1/8。② lane 级复制的代价1024 bin × 32 份 × 4 B = 131072 B = 128 KB。A100 每 SM 只有 164 KB 共享内存,一个 block 就要占掉 128 KB,每 SM 只能驻留 1 个 block,占用率骤降到 8 warp / 64 warp = 12.5%,远远低于 25% 的危险线,因此这个方案得不偿失。③ 折中方案:把每个 bin 复制 8 份,副本编号取 r = lane >> 2(即每 4 个 lane 共用一个副本),共享内存布局写成 histo[bin * 8 + r],总用量 = 1024 × 8 × 4 B = 32 KB;A100 上可驻留 164 / 32 = 5 个 block,占用率 62.5%,仍在安全区。冲突度定量分析:地址 = bin * 8 + r 个 word,bank = (bin * 8 + r) % 32。在本题的偏斜数据下,一个 warp 的 32 个 lane 只命中 bin 0..3 这 4 个 bin,而 r = lane >> 2 把它们分成 8 组;于是这 32 个 lane 触及的地址集合是 {bin*8 + r : bin ∈ 0..3, r ∈ 0..7} = {0, 1, 2, …, 31}——恰好是 32 个互不相同的 word,落在 bank 0 到 bank 31 上,冲突度为 1(即完全无冲突),而且每组 4 个同地址的 lane 由硬件广播处理。对比原先的 7–8 路冲突,共享内存原子吞吐提升约 7–8 倍,代价只是 8 倍的共享内存(4 KB → 32 KB)与占用率的轻微下降(100% → 62.5%)。最终判断标准仍然是实测:用 ncu --metrics l1tex__data_bank_conflicts_pipe_lsu_mem_shared_op_atom.sum 比较”不复制 / 复制 8 份 / 复制 32 份”三种布局的冲突计数与总耗时,取最优——因为冲突度还与数据的实际分布强相关,纯解析计算只能给出上界与趋势。