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) | 数据从哪来、格式、典型尺寸与内存占用(决定是否放得下显存) |
| 5 | Baseline 方案(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 分 │
└─────────────────────────────────────────────────────────────────────────┘
三条容易被忽视的硬性要求:
- 代码必须在评测环境里真的能跑。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”——代码不能编译将几乎失去全部 报告分,而且这一条不写在细则条目里,很容易被漏掉。
- 提交渠道是版本控制仓库(S20 用 Gitlab,并可利用其 issue tracking),项目代码通过 RAI 接口在 GPU 上评测,每个项目配有至少一个 docker(输入数据已预先上传到服务器)。 这意味着”在我机器上能跑”不算数,必须保证在给定的 docker 环境里可复现地启动。
- 三项优化必须分别测量,不能三项一起改完只报一个总加速比。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² 的开销) |
| 2 | CT/医学图像重建(滤波反投影、迭代重建) | 每个投影/体素可独立计算,投影数×探测器数是天然的大规模并行 | 反投影阶段访存不规则(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 竞争与共享内存直方图容量限制 |
| 8 | N-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 证据) | 结论 |
|---|---|---|---|---|---|---|---|---|
| 0 | baseline | 朴素 global-memory GEMM,block=16×16,无共享内存,AI=0.25 FLOP/B | — | 8.20 | 262 | 1.00× / 1.00× | DRAM 带宽受限:实测 DRAM 吞吐 1047 GB/s = 67% of 1555;理论上限 0.25×1555 = 389 GFLOP/s | 基线成立,作为对照 |
| 1 | shared-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.42 | 1512 | 5.77× / 5.77× | DRAM 吞吐降至 378 GB/s(不再受限);转为 shared memory 吞吐受限:每 FMA 需 2 个 shared word = 8 B,需 8.59 GB/ms 的 shared 带宽 | 保留 |
| 2 | block 大小 16×16 → 16×32 | 仅改 dim3 block(16,32),其它一切不变 | 占用率从 50% 提到 100%,更多 warp 隐藏 shared 延迟 | 1.21 | 1774 | 1.17× / 6.78× | 占用率 50%→100%;ncu 中 stall_long_scoreboard 占比下降 31% | 保留 |
| 3 | 线程粗化 COARSEN=4 | 每线程算 4 行输出,block(32,8),寄存器 38→58 | shared 访存/FMA 从 8 B 降到 5 B(1 As×4 + 1 Bs 供 4 次 FMA),理论 1.6× | 0.71 | 3024 | 1.70× / 11.55× | shared 吞吐 7563 GB/s = 39% of 19.5 TB/s;转为 延迟/占用率受限 | 保留 |
| 4 | TK 16 → 32 | 仅改 K 方向分块宽度(同步次数减半) | __syncthreads() 次数从 K/16=64 次降到 32 次 | 0.68 | 3158 | 1.04× / 12.06× | 同步已非瓶颈,收益落在 ±5% 噪声范围内 | 保留但标注”几乎无收益” |
| 5 | float4 向量化 | 全局访存改 float4,边界补零 | 访存指令数减为 1/4,每线程字节数 ×4 | 0.74 | 2901 | 0.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 方向分块 TK | 8, 16, 32, 64 | 同步频率、共享内存用量 | TK 太小则 __syncthreads 频繁;TK 不是 32 的倍数时 Bs 访问产生 bank conflict |
| 线程粗化因子 COARSEN | 1, 2, 4, 8 | 寄存器级复用、指令级并行度(ILP) | 超过 4–8 后寄存器溢出(spill),本地内存流量暴涨 |
| 共享内存 padding | +0, +1, +4(如 [32][33]) | 消除 bank conflict | padding 增加共享内存用量,可能降低占用率 |
| 覆盖方式 | 精确覆盖 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),而不是只报一个数字
参数扫描表模板(直接抄进报告附录):
| block | tile/TK | COARSEN | 共享内存 (B) | 理论占用率 | 实测耗时 (ms) | GFLOP/s | 相对最优 | 正确性 | 备注 |
|---|---|---|---|---|---|---|---|---|---|
| 128 | 8 | 1 | 1152 | 100% | 6.412 | 335 | 9.9% | PASS | 线程太少,每 FMA 的 shared 访存比过高 |
| 256 | 16 | 1 | 2560 | 100% | 3.205 | 670 | 19.8% | PASS | 无粗化,复用不足 |
| 256 | 16 | 2 | 3072 | 100% | 1.983 | 1083 | 32.0% | PASS | 均衡点,寄存器 40 个仍满占用 |
| 512 | 32 | 4 | 12288 | 75% | 0.633 | 3392 | 100% | PASS | 最优:粗化带来的复用压倒了占用率损失 |
| 256 | 32 | 4 | 8192 | 100% | 0.712 | 3016 | 88.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” 的三条要点必须准确复述:
- 风险自负:课程明确”discourage you from using random tools as a learning aid, as such tools routinely fabricate information”;如果你选择使用除课程材料(教材、讲义)与教学人员(教授、助教)之外的任何工具,你承担全部风险。若你因此获得错误信息并用于作业或考试,不会得到任何分数补偿,并且”Please do not ask”。
- 抄袭责任的归属:如果某个工具”ingests code or answers written by another person”并把那些材料给了你,导致作业或考试答案中被检测出抄袭,你将被完全认定为学术诚信违规。也就是说,”AI 给我的”不是免责理由。
- 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 | 封面与团队信息 | 项目名、成员、日期、代码仓库链接 |
| 1 | Resources | 一句话声明是否使用了私有 GPU 资源(未声明按 Rubric 扣分) |
| 2 | Application | 问题是什么、为什么有趣、总体并行思路是什么;配一张架构/数据流图 |
| 3 | Background(来自 Proposal/相关工作) | 别人怎么做的、有哪些算法可选、我们为什么选这个 baseline;带引用 |
| 4 | Implementation | 有几个 kernel、数据如何在 kernel 间流动、每个 kernel 的线程负责什么;baseline 的性能数字与串行程序的对比必须在这里出现 |
| 5 | Optimization | 至少 3 项优化,每项包含:策略说明 → 代码改动 → 理论预期收益(写出公式与代入数字,如同课内分析)→ 实测收益 → 实现困难 → 对正确性的影响(”结果不可区分”也要明确说明) |
| 6 | Results | 最终性能图(加速比表、Roofline 图、规模扩展曲线、参数热力图);若有串行版本必须给最终加速比并注明输入规模;解读”为什么是这个数” |
| 7 | 性能瓶颈分析(可并入 Results) | 用 ncu 指标 + Roofline 定位最终瓶颈,说明还剩多少空间、被什么限制住 |
| 8 | 局限与未来工作(Limitations & Future Work) | 至少 3 条具体限制:扫描范围没覆盖的参数、只在小规模上验证的情形、算法本身的串行部分上限 |
| 9 | Conclusions | 反思而非摘要:关于在这个应用上使用并行,我们学到了什么?希望开始之前就知道什么?(Rubric 明确:重复结果得 0 分) |
| 10 | References | IEEE 或 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_CHECK、cudaEvent 计时、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;
}
- 【代码做什么?】
- 参数解析(
parseArgs):把问题规模M/N/K、tile 宽度、block 线程数、试验次数、容差全部暴露为命令行参数。所有合法性检查(bs % tile == 0、bs ≤ 1024、tile ≤ 64)在解析阶段完成,失败时立即退出并给出可读原因——设计空间扫描时非法组合必须被拒绝而不是崩溃。 - 数据生成:用固定种子的 LCG 生成
[-1, 1)的均匀随机数。固定种子意味着”同样的命令永远得到同样的输入”,这是可复现性的最低要求。 - Golden reference:CPU 上三重循环,用
double累加。选 double 而不是 float 的理由是让参考值的舍入误差(~1e-16 量级)远小于被测 float kernel 的误差(~1e-5 量级),从而把误差来源唯一地归因到 GPU 侧。 - Kernel 执行流程:线程
(tx, ty)负责输出元素C[row0+ty][col0+tx]。沿 K 方向以TILE为步长循环:每个 k 分块先把A的ROWS×TILE面板和B的TILE×TILE面板协作搬进共享内存,同步一次,然后每个线程做TILE次乘加,再同步一次。 - 边界处理:三个地方。①
A/B的加载用(gr < M && gc < K)判断,越界填 0——填 0 而不是跳过,因为累加 0 不影响数学结果,避免了 warp 发散条件下的分支分歧;② 网格维度用ceil计算,所以最后一个 block 里可能有整行/整列线程没有输出;③ 写回时if (gr < M && gc < N)做最终检查。 - 计时与报告:warmup 3 次(把首次启动的驱动初始化、PTX→SASS 的 JIT、页表建立等一次性开销排除),然后
iters次独立测量,排序后同时报告 min/median/mean。报告 min 是因为它最接近”纯 kernel 时间”,报告 median 是因为它最抗偶发抖动。 - 验证与退出码:比对不通过时打印首个失配的 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落在 banka % 32。以tile = 16为例,warp 0 是ty=0的 16 个线程加ty=1的 16 个线程。访问As[ty*16 + kk]:ty=0的半 warp 全部读地址kk(bankkk%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 = 32:As[ty*32+kk]是全 warp 单地址广播,Bs[kk*32+tx]覆盖 bank 0..31 各一次——完美无冲突。这正是”tile 取 32 时共享内存访问最干净”的原因;若把 Bs 声明成[TILE][TILE+1]做 padding,对 TILE=32 的情形完全没有必要(反而多占内存)。 - 全局内存合并(coalescing)分析:以
tile=16、K=N=1024为例,A 面板加载时i = tid,c = 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)的来源。
- 线程到 warp 的映射:
- 【性能优化分析】
- 算术强度推导:这个 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=16:bytes = 4×1.074e9×(1/16+1/16) = 4×1.074e9×0.125 = 0.537 GB,FLOP = 2·M·N·K = 2.147 GFLOP,AI = 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,却要读
As和Bs各 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)
- 算术强度推导:这个 kernel 的 DRAM 流量可以解析地写出。A 的每个元素被
这个程序把”人肉调参”变成一条命令:遍历 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;
}
- 【代码做什么?】
- 一次性准备:读入
M/N/K/iters,生成固定种子的随机矩阵,在 CPU 上用 double 算出 golden reference。这个参考值在 27 个组合之间共享,从而保证所有组合比对的是同一个基准(否则每个组合一个参考值,无法区分”配置差异”与”参考差异”)。 - 三层循环枚举设计空间:外层
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}。 - 合法性过滤:三处
continue。①bs % BX != 0;② 动态共享内存超过prop.sharedMemPerBlock;③grid.y > 65535。被跳过的组合计入skipped并(非 CSV 模式)打印原因——跳过必须留痕,否则读者会以为扫描覆盖了全部 27 个点。 - 占用率查询:调用
cudaOccupancyMaxActiveBlocksPerMultiprocessor(&numBlocks, kernel, blockSize, dynamicSmem),由驱动根据该 kernel 的真实寄存器用量、共享内存用量与该架构上限给出每 SM 可驻留的 block 数。再换算成百分比占用率numBlocks × bs / 2048。这比”手算寄存器个数”可靠得多,也是报告里可以引用的权威数字。 - 每个组合:先跑一次验正确性,再跑 3 次预热 + iters 次计时取中位数。计时用
cudaEvent包住整个 kernel 启动,cudaEventSynchronize之后才读取时间。 - 排序与展示:
qsort按 GFLOP/s 降序排序,打印对齐表格(%-7d、%-11.1f%%等宽度控制),并单独打印最优配置的四项关键量:占用率、耗时、GFLOP/s、相对最慢组合的倍数。加-csv时改为输出一行表头的 CSV,供报告附录与绘图脚本消费。 - 资源释放:无论走 CSV 分支还是表格分支,都
cudaFree三个设备缓冲。
- 一次性准备:读入
- 【并行机制与硬件映射解说】
- 为什么固定 BX = 32:
blockDim.x = 32让tid = ty*32 + tx的映射下,一个 warp 恰好是一整行(同一ty,tx = 0..31)。这带来两处直接好处:Bs[kk*32 + tx]的访问跨越 bank 0..31 各一次,零 bank conflict;As[(ty + r*BY)*TK + kk]在 warp 内是单地址广播(32 个线程读同一个 4 字节值),同样一个事务完成。这是”用线程映射换共享内存效率”的经典手法。 - 共享内存 bank 分析(具体到 bank 编号):设
TK = 32、ty = 0、kk = 5。As[0*32 + 5] = As[5]→ 地址 20 字节 → bank(20/4) % 32 = 5,全 warp 同一地址 → 广播,1 个事务。Bs[5*32 + tx]→ 地址字偏移160 + tx,tx = 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 = tid,c = i % 31,r = i / 31,As[i]的地址是连续的tid,所以写入仍然无冲突。真正需要 padding 的场景是”每行长度是 32 的倍数、而 warp 沿列方向访问不同行”的经典方阵 tile(如As[32][32]中As[ty][kk]的转置访问),此时应写成As[32][33]把行首偏移错开一个 word。 - 全局内存合并分析:
As面板加载时i = tid,r = i/TK,c = i%TK,全局地址(row0+r)*K + k0 + c。当TK = 32时r = ty、c = 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=4时bs=512只能驻留 3 个 block(1536 线程 = 75%),而COARSEN=1时同样bs=512能驻留 4 个(100%)。- warp 发散:
if (r < COARSEN)中的r经#pragma unroll展开后是编译期常量,不产生运行时分发。真正的发散只出现在最后一个不完整的 block 的写回处。这里有一个细节:acc[r]对越界行仍被计算(读到的是 0),只是不写回——用”多算一点、少写一点”换取无分支的计算主体。
- 为什么固定 BX = 32:
- 【性能优化分析】
- 共享内存流量公式:每个 warp 在每个
kk步需要COARSEN次As广播(各 1 个事务)+ 1 次Bs读取(1 个事务),而完成COARSEN × 32次 FMA。于是每个 FMA 的共享内存事务数为T/FMA = (COARSEN + 1) / (32 × COARSEN)。COARSEN = 1时 =2/32 = 0.0625;COARSEN = 2时 =3/64 = 0.0469;COARSEN = 4时 =5/128 = 0.0391。 也就是说从COARSEN=1到COARSEN=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=4是 100%,但 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、大TK、bs取 256–512”的角上。这不是巧合:COARSEN提高复用、TK降低同步频率、bs影响占用率——三者的方向一致时才互相加强。但注意TK=64、COARSEN=8之类的”更极端”取值没有出现在扫描里,这不代表它们不好,只代表本次扫描的范围没覆盖——报告里必须写明扫描范围与这条局限(对应教学目标 17”识别局限与未来方向”)。代码示例 3:报告数据生成器 —— Roofline 与规模扩展曲线的 CSV
- 共享内存流量公式:每个 warp 在每个
最终报告里的两张核心图是 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;
}
- 【代码做什么?】
- 固定实验条件:所有规模的输入都在同一进程内生成、同一块 GPU 上测量、同一个
timeMedianMs(3 次预热 + iters 次取中位数)。报告中”不同数字来自不同测量方法”是最常见的不一致来源,这个程序用代码结构强制统一。 - 两个 kernel 对照:
naiveMatMulKernel每个输出元素从全局内存读2K个 float,算术强度恒为2·M·N·K / (8·M·N·K) = 0.25 FLOP/Byte;tiledMatMulKernel用As/Bs面板把算术强度提到1/(2·(1/16+1/16)) = 4 FLOP/Byte。两者在 Roofline 上应当落在同一条带宽斜线上的不同位置——这正是报告里用来”证明瓶颈是带宽”的关键证据。 - 验证阶梯:
N ≤ 1024时与 CPU double 参考比对;N > 1024时让 tiled 与 naive 互比(naive 在小规模上已独立通过 CPU 校验)。这样既避免了 2048³ 的 CPU 双重循环(约 8.6 GFLOP,需要数秒到数十秒),又没有放弃大规模的正确性检查。降级的验证方法必须在报告里写明,不能悄悄降级。 - CSV 输出:每一行是一个 (kernel, size) 数据点,列名固定。CSV 而不是 markdown 表格的原因是可以直接被 gnuplot / pandas / Excel 读取,并在报告里”可复现地”重绘。
- 占用率只查一次:
cudaOccupancyMaxActiveBlocksPerMultiprocessor的结果只取决于 kernel 的资源用量(寄存器、共享内存)与 block 大小,与问题规模无关,所以放在循环外查一次即可,避免重复开销。
- 固定实验条件:所有规模的输入都在同一进程内生成、同一块 GPU 上测量、同一个
- 【并行机制与硬件映射解说】
- 两个 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% 带宽的根本原因——带宽浪费在重复传输上,而不是没打满。 - 分块版的共享内存 bank:
As[16][16]的行长 16 个 float。warp 0 的两个半 warp 分别读As[0][kk](bankkk)与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%。
- 两个 kernel 的 warp 映射完全不同:朴素版用
- 【性能优化分析】
- 两条 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 的”风险”一节。性能优化技巧总结
- 两条 Roofline 坐标:朴素版
- 先写对,再写快,最后写报告。任何时刻都要有一个能跑通、能通过 golden reference 的版本;优化分支永远从已验证的 baseline 上切出来。为什么有效:一个不能运行的版本无法测量,”感觉快了”不是数据。
- 把基准测试做成一条命令。把问题规模、block、tile、粗化因子全部参数化(示例 1 的
parseArgs),因为手工改代码测 27 个组合必然出错,而且无法追溯”这个数字是哪次跑出来的”。 - 一次只改一个变量。改两个变量后,性能变化无法归因,报告里的”优化贡献度”一栏就成了猜测。为什么有效:受控实验是唯一能把”观测”变成”结论”的方法。
- 每次测量重复 ≥5 次,报告最小值与中位数。GPU 上的单次测量波动常达 ±5%~15%(时钟boost、其它进程、L2 残留状态)。为什么有效:最小值最接近纯 kernel 时间,中位数最抗离群点,两者结合能暴露”测量本身不稳定”这一事实。
- 先做 Roofline 估算再动手。用
AI = FLOP/Bytes与峰值/带宽相比,5 分钟就能知道该优化算力还是优化访存。为什么有效:优化了错误的资源等于白干——在一个 AI=8 的 kernel 上拼命降低 DRAM 流量不会有效果。 - 用
cudaOccupancyMaxActiveBlocksPerMultiprocessor而不是手算寄存器。API 反映的是编译器实际分配的寄存器数(受__launch_bounds__与-maxrregcount影响),手算的-Xptxas -v输出是优化前的快照。为什么有效:占用率的分子分母都必须来自同一次编译的产物。 - 线程粗化是性价比最高的单项优化。让每个线程计算 2–8 个输出,把
Bs的一次读取分摊到多次 FMA 上(示例 2 中共享内存事务数从2/32降到5/128)。为什么有效:它同时提高复用率、降低共享内存压力、增加 ILP(多个独立累加器打断依赖链)。 - 用共享内存 padding 消除 bank conflict。对”行长为 32 的倍数且 warp 沿列访问不同行”的模式,把
As[32][32]改成As[32][33]。为什么有效:连续行的起始地址错开一个 word 后,同一 warp 的不同行访问落到不同 bank,32 路冲突降为 1 路。 - tile 尺寸的选择要算浪费率:
(T+W-1)²/T²是 halo 开销,ceil(N/T)·T/N - 1是尾块浪费。为什么有效:单调增大 tile 会让共享内存占用按平方增长,最终把占用率压垮——最优点通常在”复用够用 + 占用率不塌”的折中处。 - 让访存对齐到 128 字节。warp 内 32 个线程访问连续 32 个 float 恰好是一条 cache line;
float4向量化把每线程字节数提到 16,但会同时抬高寄存器压力。为什么有效:对齐访问的 memory efficiency = 100%,而未对齐/跨步访问会把一次 128 字节的传输浪费掉一大半。 - 减少
__syncthreads()的次数,而不是减少它的开销。增大 K 方向分块宽度 TK 可以把同步次数从K/TK次降下来。为什么有效:每次同步都是一个流水线气泡,且同步次数与 K 成正比时,同步开销随问题规模增长而增长。 - 把”没有提升”的尝试也写进优化日志。示例日志中的”float4 向量化导致寄存器压力上升、占用率从 100% 降到 75%、性能反而下降 8%”是一条极有价值的负结果。为什么有效:它向读者证明你真的做了受控实验,而不是只挑好看的数字。
- 消除 host↔device 往返。PCIe 的带宽约 25 GB/s,只有 A100 HBM 带宽(1555 GB/s)的 1.6%。为什么有效:把需要逐次迭代的结果搬回主机再搬回去,会让整个加速比被这条”细管子”钉死。
- 在报告里给每个数字配一条命令。例如”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 conflict:
As[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.sharedMemPerBlock与prop.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 的前提上——快而错的数据没有资格进入报告。
