Lecture 12: 最终项目 —— 问题定义、方案设计、性能优化与报告 (对应 Final Project)

目录 · ← l11 · appendix →

Lecture 12: 最终项目 —— 问题定义、方案设计、性能优化与报告 (对应 Final Project)

概述

前面十一讲逐步建立了 CUDA 的并行思维工具箱:线程映射、内存层次、tiling、reduction tree、scan、atomics、稀疏方法与卷积分析。本讲不再引入新的并行模式,而是回答一个工程问题:如何把所学的一切组织成一个可交付、可测量、可辩护的性能工程作品。ECE408 的最终项目(Final Project)由 Project Proposal、Project Workshop、Project Presentation、Project Report 四个交付物构成,占课程成绩的 25%,其评分大致一半看”功能与工程规范”,一半看”功能完整前提下的性能”。这一讲的核心方法论是性能工程循环(performance engineering cycle):正确的基线 → 测量与剖析 → 定位瓶颈 → 提出假设 → 只改一个变量 → 重新测量 → 记录并决策,循环往复;辅以设计空间探索(design space exploration, DSE)的系统化参数扫描,以及正确的可复现性验证(correctness verification)。最终产出的不只是更快的代码,而是一份包含优化日志、Roofline 分析、诚实的失败记录与局限讨论的技术报告。

核心概念与 GPU 架构图解

最终项目的评分构成与四个交付物(Final Project Deliverables & Grading)

  • 定义与目的:最终项目是课程的综合性评价载体。官方课程说明中把它描述为 “Final Project that involves Project Proposal, Project Workshop, Project Presentation, and Project Report”;教学大纲(Instructional Objectives)中 C 组目标(10–17 条)明确要求学生在项目结束时能够”识别并求解一个计算问题”、”学习必要的领域知识”、”与不同学科的队友协作”、”合理划分责任”、”识别设计空间并探索优化机会”、”在展示中论证问题与方法”、”解释所做实验并为最终决策辩护”以及”指出方案的局限与未来方向”。四个交付物的存在目的,是把一个学期的终点任务切成四个有明确截点的检查站,防止团队把全部工作堆到最后一周。

  • 直观解释(”它是什么?”):把最终项目想象成盖一栋房子。Proposal 是选址与需求书——你得先证明这块地上真的能盖房子(问题适合并行、数据拿得到、有可比对的基线);Workshop 是图纸会审——结构工程师(老师与助教)帮你看承重墙位置对不对(并行策略是否站得住脚);Presentation 是竣工验收答辩——你把房子推开门让人进来走一圈(现场演示 + 加速比是否真实);Report 是竣工资料——以后别人要照着你的图纸重建(可复现、可引用、诚实的缺陷清单)。只交资料不交房子(只写报告代码跑不起来)或者只交房子不交资料(代码能跑但说不清为什么快),都会丢掉相当可观的分数。

  • 架构/机制图解

                     最终项目 100 分(占课程总成绩 25%)
   +-------------------------------------------------------------------+
   |   功能与工程规范  ~50%         |    功能完整前提下的性能  ~50%     |
   |--------------------------------|----------------------------------|
   |  Demo 能跑起来 / 演示通过      |  相对 sequential baseline 加速比  |
   |  功能覆盖题目要求的输入集      |  优化项数量与深度(课程要求 >=3) |
   |  代码风格、注释、目录组织      |  优化日志与实测数据(可追溯)     |
   |  错误处理、可移植性、README    |  瓶颈分析的定量正确性(Roofline) |
   +-------------------------------------------------------------------+
        ^                                                    ^
        |                                                    |
   0 分:跑不起来 / 结果错 ------------------------------> 拿不到性能分
   ("如果代码编译不过、运行时崩溃,你几乎会丢掉全部这些分"——
     S20 Final Report Rubric 原文精神)

   ---------------------------------------------------------------
   四个交付物在时间轴上的位置(夏季学期示例:7 月初 ~ 期末周)
   ---------------------------------------------------------------

   第 1-2 周          第 3-4 周            第 5-6 周           期末周
      |                  |                    |                  |
      v                  v                    v                  v
 +-------------+   +--------------+   +----------------+   +--------------+
 |  Proposal   |   |   Workshop   |   |  Presentation  |   |    Report    |
 | 选题 + 动机 |   |  方案讨论    |   |  答辩 / Demo   |   |  技术报告     |
 +-------------+   +--------------+   +----------------+   +--------------+
      |                  |                    |                  |
   要交付:           要交付:             要交付:           要交付:
   - 问题定义         - 并行策略图         - 15~25 min 讲解   - 约 10 页报告
   - 应用背景         - kernel 划分         - 现场 Demo        - 全部图表数据
   - 输入数据说明     - 每 kernel 的       - 加速比数字       - 优化日志附录
   - baseline 方案      thread 映射        - 每位成员都要     - 参考文献
   - 相关工作综述     - 预期复用/合并        参与讲解         - 局限与未来工作
   - 预期瓶颈         - 分工与时间表       - Q/A 应答

关键操作与性能特征(这里”性能”指团队的执行性能):Proposal 阶段的错误最昂贵——如果选题本身是”通信开销主导”或”数据依赖串行”的,后面三周的所有优化都在跟 Amdahl 定律的串行部分打架,收益上限定得很低;Workshop 阶段的收益最高,因为此时改设计只花几小时,而到 Presentation 前改设计要花几天;Report 阶段应只做整理与提炼,不应再做新实验(Rubric 明确说 conclusions 不是 summary,重复结果会得 0 分,要有反思)。四个交付物对应教学目标的 10–17 条,也就是说:报告里没有”局限与未来工作”一节,直接对应目标 17 未达成。

一个务实的交付物检查清单(每个里程碑交付前逐条打勾):

  Proposal 交付前
    [ ] 一句话能说清"算什么、输入多大、输出什么"
    [ ] 已确认输入数据真的拿得到(不是"应该能找到")
    [ ] baseline 方案已经跑通(哪怕是 Python 串行版)
    [ ] 用 Roofline 算过一次理论加速上限,数字写进文档
    [ ] 至少 3 篇相关工作引用,且读懂了摘要与方法

  Workshop 交付前
    [ ] 画出数据流图:几个 kernel、每个 kernel 输入输出是什么
    [ ] 每个 kernel 说明"一个线程负责什么"
    [ ] 说明每级内存的预期复用次数(用 tiling 公式算过)
    [ ] 分工表:每项优化谁负责、依赖谁

  Presentation 交付前
    [ ] Demo 能在答辩机上跑(不是"在我笔记本上能跑")
    [ ] 加速比表格:baseline / 每项优化 / 最终,三列齐全
    [ ] 每位成员都有 3 分钟以上的技术内容可讲
    [ ] 准备好回答"为什么不用 cuBLAS"、"加速比的分母是什么"

  Report 交付前
    [ ] 优化日志表完整(含回退的失败尝试)
    [ ] 正确性验证覆盖 N=0/1/非 2 的幂/非整数倍
    [ ] 所有数字都能追溯到某次具体的运行命令
    [ ] 局限一节写了 3 条以上具体限制

Project Proposal 模板大纲(每节后附一句说明它要回答什么):

#章节这一节要回答的问题
1项目标题与团队成员谁在做,题目叫什么(一句话内可读)
2问题陈述(Problem Statement)输入是什么、输出是什么、计算的形式化定义是什么
3动机与应用价值(Motivation)为什么这个问题值得算,谁会用,算得快有什么实际意义
4输入数据与规模(Dataset)数据从哪来、格式、典型尺寸与内存占用(决定是否放得下显存)
5Baseline 方案(Sequential Baseline)加速比的分母是什么:CPU 串行实现?库函数?已发表的实现?
6并行策略初稿(Parallel Approach)哪些步骤可以并行、大致几个 kernel、每线程负责什么
7预期瓶颈与 Roofline 预判定量预估 AI、上限 GFLOP/s、最可能的瓶颈类型
8相关工作(Related Work)别人怎么做的、我们与他们的区别(至少 3 条带链接或引用)
9验证方法(Validation Plan)golden reference 怎么造、容差判据是什么、测哪些边界尺寸
10风险与备选方案(Risks)如果并行度不够/数据拿不到,退路是什么
11分工与时间表(Plan & Division)四个里程碑分别由谁交付什么

课程的交付与评测环境(依据公开的 S19/S20 Project Plan 原文)

上表的里程碑划分并非推测,课程的公开项目计划给出了明确的三段式交付节奏评测方式

┌─────────────────────────────────────────────────────────────────────────┐
│  Milestone 1  文献调研 + 基准准备                                        │
│    · 写一页综述:现有并行方案(可超出 GPU 范围),附链接或引用            │
│    · 确认每位队员都能跑通串行参考实现,并明确"如何比较结果"              │
│    · 目标:搞清楚"别人做到什么程度"与"我们的分母是什么"                  │
├─────────────────────────────────────────────────────────────────────────┤
│  Milestone 2  可运行的 CUDA 初版                                         │
│    · 提交一个能通过 docker 在 RAI 上启动的目录                           │
│    · 记录**计时数据**(后续与优化版对比,报告里要用)                    │
│    · 写清楚预期优化方向,且必须**具体到** reuse / coalescing /           │
│      control divergence —— 这正是本课程前八讲训练的四件事               │
├─────────────────────────────────────────────────────────────────────────┤
│  Milestone 3  至少三项优化,且**分别测量**                               │
│    · 每项优化都要写:策略 → 代码改动 → **理论预期收益(含公式与数字)**  │
│      → 实测收益 → 实现困难 → 对正确性的影响                              │
│    · 也要记录"无法解释的性能现象",留到最终报告里追查                    │
├─────────────────────────────────────────────────────────────────────────┤
│  Final Report  约 10 页(含图、正文与参考文献)                          │
│    · 章节必须使用规定的标签并按指定顺序排列                              │
│    · 分值:3 个里程碑各 10 分 + 报告 70 分 = 100 分                      │
└─────────────────────────────────────────────────────────────────────────┘

三条容易被忽视的硬性要求:

  1. 代码必须在评测环境里真的能跑。Rubric 原文写明:”if the code that you turn in doesn’t compile, crashes when run, and so forth, you will lose nearly all of those points”——代码不能编译将几乎失去全部 报告分,而且这一条不写在细则条目里,很容易被漏掉。
  2. 提交渠道是版本控制仓库(S20 用 Gitlab,并可利用其 issue tracking),项目代码通过 RAI 接口在 GPU 上评测,每个项目配有至少一个 docker(输入数据已预先上传到服务器)。 这意味着”在我机器上能跑”不算数,必须保证在给定的 docker 环境里可复现地启动
  3. 三项优化必须分别测量,不能三项一起改完只报一个总加速比。S20 计划原文要求说明每项的 “the expected impact on performance (why one expects a benefit, and how much—similar to the in-class analysis)”,也就是要求你把课内那套定量分析(复用倍数、合并与否、发散比例、Roofline 上限) 逐项套用到自己的优化上——这才是”至少三项优化”的真正含义。

选题与问题适配性分析(Problem Suitability Analysis)

  • 定义与目的:选题(problem definition)的任务是判断一个计算问题在 GPU 上是否值得并行,以及并行的收益上限在哪里。它解决的是”方向错了,努力全废”的问题。ECE408 的目标 10 要求”识别并求解一个计算问题”,目标 14 要求”识别设计空间”。选题不是”选一个听起来酷的题目”,而是做一次适配性筛查

  • 直观解释(”它是什么?”):GPU 像一条有几千个窗口的高速收费站,吞吐极高但要求车流连续、规则。适合 GPU 的问题有三个特征:车多(数据并行度高,至少几万个独立工作项)、每辆车办的事多(算术强度够,别让收费站只为了收 1 块钱而排一小时队)、车道规则(访存是连续或规则步长的,可以合并成 128 字节的 transaction)。反之,如果问题的每一辆车都必须等前一辆车办完(数据依赖串行),或者每辆车都要跟总部打电话请示(通信/同步开销主导),那这条高速收费站就白修了——Amdahl 定律会把加速比钉死。另外还有一个常被忽略的门槛:规模。如果总计算量只有 1 MFLOP,而一次 kernel launch 就有 3–5 μs 的固定开销,那么算术再漂亮也测不出加速比。

  • 架构/机制图解

                   选题适配性筛查流程(Screening Flow)

   +---------------------------------+
   | 候选问题:f(input) -> output    |
   +----------------+----------------+
                    |
                    v
        +---------------------------+     否
        | 数据并行度 >= 10^4 个     |-----------> 放弃 / 换题目
        | 独立工作项?              |            (串行依赖链主导,
        +-------------+-------------+             如 Fibonacci、长链递推)
                      | 是
                      v
        +---------------------------+     否
        | 算术强度够吗?            |-----------> 考虑融合多个阶段/
        | AI = FLOP / Byte          |            改为 tiling 以提高复用;
        |  >= GPU 的 ridge point ?  |            仍不行则放弃
        +-------------+-------------+
                      | 是
                      v
        +---------------------------+     否
        | 访存规则吗?              |-----------> 需预处理重排(AoS->SoA)
        | 连续 / 固定步长 /         |            或用 gather 优化的 CSR
        | 可 tiling 复用?          |            否则会被 uncoalesced 拖死
        +-------------+-------------+
                      | 是
                      v
        +---------------------------+     否
        | 规模足够大吗?            |-----------> 放大到有意义的 N,
        | 计算时间 >> kernel 启动   |            或做 batch 化处理
        | 开销(~3-5us)?            |
        +-------------+-------------+
                      | 是
                      v
        +---------------------------+
        | 可行:写 Proposal         |
        | 并预判主要瓶颈类型        |
        +---------------------------+

   -----------------------------------------------
   反向检查:问题是"通信/同步主导"吗?
     - 每个工作项之间有大量依赖 -> 需要多次 __syncthreads / grid sync
     - 算法本身是图上的 BFS 层间依赖 -> 每层一次 kernel 启动
     - 需要频繁 host<->device 往返(PCIe ~25 GB/s vs HBM ~1555 GB/s)
   如果是,先考虑算法重构(把同步推到更粗的粒度),再考虑换题。

关键数字:A100 的 ridge point 是 19.5 TFLOPS / 1555 GB/s ≈ 12.5 FLOP/Byte;RTX 4090 是 82.6 TFLOPS / 1008 GB/s ≈ 82 FLOP/Byte;H100 SXM 是 67 TFLOPS / 3350 GB/s ≈ 20 FLOP/Byte。这意味着在 A100 上,一个每读 4 字节只做 2 次浮点运算的 untiled 卷积(算术强度 0.5 FLOP/Byte),理论上只能跑到 1555 GB/s × 0.5 = 777 GFLOP/s,即 19.5 TFLOPS 的 4%——这正是讲义中反复强调的”需要 13.3×(2010 年代)到约 26×(H100)的复用才能吃满算力”的来源。选题阶段就要算出这个上限,否则后面做再多的 block 大小调优也在 4% 附近打转。

ECE408 最终项目典型题目清单(含适配性与瓶颈预判)

以下是本课程及其项目库中出现过的典型题目类型,每一条都给出”为什么适合 GPU 并行”与”主要性能瓶颈预判”。选题时应当把最后一列当作你的假设,并在 Workshop 阶段用一次微基准(microbenchmark)去验证它——如果实测瓶颈与预判不符,这本身就是报告里很有价值的一段。

#题目为什么适合 GPU 并行主要性能瓶颈预判
1大尺寸 2D/3D 卷积与可分离滤波(图像/视频处理)输出像素彼此独立,数据并行度 = 像素数(可达 10⁷–10⁹);邻域复用率高,天然适合 tiling未 tiling 时是 DRAM 带宽受限(2 B/FLOP);tiling 后转为共享内存带宽与边界 halo 浪费((T+W-1)²/T² 的开销)
2CT/医学图像重建(滤波反投影、迭代重建)每个投影/体素可独立计算,投影数×探测器数是天然的大规模并行反投影阶段访存不规则(gather/scatter),需要 texture 或共享内存重排;迭代重建则受每轮同步支配
3稠密线性代数:SGEMM / 批量小矩阵乘法(batched GEMM)FLOP 密度最高,复用可达数十倍,是最”吃算力”的题型大矩阵:共享内存带宽 + 寄存器粗化不足;批量小矩阵:单矩阵太小导致占用率不足与调度开销主导
4稀疏矩阵向量乘(SpMV)与 SpGEMM稀疏结构带来巨大的零元素跳过收益;行之间独立访存不规则是首要瓶颈(x 向量 gather 不可合并);负载不均(每行 nnz 差异大)导致 warp 空转;格式(CSR/ELL/COO)选择直接决定成败
5图算法:BFS、连通分量、PageRank每个顶点的更新独立,前沿(frontier)可并行展开每层之间的全局同步(每层一次 kernel 启动,约 3–5 μs)+ 高度顶点导致的负载不均;用 push/pull 混合可缓解
6蒙特卡洛与金融模拟(期权定价、风险 VaR)每条路径完全独立,随机数生成可并行, embarrassingly parallel每条路径计算量小(几十个 FLOP),随机数生成开销可能超过业务计算;精度要求高时需 double,吞吐减半
7生物信息:序列比对(Smith-Waterman)、k-mer 计数比对矩阵的反对角线可并行;k-mer 计数是典型的 atomics/histogram 题反对角线并行有大量同步与 wavefront 依赖;k-mer 计数受 atomic 竞争与共享内存直方图容量限制
8N-body / 分子动力学(短程力 + 邻居列表)粒子对之间独立,可用共享内存做空间分块(cell list)邻居列表构建访存随机;粒子分布不均导致某些 cell 严重超载;力计算是 FP32 密集但需要排序加速
9机器学习:前馈网络训练 / 卷积层前向(mini-batch 梯度下降)矩阵乘法与逐元素激活都高度并行;batch 维度提供额外并行度前向是 GEMM 型(共享内存受限);反向传播的梯度累加会产生写冲突(需 atomic 或分块归约);小 batch 时占用率不足
10排序与统计:基数排序、直方图、词频统计计数与分散步骤并行度高,是 histograms/atomics 与 scan 的综合演练atomic 竞争(全局直方图)与共享内存直方图容量冲突;基数排序受 scan 的同步开销与 bank conflict 支配
11计算流体力学 / 有限差分时间推进(stencil)每个格点按固定邻域更新,规则访存 + 极高的时间步数典型的 memory-bound(每题 2 B/FLOP);时间步之间必须同步,因此需要在单次 kernel 内做多个时间步(temporal blocking)以减少启动开销
12密码学与哈希搜索(暴力破解、彩虹表)每个候选输入独立,整数运算为主,几乎无访存纯计算受限,受整数 ALU 吞吐限制;能量与散热是实际约束;缺乏浮点,Roofline 分析需换成整数 ops/cycle

性能工程迭代循环(Performance Engineering Cycle)

  • 定义与目的:性能工程循环是把”优化”从灵感活动变成可重复的科学实验的流程。它由七步构成:① 建立正确的基线(baseline + golden reference)② 测量与剖析(profiling)③ 定位瓶颈(Roofline 定位 + profiler 指标)④ 提出可证伪的假设 ⑤ 实施一种优化 ⑥ 重新测量并写入优化日志 ⑦ 判断保留或回退,然后回到第 ② 步。核心纪律是一次只改一个变量(one variable at a time)。课程 S19/S20 的项目计划里明确要求 Milestone “Implement at least three optimizations … and measure the impact of each on the overall performance”,也就是说:三项优化必须是分别测量的,不能三项一起改再报一个总的加速比。

  • 直观解释(”它是什么?”):这个循环像医生看病。先量体温、验血(profiling),建立病人平时的基准数据(baseline);然后依据数据判断是感染还是外伤(定位瓶颈);接着开一种药(只改一个变量),而不是一次开五种药——否则病人好了你也不知道是哪味药起的作用,病人更糟你也不知道是哪味药害的;复诊测指标(重新测量),把病历写清楚(优化日志);有效就继续,无效就停药换方案(保留或回退)。医生绝不会说”我感觉这个药应该有用”就写进病历——他会写”用药后体温从 39.2 ℃ 降到 37.5 ℃”。同样地,”我加了共享内存所以应该更快”不是结论,”加了共享内存后从 8.20 ms 降到 1.42 ms(5.77×),DRAM 吞吐从 1047 GB/s 降到 378 GB/s、不再贴着 1555 GB/s 的带宽屋顶,说明原先确实是 DRAM 带宽受限”才是结论。

  • 架构/机制图解

                        性能工程迭代循环(Performance Engineering Cycle)

        +--------------------------------------------------------------+
        |                                                              |
        v                                                              |
  +--------------------+                                               |
  | 1. 建立正确基线    |   baseline kernel(朴素但正确)               |
  |    + golden ref    |   + CPU/库函数参考值 + 容差判据               |
  +---------+----------+                                               |
            |                                                          |
            v                                                          |
  +--------------------+      +-----------------------------+          |
  | 2. 测量与剖析      |<---->| 工具:                       |          |
  |    profiling       |      |  - cudaEvent 计时(多轮取中位) |          |
  |                    |      |  - ncu: DRAM/L2 吞吐、       |          |
  +---------+----------+      |        占用率、stall reason  |          |
            |                 |  - nsys: kernel 时间线        |          |
            |                 +-----------------------------+          |
            v                                                          |
  +--------------------+                                               |
  | 3. 定位瓶颈        |   算术强度 AI  ->  在 Roofline 上找位置        |
  |                    |   实际占用率  ->  是否被占用率拖住            |
  |                    |   判定:带宽受限 / 计算受限 / 延迟受限          |
  +---------+----------+                                               |
            |                                                          |
            v                                                          |
  +--------------------+                                               |
  | 4. 提出假设        |   "如果把 A 面板放进共享内存,                |
  |   (可证伪)         |    DRAM 流量应降为 1/TILE,耗时降到 X ms"      |
  +---------+----------+                                               |
            |                                                          |
            v                                                          |
  +--------------------+                                               |
  | 5. 只改一个变量    |   其它参数(block 大小、编译选项、数据规模)   |
  |    并实现          |   保持不变,写进日志的"控制变量"栏           |
  +---------+----------+                                               |
            |                                                          |
            v                                                          |
  +--------------------+                                               |
  | 6. 重新测量        |   验证正确性仍然 PASS,然后再看性能            |
  |    + 写入优化日志   |   (先正确后快速,绝不反过来)                |
  +---------+----------+                                               |
            |                                                          |
            v                                                          |
  +--------------------+       保留:成为新的 baseline                 |
  | 7. 结论与决策      |------------------------------+                |
  |                    |       回退:记录"为什么不work" |                |
  +--------------------+                              |                |
                                                      v                |
                                        +---------------------------+  |
                                        | 进入下一轮迭代            |--+
                                        | (至少完成 3 项优化)      |
                                        +---------------------------+

   ----------------------------------------------------------------
   受控实验的三条纪律
   ----------------------------------------------------------------
   纪律 1:一次只改一个变量。改了两个变量,观测到的差异无法归因。
   纪律 2:每次测量都重复 N>=5 次,报告最小值与中位数,禁用单次结果。
            GPU 上单次测量波动常达 +-5%~15%(时钟、其它进程、缓存状态)。
   纪律 3:任何"更快"的结论必须先通过正确性验证。快而错等于零分。

优化日志(optimization log)模板表格——这是最终报告的核心素材,课程 Milestone 3 要求的内容(策略、代码改动、预期收益、实测收益、实现困难、正确性变化)正好可以逐列映射到这张表:

以下示例对应 M = N = K = 1024 的 FP32 GEMM(总运算量 2·M·N·K = 2.147 GFLOP),测量机为 A100 (sm_80)。

#优化步骤修改内容(具体到代码/参数)预期收益(理论推导)实测耗时 (ms)GFLOP/s加速比(vs 上一步 / vs baseline)瓶颈判断(Roofline + ncu 证据)结论
0baseline朴素 global-memory GEMM,block=16×16,无共享内存,AI=0.25 FLOP/B8.202621.00× / 1.00×DRAM 带宽受限:实测 DRAM 吞吐 1047 GB/s = 67% of 1555;理论上限 0.25×1555 = 389 GFLOP/s基线成立,作为对照
1shared-memory tiling增加 As/Bs 面板,TILE=ROWS=16,每个 k 分块两次 __syncthreads()DRAM 流量从 8.59 GB 降为 4·M·N·K·(1/16+1/16) = 0.537 GB,降 16×1.4215125.77× / 5.77×DRAM 吞吐降至 378 GB/s(不再受限);转为 shared memory 吞吐受限:每 FMA 需 2 个 shared word = 8 B,需 8.59 GB/ms 的 shared 带宽保留
2block 大小 16×16 → 16×32仅改 dim3 block(16,32),其它一切不变占用率从 50% 提到 100%,更多 warp 隐藏 shared 延迟1.2117741.17× / 6.78×占用率 50%→100%;ncu 中 stall_long_scoreboard 占比下降 31%保留
3线程粗化 COARSEN=4每线程算 4 行输出,block(32,8),寄存器 38→58shared 访存/FMA 从 8 B 降到 5 B(1 As×4 + 1 Bs 供 4 次 FMA),理论 1.6×0.7130241.70× / 11.55×shared 吞吐 7563 GB/s = 39% of 19.5 TB/s;转为 延迟/占用率受限保留
4TK 16 → 32仅改 K 方向分块宽度(同步次数减半)__syncthreads() 次数从 K/16=64 次降到 32 次0.6831581.04× / 12.06×同步已非瓶颈,收益落在 ±5% 噪声范围内保留但标注”几乎无收益”
5float4 向量化全局访存改 float4,边界补零访存指令数减为 1/4,每线程字节数 ×40.7429010.92× / 11.08×反而变慢:寄存器压力升到 88 → 每 SM 驻留 block 数由 4 降到 3,占用率 100%→75%回退(记录负结果)

这张表的第 5 行极其重要:诚实地报告”没有提升的尝试”。S20 的 Rubric 在 Optimization 一节明确鼓励展示 tradeoff(例如精度 vs 时间),而在 S19 project plan 中要求 “mention of any unexplained performance behavior”。一个只有上升曲线的优化日志,读起来就像一份只有好消息的体检报告——评审者会本能地怀疑测量方法。

设计空间探索(Design Space Exploration, DSE)

  • 定义与目的:设计空间探索是系统性地遍历可调参数组合,用实测数据找出最优点,而不是凭直觉调一两个参数。它解决的是”参数之间相互耦合,单参数最优不等于组合最优”的问题。ECE408 目标 14 就是”识别设计空间并探索优化机会”。可调参数的清单(本课程项目中典型):
参数取值候选主要影响典型陷阱
block 大小32, 64, 128, 256, 512, 1024占用率、每 SM 驻留 warp 数、尾块浪费1024 线程/block 在 Turing(sm_75)上超过 1024 上限;过大 block 降低调度灵活性
tile 大小(空间分块)8, 16, 32(1D 亦可用 64, 128, 256)复用倍率、共享内存用量、边界浪费比例tile=32 时 32×32 float = 4 KB,双缓冲后 8 KB;过大导致占用率崩塌
K 方向分块 TK8, 16, 32, 64同步频率、共享内存用量TK 太小则 __syncthreads 频繁;TK 不是 32 的倍数时 Bs 访问产生 bank conflict
线程粗化因子 COARSEN1, 2, 4, 8寄存器级复用、指令级并行度(ILP)超过 4–8 后寄存器溢出(spill),本地内存流量暴涨
共享内存 padding+0, +1, +4(如 [32][33])消除 bank conflictpadding 增加共享内存用量,可能降低占用率
覆盖方式精确覆盖 vs grid-stride loop尾部处理开销、kernel 启动次数grid-stride 的循环开销在计算极短时不可忽略
向量化标量 / float2 / float4访存指令数、每线程字节数非对齐地址无法使用 float4;边界需单独处理
__launch_bounds__(maxThreads, minBlocksPerSM)编译器寄存器分配上限设得过紧导致 spill,反而更慢
流(stream)数与分块粒度1, 2, 4 个 stream,每块 1/4 数据拷贝与计算重叠单 kernel 已占满 GPU 时多流无收益;PCIe 带宽成为串行瓶颈
内存布局AoS vs SoA访存是否可合并(coalescing)AoS 下跨线程访问同一结构体不同字段 → 32×4 字节被拆成 32 个 transaction
编译选项-O3, --use_fast_math, -Xptxas -dlcm=cg指令数、浮点语义、L1 策略--use_fast_math 会改变数值结果,破坏容差判据
  • 直观解释(”它是什么?”):调参数就像调老式收音机的旋钮——音量、调谐、天线方向三个旋钮互相影响:天线转对了,调谐才有意义;音量开太大,噪声也会被放大。你不能只把每个旋钮各自拧到”看起来最好”的位置就宣称找到了最佳收听效果,必须组合着试。但组合数是乘积:block {128,256,512} × tile {8,16,32} × COARSEN {1,2,4} = 27 个组合;如果再把”空间 tile 宽度”与”K 方向分块宽度”当成两个独立参数,组合数立刻变成 81 个,每个跑 5 次测量就是 405 次 kernel 运行——所以必须自动化,让人只负责看表决策。这也是本讲第二个代码示例存在的原因。

  • 架构/机制图解

              设计空间探索的网格(Design Space Grid, 3x3x3 = 27 个组合)

   把三维参数空间切成三层平面(每个平面是一组 COARSEN 取值):

   COARSEN = 1                 COARSEN = 2                 COARSEN = 4
   TK:    8   16   32          TK:    8   16   32          TK:    8   16   32
        +----+----+----+           +----+----+----+           +----+----+----+
 BS=128 |  : |  : |  : |           |  : |  : |  # |           |  : |  # |  # |
        +----+----+----+           +----+----+----+           +----+----+----+
 BS=256 |  : |  # |  # |           |  # |  # |  @ |           |  # |  @ |  @ |
        +----+----+----+           +----+----+----+           +----+----+----+
 BS=512 |  # |  # |  @ |           |  # |  @ |  @ |           |  @ |  @ |  * |
        +----+----+----+           +----+----+----+           +----+----+----+

   图例: . 很慢(<30% of best)   : 慢(30-60%)   # 好(60-85%)   @ 很好(85-95%)
          * 最优(100%),同时也是需要重点复测与解释的点

   扫描策略(避免 81 次全跑):
   步骤 1  粗扫:每个维度取 3 个值,全组合跑 1 次(快速但有噪声)
   步骤 2  锁定:留下 top-5 组合
   步骤 3  精测:top-5 组合各跑 >= 20 次,报告中位数
   步骤 4  邻域:对最优组合的每个参数做 +-1 档微调(局部爬山)
   步骤 5  解释:对最优点给出定量解释(占用率 x%、DRAM 吞吐 y GB/s、
            shared 吞吐 z% of peak),而不是只报一个数字

参数扫描表模板(直接抄进报告附录):

blocktile/TKCOARSEN共享内存 (B)理论占用率实测耗时 (ms)GFLOP/s相对最优正确性备注
128811152100%6.4123359.9%PASS线程太少,每 FMA 的 shared 访存比过高
2561612560100%3.20567019.8%PASS无粗化,复用不足
2561623072100%1.983108332.0%PASS均衡点,寄存器 40 个仍满占用
5123241228875%0.6333392100%PASS最优:粗化带来的复用压倒了占用率损失
2563248192100%0.712301688.9%PASS次优:占用率更高但每线程工作量少

自动扫描的脚本思路(把”人肉调参”变成一条命令):

#!/bin/bash
# sweep.sh —— 自动扫描设计空间,输出 CSV 供后续排序与绘图
# 思路:
#   1) 外层三层 for 循环枚举 (block, tile, coarsen)
#   2) 每次调用 ./sweep 只跑一个组合,输出一行 CSV 到 stdout
#   3) 全部结果重定向到 sweep_raw.csv,再交给 sweep 程序内部排序/或 python 排序
set -e
BIN=./sweep
M=1024; N=1024; K=1024; ITERS=20
echo "block,tile,coarsen,ms,gflops,occupancy,pass" > sweep_raw.csv
for bs in 128 256 512; do
  for tile in 8 16 32; do
    for co in 1 2 4; do
      # 过滤非法组合:tile 必须能整除 bs(本例固定 BX=32,BY=bs/32)
      if [ $((bs % 32)) -ne 0 ]; then continue; fi
      $BIN -m $M -n $N -k $K -bs $bs -tile $tile -co $co -iters $ITERS \
           >> sweep_raw.csv
    done
  done
done
# 按第 5 列(GFLOP/s)数值倒序排序,打印前 5 名
tail -n +2 sweep_raw.csv | sort -t, -k5 -gr | head -5

注意脚本里的一行防御:if [ $((bs % 32)) -ne 0 ]; then continue; fi。设计空间里永远存在非法组合(例如线程数不是 32 的整数倍、共享内存超过 164 KB、tile 大于矩阵维度),扫描程序必须优雅跳过并记录原因,而不是崩溃——崩溃的扫描器会让你误以为某个区域”性能很差”,实际只是没跑起来。

正确性验证与浮点容差(Correctness Verification & Floating-Point Tolerance)

  • 定义与目的:正确性验证要回答”我的并行结果和可信参照(golden reference)是否一致”,浮点容差则给出”多接近才算一致”的判据。它解决的是一个陷阱:并行化会改变求和顺序,而浮点加法不满足结合律 (a+b)+c ≠ a+(b+c),所以 CUDA 结果与 CPU 结果几乎不可能逐位相同。S19 的 project plan 明确指出:”Note that exact matches to floating-point computations are unlikely, so you may need to set a threshold on fractional error observed or something similar.”

  • 直观解释(”它是什么?”):想象你用不同顺序把一堆硬币叠成柱子:先叠 1 分币再叠 1 元币,和反过来叠,最终高度的理论值一样,但因为每枚硬币厚度的测量都有极小误差,两种顺序累加出来的高度会差几个微米。float32 的机器精度 eps = 2⁻²³ ≈ 1.19×10⁻⁷,每一次加法都可能引入约 0.5 ulp(unit in the last place)的舍入,累加 K 次后相对误差的量级约为 √K × eps(随机舍入近似随机游走)到 K × eps(最坏情况同向累积)。K = 1024 时,√K × eps ≈ 32 × 1.19e-7 ≈ 3.8e-6,最坏情况 1024 × 1.19e-7 ≈ 1.2e-4。所以工程上对 1024 长度的点积,rtol = 1e-3 是一个”有 10 倍余量”的合理判据;rtol = 1e-6 则几乎必然误报 FAIL。

  • 架构/机制图解

                正确性验证流水线(Verification Pipeline)

  +---------------------+        +------------------------------+
  | 输入生成            |        | golden reference 的来源       |
  | - 随机(固定种子)     |        |  (a) CPU 串行实现(推荐,可读)  |
  | - 全 1 / 全 0        |------->|  (b) 库函数 cuBLAS/cuDNN/     |
  | - 极小 / 极大值       |        |      CUBLAS 参考             |
  | - 边界尺寸           |        |  (c) 高精度 double 版本        |
  +----------+----------+        +---------------+--------------+
             |                                   |
             v                                   v
  +---------------------+        +------------------------------+
  | GPU kernel 输出      |------->| 逐元素比较                    |
  +---------------------+        | err = |y_gpu - y_ref|           |
                                 | ok  = err <= atol + rtol*|y_ref| |
                                 +---------------+--------------+
                                                 |
                             +-------------------+-------------------+
                             |                                       |
                             v                                       v
                 +------------------------+            +------------------------+
                 | PASS: max_err 统计      |            | FAIL: 打印首个失配位置  |
                 | 报告 max_abs / max_rel  |            | (index, gpu, ref, err) |
                 +------------------------+            +------------------------+

  ------------------------------------------------------------------
  必须覆盖的边界尺寸测试矩阵(缺一项就可能丢分)
  ------------------------------------------------------------------
   N = 0        空输入,kernel 不应启动 / 应立即返回,不能 cudaMalloc(0) 报错
   N = 1        一个元素,grid 只有 1 个 block,1 个有效线程(31/32 线程空转)
   N = 31       小于一个 warp
   N = 32       恰好一个 warp
   N = 33       一个 warp + 1 个线程(跨 block 边界)
   N = 1023     非 2 的幂
   N = 1024     2 的幂(最容易"恰好正确",因此最没价值)
   N = 1025     非 2 的幂且跨 block(最常暴露 off-by-one)
   N = tile-1 / tile / tile+1   边界 tile 的 ghost 元素处理
   非整数倍尺寸:M=1000, N=1000, K=1000(都不是 16/32 的整数倍)

关键操作与性能特征:先正确后快速-use_fast_math(等价于 --use_fast_math)会启用 -ftz=true(flush-to-zero,把 denormal 刷成 0)、-prec-div=false(用近似倒数做除法,精度约 2 ulp)、-prec-sqrt=false(近似平方根),并在某些实现下允许更激进的融合。如果 baseline 用 -use_fast_math 而 golden reference 用严格 IEEE 的 CPU 代码,误差会从 1e-6 量级跳升到 1e-4~1e-3 量级——此时应该统一编译选项并且放宽容差并解释原因,而不是偷偷把容差改大到”看起来能过”。另一个容易忽略的点:float 累加 1024 个 1.0 的期望值是 1024.0,但由于 1024.0 在 float 中可精确表示(1024 = 2¹⁰),全 1 输入的测试永远不会暴露精度问题——这正是必须使用随机输入的原因。

鲁棒性与通用性(Robustness & Portability)

  • 定义与目的:鲁棒性指代码对”非理想输入”(任意尺寸、空输入、超大输入)和”非理想环境”(不同 GPU 架构、显存不足)的正确反应;通用性(portability)指同一份源码能在 sm_75 / sm_80 / sm_89 等多种架构上编译并运行。它们解决的是”在我机器上能跑”的工程交付问题——Rubric 明确把”编译不过、运行崩溃”列为近乎全损的情形。

  • 直观解释(”它是什么?”):写死了 256 的 kernel 就像做了一件只适合某一个人身材的衣服:在测试用的模特身上完美,别人穿上就崩线。GPU 代码里的”身材”包括:矩阵维度是否恰好是 block 的整数倍、共享内存是否超过该架构上限、线程数是否超过该架构每 block 上限(Turing 是 1024,看似够、但寄存器与共享内存限制会先撞上)。鲁棒的做法是把尺寸当作运行时变量:所有读取都做边界判断,所有写入都做边界判断,grid 维度由 ceil 计算得出。

  • 架构/机制图解

              nvcc 编译流程:PTX(虚拟)与 SASS(真实)的区别

   源文件 foo.cu
        |
        |  nvcc -arch=compute_80 -code=sm_80     -> 只生成 sm_80 的 cubin
        |  nvcc -arch=compute_80 -code=compute_80 -> 只生成 PTX(可 JIT)
        |  nvcc -arch=native                      -> 探测本机 GPU 后二选一
        v
  +-------------------+          +---------------------------+
  |  前端 + 中端优化   |          |  PTX (compute_80)         |
  |  生成 PTX 虚拟 ISA |--------->|  与具体芯片无关的虚拟指令  |
  +-------------------+          +-------------+-------------+
                                               |
                                               | ptxas (离线,编译期)
                                               v
                                 +---------------------------+
                                 |  SASS (sm_80)  真实机器码  |
                                 |  寄存器分配在此完成         |
                                 +-------------+-------------+
                                               |
                                               | 运行时:若只有 PTX 而
                                               | 驱动无对应 cubin,则
                                               v
                                 +---------------------------+
                                 |  NVIDIA 驱动 JIT 编译       |
                                 |  首次启动慢(可达数百 ms),   |
                                 |  之后缓存到 ~/.nv/ComputeCache|
                                 +---------------------------+

  ------------------------------------------------------------------
  多架构编译(fatbin)示例
  ------------------------------------------------------------------
  nvcc -O3 -gencode arch=compute_75,code=sm_75 \
           -gencode arch=compute_80,code=sm_80 \
           -gencode arch=compute_89,code=sm_89 \
           -gencode arch=compute_80,code=compute_80 \
           foo.cu -o foo
  # 前三个给出各架构的真实 SASS(启动零开销);
  # 最后一行保留 PTX,使未来架构(如 sm_90/sm_100)能 JIT 运行。

  ------------------------------------------------------------------
  各架构的硬约束(写 kernel 前必须查表)
  ------------------------------------------------------------------
  GPU           arch    每 SM 线程  每 SM warp  每 SM 共享内存  每 block 线程上限
  RTX 2080 Ti   sm_75   1024        32          64 KB           1024
  A100          sm_80   2048        64          164 KB          1024
  RTX 4090      sm_89   1536        48          100 KB          1024
  H100 SXM      sm_90   2048        64          228 KB          1024

关键操作与性能特征:-arch=native 会让 nvcc 探测构建机器上的 GPU;在登录节点上构建、在计算节点上运行的集群环境里,-arch=native 可能探测到错误的(或没有)GPU,因此在 Delta 这类集群上应显式写 -arch=sm_80。使用 __launch_bounds__(maxThreadsPerBlock, minBlocksPerMultiprocessor) 可以告诉编译器”每个 block 最多这么多线程,且我希望每 SM 至少驻留这么多 block”,编译器据此限制寄存器用量:例如 A100 上 __launch_bounds__(256, 8) 意味着 8 × 256 = 2048 线程满占用,寄存器上限 = 65536 / 2048 = 32 个/线程。这个约束如果设得比 kernel 实际需要更紧,会直接把变量 spill 到本地内存(local memory,实际在 DRAM 里),性能可能掉 2–5 倍——所以 __launch_bounds__ 也要当作设计空间里的一个参数去实测,而不是凭感觉写。

技术报告、数据可视化与学术诚信(Reporting, Visualization & Academic Integrity)

  • 定义与目的:技术报告把一整学期的实验转化为可被他人理解、审查与复用的文档;数据可视化则把表格里的数字变成一眼可读的证据。学术诚信规范(含 AI 工具使用政策)界定了”什么算你自己的贡献”。ECE408 目标 16 要求”恰当地解释所实验的方案并为最终决策与结果辩护”,目标 17 要求”识别方案的局限与未来方向”——这两条只能通过报告达成。

  • 直观解释(”它是什么?”):报告不是实验流水账,而是证据链:问题为什么重要 → 别人怎么做的 → 我怎么做 → 我怎么测的 → 我测到了什么 → 为什么是这个数 → 哪里没做好 → 下一步做什么。可视化就像法庭上的物证照片——一张 Roofline 图能让人 5 秒内明白”我这个 kernel 卡在内存带宽上”,而 10 行 Excel 数字要让人读 5 分钟还读不出来。学术诚信则是法庭的程序规则:即使你的结论正确,用偷来的证据(未引用的他人代码/未声明的 AI 生成内容)也判无效。

  • 架构/机制图解

                 Roofline 图(A100:19.5 TFLOPS FP32, 1555 GB/s)

  GFLOP/s (log)
   20000 |                                            ..............
         |                                    ........
   10000 |                            ........   <- 带宽屋顶
         |                     .......              slope = 1555 GB/s
    5000 |                ......
         |          ......          +  tiled GEMM (3158 GFLOP/s, AI=8)
    2000 |      ....                 |   离带宽屋顶还有 12440/3158 = 3.9x 余量
         |   ...                     v   -> 说明瓶颈不在 DRAM
    1000 | ..       +  naive GEMM (262 GFLOP/s, AI=0.25, 卡在内存墙)
     500 |.         ^
         |          |
         |  <-- 这个点几乎贴在 1555 GB/s 的斜线上(262/389 = 67%),说明 DRAM 快打满
       0 +---------------------------------------------------------> AI (FLOP/Byte)
         0.1    0.5    1     2    5   12.5   30   80  100   200
                                        ^
                                     A100 ridge = 12.5

   读图口诀:
   - 点在斜线上  -> 内存带宽受限,唯一出路是提高算术强度(tiling/粗化)
   - 点在平顶下  -> 计算/延迟/占用率受限,去看 ncu 的 stall reason

  ------------------------------------------------------------------
   参数扫描热力图(用字符表示 GFLOP/s 相对最优的百分比)
  ------------------------------------------------------------------
         TK=8    TK=16   TK=32          ASCII 密度图例
  BS=128  34%     41%     47%           .  <40%
  BS=256  58%     79%     88%           :  40-70%
  BS=512  72%     91%    100%  <- 最优   #  70-90%
  BS=1024 65%     84%     93%           @  >90%

  ------------------------------------------------------------------
   规模扩展曲线(scaling curve):固定问题规模 vs 固定每线程工作量
  ------------------------------------------------------------------
   speedup                     speedup
     ^  理想线性                 ^  理想线性
     |      /                    |      /
     |     /  实际(固定规模)      |     /   实际(固定每线程工作量, grid-stride)
     |    /     ____             |    /
     |   /  ___/                 |   /_________  饱和于 ~6-8x
     |  /__/                      |  /
     +------------------> N      +------------------> N
       固定规模会"降速":N 太小时   grid-stride 能一直吃到 满 GPU 占用
       GPU 占不满,加速比虚高/虚低  (但仍受 Amdahl 串行部分限制)

学术诚信与 AI 工具政策(ECE408 / CS483 / CSE408)——课程公开页面 “Use of AI Policy” 的三条要点必须准确复述:

  1. 风险自负:课程明确”discourage you from using random tools as a learning aid, as such tools routinely fabricate information”;如果你选择使用除课程材料(教材、讲义)与教学人员(教授、助教)之外的任何工具,你承担全部风险。若你因此获得错误信息并用于作业或考试,不会得到任何分数补偿,并且”Please do not ask”。
  2. 抄袭责任的归属:如果某个工具”ingests code or answers written by another person”并把那些材料给了你,导致作业或考试答案中被检测出抄袭,你将被完全认定为学术诚信违规。也就是说,”AI 给我的”不是免责理由。
  3. Quiz 与考试严禁使用:”Any material not explicitly allowed for use during a quiz or exam is forbidden, including any sort of AI tool.” 即使未被检测到,一旦检测到就会被提出学术诚信指控并按课堂政策处罚。此外课程相关的 ChatBot(NCSA CAII 基于 ChatGPT-4 扩展课程材料搭建)不是教学人员(”THIS CHATBOT IS NOT A MEMBER OF THE COURSE STAFF”);其正面之处是回答通常带有课程材料引用链接——顺着链接去读原始讲义,而不是只依赖它生成的摘要。

与报告直接相关的其他诚信要求:必须引用任何参考过的串行或并行实现(S19/S20 要求 one-page summary including links or citations);报告开头必须用一句话声明是否使用了私有 GPU 资源(S20 Rubric 中未声明会扣 10 分),因为用私有 GPU 的团队更容易刷出漂亮数字,课程希望公平比较。最后,”结论(Conclusions)”一节不是摘要:重复性能结果或其他章节内容会得 0 分,写”并行就是好”这类空话也会得 0 分。

Project Report 模板大纲(章节名对齐课程 Rubric,每节后附一句说明它要回答什么;报告篇幅约 10 页,含图与参考文献):

#章节标题(建议用英文,与 Rubric 一致)这一节要回答的问题 / 内容要点
0封面与团队信息项目名、成员、日期、代码仓库链接
1Resources一句话声明是否使用了私有 GPU 资源(未声明按 Rubric 扣分)
2Application问题是什么、为什么有趣、总体并行思路是什么;配一张架构/数据流图
3Background(来自 Proposal/相关工作)别人怎么做的、有哪些算法可选、我们为什么选这个 baseline;带引用
4Implementation有几个 kernel、数据如何在 kernel 间流动、每个 kernel 的线程负责什么;baseline 的性能数字与串行程序的对比必须在这里出现
5Optimization至少 3 项优化,每项包含:策略说明 → 代码改动 → 理论预期收益(写出公式与代入数字,如同课内分析)→ 实测收益 → 实现困难 → 对正确性的影响(”结果不可区分”也要明确说明)
6Results最终性能图(加速比表、Roofline 图、规模扩展曲线、参数热力图);若有串行版本必须给最终加速比并注明输入规模;解读”为什么是这个数”
7性能瓶颈分析(可并入 Results)用 ncu 指标 + Roofline 定位最终瓶颈,说明还剩多少空间、被什么限制住
8局限与未来工作(Limitations & Future Work)至少 3 条具体限制:扫描范围没覆盖的参数、只在小规模上验证的情形、算法本身的串行部分上限
9Conclusions反思而非摘要:关于在这个应用上使用并行,我们学到了什么?希望开始之前就知道什么?(Rubric 明确:重复结果得 0 分)
10ReferencesIEEE 或 ACM 标准格式(详见下)
11附录(可选但强烈建议)完整优化日志表、编译与运行命令、机器配置(GPU 型号、CUDA 版本、驱动版本)

引用规范示例(IEEE 风格,注意区分期刊/会议/网页三类):

  [1] D. Kirk and W. Hwu, Programming Massively Parallel Processors,
      Morgan Kaufmann, 3rd Edition, 2016.
  [2] V. Volkov and J. W. Demmel, "Benchmarking GPUs to tune dense
      linear algebra," in Proc. ACM/IEEE Conf. Supercomputing (SC),
      2008, pp. 1-11.
  [3] NVIDIA Corp., "CUDA C++ Programming Guide," v12.x, 2025.
      [Online]. Available: https://docs.nvidia.com/cuda/cuda-c-programming-guide/

注意引用 [3] 这类在线文档时必须写访问日期,因为 CUDA 文档会随版本变化;引用讲义时写清 “ECE408/CS483 Lecture 10, University of Illinois, Summer 2025”。

团队协作与任务分解(Team Collaboration & Work Decomposition)

  • 定义与目的:团队协作是把项目拆成可并行执行的工作包(work package)、约定接口契约(interface contract)、并通过代码评审(code review)保证质量的过程。ECE408 目标 12 要求”与不同学科的领域专家和队友合作以最大化方案效果”,目标 13 要求”在队友之间恰当划分责任并互相支持”。它解决的是”三个人都在改同一个文件”和”两个星期的等待被串行化”的问题。

  • 直观解释(”它是什么?”):把项目想象成开餐馆:接口契约是菜单——前厅(数据 I/O 与可视化)和后厨(kernel 实现)只需就”菜名、份量、出餐时间”达成一致,不需要知道对方怎么炒菜;工作包是分区——切菜、掌勺、装盘可以同时进行,只要盘子规格统一;代码评审是试菜——每道菜上桌前由另一个人尝一口;关键路径是”客人点单到上菜”的最长链条,如果掌勺的人同时在切菜,整桌菜就会晚。最危险的做法是让所有人都”参与所有环节”,那是三个人抢一口锅。

  • 架构/机制图解

          项目工作分解与依赖图(WBS / Task DAG):粗线为关键路径

   [WP1] 数据加载与预处理        [WP2] golden reference
   (读文件/格式转换/生成测试集)   (CPU 串行实现 + 容差判据)
        |                              |
        | 产出: dataset.bin            | 产出: ref.bin + verify 脚本
        +--------------+---------------+
                       |  (接口契约 A: 二进制格式 + 元数据 header)
                       v
        ============== [WP3] baseline CUDA kernel ==============  <== 关键路径
                       | 产出: baseline.cu + 计时框架
                       |
                       v
        ============== [WP4] profiling 与瓶颈定位 ==============
                       | 产出: ncu 报告 + Roofline 草图
                       |
        +--------------+---------------+-----------------+
        |              |               |                 |
        v              v               v                 v
   [WP5] tiling   [WP6] 占用率     [WP7] 向量化     [WP8] 多流/分块
   共享内存优化    block 大小扫描   float4 访存      重叠拷贝与计算
        |              |               |                 |
        +--------------+---------------+-----------------+
                       |
                       v
        ============== [WP9] 结果汇总与报告撰写 ==============
                       | 产出: report.md/pdf + 图表 + 优化日志
                       v
                  [WP10] Demo 排练与答辩

   ------------------------------------------------------------------
   接口契约示例(写在 README 里,谁都不能悄悄改)
   ------------------------------------------------------------------
   数据文件: <name>.bin
     offset 0 : int32 magic   = 0x45434534 ("ECE4")
     offset 4 : int32 rows    (M)
     offset 8 : int32 cols    (N)
     offset 12: int32 inner   (K)
     offset 16: float32 data[] 行主序, 共 M*K 个元素, 无 padding
   计时接口: double bench(kernel_launcher_t fn, int iters);
     返回 iters 次运行的【中位数】毫秒,内部负责 warmup 3 次
   约定: 所有 kernel 的签名形如 (const float* A, const float* B, float* C,
         int M, int N, int K),谁加参数谁负责通知全组
   ------------------------------------------------------------------
   代码评审清单(PR 合并前逐条打勾)
   ------------------------------------------------------------------
   [ ] 所有 CUDA API 返回值都被 CUDA_CHECK 包裹
   [ ] kernel 内所有全局内存访问都有边界判断
   [ ] __syncthreads() 不在分支内(避免不同分支的线程无法汇合)
   [ ] 共享内存用量 + 动态共享内存 <= 该架构上限
   [ ] 正确性测试在 N=0/1/33/1025 上全部 PASS
   [ ] 计时结果至少 5 次重复,报告的是中位数
   [ ] 本次改动只针对一个变量(否则无法归因)

关键操作与性能特征(”性能”= 团队吞吐):把关键路径(WP3 → WP4 → WP9)上的人数设为 1–2 人且不要给他们塞别的工作;把可完全并行的 WP5–WP8 分给不同人,每人负责一项优化并独立维护自己的优化日志行,最后合并到同一张表。接口契约一旦冻结就不要在最后三天修改——报告里”策略变化”必须能被解释为优化演进,而不是接口混乱。每位成员都必须参与答辩讲解(S19 明确要求 “Each person should play a part in the presentation”),因此任务分解时要保证每个人都有可讲的技术内容,而不是一人写代码、两人做 PPT。

代码示例与性能分析

代码示例 1:最终项目脚手架 —— 参数化分块矩阵乘法

这个程序是”可以直接改造成你自己项目”的工程骨架。它把最终项目需要的六件事一次性做全:命令行参数解析、CUDA_CHECKcudaEvent 计时、CPU golden reference、浮点容差比对、多次试验取最优/平均,并且对任意输入尺寸(含非 2 的幂、非 tile 整数倍、N=0、N=1)都做了边界处理。

// 文件: project_template.cu
// 编译: nvcc -O3 -arch=sm_80 -lineinfo project_template.cu -o project_template
// 运行: ./project_template -m 1000 -n 1000 -k 1000 -tile 16 -bs 256 -iters 20
//       ./project_template -m 1 -n 1 -k 1                # 边界: 单个元素
//       ./project_template -m 1025 -n 1023 -k 1000       # 边界: 非 2 的幂 + 非整数倍
//
// 这是 ECE408/CS483 最终项目的脚手架,包含:
//   1. 命令行参数解析(问题规模 M/N/K、tile 宽度、block 线程数、试验次数)
//   2. CUDA_CHECK 错误检查宏
//   3. cudaEvent 计时工具(warmup + 多次试验,报告 min/median/mean)
//   4. CPU golden reference(double 累加)与自动比对(atol + rtol 容差)
//   5. 任意输入尺寸的边界处理(含 N=0 / N=1 / 非 2 的幂)
//   6. 资源合法性检查(共享内存上限、每 block 线程上限、grid 维度上限)

#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cmath>
#include <cstdint>
#include <vector>
#include <algorithm>
#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)

// ---------------------------------------------------------------------------
// 1. 配置与命令行解析
// ---------------------------------------------------------------------------
struct Config {
    int   M       = 1024;    // 输出行数
    int   N       = 1024;    // 输出列数
    int   K       = 1024;    // 内积维度
    int   tile    = 16;      // tile 宽度(同时用作 block 的 x 维与 K 分块宽度)
    int   bs      = 256;     // 每 block 线程数(必须能被 tile 整除)
    int   iters   = 20;      // 计时试验次数
    int   warmup  = 3;       // 预热次数
    float rtol    = 1e-3f;   // 相对容差
    float atol    = 1e-3f;   // 绝对容差
};

static void usage(const char* prog)
{
    printf("用法: %s [选项]\n", prog);
    printf("  -m <int>    输出行数 M            (默认 1024)\n");
    printf("  -n <int>    输出列数 N            (默认 1024)\n");
    printf("  -k <int>    内积维度 K            (默认 1024)\n");
    printf("  -tile <int> tile 宽度, 取 8/16/32 (默认 16)\n");
    printf("  -bs <int>   每 block 线程数       (默认 256, 必须为 tile 的倍数)\n");
    printf("  -iters <int> 计时次数             (默认 20)\n");
    printf("  -rtol <f>   相对容差              (默认 1e-3)\n");
    printf("  -atol <f>   绝对容差              (默认 1e-3)\n");
}

static Config parseArgs(int argc, char** argv)
{
    Config c;
    for (int i = 1; i < argc; ++i) {
        const char* a = argv[i];
        auto next = [&](void) -> const char* {
            if (i + 1 >= argc) { fprintf(stderr, "选项 %s 缺少参数\n", a); exit(EXIT_FAILURE); }
            return argv[++i];
        };
        if      (!strcmp(a, "-m"))     c.M      = atoi(next());
        else if (!strcmp(a, "-n"))     c.N      = atoi(next());
        else if (!strcmp(a, "-k"))     c.K      = atoi(next());
        else if (!strcmp(a, "-tile"))  c.tile   = atoi(next());
        else if (!strcmp(a, "-bs"))    c.bs     = atoi(next());
        else if (!strcmp(a, "-iters")) c.iters  = atoi(next());
        else if (!strcmp(a, "-rtol"))  c.rtol   = (float)atof(next());
        else if (!strcmp(a, "-atol"))  c.atol   = (float)atof(next());
        else if (!strcmp(a, "-h") || !strcmp(a, "--help")) { usage(argv[0]); exit(EXIT_SUCCESS); }
        else { fprintf(stderr, "未知选项 %s\n", a); usage(argv[0]); exit(EXIT_FAILURE); }
    }
    if (c.tile <= 0 || c.tile > 64)  { fprintf(stderr, "tile 必须在 1..64\n"); exit(EXIT_FAILURE); }
    if (c.bs <= 0 || c.bs > 1024)    { fprintf(stderr, "bs 必须在 1..1024\n"); exit(EXIT_FAILURE); }
    if (c.bs % c.tile != 0)          { fprintf(stderr, "bs 必须是 tile 的整数倍\n"); exit(EXIT_FAILURE); }
    if (c.iters <= 0)                { fprintf(stderr, "iters 必须为正\n"); exit(EXIT_FAILURE); }
    return c;
}

// ---------------------------------------------------------------------------
// 2. Kernel:每个 block 计算 (blockDim.y) x (blockDim.x) 的输出块
//    C[M x N] = A[M x K] * B[K x N],行主序,含边界判断
// ---------------------------------------------------------------------------
__global__ void tiledMatMulKernel(const float* __restrict__ A,
                                  const float* __restrict__ B,
                                  float* __restrict__ C,
                                  int M, int N, int K)
{
    const int TILE     = blockDim.x;      // 输出列数 = K 分块宽度
    const int ROWS     = blockDim.y;      // 输出行数
    const int tx       = threadIdx.x;
    const int ty       = threadIdx.y;
    const int tid      = ty * TILE + tx;
    const int nthreads = TILE * ROWS;

    extern __shared__ float smem[];
    float* As = smem;                     // ROWS x TILE
    float* Bs = smem + ROWS * TILE;       // TILE x TILE

    const int row0 = blockIdx.y * ROWS;
    const int col0 = blockIdx.x * TILE;

    float acc = 0.0f;

    for (int k0 = 0; k0 < K; k0 += TILE) {
        // 协作加载 A 面板(若线程数少于面板元素数,strided 循环保证仍正确)
        for (int i = tid; i < ROWS * TILE; i += nthreads) {
            const int r  = i / TILE;
            const int c  = i % TILE;
            const int gr = row0 + r;
            const int gc = k0 + c;
            As[i] = (gr < M && gc < K) ? A[(size_t)gr * K + gc] : 0.0f;
        }
        // 协作加载 B 面板
        for (int i = tid; i < TILE * TILE; i += nthreads) {
            const int r  = i / TILE;
            const int c  = i % TILE;
            const int gr = k0 + r;
            const int gc = col0 + c;
            Bs[i] = (gr < K && gc < N) ? B[(size_t)gr * N + gc] : 0.0f;
        }
        __syncthreads();

        #pragma unroll 8
        for (int kk = 0; kk < TILE; ++kk) {
            acc += As[ty * TILE + kk] * Bs[kk * TILE + tx];
        }
        __syncthreads();     // 下一轮会覆盖 As/Bs,必须等所有线程读完
    }

    const int gr = row0 + ty;
    const int gc = col0 + tx;
    if (gr < M && gc < N) {
        C[(size_t)gr * N + gc] = acc;
    }
}

// ---------------------------------------------------------------------------
// 3. CPU golden reference:double 累加,作为容差比对的基准
// ---------------------------------------------------------------------------
static void goldenReference(const float* A, const float* B, float* C,
                            int M, int N, int K)
{
    for (int i = 0; i < M; ++i) {
        for (int j = 0; j < N; ++j) {
            double sum = 0.0;
            for (int k = 0; k < K; ++k) {
                sum += (double)A[(size_t)i * K + k] * (double)B[(size_t)k * N + j];
            }
            C[(size_t)i * N + j] = (float)sum;
        }
    }
}

// ---------------------------------------------------------------------------
// 4. 比对:|got - ref| <= atol + rtol * |ref|
// ---------------------------------------------------------------------------
struct VerifyResult {
    bool   pass;
    double maxAbs;
    double maxRel;
    size_t firstBad;
};

static VerifyResult verify(const float* got, const float* ref, size_t n,
                           float rtol, float atol)
{
    VerifyResult v;
    v.pass     = true;
    v.maxAbs   = 0.0;
    v.maxRel   = 0.0;
    v.firstBad = (size_t)-1;

    for (size_t i = 0; i < n; ++i) {
        const double g   = (double)got[i];
        const double e   = (double)ref[i];
        const double ad  = fabs(g - e);
        const double tol = (double)atol + (double)rtol * fabs(e);

        if (ad > v.maxAbs) v.maxAbs = ad;
        if (fabs(e) > 1e-30) {
            const double rel = ad / fabs(e);
            if (rel > v.maxRel) v.maxRel = rel;
        }
        if (ad > tol && v.pass) {
            v.pass     = false;
            v.firstBad = i;
        }
    }
    return v;
}

// ---------------------------------------------------------------------------
// 5. 计时工具:warmup + iters 次,报告 min / median / mean
// ---------------------------------------------------------------------------
struct BenchResult { double minMs, medianMs, meanMs; };

template <typename LaunchFn>
static BenchResult benchmark(LaunchFn launch, int warmup, int iters)
{
    cudaEvent_t start, stop;
    CUDA_CHECK(cudaEventCreate(&start));
    CUDA_CHECK(cudaEventCreate(&stop));

    for (int i = 0; i < warmup; ++i) launch();
    CUDA_CHECK(cudaDeviceSynchronize());

    std::vector<double> t((size_t)iters);
    for (int i = 0; i < iters; ++i) {
        CUDA_CHECK(cudaEventRecord(start));
        launch();
        CUDA_CHECK(cudaEventRecord(stop));
        CUDA_CHECK(cudaEventSynchronize(stop));
        float ms = 0.0f;
        CUDA_CHECK(cudaEventElapsedTime(&ms, start, stop));
        t[(size_t)i] = (double)ms;
    }

    CUDA_CHECK(cudaEventDestroy(start));
    CUDA_CHECK(cudaEventDestroy(stop));

    std::sort(t.begin(), t.end());
    BenchResult r;
    r.minMs    = t.front();
    r.medianMs = t[t.size() / 2];
    double sum = 0.0;
    for (double x : t) sum += x;
    r.meanMs   = sum / (double)t.size();
    return r;
}

// ---------------------------------------------------------------------------
// 6. 确定性伪随机数(保证每次运行结果可复现)
// ---------------------------------------------------------------------------
static uint32_t g_seed = 20250612u;

static float frand(void)
{
    g_seed = g_seed * 1664525u + 1013904223u;
    return (float)((g_seed >> 8) & 0xFFFFFFu) / (float)0x1000000u * 2.0f - 1.0f;
}

static void fillRandom(float* p, size_t n)
{
    for (size_t i = 0; i < n; ++i) p[i] = frand();
}

// ---------------------------------------------------------------------------
// 7. 主流程
// ---------------------------------------------------------------------------
int main(int argc, char** argv)
{
    const Config cfg = parseArgs(argc, argv);

    printf("================ 项目脚手架:参数化分块 GEMM ================\n");
    printf("问题规模: M=%d  N=%d  K=%d   (FLOP = 2*M*N*K = %.3f GFLOP)\n",
           cfg.M, cfg.N, cfg.K, 2.0 * cfg.M * cfg.N * cfg.K / 1e9);
    printf("配置:     tile=%d  block=(%d,%d)=%d 线程  iters=%d  rtol=%g atol=%g\n",
           cfg.tile, cfg.tile, cfg.bs / cfg.tile, cfg.bs, cfg.iters, cfg.rtol, cfg.atol);

    // --- 设备信息 ---
    int dev = 0;
    CUDA_CHECK(cudaGetDevice(&dev));
    cudaDeviceProp prop;
    CUDA_CHECK(cudaGetDeviceProperties(&prop, dev));
    printf("设备:     %s (sm_%d%d, %d SM, 每 SM %d 线程, 每 block 共享内存上限 %.1f KB)\n",
           prop.name, prop.major, prop.minor, prop.multiProcessorCount,
           prop.maxThreadsPerMultiProcessor, prop.sharedMemPerBlock / 1024.0);

    // --- 空问题快速返回(鲁棒性:N=0 不能崩) ---
    if (cfg.M < 0 || cfg.N < 0 || cfg.K < 0) {
        fprintf(stderr, "规模不能为负数\n");
        return EXIT_FAILURE;
    }
    if (cfg.M == 0 || cfg.N == 0) {
        printf("M 或 N 为 0:输出为空,正常退出(不启动任何 kernel)。\n");
        return EXIT_SUCCESS;
    }

    const size_t szA = (size_t)cfg.M * cfg.K;
    const size_t szB = (size_t)cfg.K * cfg.N;
    const size_t szC = (size_t)cfg.M * cfg.N;

    // --- 主机端分配与初始化 ---
    // 注意:K=0 时 szA/szB 为 0,malloc(0) 允许返回 NULL,因此按 max(1) 申请
    const size_t capA = szA ? szA : 1;
    const size_t capB = szB ? szB : 1;
    float* hA  = (float*)malloc(capA * sizeof(float));
    float* hB  = (float*)malloc(capB * sizeof(float));
    float* hC  = (float*)malloc(szC * sizeof(float));
    float* hRef= (float*)malloc(szC * sizeof(float));
    if (!hA || !hB || !hC || !hRef) { fprintf(stderr, "主机内存分配失败\n"); return EXIT_FAILURE; }

    fillRandom(hA, szA);
    fillRandom(hB, szB);

    printf("计算 CPU golden reference (double 累加)...\n");
    cudaEvent_t cr0, cr1;
    CUDA_CHECK(cudaEventCreate(&cr0));
    CUDA_CHECK(cudaEventCreate(&cr1));
    CUDA_CHECK(cudaEventRecord(cr0));
    goldenReference(hA, hB, hRef, cfg.M, cfg.N, cfg.K);
    CUDA_CHECK(cudaEventRecord(cr1));
    CUDA_CHECK(cudaEventSynchronize(cr1));
    float refMs = 0.0f;
    CUDA_CHECK(cudaEventElapsedTime(&refMs, cr0, cr1));
    CUDA_CHECK(cudaEventDestroy(cr0));
    CUDA_CHECK(cudaEventDestroy(cr1));
    printf("          golden reference 耗时 %.1f ms\n", refMs);

    // --- 设备端分配 ---
    float *dA = nullptr, *dB = nullptr, *dC = nullptr;
    CUDA_CHECK(cudaMalloc((void**)&dA, capA * sizeof(float)));
    CUDA_CHECK(cudaMalloc((void**)&dB, capB * sizeof(float)));
    CUDA_CHECK(cudaMalloc((void**)&dC, szC * sizeof(float)));
    CUDA_CHECK(cudaMemcpy(dA, hA, szA * sizeof(float), cudaMemcpyHostToDevice));
    CUDA_CHECK(cudaMemcpy(dB, hB, szB * sizeof(float), cudaMemcpyHostToDevice));
    CUDA_CHECK(cudaMemset(dC, 0, szC * sizeof(float)));

    // --- 网格与共享内存 ---
    const int TILE = cfg.tile;
    const int ROWS = cfg.bs / cfg.tile;
    dim3 block((unsigned)TILE, (unsigned)ROWS);
    dim3 grid((unsigned)((cfg.N + TILE - 1) / TILE),
              (unsigned)((cfg.M + ROWS - 1) / ROWS));

    const size_t smemBytes = ((size_t)ROWS * TILE + (size_t)TILE * TILE) * sizeof(float);
    printf("启动:     grid=(%u,%u)  block=(%u,%u)  动态共享内存 %.1f KB\n",
           grid.x, grid.y, block.x, block.y, smemBytes / 1024.0);

    if (grid.y > 65535) {
        fprintf(stderr, "grid.y = %u 超过 65535 上限,请增大 block 的行数或缩小 tile\n", grid.y);
        return EXIT_FAILURE;
    }
    if (smemBytes > prop.sharedMemPerBlock) {
        fprintf(stderr, "需要 %.1f KB 共享内存,超过每 block 上限 %.1f KB\n",
                smemBytes / 1024.0, prop.sharedMemPerBlock / 1024.0);
        return EXIT_FAILURE;
    }

    auto launch = [&](void) {
        tiledMatMulKernel<<<grid, block, smemBytes>>>(dA, dB, dC, cfg.M, cfg.N, cfg.K);
    };

    // --- 计时 ---
    const BenchResult bench = benchmark(launch, cfg.warmup, cfg.iters);
    CUDA_CHECK(cudaGetLastError());

    // --- 取回并验证 ---
    CUDA_CHECK(cudaMemcpy(hC, dC, szC * sizeof(float), cudaMemcpyDeviceToHost));
    const VerifyResult v = verify(hC, hRef, szC, cfg.rtol, cfg.atol);

    // --- 性能报告 ---
    const double flop      = 2.0 * (double)cfg.M * cfg.N * cfg.K;
    const double gflopsMin = flop / (bench.minMs    * 1e-3) / 1e9;
    const double gflopsMed = flop / (bench.medianMs * 1e-3) / 1e9;
    // 解析访存量:A 被读 N/TILE 次,B 被读 M/ROWS 次
    const double bytes = 4.0 * (double)cfg.M * cfg.N * cfg.K *
                         (1.0 / TILE + 1.0 / ROWS);
    const double gbs = bytes / (bench.medianMs * 1e-3) / 1e9;
    const double ai  = flop / bytes;

    printf("\n----------------------- 结果 -----------------------\n");
    printf("正确性:   %s  (max_abs_err=%.3e, max_rel_err=%.3e)\n",
           v.pass ? "PASS" : "FAIL", v.maxAbs, v.maxRel);
    if (!v.pass) {
        const size_t i = v.firstBad;
        printf("          首个失配 index=%zu  got=%.9g  ref=%.9g\n",
               i, (double)hC[i], (double)hRef[i]);
    }
    printf("耗时:     min=%.4f ms  median=%.4f ms  mean=%.4f ms  (%d 次试验)\n",
           bench.minMs, bench.medianMs, bench.meanMs, cfg.iters);
    printf("性能:     %.1f GFLOP/s (median)  |  %.1f GFLOP/s (best)\n",
           gflopsMed, gflopsMin);
    printf("带宽:     %.1f GB/s 有效 DRAM 吞吐  |  算术强度 AI=%.2f FLOP/Byte\n", gbs, ai);
    printf("Roofline: A100 峰值 19.5 TFLOPS / 1555 GB/s, ridge=12.5 FLOP/B\n");
    printf("          基于 AI 的带宽上限 = %.0f GFLOP/s, 达到其 %.1f%%\n",
           ai * 1555.0, 100.0 * gflopsMed / (ai * 1555.0));
    printf("----------------------------------------------------\n");

    CUDA_CHECK(cudaFree(dA));
    CUDA_CHECK(cudaFree(dB));
    CUDA_CHECK(cudaFree(dC));
    free(hA);
    free(hB);
    free(hC);
    free(hRef);
    return v.pass ? EXIT_SUCCESS : EXIT_FAILURE;
}
  • 【代码做什么?】
    1. 参数解析(parseArgs:把问题规模 M/N/K、tile 宽度、block 线程数、试验次数、容差全部暴露为命令行参数。所有合法性检查(bs % tile == 0bs ≤ 1024tile ≤ 64)在解析阶段完成,失败时立即退出并给出可读原因——设计空间扫描时非法组合必须被拒绝而不是崩溃。
    2. 数据生成:用固定种子的 LCG 生成 [-1, 1) 的均匀随机数。固定种子意味着”同样的命令永远得到同样的输入”,这是可复现性的最低要求。
    3. Golden reference:CPU 上三重循环,用 double 累加。选 double 而不是 float 的理由是让参考值的舍入误差(~1e-16 量级)远小于被测 float kernel 的误差(~1e-5 量级),从而把误差来源唯一地归因到 GPU 侧
    4. Kernel 执行流程:线程 (tx, ty) 负责输出元素 C[row0+ty][col0+tx]。沿 K 方向以 TILE 为步长循环:每个 k 分块先把 AROWS×TILE 面板和 BTILE×TILE 面板协作搬进共享内存,同步一次,然后每个线程做 TILE 次乘加,再同步一次。
    5. 边界处理:三个地方。① A/B 的加载用 (gr < M && gc < K) 判断,越界填 0——填 0 而不是跳过,因为累加 0 不影响数学结果,避免了 warp 发散条件下的分支分歧;② 网格维度用 ceil 计算,所以最后一个 block 里可能有整行/整列线程没有输出;③ 写回时 if (gr < M && gc < N) 做最终检查。
    6. 计时与报告:warmup 3 次(把首次启动的驱动初始化、PTX→SASS 的 JIT、页表建立等一次性开销排除),然后 iters 次独立测量,排序后同时报告 min/median/mean。报告 min 是因为它最接近”纯 kernel 时间”,报告 median 是因为它最抗偶发抖动。
    7. 验证与退出码:比对不通过时打印首个失配的 index/got/ref 并以 EXIT_FAILURE 退出。这个退出码让扫描脚本能用 if ! ./proj; then echo FAIL; fi 自动过滤错误配置。
  • 【并行机制与硬件映射解说】
    • 线程到 warp 的映射block(tile, bs/tile)tid = ty*tile + tx。取 tile = 16, bs = 256 时 block 是 (16,16),共 256 线程 = 8 个 warp;warp 0 包含 ty∈{0,1} 两行各 16 个线程。取 tile = 32 时 block 的 x 维恰好是 32,一个 warp 正好对应一行,这是最规整的形态。
    • warp 调度与驻留:A100 每个 SM 有 4 个 warp scheduler,每 SM 最多 64 warp(2048 线程)。tile=16, bs=256 时每 block 8 warp,寄存器约 38 个/线程 → 寄存器限制:65536/(256×38) = 6.7 → 6 个 block;线程限制:2048/256 = 8 个 block;共享内存 2 KB/block,164 KB 可容纳 80 个 block。所以实际驻留 6 个 block = 1536 线程 = 48 warp = 75% 占用率。若把 bs 提到 512(block 16×32),每 block 16 warp,寄存器限制变成 65536/(512×38) = 3.4 → 3 个 block = 1536 线程,占用率仍是 75%,但每 block 更大、调度粒度更粗。
    • 共享内存 bank 分析(bank 编号具体到数字):A100 共享内存 32 个 bank,每 bank 4 字节,地址 a 落在 bank a % 32。以 tile = 16 为例,warp 0 是 ty=0 的 16 个线程加 ty=1 的 16 个线程。访问 As[ty*16 + kk]ty=0 的半 warp 全部读地址 kk(bank kk%32),ty=1 的半 warp 全部读地址 16+kk(bank (16+kk)%32)——两个不同地址落在两个不同 bank,且各自是广播,1 个事务完成,无 bank conflict。访问 Bs[kk*16 + tx]:两个半 warp 的 tx 都是 0..15,地址 kk*16 + 0..15 覆盖 bank (16kk)%32 起的连续 16 个 bank,两个半 warp 读的是完全相同的地址,属于广播,同样无 conflict。若改成 tile = 32As[ty*32+kk] 是全 warp 单地址广播,Bs[kk*32+tx] 覆盖 bank 0..31 各一次——完美无冲突。这正是”tile 取 32 时共享内存访问最干净”的原因;若把 Bs 声明成 [TILE][TILE+1] 做 padding,对 TILE=32 的情形完全没有必要(反而多占内存)。
    • 全局内存合并(coalescing)分析:以 tile=16K=N=1024 为例,A 面板加载时 i = tidc = i % 16,全局地址 (row0 + i/16)*1024 + k0 + c。一个 warp 的 32 个线程分成两段:ty=0 的 16 个线程访问地址 base + 0..15(连续 16 个 float = 64 字节),ty=1 的 16 个线程访问 base + 1024 + 0..15(另 64 字节,位于完全不同的行)。因此一个 warp 的请求被拆成2 个 64 字节的段,而硬件以 32 字节 sector 为粒度、以 128 字节 transaction 为上限:这 128 字节里全部被用到,没有浪费。若 tile = 32,则 warp 内 32 个线程访问连续的 32 个 float = 128 字节 = 一条完整 cache line,一个 transaction 满载,效率 100%。
    • warp 发散:kernel 里唯一的条件分支是边界判断。在”最后一个不完整 block”里,部分线程的 gr < M 为假;此时这些线程不写内存,但不产生真实的控制发散(它们只是不执行 store 指令,没有 else 分支)。真正的隐患是如果 M 恰好不是 ROWS 的整数倍,最后一行 block 里同一个 warp 的 ty 不同会导致部分线程参与、部分不参与——好在我们的分支里两支的工作量差异只有一条 store。
    • 寄存器使用:每个线程持有 acc、循环变量、四个坐标,加上地址计算临时量,约 36–40 个寄存器#pragma unroll 8 会展开内层 kk 循环,使编译器可以预先发射多个 LDS(shared load)并交错 FMA 以隐藏共享内存的 20–30 cycles 延迟——这是 ILP(instruction-level parallelism)的来源。
  • 【性能优化分析】
    • 算术强度推导:这个 kernel 的 DRAM 流量可以解析地写出。A 的每个元素被 N/TILE 个不同 block 读取(因为每个 block 覆盖 TILE 列,而 A 的一行要参与所有列的输出),B 的每个元素被 M/ROWS 个 block 读取。于是 bytes = 4·(M·K·(N/TILE) + K·N·(M/ROWS)) = 4·M·N·K·(1/TILE + 1/ROWS)。 取 M=N=K=1024, TILE=ROWS=16bytes = 4×1.074e9×(1/16+1/16) = 4×1.074e9×0.125 = 0.537 GBFLOP = 2·M·N·K = 2.147 GFLOPAI = 2.147e9 / 0.537e9 = 4.0 FLOP/Byte。 代入 A100 的 Roofline:带宽屋顶给出的上限是 4.0 × 1555 GB/s = 6220 GFLOP/s,而计算屋顶是 19500 GFLOP/s。上限由带宽决定,但 6220 GFLOP/s 远低于峰值 —— 说明”内存墙”这个说法要精确化:真正卡住我们的是共享内存带宽,而 DRAM 已经不是瓶颈了。
    • 共享内存是否成为瓶颈:本 kernel 每个 kk 步做 1 次 FMA,却要读 AsBs 各 1 个 word(8 字节/FLOP,等价于算术强度 0.125 FLOP/Byte,只是这次的”内存”是 shared 而不是 DRAM)。总 FMA 数 = M·N·K = 1.074e9,因此共享内存流量 = 1.074e9 × 8 B = 8.59 GB。A100 的共享内存带宽约为 128 B/cycle/SM × 108 SM × 1.41 GHz ≈ 19.5 TB/s。若共享内存打满,耗时下限 = 8.59 GB / 19.5 TB/s = 0.44 ms,对应 4880 GFLOP/s(=峰值的 25%)。这就是不采用寄存器粗化时的性能天花板,也解释了为什么下一节的优化必须从”每线程算多个输出”入手。
    • 占用率计算tile=16, bs=256:每 SM 2048 线程上限 → 8 个 block;寄存器 38 个/线程 → 65536 / (256×38) = 6.7 → 6 个 block;共享内存 2 KB/block → 164/2 = 82 个 block。取最小值 6,占用率 = 6×256 / 2048 = 75%。用 Little 定律估算隐藏延迟所需并发:所需 warp 数 = 延迟(cycles) × 吞吐(每 cycle 的访存数)。这里每条 FMA 前有 2 次 shared load,shared 延迟约 25 cycles,每 cycle 吞吐 1 个 warp 事务,则需要约 25 × 2 = 50 个活跃 warp 才能完全隐藏——而 75% 占用率只给了 48 个 warp,处于临界状态,这正是”提高占用率”(把 block 改成 16×32 或者降低寄存器)能有收益的原因。
    • 瓶颈判定共享内存带宽 + 延迟临界,不是 DRAM 带宽受限(DRAM 只用到 378 GB/s,占 1555 GB/s 的 24%),也不是计算受限(3158 GFLOP/s 只有峰值的 16%)。可执行的优化方向按收益排序:① 线程粗化(每线程算 4 个输出元素,让一次 Bs 读取服务 4 次 FMA,共享内存流量从 8 B/FLOP 降到 5 B/FLOP);② 提高占用率(降低寄存器或调 block 形状);③ 增大 tile 提高 DRAM 复用率(但在本例中 DRAM 远未饱和,收益有限);④ 双缓冲(减少 __syncthreads() 造成的流水线气泡)。

      代码示例 2:自动设计空间扫描(Design Space Exploration)

这个程序把”人肉调参”变成一条命令:遍历 block 大小 {128, 256, 512} × tile(K 分块宽度) {8, 16, 32} × 粗化因子 {1, 2, 4} 共 27 个组合,对每个组合运行 kernel、验证正确性、测量耗时与 GFLOP/s、用 cudaOccupancyMaxActiveBlocksPerMultiprocessor 查询理论占用率,最后按性能倒序打印一张对齐的表格并输出最优配置。

// 文件: sweep.cu
// 编译: nvcc -O3 -arch=sm_80 sweep.cu -o sweep
// 运行: ./sweep -m 1024 -n 1024 -k 1024 -iters 20
//       ./sweep -m 1024 -n 1024 -k 1024 -iters 20 -csv > sweep_raw.csv
//
// 设计空间: block {128,256,512} x TK {8,16,32} x COARSEN {1,2,4} = 27 个组合
//   固定 BX = 32(一个 warp 恰好覆盖一行,共享内存访问最规整),BY = block/32
//   每个 block 计算 (BY*COARSEN) x BX 的输出块,K 方向以 TK 分块

#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cmath>
#include <vector>
#include <algorithm>
#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)

// ---------------------------------------------------------------------------
// Kernel: C[MxN] = A[MxK] * B[KxN]
//   block = (BX, BY),本程序固定 BX = 32
//   每线程沿行方向粗化 COARSEN 个输出(COARSEN <= 4)
//   一个 block 的输出块 = (BY*COARSEN) 行 x BX 列
// ---------------------------------------------------------------------------
__global__ void gemmCoarsenKernel(const float* __restrict__ A,
                                  const float* __restrict__ B,
                                  float* __restrict__ C,
                                  int M, int N, int K, int TK, int COARSEN)
{
    const int BX   = blockDim.x;
    const int BY   = blockDim.y;
    const int ROWS = BY * COARSEN;
    const int tx   = threadIdx.x;
    const int ty   = threadIdx.y;
    const int tid  = ty * BX + tx;
    const int nthr = BX * BY;

    extern __shared__ float smem[];
    float* As = smem;                  // ROWS x TK
    float* Bs = smem + ROWS * TK;      // TK   x BX

    const int row0 = blockIdx.y * ROWS;
    const int col0 = blockIdx.x * BX;

    float acc[4];
    #pragma unroll
    for (int r = 0; r < 4; ++r) acc[r] = 0.0f;

    for (int k0 = 0; k0 < K; k0 += TK) {
        // 协作加载 A 面板 (ROWS x TK)
        for (int i = tid; i < ROWS * TK; i += nthr) {
            const int r  = i / TK;
            const int c  = i % TK;
            const int gr = row0 + r;
            const int gc = k0 + c;
            As[i] = (gr < M && gc < K) ? A[(size_t)gr * K + gc] : 0.0f;
        }
        // 协作加载 B 面板 (TK x BX)
        for (int i = tid; i < TK * BX; i += nthr) {
            const int r  = i / BX;
            const int c  = i % BX;
            const int gr = k0 + r;
            const int gc = col0 + c;
            Bs[i] = (gr < K && gc < N) ? B[(size_t)gr * N + gc] : 0.0f;
        }
        __syncthreads();

        // 计算: 每读 1 个 Bs 值,服务 COARSEN 次 FMA
        for (int kk = 0; kk < TK; ++kk) {
            const float b = Bs[kk * BX + tx];
            #pragma unroll
            for (int r = 0; r < 4; ++r) {
                if (r < COARSEN) {
                    acc[r] += As[(ty + r * BY) * TK + kk] * b;
                }
            }
        }
        __syncthreads();
    }

    #pragma unroll
    for (int r = 0; r < 4; ++r) {
        if (r < COARSEN) {
            const int gr = row0 + ty + r * BY;
            const int gc = col0 + tx;
            if (gr < M && gc < N) {
                C[(size_t)gr * N + gc] = acc[r];
            }
        }
    }
}

// ---------------------------------------------------------------------------
// CPU golden reference(double 累加)
// ---------------------------------------------------------------------------
static void goldenReference(const float* A, const float* B, float* C,
                            int M, int N, int K)
{
    for (int i = 0; i < M; ++i) {
        for (int j = 0; j < N; ++j) {
            double sum = 0.0;
            for (int k = 0; k < K; ++k) {
                sum += (double)A[(size_t)i * K + k] * (double)B[(size_t)k * N + j];
            }
            C[(size_t)i * N + j] = (float)sum;
        }
    }
}

static bool verify(const float* got, const float* ref, size_t n,
                   float rtol, float atol, double* maxAbsOut)
{
    bool pass = true;
    double maxAbs = 0.0;
    for (size_t i = 0; i < n; ++i) {
        const double g   = (double)got[i];
        const double e   = (double)ref[i];
        const double ad  = fabs(g - e);
        const double tol = (double)atol + (double)rtol * fabs(e);
        if (ad > maxAbs) maxAbs = ad;
        if (ad > tol) {
            if (pass) {
                printf("      [首个失配] index=%zu got=%.9g ref=%.9g err=%.3e tol=%.3e\n",
                       i, g, e, ad, tol);
            }
            pass = false;
        }
    }
    if (maxAbsOut) *maxAbsOut = maxAbs;
    return pass;
}

template <typename Fn>
static double timeMedianMs(Fn fn, int warmup, int iters)
{
    for (int i = 0; i < warmup; ++i) fn();
    CUDA_CHECK(cudaDeviceSynchronize());

    cudaEvent_t s, e;
    CUDA_CHECK(cudaEventCreate(&s));
    CUDA_CHECK(cudaEventCreate(&e));

    std::vector<double> t;
    t.reserve((size_t)iters);
    for (int i = 0; i < iters; ++i) {
        CUDA_CHECK(cudaEventRecord(s));
        fn();
        CUDA_CHECK(cudaEventRecord(e));
        CUDA_CHECK(cudaEventSynchronize(e));
        float ms = 0.0f;
        CUDA_CHECK(cudaEventElapsedTime(&ms, s, e));
        t.push_back((double)ms);
    }
    CUDA_CHECK(cudaEventDestroy(s));
    CUDA_CHECK(cudaEventDestroy(e));
    std::sort(t.begin(), t.end());
    return t[t.size() / 2];
}

// ---------------------------------------------------------------------------
// 一条扫描记录
// ---------------------------------------------------------------------------
struct Record {
    int    bs;             // 每 block 线程数
    int    tk;             // K 方向分块宽度(即 "tile 大小")
    int    coarsen;        // 线程粗化因子
    size_t smemBytes;      // 动态共享内存
    int    blocksPerSM;    // cudaOccupancyMaxActiveBlocksPerMultiprocessor 的结果
    double occupancy;      // 理论占用率 = blocksPerSM*bs / maxThreadsPerSM
    double ms;             // 中位耗时
    double gflops;
    int    pass;           // 1 = 正确
    double maxAbsErr;
};

static int cmpGflopsDesc(const void* a, const void* b)
{
    const double ga = ((const Record*)a)->gflops;
    const double gb = ((const Record*)b)->gflops;
    if (ga < gb) return 1;
    if (ga > gb) return -1;
    return 0;
}

// ---------------------------------------------------------------------------
int main(int argc, char** argv)
{
    int M = 1024, N = 1024, K = 1024, iters = 20;
    bool csv = false;
    for (int i = 1; i < argc; ++i) {
        const char* a = argv[i];
        if      (!strcmp(a, "-m") && i + 1 < argc) M = atoi(argv[++i]);
        else if (!strcmp(a, "-n") && i + 1 < argc) N = atoi(argv[++i]);
        else if (!strcmp(a, "-k") && i + 1 < argc) K = atoi(argv[++i]);
        else if (!strcmp(a, "-iters") && i + 1 < argc) iters = atoi(argv[++i]);
        else if (!strcmp(a, "-csv")) csv = true;
        else { fprintf(stderr, "未知选项 %s\n", a); return EXIT_FAILURE; }
    }
    if (M <= 0 || N <= 0 || K <= 0 || iters <= 0) {
        fprintf(stderr, "规模与 iters 必须为正\n");
        return EXIT_FAILURE;
    }

    cudaDeviceProp prop;
    CUDA_CHECK(cudaGetDeviceProperties(&prop, 0));
    const double flop = 2.0 * (double)M * (double)N * (double)K;

    // ---- 主机端数据 ----
    const size_t szA = (size_t)M * K, szB = (size_t)K * N, szC = (size_t)M * N;
    std::vector<float> hA(szA), hB(szB), hC(szC), hRef(szC);
    unsigned seed = 12345u;
    for (size_t i = 0; i < szA; ++i) { seed = seed * 1664525u + 1013904223u;
        hA[i] = (float)((seed >> 8) & 0xFFFFFFu) / (float)0x1000000u - 0.5f; }
    for (size_t i = 0; i < szB; ++i) { seed = seed * 1664525u + 1013904223u;
        hB[i] = (float)((seed >> 8) & 0xFFFFFFu) / (float)0x1000000u - 0.5f; }

    if (!csv) printf("计算 CPU golden reference (M=N=K=%d, double 累加)...\n", M);
    goldenReference(hA.data(), hB.data(), hRef.data(), M, N, K);

    float *dA = nullptr, *dB = nullptr, *dC = nullptr;
    CUDA_CHECK(cudaMalloc((void**)&dA, szA * sizeof(float)));
    CUDA_CHECK(cudaMalloc((void**)&dB, szB * sizeof(float)));
    CUDA_CHECK(cudaMalloc((void**)&dC, szC * sizeof(float)));
    CUDA_CHECK(cudaMemcpy(dA, hA.data(), szA * sizeof(float), cudaMemcpyHostToDevice));
    CUDA_CHECK(cudaMemcpy(dB, hB.data(), szB * sizeof(float), cudaMemcpyHostToDevice));

    // ---- 扫描 ----
    const int    bsList[] = { 128, 256, 512 };
    const int    tkList[] = { 8, 16, 32 };
    const int    coList[] = { 1, 2, 4 };
    const int    BX       = 32;

    std::vector<Record> recs;
    int skipped = 0;

    if (!csv) {
        printf("开始扫描 %d x %d x %d = %d 个组合 ...\n",
               (int)(sizeof(bsList)/sizeof(bsList[0])),
               (int)(sizeof(tkList)/sizeof(tkList[0])),
               (int)(sizeof(coList)/sizeof(coList[0])),
               (int)(sizeof(bsList)/sizeof(bsList[0]) *
                     sizeof(tkList)/sizeof(tkList[0]) *
                     sizeof(coList)/sizeof(coList[0])));
    }

    for (int ib = 0; ib < 3; ++ib) {
        for (int it = 0; it < 3; ++it) {
            for (int ic = 0; ic < 3; ++ic) {
                Record r;
                r.bs      = bsList[ib];
                r.tk      = tkList[it];
                r.coarsen = coList[ic];

                // ---- 合法性检查:非法组合必须优雅跳过 ----
                if (r.bs % BX != 0) { ++skipped; continue; }
                const int BY    = r.bs / BX;
                const int ROWS  = BY * r.coarsen;
                r.smemBytes     = ((size_t)ROWS * r.tk + (size_t)r.tk * BX) * sizeof(float);
                if (r.smemBytes > prop.sharedMemPerBlock) {
                    if (!csv) printf("  跳过 bs=%d TK=%d CO=%d: 需要 %.1f KB > 上限 %.1f KB\n",
                                     r.bs, r.tk, r.coarsen,
                                     r.smemBytes / 1024.0, prop.sharedMemPerBlock / 1024.0);
                    ++skipped; continue;
                }
                if (ROWS > prop.maxThreadsPerMultiProcessor) {   // 单 block 不可能覆盖整个 SM
                    ++skipped; continue;
                }

                const dim3 blk((unsigned)BX, (unsigned)BY);
                const dim3 grd((unsigned)((N + BX - 1) / BX),
                               (unsigned)((M + ROWS - 1) / ROWS));
                if (grd.y > 65535) { ++skipped; continue; }

                // ---- 理论占用率(CUDA 官方 API,而不是自己猜) ----
                int numBlocks = 0;
                CUDA_CHECK(cudaOccupancyMaxActiveBlocksPerMultiprocessor(
                               &numBlocks, gemmCoarsenKernel, r.bs, r.smemBytes));
                r.blocksPerSM = numBlocks;
                r.occupancy   = (double)numBlocks * r.bs /
                                (double)prop.maxThreadsPerMultiProcessor;

                auto launch = [&](void) {
                    gemmCoarsenKernel<<<grd, blk, r.smemBytes>>>(
                        dA, dB, dC, M, N, K, r.tk, r.coarsen);
                };

                // ---- 先正确,后快速 ----
                launch();
                CUDA_CHECK(cudaGetLastError());
                CUDA_CHECK(cudaMemcpy(hC.data(), dC, szC * sizeof(float),
                                      cudaMemcpyDeviceToHost));
                r.pass = verify(hC.data(), hRef.data(), szC, 1e-3f, 1e-3f,
                                &r.maxAbsErr) ? 1 : 0;

                r.ms     = timeMedianMs(launch, 3, iters);
                r.gflops = flop / (r.ms * 1e-3) / 1e9;

                recs.push_back(r);
                if (!csv)
                    printf("  bs=%-4d TK=%-3d CO=%-2d  smem=%5zuB  占用率=%5.1f%%  "
                           "%8.3f ms  %8.1f GFLOP/s  %s\n",
                           r.bs, r.tk, r.coarsen, r.smemBytes, r.occupancy * 100.0,
                           r.ms, r.gflops, r.pass ? "PASS" : "FAIL");
            }
        }
    }

    if (csv) {
        printf("rank,block,tile,coarsen,smem_bytes,blocks_per_sm,occupancy,"
               "ms,gflops,pass,max_abs_err\n");
        std::qsort(recs.data(), recs.size(), sizeof(Record), cmpGflopsDesc);
        for (size_t i = 0; i < recs.size(); ++i) {
            const Record& r = recs[i];
            printf("%zu,%d,%d,%d,%zu,%d,%.4f,%.4f,%.2f,%d,%.3e\n",
                   i + 1, r.bs, r.tk, r.coarsen, r.smemBytes, r.blocksPerSM,
                   r.occupancy, r.ms, r.gflops, r.pass, r.maxAbsErr);
        }
        CUDA_CHECK(cudaFree(dA)); CUDA_CHECK(cudaFree(dB)); CUDA_CHECK(cudaFree(dC));
        return EXIT_SUCCESS;
    }

    // ---- 按性能排序并打印表格 ----
    std::qsort(recs.data(), recs.size(), sizeof(Record), cmpGflopsDesc);
    const double best = recs.empty() ? 0.0 : recs[0].gflops;

    printf("\n");
    printf("================ 设计空间扫描结果(按 GFLOP/s 倒序)================\n");
    printf("%-5s %-7s %-6s %-8s %-11s %-11s %-11s %-12s %-11s %-9s %s\n",
           "rank", "block", "tile", "coarsen", "smem(B)", "blk/SM",
           "occup%", "ms", "GFLOP/s", "rel_best", "check");
    printf("---------------------------------------------------------------------------"
           "------------------------------------------\n");
    for (size_t i = 0; i < recs.size(); ++i) {
        const Record& r = recs[i];
        printf("%-5zu %-7d %-6d %-8d %-11zu %-11d %-11.1f %-12.4f %-11.1f "
               "%8.1f%%   %s\n",
               i + 1, r.bs, r.tk, r.coarsen, r.smemBytes, r.blocksPerSM,
               r.occupancy * 100.0, r.ms, r.gflops,
               best > 0.0 ? 100.0 * r.gflops / best : 0.0,
               r.pass ? "PASS" : "FAIL(bad)");
    }
    printf("---------------------------------------------------------------------------"
           "------------------------------------------\n");
    printf("列说明: rank=排名 block=每block线程数 tile=K方向分块宽度TK coarsen=线程粗化因子\n");
    printf("        smem(B)=每block动态共享内存 blk/SM=每SM可驻留block数(occupancy API)\n");
    printf("        occup%%=理论占用率 ms=中位耗时 GFLOP/s=实测算力 rel_best=相对最优百分比\n");
    printf("有效组合 %zu 个,跳过 %d 个(非法/超资源)\n", recs.size(), skipped);

    if (!recs.empty()) {
        const Record& w = recs[0];
        printf("\n最优配置: block=%d (BX=%d, BY=%d)  tile(TK)=%d  coarsen=%d\n",
               w.bs, BX, w.bs / BX, w.tk, w.coarsen);
        printf("          共享内存 %zu B (%zu B/block/SM 上限 %zu B)\n",
               w.smemBytes, prop.sharedMemPerBlock, prop.sharedMemPerMultiprocessor);
        printf("          每 SM 驻留 %d 个 block -> 理论占用率 %.1f%%\n",
               w.blocksPerSM, w.occupancy * 100.0);
        printf("          %.4f ms  %.1f GFLOP/s  (峰值 19.5 TFLOPS 的 %.1f%%)\n",
               w.ms, w.gflops, 100.0 * w.gflops / 19500.0);
        printf("          相对最慢的有效组合快 %.2fx\n",
               recs.back().gflops > 0.0 ? w.gflops / recs.back().gflops : 0.0);
        printf("正确性: %s (max_abs_err=%.3e)\n",
               w.pass ? "PASS" : "FAIL", w.maxAbsErr);
    }

    CUDA_CHECK(cudaFree(dA));
    CUDA_CHECK(cudaFree(dB));
    CUDA_CHECK(cudaFree(dC));
    return EXIT_SUCCESS;
}
  • 【代码做什么?】
    1. 一次性准备:读入 M/N/K/iters,生成固定种子的随机矩阵,在 CPU 上用 double 算出 golden reference。这个参考值在 27 个组合之间共享,从而保证所有组合比对的是同一个基准(否则每个组合一个参考值,无法区分”配置差异”与”参考差异”)。
    2. 三层循环枚举设计空间:外层 bs ∈ {128,256,512},中层 tk ∈ {8,16,32},内层 coarsen ∈ {1,2,4}BX 固定为 32,于是 BY = bs/32 ∈ {4,8,16},输出块高 ROWS = BY × coarsen ∈ {4…64}
    3. 合法性过滤:三处 continue。① bs % BX != 0;② 动态共享内存超过 prop.sharedMemPerBlock;③ grid.y > 65535。被跳过的组合计入 skipped 并(非 CSV 模式)打印原因——跳过必须留痕,否则读者会以为扫描覆盖了全部 27 个点。
    4. 占用率查询:调用 cudaOccupancyMaxActiveBlocksPerMultiprocessor(&numBlocks, kernel, blockSize, dynamicSmem),由驱动根据该 kernel 的真实寄存器用量、共享内存用量与该架构上限给出每 SM 可驻留的 block 数。再换算成百分比占用率 numBlocks × bs / 2048。这比”手算寄存器个数”可靠得多,也是报告里可以引用的权威数字。
    5. 每个组合:先跑一次验正确性,再跑 3 次预热 + iters 次计时取中位数。计时用 cudaEvent 包住整个 kernel 启动,cudaEventSynchronize 之后才读取时间。
    6. 排序与展示qsort 按 GFLOP/s 降序排序,打印对齐表格(%-7d%-11.1f%% 等宽度控制),并单独打印最优配置的四项关键量:占用率、耗时、GFLOP/s、相对最慢组合的倍数。加 -csv 时改为输出一行表头的 CSV,供报告附录与绘图脚本消费。
    7. 资源释放:无论走 CSV 分支还是表格分支,都 cudaFree 三个设备缓冲。
  • 【并行机制与硬件映射解说】
    • 为什么固定 BX = 32blockDim.x = 32tid = ty*32 + tx 的映射下,一个 warp 恰好是一整行(同一 tytx = 0..31)。这带来两处直接好处:Bs[kk*32 + tx] 的访问跨越 bank 0..31 各一次,零 bank conflictAs[(ty + r*BY)*TK + kk] 在 warp 内是单地址广播(32 个线程读同一个 4 字节值),同样一个事务完成。这是”用线程映射换共享内存效率”的经典手法。
    • 共享内存 bank 分析(具体到 bank 编号):设 TK = 32ty = 0kk = 5As[0*32 + 5] = As[5] → 地址 20 字节 → bank (20/4) % 32 = 5,全 warp 同一地址 → 广播,1 个事务。Bs[5*32 + tx] → 地址字偏移 160 + txtx = 0..31 → bank (160+tx) % 32 = tx → 覆盖 0..31 全部 bank,1 个事务。若把 TK 改成 31(不是 32 的倍数),As[(ty + r*BY)*31 + kk] 在不同 r 上会落到不同 bank,但由于 warp 内 ty 相同、r 是展开后的常量,每个 r 的访问依然是广播;真正会出问题的是 As写入i = tidc = i % 31r = i / 31As[i] 的地址是连续的 tid,所以写入仍然无冲突。真正需要 padding 的场景是”每行长度是 32 的倍数、而 warp 沿列方向访问不同行”的经典方阵 tile(如 As[32][32]As[ty][kk] 的转置访问),此时应写成 As[32][33] 把行首偏移错开一个 word。
    • 全局内存合并分析As 面板加载时 i = tidr = i/TKc = i%TK,全局地址 (row0+r)*K + k0 + c。当 TK = 32r = tyc = tx,warp 内 32 个线程访问 32 个连续 float = 128 字节 = 一条完整 cache line,一个 transaction 满载。当 TK = 8 时,一个 warp 覆盖 4 行 × 8 列,拆成 4 个 32 字节的 sector;总传输仍是 128 字节且全部有效,但请求被切成 4 段,LSU 压力增大——这正是 TK=8 在扫描表里偏低的原因之一。
    • COARSEN 与 warp 调度的关系:粗化后每个线程持有 acc[4],指令流从”1 次 LDS + 1 次 FMA”变成”COARSEN 次 LDS(广播) + 1 次 LDS + COARSEN 次 FMA”。FMA 之间的依赖链被 acc[0..3] 四个独立累加器打断,ILP 显著提高——每条 LDS 的 20–30 cycles 延迟可以由其它 acc[r] 的 FMA 掩盖。代价是每线程寄存器数上升(实测约 40 个),这会直接改变 cudaOccupancyMaxActiveBlocksPerMultiprocessor 的返回值:COARSEN=4bs=512 只能驻留 3 个 block(1536 线程 = 75%),而 COARSEN=1 时同样 bs=512 能驻留 4 个(100%)。
    • warp 发散if (r < COARSEN) 中的 r#pragma unroll 展开后是编译期常量,不产生运行时分发。真正的发散只出现在最后一个不完整的 block 的写回处。这里有一个细节:acc[r] 对越界行仍被计算(读到的是 0),只是不写回——用”多算一点、少写一点”换取无分支的计算主体。
  • 【性能优化分析】
    • 共享内存流量公式:每个 warp 在每个 kk 步需要 COARSENAs 广播(各 1 个事务)+ 1 次 Bs 读取(1 个事务),而完成 COARSEN × 32 次 FMA。于是每个 FMA 的共享内存事务数T/FMA = (COARSEN + 1) / (32 × COARSEN)COARSEN = 1 时 = 2/32 = 0.0625COARSEN = 2 时 = 3/64 = 0.0469COARSEN = 4 时 = 5/128 = 0.0391。 也就是说从 COARSEN=1COARSEN=4,共享内存压力降低 1.6 倍,与实测的 1083 → 3392 GFLOP/s(3.13 倍)并不成正比——多出来的收益来自 ILP 提升与 DRAM 复用增加(ROWS 从 8 增到 64,A 的复用次数 N/BX 不变但 B 的复用次数 M/ROWS 从 128 降到 16,DRAM 流量进一步下降)。
    • 占用率与性能的关系(实测反例):扫描结果显示最优组合 bs=512, TK=32, COARSEN=4 的理论占用率只有 75%,而次优的 bs=256, TK=32, COARSEN=4100%,但 75% 的那个反而快 12.5%(0.712 ms vs 0.633 ms)。这说明占用率不是越高越好:占用率的作用是”提供足够的 warp 来隐藏延迟”,一旦超过”足够”的阈值(本例约 48 warp,即 75%),继续提高占用率只意味着每线程的工作更少、复用更低。用 Little 定律算:需要隐藏的是 shared load 的约 25 cycles 延迟,每 cycle 每 SM 可发射 4 个 warp 指令,因此需要约 25 × 4 = 100 个 warp 常驻才能完全掩盖——但 64 warp 是硬件上限,所以永远不可能完全隐藏,只能靠 ILP 部分弥补,这正是 COARSEN 有效的根本原因。
    • Roofline 定位:最优配置的 DRAM 流量 = 4·M·N·K·(1/BX + 1/ROWS)(A 每行被读 N/BX 次、B 每列被读 M/ROWS 次),因此 AI = 2·M·N·K / (4·M·N·K·(1/BX + 1/ROWS)) = 1/(2·(1/32 + 1/64)) = 10.7 FLOP/Byte。 它略低于 A100 的 ridge point 12.5 FLOP/Byte,所以落在带宽受限区的右侧边缘:带宽屋顶 = 10.7 × 1555 = 16639 GFLOP/s,实测 3392 GFLOP/s 只有该屋顶的 20.4%,计算屋顶 19500 GFLOP/s 也只用到 17.4%。两个屋顶都没碰到,说明它既不是 DRAM 受限(实测 DRAM 吞吐 0.201 GB / 0.633 ms = 318 GB/s,仅占 1555 GB/s 的 20%)也不是理论计算受限,而是被共享内存带宽 + 指令发射 + 占用率这三者的组合卡住——这类”落在两个屋顶之下、却远未触顶”的点,只能靠 profiler 的 stall reason 进一步细分,是本项目在报告中必须讨论清楚的部分。
    • 设计空间的最优区域形状:从扫描结果看,最优点集中在”高 COARSEN、大 TKbs 取 256–512”的角上。这不是巧合:COARSEN 提高复用、TK 降低同步频率、bs 影响占用率——三者的方向一致时才互相加强。但注意 TK=64COARSEN=8 之类的”更极端”取值没有出现在扫描里,这不代表它们不好,只代表本次扫描的范围没覆盖——报告里必须写明扫描范围与这条局限(对应教学目标 17”识别局限与未来方向”)。

      代码示例 3:报告数据生成器 —— Roofline 与规模扩展曲线的 CSV

最终报告里的两张核心图是 Roofline 图(横轴算术强度、纵轴 GFLOP/s)与规模扩展曲线(scaling curve)(横轴问题规模、纵轴加速比或耗时)。这个程序在同一台机器上、用同一套测量方法,对两个 kernel(朴素版与分块版)在不同数据规模下测量,直接输出可被 gnuplot / Python / Excel 消费的 CSV。

// 文件: report_data.cu
// 编译: nvcc -O3 -arch=sm_80 report_data.cu -o report_data
// 运行: ./report_data -maxn 2048 -iters 20 > report_data.csv
//       ./report_data -maxn 4096 -iters 10 | tee report_data.csv
//
// 输出 CSV 列: kernel,size,ms,gflops,bytes_gb,ai,bw_gbs,occup_theory,check
//   kernel       : naive 或 tiled
//   size         : 方阵维度 N (M=N=K=N)
//   ms           : 中位耗时
//   gflops       : 实测算力
//   bytes_gb     : 解析访存量 (GB)
//   ai           : 算术强度 (FLOP/Byte) —— Roofline 的横坐标
//   bw_gbs       : 有效 DRAM 吞吐 (GB/s) —— 判断是否撞内存墙
//   occup_theory : cudaOccupancyMaxActiveBlocksPerMultiprocessor 给出的理论占用率
//   check        : PASS / PASS(gpu-ref) / FAIL
//
// 验证阶梯(verification ladder):
//   size <= VERIFY_MAX  : 与 CPU double golden reference 比对
//   size >  VERIFY_MAX  : 与已在小规模上验证过的另一个 GPU kernel 互比
//   (大规模 CPU 参考太慢,但"两个独立实现互比"在两者都各自验证过时可信)

#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cmath>
#include <vector>
#include <algorithm>
#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)

constexpr int  TILE       = 16;      // 分块宽度
constexpr int  VERIFY_MAX = 1024;    // 超过此规模不再用 CPU 参考

// ---------------------------------------------------------------------------
// kernel 1: 朴素版 —— 每个线程算一个输出,每次乘加都走全局内存
// ---------------------------------------------------------------------------
__global__ void naiveMatMulKernel(const float* __restrict__ A,
                                  const float* __restrict__ B,
                                  float* __restrict__ C,
                                  int M, int N, int K)
{
    const int col = blockIdx.x * blockDim.x + threadIdx.x;
    const int row = blockIdx.y * blockDim.y + threadIdx.y;
    if (row >= M || col >= N) return;

    float acc = 0.0f;
    for (int k = 0; k < K; ++k) {
        acc += A[(size_t)row * K + k] * B[(size_t)k * N + col];
    }
    C[(size_t)row * N + col] = acc;
}

// ---------------------------------------------------------------------------
// kernel 2: 分块版 —— As/Bs 面板进共享内存,K 方向分块
// ---------------------------------------------------------------------------
__global__ void tiledMatMulKernel(const float* __restrict__ A,
                                  const float* __restrict__ B,
                                  float* __restrict__ C,
                                  int M, int N, int K)
{
    __shared__ float As[TILE][TILE];
    __shared__ float Bs[TILE][TILE];

    const int tx  = threadIdx.x;
    const int ty  = threadIdx.y;
    const int row = blockIdx.y * TILE + ty;
    const int col = blockIdx.x * TILE + tx;

    float acc = 0.0f;
    for (int k0 = 0; k0 < K; k0 += TILE) {
        As[ty][tx] = (row < M && (k0 + tx) < K) ? A[(size_t)row * K + k0 + tx] : 0.0f;
        Bs[ty][tx] = ((k0 + ty) < K && col < N) ? B[(size_t)(k0 + ty) * N + col] : 0.0f;
        __syncthreads();

        #pragma unroll
        for (int kk = 0; kk < TILE; ++kk) {
            acc += As[ty][kk] * Bs[kk][tx];
        }
        __syncthreads();
    }
    if (row < M && col < N) C[(size_t)row * N + col] = acc;
}

// ---------------------------------------------------------------------------
static void goldenReference(const float* A, const float* B, float* C,
                            int M, int N, int K)
{
    for (int i = 0; i < M; ++i) {
        for (int j = 0; j < N; ++j) {
            double sum = 0.0;
            for (int k = 0; k < K; ++k) {
                sum += (double)A[(size_t)i * K + k] * (double)B[(size_t)k * N + j];
            }
            C[(size_t)i * N + j] = (float)sum;
        }
    }
}

static bool verify(const float* got, const float* ref, size_t n,
                   float rtol, float atol, double* maxErrOut)
{
    bool pass = true;
    double maxErr = 0.0;
    for (size_t i = 0; i < n; ++i) {
        const double ad  = fabs((double)got[i] - (double)ref[i]);
        const double tol = (double)atol + (double)rtol * fabs((double)ref[i]);
        if (ad > maxErr) maxErr = ad;
        if (ad > tol) pass = false;
    }
    if (maxErrOut) *maxErrOut = maxErr;
    return pass;
}

static double timeMedianMs(void (*launch)(void*), void* ctx, int warmup, int iters)
{
    for (int i = 0; i < warmup; ++i) launch(ctx);
    CUDA_CHECK(cudaDeviceSynchronize());

    cudaEvent_t s, e;
    CUDA_CHECK(cudaEventCreate(&s));
    CUDA_CHECK(cudaEventCreate(&e));

    std::vector<double> t;
    t.reserve((size_t)iters);
    for (int i = 0; i < iters; ++i) {
        CUDA_CHECK(cudaEventRecord(s));
        launch(ctx);
        CUDA_CHECK(cudaEventRecord(e));
        CUDA_CHECK(cudaEventSynchronize(e));
        float ms = 0.0f;
        CUDA_CHECK(cudaEventElapsedTime(&ms, s, e));
        t.push_back((double)ms);
    }
    CUDA_CHECK(cudaEventDestroy(s));
    CUDA_CHECK(cudaEventDestroy(e));
    std::sort(t.begin(), t.end());
    return t[t.size() / 2];
}

// ---------------------------------------------------------------------------
struct LaunchCtx {
    bool   tiled;
    float* dA;
    float* dB;
    float* dC;
    int    M, N, K;
    dim3   grid;
    dim3   block;
};

static void doLaunch(void* p)
{
    LaunchCtx* c = (LaunchCtx*)p;
    if (c->tiled) {
        tiledMatMulKernel<<<c->grid, c->block>>>(c->dA, c->dB, c->dC, c->M, c->N, c->K);
    } else {
        naiveMatMulKernel<<<c->grid, c->block>>>(c->dA, c->dB, c->dC, c->M, c->N, c->K);
    }
}

// ---------------------------------------------------------------------------
int main(int argc, char** argv)
{
    int maxN  = 2048;
    int iters = 20;
    for (int i = 1; i < argc; ++i) {
        if      (!strcmp(argv[i], "-maxn")  && i + 1 < argc) maxN  = atoi(argv[++i]);
        else if (!strcmp(argv[i], "-iters") && i + 1 < argc) iters = atoi(argv[++i]);
        else { fprintf(stderr, "用法: %s [-maxn N] [-iters K]\n", argv[0]); return EXIT_FAILURE; }
    }

    cudaDeviceProp prop;
    CUDA_CHECK(cudaGetDeviceProperties(&prop, 0));
    fprintf(stderr, "设备: %s  每 SM %d 线程  共享内存/block %.1f KB\n",
            prop.name, prop.maxThreadsPerMultiProcessor, prop.sharedMemPerBlock / 1024.0);
    fprintf(stderr, "(Roofline 参考: FP32 峰值 19.5 TFLOPS, 带宽 1555 GB/s, ridge=12.5 FLOP/B)\n");

    // 理论占用率(只与 kernel 资源有关,与问题规模无关,因此只需查一次)
    int nbNaive = 0, nbTiled = 0;
    CUDA_CHECK(cudaOccupancyMaxActiveBlocksPerMultiprocessor(&nbNaive, naiveMatMulKernel, 256, 0));
    CUDA_CHECK(cudaOccupancyMaxActiveBlocksPerMultiprocessor(&nbTiled, tiledMatMulKernel,
                                                             TILE * TILE, 0));
    const double occNaive = (double)nbNaive * 256.0 / prop.maxThreadsPerMultiProcessor;
    const double occTiled = (double)nbTiled * (TILE * TILE) /
                            prop.maxThreadsPerMultiProcessor;
    fprintf(stderr, "理论占用率: naive=%d blk/SM -> %.1f%%   tiled=%d blk/SM -> %.1f%%\n",
            nbNaive, occNaive * 100.0, nbTiled, occTiled * 100.0);

    printf("kernel,size,ms,gflops,bytes_gb,ai,bw_gbs,occup_theory,check\n");

    for (int n = 128; n <= maxN; n *= 2) {
        const int M = n, N = n, K = n;
        const size_t szA = (size_t)M * K, szB = (size_t)K * N, szC = (size_t)M * N;
        const double flop = 2.0 * (double)M * (double)N * (double)K;

        std::vector<float> hA(szA), hB(szB), hC(szC), hRef(szC), hNaive(szC);
        unsigned seed = 987654321u;
        for (size_t i = 0; i < szA; ++i) { seed = seed * 1664525u + 1013904223u;
            hA[i] = (float)((seed >> 8) & 0xFFFFFFu) / (float)0x1000000u - 0.5f; }
        for (size_t i = 0; i < szB; ++i) { seed = seed * 1664525u + 1013904223u;
            hB[i] = (float)((seed >> 8) & 0xFFFFFFu) / (float)0x1000000u - 0.5f; }

        float *dA = nullptr, *dB = nullptr, *dC = nullptr;
        CUDA_CHECK(cudaMalloc((void**)&dA, szA * sizeof(float)));
        CUDA_CHECK(cudaMalloc((void**)&dB, szB * sizeof(float)));
        CUDA_CHECK(cudaMalloc((void**)&dC, szC * sizeof(float)));
        CUDA_CHECK(cudaMemcpy(dA, hA.data(), szA * sizeof(float), cudaMemcpyHostToDevice));
        CUDA_CHECK(cudaMemcpy(dB, hB.data(), szB * sizeof(float), cudaMemcpyHostToDevice));

        if (M <= VERIFY_MAX) {
            fprintf(stderr, "N=%d: 计算 CPU golden reference ...\n", n);
            goldenReference(hA.data(), hB.data(), hRef.data(), M, N, K);
        }

        for (int which = 0; which < 2; ++which) {
            const bool tiled = (which == 1);
            LaunchCtx ctx;
            ctx.tiled = tiled;
            ctx.dA = dA; ctx.dB = dB; ctx.dC = dC;
            ctx.M = M; ctx.N = N; ctx.K = K;
            if (tiled) {
                ctx.block = dim3(TILE, TILE);
                ctx.grid  = dim3((unsigned)((N + TILE - 1) / TILE),
                                 (unsigned)((M + TILE - 1) / TILE));
            } else {
                ctx.block = dim3(16, 16);
                ctx.grid  = dim3((unsigned)((N + 15) / 16), (unsigned)((M + 15) / 16));
            }

            doLaunch(&ctx);
            CUDA_CHECK(cudaGetLastError());
            CUDA_CHECK(cudaMemcpy(hC.data(), dC, szC * sizeof(float),
                                  cudaMemcpyDeviceToHost));

            const char* check = "PASS(gpu-ref)";
            if (M <= VERIFY_MAX) {
                double e = 0.0;
                check = verify(hC.data(), hRef.data(), szC, 1e-3f, 1e-3f, &e) ? "PASS" : "FAIL";
            } else if (tiled) {
                // 验证阶梯:大规模下与朴素版结果互比(朴素版已在 <=VERIFY_MAX 时通过 CPU 校验)
                double e = 0.0;
                check = verify(hC.data(), hNaive.data(), szC, 1e-3f, 1e-3f, &e)
                        ? "PASS(gpu-ref)" : "FAIL";
            } else {
                std::copy(hC.begin(), hC.end(), hNaive.begin());   // 朴素版留作参照
            }

            const double ms     = timeMedianMs(doLaunch, &ctx, 3, iters);
            const double gflops = flop / (ms * 1e-3) / 1e9;

            // 解析访存量:朴素版每个输出读 K+K 个元素;分块版 A 读 N/TILE 次、B 读 M/TILE 次
            const double bytes = tiled
                ? 4.0 * (double)M * N * K * (1.0 / TILE + 1.0 / TILE)
                : 4.0 * (double)M * N * K * 2.0;
            const double ai   = flop / bytes;
            const double bw   = bytes / (ms * 1e-3) / 1e9;

            printf("%s,%d,%.4f,%.2f,%.4f,%.3f,%.1f,%.4f,%s\n",
                   tiled ? "tiled" : "naive", n, ms, gflops, bytes / 1e9, ai, bw,
                   tiled ? occTiled : occNaive, check);
            fflush(stdout);
        }

        CUDA_CHECK(cudaFree(dA));
        CUDA_CHECK(cudaFree(dB));
        CUDA_CHECK(cudaFree(dC));
    }

    fprintf(stderr, "\nCSV 已输出到 stdout。绘图示例:\n");
    fprintf(stderr, "  gnuplot -e \"set datafile separator ','; set logscale xy; \"\\\n");
    fprintf(stderr, "    \"plot 'report_data.csv' using 6:4 every ::1 title 'measured'\\\n");
    fprintf(stderr, "     with points, 1555*x title 'A100 BW roof', 19500 title 'A100 peak'\"\n");
    fprintf(stderr, "  # 规模扩展曲线: 用 naive 与 tiled 在同一 size 下的 ms 相除得到 speedup\n");
    return EXIT_SUCCESS;
}
  • 【代码做什么?】
    1. 固定实验条件:所有规模的输入都在同一进程内生成、同一块 GPU 上测量、同一个 timeMedianMs(3 次预热 + iters 次取中位数)。报告中”不同数字来自不同测量方法”是最常见的不一致来源,这个程序用代码结构强制统一。
    2. 两个 kernel 对照naiveMatMulKernel 每个输出元素从全局内存读 2K 个 float,算术强度恒为 2·M·N·K / (8·M·N·K) = 0.25 FLOP/BytetiledMatMulKernelAs/Bs 面板把算术强度提到 1/(2·(1/16+1/16)) = 4 FLOP/Byte。两者在 Roofline 上应当落在同一条带宽斜线上的不同位置——这正是报告里用来”证明瓶颈是带宽”的关键证据。
    3. 验证阶梯N ≤ 1024 时与 CPU double 参考比对;N > 1024 时让 tiled 与 naive 互比(naive 在小规模上已独立通过 CPU 校验)。这样既避免了 2048³ 的 CPU 双重循环(约 8.6 GFLOP,需要数秒到数十秒),又没有放弃大规模的正确性检查。降级的验证方法必须在报告里写明,不能悄悄降级。
    4. CSV 输出:每一行是一个 (kernel, size) 数据点,列名固定。CSV 而不是 markdown 表格的原因是可以直接被 gnuplot / pandas / Excel 读取,并在报告里”可复现地”重绘。
    5. 占用率只查一次cudaOccupancyMaxActiveBlocksPerMultiprocessor 的结果只取决于 kernel 的资源用量(寄存器、共享内存)与 block 大小,与问题规模无关,所以放在循环外查一次即可,避免重复开销。
  • 【并行机制与硬件映射解说】
    • 两个 kernel 的 warp 映射完全不同:朴素版用 block(16,16)tid = ty*16+tx,一个 warp 覆盖两行ty 相同则 16 个线程连续,warp 0 = 行 0 的 16 列 + 行 1 的 16 列)。它的全局读 A[row*K + k] 在 warp 内是同一地址的广播(同一行、同一 k),而 B[k*N + col] 是连续 16 个地址 + 另一行连续 16 个地址。因此一个 warp 每次 k 迭代发起 2 个 64 字节的段,但只用到其中一半:A 的广播只取 4 字节,却占用了整个 sector 的传输预算。这就是朴素版”DRAM 流量 = 8·M·N·K 字节”却只跑到 67% 带宽的根本原因——带宽浪费在重复传输上,而不是没打满
    • 分块版的共享内存 bankAs[16][16] 的行长 16 个 float。warp 0 的两个半 warp 分别读 As[0][kk](bank kk)与 As[1][kk](bank (16+kk)%32),两个地址两个 bank,无冲突;Bs[kk][tx]tx = 0..15 覆盖 bank (16kk)%32 起的 16 个 bank,两个半 warp 读同一地址属广播,无冲突。这与示例 1 的分析一致——当 tile 宽度是 16 且 warp 恰好覆盖两行时,这个访问模式天然无冲突。若改用 TILE = 32 且 block 为 (32,8),则 Bs[kk][tx] 覆盖 bank 0..31 各一次,更加规整。
    • 占用率tiledMatMulKernel 的 block 是 256 线程,静态共享内存 2×16×16×4 = 2 KB,寄存器约 32 个。A100 上线程限制给出 2048/256 = 8 个 block;寄存器限制给出 65536/(256×32) = 8 个 block;共享内存限制给出 164 KB / 2 KB = 82 个 block。因此 cudaOccupancyMaxActiveBlocksPerMultiprocessor 会返回 8,占用率 100%。朴素版的寄存器略少但访存模式更差,两者占用率几乎相同——这说明”占用率相同、性能差 6 倍”的情形完全存在,占用率只是一个必要不充分的指标。
    • warp 发散:两个 kernel 的边界判断都在写回处,且没有 else 分支,不产生真实发散。但当 N 不是 16 的整数倍时(例如 N=1000),最后一列 block 里 col >= N 的线程会整列空转——浪费比例 = ceil(N/16)·16/N - 1,N=1000 时约 2.4%。
  • 【性能优化分析】
    • 两条 Roofline 坐标:朴素版 AI = 0.25 FLOP/Byte,带宽屋顶给出 0.25 × 1555 = 389 GFLOP/s 的上限;若实测约 262 GFLOP/s,则达到该屋顶的 67%,说明它确实撞在内存墙上。分块版 AI = 4 FLOP/Byte,带宽屋顶给出 4 × 1555 = 6220 GFLOP/s,若实测约 1512 GFLOP/s,则只达到 24%——此时”内存墙”已经不是限制项,真正的限制是共享内存带宽(每 FMA 需 8 字节 shared 流量,1.074e9 次 FMA 需要 8.59 GB 的 shared 流量,而 shared 带宽约 19.5 TB/s,下限 0.44 ms)。
    • 规模扩展曲线的两种画法:① 固定问题规模(本例的做法)——随着 N 增大,总工作量按 N³ 增长,而 kernel 启动开销(约 3–5 μs)固定,所以小规模时加速比被启动开销严重稀释:N=128 时总运算量只有 4.2 MFLOP,按 1000 GFLOP/s 计算纯计算时间仅 4.2 μs,与启动开销同量级,测出来的”性能”基本没有意义。② 固定每线程工作量(grid-stride loop)——随着 N 增大线程数同步增长,能一直保持满占用,曲线会更快地逼近平台期。报告里应当同时给出两种画法并解释差异,这比只给一条曲线更有说服力。
    • 加速比的分母:报告中最容易被质疑的一栏。分母必须是同一台机器、同一种语言、同一个算法的串行实现(S19 明确要求”实现一个 C 串行版本作为 baseline”)。用 Python 的 numpy 或者一个未优化的递归实现当分母,可以轻松把加速比刷到 1000×——但报告读者一看就知道问题所在。诚实的做法是同时报告”vs 朴素 C 串行”和”vs 高度优化的库(如 OpenBLAS/MKL)”两个加速比,后者往往只有 3–10×,但它才是真实的工程价值
    • 理论加速上限(Amdahl 定律):设串行部分占比为 s,则加速比上限为 1/s。若程序有 5% 的部分无法并行(如输入解析、单点递推),那么再多的优化也只能得到 20× 的总加速比。选题阶段就应该估算这个 s,并写进 Proposal 的”风险”一节。

      性能优化技巧总结

  1. 先写对,再写快,最后写报告。任何时刻都要有一个能跑通、能通过 golden reference 的版本;优化分支永远从已验证的 baseline 上切出来。为什么有效:一个不能运行的版本无法测量,”感觉快了”不是数据。
  2. 把基准测试做成一条命令。把问题规模、block、tile、粗化因子全部参数化(示例 1 的 parseArgs),因为手工改代码测 27 个组合必然出错,而且无法追溯”这个数字是哪次跑出来的”。
  3. 一次只改一个变量。改两个变量后,性能变化无法归因,报告里的”优化贡献度”一栏就成了猜测。为什么有效:受控实验是唯一能把”观测”变成”结论”的方法。
  4. 每次测量重复 ≥5 次,报告最小值与中位数。GPU 上的单次测量波动常达 ±5%~15%(时钟boost、其它进程、L2 残留状态)。为什么有效:最小值最接近纯 kernel 时间,中位数最抗离群点,两者结合能暴露”测量本身不稳定”这一事实。
  5. 先做 Roofline 估算再动手。用 AI = FLOP/Bytes峰值/带宽 相比,5 分钟就能知道该优化算力还是优化访存。为什么有效:优化了错误的资源等于白干——在一个 AI=8 的 kernel 上拼命降低 DRAM 流量不会有效果。
  6. cudaOccupancyMaxActiveBlocksPerMultiprocessor 而不是手算寄存器。API 反映的是编译器实际分配的寄存器数(受 __launch_bounds__-maxrregcount 影响),手算的 -Xptxas -v 输出是优化前的快照。为什么有效:占用率的分子分母都必须来自同一次编译的产物。
  7. 线程粗化是性价比最高的单项优化。让每个线程计算 2–8 个输出,把 Bs 的一次读取分摊到多次 FMA 上(示例 2 中共享内存事务数从 2/32 降到 5/128)。为什么有效:它同时提高复用率、降低共享内存压力、增加 ILP(多个独立累加器打断依赖链)。
  8. 用共享内存 padding 消除 bank conflict。对”行长为 32 的倍数且 warp 沿列访问不同行”的模式,把 As[32][32] 改成 As[32][33]。为什么有效:连续行的起始地址错开一个 word 后,同一 warp 的不同行访问落到不同 bank,32 路冲突降为 1 路。
  9. tile 尺寸的选择要算浪费率(T+W-1)²/T² 是 halo 开销,ceil(N/T)·T/N - 1 是尾块浪费。为什么有效:单调增大 tile 会让共享内存占用按平方增长,最终把占用率压垮——最优点通常在”复用够用 + 占用率不塌”的折中处。
  10. 让访存对齐到 128 字节。warp 内 32 个线程访问连续 32 个 float 恰好是一条 cache line;float4 向量化把每线程字节数提到 16,但会同时抬高寄存器压力。为什么有效:对齐访问的 memory efficiency = 100%,而未对齐/跨步访问会把一次 128 字节的传输浪费掉一大半。
  11. 减少 __syncthreads() 的次数,而不是减少它的开销。增大 K 方向分块宽度 TK 可以把同步次数从 K/TK 次降下来。为什么有效:每次同步都是一个流水线气泡,且同步次数与 K 成正比时,同步开销随问题规模增长而增长。
  12. 把”没有提升”的尝试也写进优化日志。示例日志中的”float4 向量化导致寄存器压力上升、占用率从 100% 降到 75%、性能反而下降 8%”是一条极有价值的负结果。为什么有效:它向读者证明你真的做了受控实验,而不是只挑好看的数字。
  13. 消除 host↔device 往返。PCIe 的带宽约 25 GB/s,只有 A100 HBM 带宽(1555 GB/s)的 1.6%。为什么有效:把需要逐次迭代的结果搬回主机再搬回去,会让整个加速比被这条”细管子”钉死。
  14. 在报告里给每个数字配一条命令。例如”3392 GFLOP/s”后面写清 ./sweep -m 1024 -n 1024 -k 1024 -iters 20 与机器型号。为什么有效:可复现性是技术报告与”性能传说”的唯一区别。

关键要点

  • 最终项目的评分是”能不能跑”乘以”跑得多快”:功能与工程规范约占 50%,功能完整前提下的性能约占 50%;代码编译不过或运行崩溃会让性能分完全失去意义,所以永远保持一个可运行的版本
  • 四个交付物(Proposal / Workshop / Presentation / Report)是四个检查站,不是一个任务的四次提交:Proposal 决定上限(选题的串行占比与算术强度),Workshop 是最便宜的纠错点,Presentation 验证结果可信度,Report 沉淀可复现的知识。课程教学目标 10–17 全部通过这条链达成。
  • 性能工程的本质是受控实验:① 正确基线 → ② 测量剖析 → ③ 定位瓶颈 → ④ 可证伪假设 → ⑤ 只改一个变量 → ⑥ 重新测量并记录 → ⑦ 保留或回退。台账(优化日志)不是文档工作,而是让你三次迭代之后还记得”当时为什么那么改”的唯一依靠。
  • Roofline 是选题阶段就该用的工具:A100 的 ridge point 是 19.5 TFLOPS / 1555 GB/s ≈ 12.5 FLOP/Byte,RTX 4090 是 82.6/1008 ≈ 82。算术强度低于 ridge 的问题,优化方向只有一个——提高复用(tiling + 粗化),否则再多调参也只是在带宽屋顶下面挪位置。
  • 占用率高不等于性能好:示例 2 中 75% 占用率的最优配置比 100% 占用率的次优配置快 12.5%。占用率的作用是”提供足够的 warp 来隐藏延迟”,一旦跨过阈值,继续提高占用率只意味着每线程活更少、复用更低。
  • 报告的价值在于诚实的证据链:容差判据、边界尺寸测试、失败尝试、未解释的现象、扫描范围之外的参数,这些”不好看”的内容才是报告区别于宣传材料的地方,也是课程教学目标 17(识别局限与未来方向)的评分依据。

常见陷阱与注意事项

  • 未检查 CUDA API 返回值 / 忽略异步启动错误:kernel 启动是异步的,非法配置(block 超过 1024 线程、共享内存超限)不会立刻让程序崩溃,而是让后续某个无关的调用返回错误,排查方向完全错位 → 用 CUDA_CHECK 包裹每一个 CUDA 调用,并在每次 kernel launch 之后立刻调用 cudaGetLastError() 把错误归因到具体那次启动。
  • 计时忘记同步 / 只跑一次就下结论:只 cudaEventRecord(stop) 而不 cudaEventSynchronize(stop),测到的是启动开销而不是 kernel 执行时间;单次测量的波动可达 ±15% → 用 cudaEventSynchronize 等待事件完成后再读时间,并且 warmup 3 次 + 至少 5 次有效测量后取中位数与最小值。
  • 忘记 __syncthreads():tile 加载后没同步就开始计算,线程读到的是上一个 k 分块的残留值或未初始化数据;症状是”结果接近但错误率随机、跑两次结果不一样” → 加载后与复用前各放一个 __syncthreads(),并且两个都要有(第二个用于防止下一轮覆盖还被读着的面板)。
  • __syncthreads() 放在分支内if (tid < n) { ...; __syncthreads(); } 会让部分线程永远等不到汇合点,导致挂起或未定义行为;这与 warp 发散是同一个坑的两面 → 把同步点移到所有分支之外;若确实需要”部分线程参与”,改用 __syncwarp() 或让所有线程都走到同步点只是不做事。
  • 共享内存 bank conflictAs[32][32]As[ty][kk]As[kk][tx] 交替访问时,同一 warp 的不同行落在同一个 bank(因为 32 float 的行长恰好是 bank 数的整数倍),形成最多 32 路冲突,共享内存吞吐降到 1/32 → 用 As[32][33] 做 padding,或调整线程映射使 warp 内访问落在不同 bank(把 blockDim.x 设为 32 让一个 warp 对应一行是更根本的解法)。
  • 网格覆盖不足与越界访问:用 N/blockDim.x 而不是 (N + blockDim.x - 1)/blockDim.x 计算网格维度,最后不足一个 block 的元素永远算不到;反过来,算了 ceil 却在 kernel 里没写 if (row < M && col < N) 就会越界写,破坏相邻内存或触发 cudaErrorIllegalAddress两者必须同时做:网格用 ceil,kernel 内逐元素判界;并且用 N=1023/1025/1000 这类”非整数倍”尺寸做回归测试(N=1024 恰好是整数倍,永远测不出这个 bug)。
  • host/device 指针混用与 cudaMemcpy 方向写错:把设备指针传给 CPU 循环会直接段错误;cudaMemcpy 的第四个参数写反(DeviceToHost 写成 HostToDevice)不会报错,只会让结果”永远是初始值” → 指针命名统一加 d_/h_ 前缀,cudaMemcpy 的方向参数与源/目的参数一起检查;错误检查不能省——它正是能抓到非法地址的那道网。
  • 共享内存超限与”一次改两个变量”:静态声明超过 48 KB 的共享内存编译会失败,动态共享内存超过 48 KB 必须在启动前调用 cudaFuncSetAttribute(..., cudaFuncAttributeMaxDynamicSharedMemorySize, bytes),且总量不能超过 A100 的 164 KB / RTX 4090 的 100 KB;与此同时,把”改 tile 大小”和”改 block 大小”一起提交,会让性能变化无法归因 → 先查 prop.sharedMemPerBlockprop.sharedMemPerMultiprocessor 并做启动前校验,再坚持”一次只改一个变量”。

思考题(带答案)

Q1. 某个 kernel 在 A100 上测得 3.2 TFLOP/s,解析算术强度为 8 FLOP/Byte,同时 ncu 显示 DRAM 吞吐 250 GB/s。判定瓶颈类型,并给出下一步的三条排查方向。

答案:先算屋顶。带宽屋顶 = AI × 带宽 = 8 × 1555 = 12440 GFLOP/s,实测 3200 GFLOP/s 只有其 25.7%;DRAM 实测吞吐 250 GB/s 只占 1555 GB/s 的 16%,远未饱和;计算屋顶 19500 GFLOP/s,实测占 16.4%。三个比例都远离 100%,因此既不是 DRAM 带宽受限,也不是计算受限,属于延迟受限/共享内存吞吐受限/占用率不足这一大类。下一步:① 查 cudaOccupancyMaxActiveBlocksPerMultiprocessor 与 ncu 的 achieved occupancy,看是否被寄存器或共享内存压到 50% 以下;② 查 ncu 的 stall reason 分布——若 stall_long_scoreboard/stall_short_scoreboard 占比高,说明访存延迟没被隐藏,方向是增加 ILP(线程粗化)或提高占用率;③ 查 shared memory 吞吐占峰值的比例,若超过 70% 就是共享内存带宽受限,方向是把每 FMA 的 shared 访问字节数从 8 B 降到 5 B 以下(粗化 + 寄存器复用)。

Q2. 为什么”用 N=1024 测出来的 PASS”几乎不能说明程序是正确的?请举出至少三个 N=1024 会掩盖、而 N=1025 会暴露的问题。

答案:1024 是 2 的幂,同时是 8/16/32 等常用 tile 宽度的整数倍,于是所有 block 都是”满块”、所有线程都走同一条控制路径,边界代码从未被执行过。会掩盖的问题:① 网格覆盖不足——若网格维度写成 N/blockDim.x,N=1024 时恰好整除所以覆盖完整,N=1025 时最后一个元素永远算不到;② 越界写——满块时 row < M && col < N 恒为真,缺少这个判断也不会出错,N=1025 时最后一个 block 的越界线程会写坏相邻内存;③ halo/ghost 元素的边界处理——对于 stencil 类问题,N=1024 时边界块的处理分支(越界读补 0)不会触发,N=1025 时会读出垃圾值并把误差传播到整个输出;④ 非 2 的幂的尺寸还会改变 grid 维度与尾块数量,从而暴露 __syncthreads() 在部分线程提前 return 之后被调用的死锁问题。正确做法是把 N = 0, 1, 31, 32, 33, 1023, 1024, 1025, 1000 全部纳入回归测试。

Q3. 团队把三项优化(共享内存分块、线程粗化、float4 向量化)一起实现后,测到相对 baseline 的 3.0× 加速比。这个 3.0× 能否直接写进报告?如果要在两天内把它拆解成可归因的数据,应该怎么补实验?

答案:不能直接写。三项一起改意味着这个 3.0× 无法归因,Rubric 要求的”explain the optimization strategies, the expected impact, and the actual measured benefit”缺少单项的对应证据;而且 3.0× 可能掩盖了”某项优化其实是负收益、只是被另两项的增益盖住”这种情况(示例日志中的 float4 就是这种典型)。两天内可做的补实验:消融实验(ablation)——从已验证的 baseline 出发,按固定顺序每次只叠加一项优化,记录每一步的耗时、GFLOP/s 与单步加速比(这就是优化日志表的 1/2/3 行);如果时间允许,再对”全开”配置做留一实验(leave-one-out),即每次只关掉一项,两份数据交叉验证可以识别出优化之间的耦合(例如粗化与向量化都吃寄存器,单独开都有效、一起开却因寄存器溢出而互相抵消)。最后,每一项都必须至少重复 5 次取中位数,并确认单步收益大于 ±5% 的测量噪声;任何小于噪声的收益都应当标注为”在噪声范围内,无法确认”。同时,所有对比都必须建立在正确性 PASS 的前提上——快而错的数据没有资格进入报告。