宾夕法尼亚大学-CIS5650-GPU-编程笔记-全-

宾夕法尼亚大学 CIS5650 GPU 编程笔记(全)

001:课程介绍与CUDA入门

在本节课中,我们将学习宾夕法尼亚大学CIS 5650 GPU编程与架构课程的基本信息,并开始学习CUDA编程的基础知识。课程将涵盖课程结构、项目安排,并深入讲解CUDA的核心概念,包括线程、块、网格以及内存管理。

课程概述与后勤

欢迎来到新学期的第一堂课。课程网站已上线,所有必要的链接和资源都已就位。

以下是课程相关的几个重要平台:

  • 课程网站:所有课程资料、幻灯片和日程安排的主页。
  • GitHub:项目代码仓库。项目零和项目一已发布。
  • Ed讨论区:课程问答与讨论的主要论坛。
  • Canvas:用于课程注册和管理。

课程安排可在网站上的教学大纲和日程表中查看。每次课前,当天的幻灯片链接会提前发布。

项目零旨在测试你的软硬件环境,请务必完成以确保为后续课程做好准备。

如果有问题,请随时举手提问。在最初的几节课中,提问时请说出你的名字,以便我认识大家。

讲师与助教介绍

我是Shahzaan Muhammad,目前在一家名为Cesium的软件技术公司担任产品与工程副总裁,该公司专注于开放平台的地理空间软件。

我教授这门课程是因为我本人曾是这门课的学生,并且我的第一份工作就是编写GPU程序。我曾是AFire的主要贡献者之一,这是一个优秀的开源GPU计算库。

我不是博士,称呼我Shahzaan即可。我们的助教团队非常出色,包括Crystal、Han和Aya,他们都曾在知名公司实习,并将为课程项目带来更新。

这门课程非常注重协作。即使大部分项目是个人项目,我们也鼓励讨论问题和解决方案。最终项目将是2-3人的团队项目。

课程结构与资源

课程网站是我们的主要阵地,所有资源都是公开的。项目代码将托管在GitHub上,并且我们鼓励大家将项目保持公开,这有助于在求职时展示你的作品。

Ed讨论区将用于所有课程讨论。请积极使用它,分享对他人有帮助的问题和解答。在LinkedIn上,我们有一个校友群组,欢迎大家加入以建立职业联系。

课程讲座时间为周一和周三。考虑到三小时讲座时间较长,我们会在中途安排休息。办公室时间分布在一周的不同时段,地点在Levine 57实验室。我们会根据大家的反馈调整办公室时间。

课程本身会教授所需的大部分知识,但GPU编程领域广阔,我们也提供了额外的阅读资源列表供大家参考。

课程难度与先修要求

这门课程以项目为核心,我们的教学旨在支持大家完成这些项目。课程工作量较大,难度也较高,但这主要是因为GPU编程与你之前接触过的编程模式截然不同。

课程极具挑战性,但也极具回报。你的投入将直接决定你的收获。GPU编程非常有趣,因为其速度优势会让你乐在其中。

课程的主要先修要求是对计算机编程、性能优化或计算机图形学有热情,并且需要扎实的C/C++基础,因为会涉及大量的底层编程和指针操作。

目前课程注册已满,有大量学生在候补名单中。通常会有一些学生在了解课程难度后退出,请候补的同学关注注册系统。我们会尽力与学校协调,争取增加名额。

课程项目虽然基于图形学概念,但核心是性能和计算加速。如果你没有图形学背景,可以尝试“一个周末实现光线追踪”这个开源项目,它能帮助你判断自己是否喜欢这类工作。

课程价值与职业发展

往届学生发现这门课程在求职面试中极具价值。项目中的README文档、性能分析和调试经验正是面试官常问的问题。

在课程中,你将深入学习调试器和性能分析器的使用,这对于GPU编程至关重要。我们甚至邀请了在英伟达调试工具团队工作的往届校友来做客座讲座。

我们鼓励大家阅读论文后给作者发邮件,无论是致谢还是提问,作者通常都很乐意回复。

从这门课程毕业的学生,其简历上最突出的项目往往就是本课程的最终项目或合作项目。课程与业界联系紧密,许多公司都从这里招聘毕业生。

为什么需要GPU编程?

GPU最初是图形处理单元,但现在已广泛应用于需要大量计算和加速的领域。

GPU的性能优势显著。例如,最新的英特尔CPU峰值算力约为12.3 TeraFLOPS,而英伟达RTX 4090 GPU的算力约为82.5 TeraFLOPS,快了近7倍。并且,这种性能差距的趋势还在不断扩大。

除了纯性能,我们还需考虑成本、功耗和芯片面积等约束条件下的性能表现。

从芯片结构上看,CPU将大量晶体管用于控制逻辑和缓存,而GPU则将绝大部分晶体管用于计算核心。这门课程的核心就是教你如何充分利用GPU上成千上万个核心和巨大的内存带宽来获取最大计算能力。

GPU的应用场景非常广泛,包括计算机图形学、高性能计算、机器学习、人工智能、计算机辅助建模、仿真、数据挖掘、生物信息学等。掌握GPU编程将使你对众多行业都具有吸引力。

课程主题与客座讲座

课程的基础部分将重点讲解GPU架构和GPU编程,主要使用CUDA。我们将学习并行算法,了解如何将传统的CPU算法移植到GPU上并实现成千上万倍的加速。

我们还将介绍图形API如何用于GPU编程,例如WebGL。今年课程将引入更新的WebGPU API,其概念与CUDA更接近,更容易学习。助教Han将为大家介绍Vulkan。所有内容都围绕一个核心目标:如何获得极致性能。

我们安排了多场客座讲座,邀请业界专家分享最新技术,包括来自英伟达调试工具团队、谷歌Chrome GPU团队(正在推动WebGPU标准化)、以及三星的专家。客座讲座的出勤是强制性的,以示对演讲者的尊重。

评分与项目

课程评分主要基于项目。常规项目占60%,最终项目占40%。我们没有考试。

项目基于图形学概念,但产出是性能和加速。每个项目都包含编码部分和书面性能分析。我们鼓励大家创建精美的README文档,向潜在雇主清晰展示你的工作成果。

请积极展示你的作品,可以在社交媒体上分享。我们有时会在课堂上随机邀请同学展示项目,这旨在锻炼大家清晰表达项目亮点的能力。

项目通常截止于当晚11:59,通过GitHub提交Pull Request。本学期你有4个“迟交日”可以灵活使用,不会扣分。如果遇到特殊情况需要更多时间,请务必提前与我们沟通。

课堂参与占5%,旨在鼓励大家积极互动。提问不仅帮助自己,也帮助了其他可能害羞的同学。请尽量避免在Ed讨论区匿名提问,实名提问有助于建立互助的社区氛围。

学术诚信与学习建议

我们强烈鼓励开源协作和讨论,但严禁抄袭。抄袭对你自己的学习毫无益处,且我们将采取零容忍政策。

你可以与同学讨论问题、进行“橡皮鸭调试”,但请勿复制他人的核心代码。

关于AI工具的使用,建议将其作为辅助工具,用于处理你已掌握知识的琐碎工作或进行确认,而不是让其代替你完成核心学习内容,例如编写CUDA内核。

所有往届学生的代码都是公开的,这反而使得抄袭行为更容易被发现。课程的重点在于如何通过你的独特工作让自己脱颖而出。

课程总结与CUDA编程入门

在本节课中,我们一起学习了课程的基本框架、期望和CUDA编程的初步概念。请记住,你们每个人在接下来的三个月里都将完成超乎我想象的出色工作。我的角色是促进你们的学习,帮助你们实现职业目标。

提醒事项

  • 加入Ed讨论区。
  • 完成学生调查以帮助我们改进教学。
  • 加入LinkedIn校友群组。
  • 项目零(环境配置)于本周五截止。
  • 项目一于下周日截止,下周三的课程将专门讲解该项目。

为什么选择CUDA?

GPU编程的历史相对较短。90年代末到21世纪初出现了GPGPU概念。2007年,随着CUDA的发布,真正的通用GPU计算诞生了。受CUDA启发,出现了更跨厂商的OpenCL。此外,还有各种图形API,如WebGL、OpenGL、Vulkan,以及苹果的Metal。2023年,WebGPU开始被广泛讨论并将成为标准。

CUDA程序的基本结构

一个典型的CPU程序顺序执行。而一个典型的CUDA程序则在主机上运行串行代码,并调用设备来并行执行大量任务。

核心术语

  • 主机:通常指CPU及其内存。
  • 设备:通常指GPU及其内存。
  • 内核:在设备上运行的函数,由大量并行线程执行。

CUDA程序的流程是:执行一些CPU工作 -> 调用GPU运行内核 -> 控制权返回CPU。

第一个CUDA内核:向量加法

这是一个简单的向量加法内核示例,将两个长度为 n 的数组 ab 相加,结果存入数组 c

// 内核函数定义
__global__ void vectorAdd(const float* a, const float* b, float* c, int n) {
    // 计算当前线程的全局索引
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    // 检查索引是否越界
    if (i < n) {
        c[i] = a[i] + b[i]; // 执行加法
    }
}

// 主机端调用内核
int main() {
    // ... 分配主机和设备内存,拷贝数据等 ...
    // 定义线程块大小和网格大小
    int threadsPerBlock = 256;
    int blocksPerGrid = (n + threadsPerBlock - 1) / threadsPerBlock;
    // 启动内核
    vectorAdd<<<blocksPerGrid, threadsPerBlock>>>(d_a, d_b, d_c, n);
    // ... 拷贝回结果,释放内存等 ...
}

关键点

  • __global__ 修饰符表示这是一个内核函数,从主机调用,在设备执行。
  • <<<blocksPerGrid, threadsPerBlock>>> 是内核启动语法,配置执行参数。
  • 传统的 for 循环被并行线程取代。每个线程根据其唯一索引 i 处理一个数据元素,将算法复杂度从 O(n) 降至 O(1)。

函数执行空间说明符

CUDA使用特定的修饰符来定义函数的执行位置:

  • __global__:内核函数。在设备执行,从主机调用。必须返回 void
  • __device__:设备函数。在设备执行,只能从设备调用(例如被内核调用)。
  • __host__:主机函数。在主机执行,从主机调用(默认)。
  • __host__ __device__:函数同时为主机和设备编译。

线程、块与网格的组织

这是CUDA编程模型的核心层级。

线程:最小的执行单元。
线程块:一组线程的集合。

  • 可以组织成一维、二维或三维。
  • 同一个内核中的所有线程块大小相同。
  • 块内的线程可以通过共享内存通信和同步。
  • 每个块最多包含1024个线程。
    网格:一个内核启动的所有线程块的集合。
  • 网格也可以是一维、二维或三维。

内置变量

  • threadIdx.x, .y, .z:线程在块内的索引。
  • blockIdx.x, .y, .z:块在网格内的索引。
  • blockDim.x, .y, .z:线程块的维度(每个方向的线程数)。
  • gridDim.x, .y, .z:网格的维度(每个方向的块数)。

索引计算:为了让每个线程处理全局数组中的正确元素,需要计算全局唯一索引。对于一维情况,公式为:
int globalId = blockIdx.x * blockDim.x + threadIdx.x;

对于二维矩阵,假设每个线程处理一个矩阵元素 (row, col)

int row = blockIdx.y * blockDim.y + threadIdx.y;
int col = blockIdx.x * blockDim.x + threadIdx.x;
if (row < height && col < width) {
    // 计算一维内存索引(假设行优先存储)
    int index = row * width + col;
    // 对 matrix[index] 进行操作
}

重要原则:线程块之间以及线程块内部的线程之间,执行顺序没有保证。调度器为了最大化性能,可能会以任何顺序执行它们。

CUDA内存管理

主机(CPU)和设备(GPU)通常拥有独立的内存空间(全局内存)。数据必须在它们之间显式拷贝。

内存分配与释放

float *d_array; // 设备指针
size_t size = N * sizeof(float);
// 在设备上分配内存
cudaMalloc((void**)&d_array, size);
// ... 使用内存 ...
// 释放设备内存
cudaFree(d_array);

内存拷贝

// 从主机拷贝到设备
cudaMemcpy(d_array, h_array, size, cudaMemcpyHostToDevice);
// 从设备拷贝到主机
cudaMemcpy(h_array, d_array, size, cudaMemcpyDeviceToHost);
// 在设备内部拷贝
cudaMemcpy(d_array2, d_array1, size, cudaMemcpyDeviceToDevice);

cudaMemcpy 是同步操作,会等待拷贝完成才返回。

完整示例:SAXPY

SAXPY是单精度标量乘向量加法的标准例程:z = a * x + y

主机端代码

void saxpy(int n, float a, float* x, float* y, float* z) {
    // 分配设备内存
    float *d_x, *d_y, *d_z;
    cudaMalloc(&d_x, n*sizeof(float));
    cudaMalloc(&d_y, n*sizeof(float));
    cudaMalloc(&d_z, n*sizeof(float));

    // 拷贝输入数据到设备
    cudaMemcpy(d_x, x, n*sizeof(float), cudaMemcpyHostToDevice);
    cudaMemcpy(d_y, y, n*sizeof(float), cudaMemcpyHostToDevice);

    // 启动内核
    int blockSize = 256;
    int numBlocks = (n + blockSize - 1) / blockSize;
    saxpy_kernel<<<numBlocks, blockSize>>>(n, a, d_x, d_y, d_z);

    // 拷贝结果回主机
    cudaMemcpy(z, d_z, n*sizeof(float), cudaMemcpyDeviceToHost);

    // 释放设备内存
    cudaFree(d_x); cudaFree(d_y); cudaFree(d_z);
}

设备端内核代码

__global__ void saxpy_kernel(int n, float a, const float* x, const float* y, float* z) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i < n) {
        z[i] = a * x[i] + y[i];
    }
}

矩阵乘法示例

矩阵乘法 P = M * N 是更复杂的例子。输出矩阵 P 的每个元素是 M 的一行和 N 的一列的点积。

一个简单的(未优化的)内核实现

__global__ void matMulKernel(const float* M, const float* N, float* P, int width) {
    // 计算当前线程对应的输出元素的行列索引
    int row = blockIdx.y * blockDim.y + threadIdx.y;
    int col = blockIdx.x * blockDim.x + threadIdx.x;

    if (row < width && col < width) {
        float pValue = 0;
        // 计算点积
        for (int k = 0; k < width; ++k) {
            pValue += M[row * width + k] * N[k * width + col];
        }
        // 写入结果
        P[row * width + col] = pValue;
    }
}

这个简单实现的局限性

  1. 性能问题:每个线程需要从全局内存中读取 M 的一整行和 N 的一整列,导致大量的全局内存访问,这是GPU性能的主要瓶颈。
  2. 规模限制:如果只用一个线程块,矩阵大小被限制在最大约32x32(1024个线程)。对于小矩阵,GPU加速可能无法抵消内存拷贝的开销。
  3. 假设条件:该内核假设矩阵是方阵,且索引不会越界。

总结与下节预告

本节课中,我们一起学习了CUDA编程的基本概念:从主机与设备的区分、内核函数的定义与启动,到线程层次结构(线程、块、网格)以及基础的设备内存管理。我们通过向量加法和矩阵乘法的例子演示了如何编写简单的CUDA程序。

当前矩阵乘法的实现是初级的,存在明显的性能瓶颈和规模限制。在下节课中,我们将深入探讨如何通过共享内存、更优的线程组织等方式来优化矩阵乘法,使其能够处理更大规模的数据并运行得更快。

请完成项目零,并开始阅读项目一的说明。下节课我们将进入更高级的CUDA主题。

002:CUDA调试实验 🐛

在本节课中,我们将学习如何使用调试工具来分析和修复CUDA程序中的问题。我们将从调试工具的高层概述开始,然后深入探讨Visual Studio调试器和NVIDIA Nsight调试器的具体功能,了解如何管理同时运行的数百万个线程。

调试工具概述

在开始调试之前,了解可用的工具非常重要。调试可以帮助你发现诸如启动配置错误、线程索引问题、块索引问题、同步问题或原子操作问题等,并找到问题所在进行修正。

在实践中,大多数人可能遵循这样的调试顺序:一旦程序出现问题,你可能会添加coutprintf来查看值的情况。这种方法有效,但扩展性不强。使用像Visual Studio调试器这样的专业调试器要好得多,因为你可以逐步执行代码。我始终建议,如果你在编写任何算法,最好通过调试器逐步执行,以确保算法按预期工作,即使你认为输出是正确的,也要确保通过调试器评估每一步。

然而,当涉及到GPU编程时,Visual Studio调试器就不够用了。虽然它可以在CPU端进行多线程编程调试,但它无法调试CUDA端的内容,因为它需要访问GPU资源。这就是Nsight发挥作用的地方,它允许你调试CUDA编程,同时提供性能分析和图形工具。

以下是存在的其他调试工具:

  • 主机端:Visual Studio调试器,Linux上的GDB。
  • 设备端:Nsight Visual Studio Edition,Nsight Eclipse Edition,以及用于HIP编程的HIP-GDB。

常用调试工具

在课堂上,我们将讨论我们了解和常用的调试工具。

以下是主要的调试工具类型:

  • 可编程工具printfcoutsstream。这些本质上是你放入代码中以获取某种输出的工具。
  • 断点:允许你逐步执行代码。
  • 条件断点:允许你设置特定条件下触发的断点。
  • 调用堆栈:显示你当前使用的函数堆栈。
  • 自动/局部/监视窗口:基于Visual Studio的窗口,你可以查看变量的值。

在CUDA端,你可以使用Nsight或CUDA-GDB进行调试。对于性能分析,我们有NVIDIA Visual Profiler(简称NVVP)。现代工具现在包括Nsight Compute和Nsight Systems来帮助你进行更深入的分析。CUDA SDK还提供了一个名为CUDA Occupancy Calculator的Excel电子表格,你可以输入诸如使用了多少内存、使用了多少寄存器等信息,以找出最佳启动配置。

CUDA-GDB简介

接下来,我们快速了解一下CUDA-GDB,它运行在Linux上。由于我们不会在这次录播中深入探讨,这里只做高层概述。

CUDA-GDB是Linux上标准GDB的扩展,是一个命令行调试工具。它允许你在实际的GPU上调试代码,直接从GPU本身获取值,并且可以同时调试CPU和GPU代码。因此,在尝试调试时,你无需在GDB和CUDA-GDB之间切换。

设置编译标志很简单。你添加-g-G标志,为CPU和GPU端生成调试信息。CUDA-GDB的所有用法,在断点快捷键、逐行执行和跳转方面,都与GDB本身非常相似。所以,如果你熟悉GDB,CUDA-GDB可能是一个很好的工具。

我通常推荐使用Nsight进行CUDA调试,即使在Linux上也是如此。但如果你想尝试CUDA-GDB,网上有很多资源,或者你可以给我发邮件。

深入Nsight前的关键概念

在深入Nsight之前,你应该了解一些术语:软件坐标和硬件坐标、线程块和内核。我们在课堂上已经讲过这些。

在硬件坐标方面,当你调试代码时,你会更多地看到这些术语被使用:

  • 通道:线程执行的位置。通道和线程之间总是一一对应的关系。
  • 线程束:一组32个一起执行的线程。许多线程束组成一个块。
  • SM:GPU上的一个流式多处理器。根据你的GPU不同,一个设备上可能有2到16个或更多的SM。
  • 设备:GPU本身。

Nsight调试器功能

在高层次上,Nsight可以帮助进行调试和性能分析。它包含GPU调试器、图形检查器和系统分析器。新工具还包括Nsight Systems和Nsight Compute,请随时尝试。我已经链接了Visual Studio版和Eclipse版的用户指南供你尝试。

那么,如何设置调试信息以便实际使用Nsight调试器呢?对于所有课堂项目,我们在CMake中通过添加标志来完成。如果你使用自己的项目,则需要遵循以下一些步骤来启用调试信息。

同时遵循这些步骤,为主机和GPU端生成调试信息,并确保设置正确的计算能力。因为如果你不这样做,你将无法获得正确的硬件坐标,并且会看到错误的信息。

有了这些准备,让我们深入Nsight本身,逐步了解它能提供哪些信息。

查看系统信息

首先,让我们看看Nsight以及它能向我们展示什么。我的Visual Studio 2017版本中安装了Nsight。我将首先进入“Windows”菜单,然后查看“系统信息”。让我们先看看这个。

当你打开这个时,它会显示你笔记本电脑或台式机的所有硬件信息。我们从左侧的“系统”选项卡开始。这将显示你的CPU核心数、安装的总物理内存(我有32GB),如果可用的话显示混合图形信息、Windows版本和Nsight版本。

“显示设备”选项卡显示所有可用的GPU。在这种情况下,它显示我内置在Intel CPU中的Intel UHD显卡。

然后,如果你进入“NVIDIA GPU设备”,这将显示你特定GPU的信息。我安装了一个GeForce GTX 1650,我安装的驱动程序是452,我相信这是最新的驱动程序模型。NVIDIA遵循两种驱动程序模型:WDDM(代表Windows显示驱动程序模型),如果你使用NVIDIA GPU进行显示,就会使用这个模型;另一个是TCC(代表Tesla计算集群)。如果你有没有显示器的GPU或使用远程GPU,这些GPU有时可以进入TCC模式,这意味着它们不运行图形应用程序,但可以执行基于计算的CUDA应用程序。

CUDA设备索引从零开始,并为每个单独的CUDA GPU递增。我只有一个GPU,所以它将保持为零。

GPU系列为你提供GPU架构的信息。我的1650 GPU是图灵架构,图灵的具体架构是TU117。所以,如果你有帕斯卡GPU,它将以P开头;开普勒GPU将以K开头;麦克斯韦GPU将有麦克斯韦架构代码。

计算能力很重要,需要记住。它再次定义了架构,但以一种系统可以读取的方式。你也可以在网上找到你的计算能力是多少。开普勒是3.x,麦克斯韦是5.x,帕斯卡是6.x,图灵是7.x,新的安培GPU(如RTX 3080、3090)将是8.x。

SM数量是流式多处理器的数量。这些是由许多CUDA核心组成的独立单元,形成一个多处理器。了解这个数字也很重要。我的有16个。我有4GB的GPU内存。

这是我的帧缓冲区带宽,本质上是如果你要在设备之间复制内存而不经过PCIe时的最大复制速度。所以,如果你只是将两个数组或一个数组复制到GPU,这是你将看到的复制器的最大速度。我们将在性能实验中重新讨论这个问题。

这三个数字是你的速度,理论速度,你可以用它们来计算你GPU的理论最大值。例如,当你进行矩阵乘法时,你会用这些来计算你GPU的理论最大值。

当然,内存类型是GDDR5。你可能会看到GDDR3、GDDR5X、GDDR6X用于新GPU。这在代码中并不重要,它只是定义了你的内存速度。

CUDA设备属性

继续,这是你需要了解的关键选项卡。它显示了你GPU的CUDA信息。我们将介绍其中的一些,但不是全部。让我们从顶部开始,按字母顺序进行。

异步引擎数量定义了你有多少个异步操作。在本学期初,我们将进行同步操作,但一旦你进入高级CUDA,可以同时进行计算和复制,这个数字定义了可以同时进行多少个这样的操作。

时钟速率与GPU设备时钟速率1560兆赫相同,对应这里的这个数字。主要和次要计算能力在那里,以便你可以以编程方式使用它,这再次映射回这里的计算能力数字。

计算模式对于WDDM模型为0,对于TCC模型为1。并发内核再次回到异步引擎,关于你有多少个同时运行的内核。在我的情况下,我的GPU有一个。这是GPU名称。所以,如果你要查询GPU名称的CUDA属性,你会得到这个。

全局内存总线带宽再次,如果你想以编程方式使用它进行高级编程,并计算复制某些内容需要多少时间,那么你会用到这个。

现在,这些数字也很重要,需要注意。这些定义了任何一维中的最大块和线程大小。请注意,这些并不定义你可以拥有的总块数或线程数,而是定义了每个块的最大值。所以,如果你看块数字,你可以在x方向有1024,y方向有1024,z方向有64。这并不意味着你可以在一个块中有6400万个线程。这意味着你可以有一个配置为1024,1,1或1,1024,1,或者如果你有1,1,64,那么你一个块中只有64个线程。这些是每个维度的最大值。每个块的最大值仍然是1024。

类似地,网格中的块数定义在维度中。网格中的最大块数定义在维度中。这个数字足够大,你永远不用担心它。

接下来是一些内存资源,这些本质上定义了每个块和每个多处理器可以有多少寄存器和共享内存。多处理器是硬件SM,一旦我们进入高级编程,我们将更多地讨论CUDA内核如何映射到多处理器,但现在请注意这个块,它本质上定义了每个块在内核中可以使用多少寄存器,对我来说是64KB。如果你超过这个限制,那么CUDA将不得不减少执行中的活动块数量以管理资源。

我们还没有讨论共享内存,但一旦我们讲到它,请记住这个数字,我们到时候会详细讨论。

我们之前讨论的另一件事是每个块的线程数。在我的情况下是1024,对于大多数(如果不是所有)GPU来说都是1024。每个多处理器的线程数,这也是硬件概念,对我来说也是1024。一旦我们进入高级CUDA,确定我们有多少资源时,这个数字也会发挥作用。

同样,多处理器数量,另一种以编程方式访问这个的方法。

这个数字也很有用,特别是如果你在进行双精度编程。它告诉你单精度浮点计算与双精度浮点计算的速度减慢程度。32意味着双精度计算比单精度计算慢32倍,计算代表乘法或加法。所以,如果你错误地使用了双精度而不是单精度,你会很快看到速度下降。

最后,线程束大小,这将是32。它已经很长时间是32了,我不相信NVIDIA有任何改变它的意图。

你也可以在这里找到OpenCL信息,它通常会直接映射到你拥有的CUDA信息,只是在如何以编程方式访问方面有不同的定义。

你还会看到英特尔显卡图表出现在这里,因为OpenCL是平台无关的。

CUDA示例:设备查询

在深入Nsight之前,我想向你展示CUDA示例。这些通常可以在C:\ProgramData\NVIDIA Corporation\CUDA Samples和你CUDA SDK的版本号下找到。显然,如果你在无法访问管理员面板的远程机器上,最好将这些复制并粘贴到你的本地目录。我不需要这样做,因为我在自己的笔记本电脑上使用这个。所以,我将在这里打开实用程序,打开设备查询,并打开我的Visual Studio版本的解决方案。

在设备查询项目中,它向你展示了如何以编程方式查询设备信息。我们看到了Nsight如何以表格形式选择信息,但你也可以通过API直接查询这些信息。这样,你可以根据程序运行在哪个GPU上做出运行时决策。你不需要事先知道它在哪个GPU上,你可以以编程方式完成这个。

这里重要的结构叫做cudaDeviceProp,你可以在CUDA SDK文档中找到关于这个结构的完整信息。填充这个cudaDeviceProp的方法是使用cudaGetDeviceProperties函数,将其作为输入传递,并传递设备ID。这个程序遍历所有可用的CUDA设备。在我的笔记本电脑上,只有一个。但如果你有多GPU系统,那么它将遍历所有设备。一旦这个deviceProp被填充,它将有一堆我们可以查看的属性:名称、运行时版本、GPU内存大小、核心和多处理器数量、时钟速率、缓存和其他信息都在这里。让我们运行它。

如你所见,这都是CPU端代码。这里没有GPU代码运行。

查看输出,你可以看到这当然是通过打印格式化的,但这几乎给了你通过表格获得的所有信息,只是现在是以编程方式完成的。所以,假设你想在运行内核之前了解可用的全局内存量,你可以以编程方式完成这个,然后决定启动多少个内核或线程。同样,对于多处理器数量,对我来说是16个,每个多处理器有64个核心,但在不同的GPU上这可能不同。所以,当你优化CUDA内核和程序时,这是一个很好的做法。

CUDA文档资源

这里我打开了CUDA文档。这包括API文档以及性能和优化指南。它有运行时API、驱动程序API、数学API(即数学函数),并为你提供了所有库的API。所以,你们中的一些人可能使用过cuBLAS或cuFFT等。这为你提供了所有这些API,它提供了示例、演示以及如何为每个架构优化你的程序。所以,如果你针对特定架构优化程序,它会告诉你应该使用什么设置以及如何调整它们。还包括编译器、Nsight、Nsight Compute等的指南。这里有太多东西要学,你花多少时间都不够。所以,请尽可能多地使用这个。

对于这个特定的调试录播,我打开了我们刚才讨论的cudaDeviceProp。所以,探索这个页面将为你提供该结构中所有属性的信息,这样你就可以以编程方式使用它们。

最后,来到Nsight Visual Studio版,这是它的文档。从安装到编译、运行、调试、性能分析、检查状态、检查内存等所有内容,你都可以在这个页面上找到。我也在使用这个页面作为进行这次录播本身的指南。

使用Nsight进行调试

如果项目一打开了,我们将使用它来展示Nsight和Visual Studio调试器如何工作。确保你在这里处于调试配置,以便所有调试符号都编译到代码中。如果你处于某个发布配置中,那么符号将不会被加载,要么你无法命中断点,要么你会看到垃圾信息。

如你所见,我在这里设置了一个断点,这是在主机端。下一代调试器的伟大之处在于它可以同时进行主机和设备端调试,所以我们可以从那里开始。为了节省时间,我已经预编译了代码,我将开始进行CUDA调试方面的事情。

我们已经命中了这里的断点,如你所见。在我们开始逐步执行代码之前,我想向你展示几个窗口。如果你进入“调试”->“窗口”,这里一些最好的窗口是“监视”,你可以有多个监视窗口,“自动窗口”、“局部变量”和“调用堆栈”。

在我们逐步执行之前,让我们先看看这些窗口本身。调用堆栈显示你调用的函数深度。所以,现在我们有main调用了init。我们在调用堆栈中看到了这一点。所以,当你调用越来越多的函数时,这里会被填充。

自动窗口显示当前作用域内的所有变量。Visual Studio根据你当前所在的行选择一组变量,并为你提供可能最相关的调试变量。你不能在这里添加更多变量,但这是一个快速开始的方式,Visual Studio很好地选择了相关变量。

然后,如果你进入局部变量,这些是函数作用域内的所有变量。例如,如果你有一个for循环或if语句,并在这些块内定义变量,那么当你处于这些块中时,这些变量将显示在局部变量中,当你退出这些块时,它们会被移除。你可以再次看到所有这些,你不能在这里添加更多,因为Visual Studio为你选择了所有这些,这些将是顶层的。所以,如果你有任何数组,你必须展开这些以了解更多信息。

监视窗口是你添加更多变量的地方。我稍后会讲到这个。所以,让我们逐步执行,我将进入局部变量窗口以便观察。

如你所见,如果某些东西变成红色,意味着上一行刚刚更改了该值。所以我们将gpuDevice设置为0。在当前行,我们将deviceCurrent设置为0。所以我要在这里高亮显示这一行,你可以看到它是一些垃圾值。现在,当我逐步执行时,它变成了0。你可以看到它变成了红色,因为它刚刚更新。

让我们继续逐步执行。现在我们处于cudaDevicePropdeviceProp变量将被填充。我要高亮显示它并展开,以确保我们看到正确的信息。当我逐步执行时,你会看到整个结构体已经更新。

这里可能有一些变量不会更新,例如,maxThreadsDim可能默认是相同的,GPU也是相同的,所以它将保持不变,因此你看不到它变成红色。所以我要继续。

我要来到这里,因为我想向你展示一个技巧。如果我们进入“名称”到监视窗口。让我们再执行一行。所以现在我们可以看到deviceName已经被填充。所以我可以在这里输入deviceName

这显示整个字符串。你可以看到这里的类型被高亮显示为std::string

你还可以做的是,像这样做[0]。所以现在你只得到第一个字符。所以现在你可以看到类型是char。虽然我展示的是字符串,它本质上是一个带有其他函数的字符数组,但这也适用于其他类型的数组。所以,如果你有一个浮点数向量或一个常规整数数组,这些字符串在那里也适用。所以,我可以做的是,我可以说deviceName, 10。所以这是要求从deviceName开始及其后的10个元素。所以当我按回车时,你可以看到这变成了“565 CUDA I”,这是“GeForce GTX 1650”的一部分。所以那是10个字符。

你也可以用浮点或整数数组来做这个。

另一件你可以做的事是,如果我获取这个背后的C字符串,即常规的C字符串,并执行, 10,它仍然有效。所以现在你可以看到这是const char[10]作为子类型。然后,如果我做,比如说,+5,这是给字符串开头添加一个偏移量。所以,不是看到“565 CUDA I”,前五个字符将消失。所以直到“C”将被偏移。所以我们将看到新的监视变量从“U”开始。所以我要按回车。现在你看到它是“UDA Intro :”,这又是10个字符。所以这也适用于其他类型。所以,虽然我只展示了字符串,但它也适用于其他数组类型。

好了,展示了监视功能后,我将进入内核方面的事情。我在这里设置了一个断点。

它给我这个错误的原因是执行当前在主机端。所以你可以看到在线程这里,它说“主线程”。这就是为什么它显示这个断点将被命中。但我们知道它在一个内核中,并且它将运行。所以我只需点击继续。我们将看到这个断点被命中。好了,让我们跳回我们的自动窗口。

Nsight的伟大之处在于它使用与Visual Studio相同的所有窗口。所以我们看到所有这些自动、局部和监视窗口仍然被填充。当然,监视窗口现在将被使用,因为它填充了所有变量,所以我要删除那个。但现在如果你回到自动窗口,你将看到blockIdxblockDimthreadIdx用于活动线程。记住,当你在内核中时,有一个线程束正在运行。一个线程束是32个线程,但每个断点都在一个特定的线程上,所以我们看到当前活动线程是块(0,0,0)的线程(0,0,0)。这很重要,要记住,确保在调试时,你在正确的线程上。

Nsight专用窗口

就像调试器有窗口一样,Nsight也有几个窗口。让我们看看那些。我们已经看到了系统信息。我现在要打开线程束信息。

就像我之前说的,一个线程束是一组在GPU上一起执行的32个线程。这个选项卡将显示为当前内核启动的所有线程束,你可以在这里添加过滤器,输入内容,我们现在不深入讨论。这里重要的是着色器信息,它显示整个线程束的块和线程ID blockIdx。一个线程束不能跨多个块分割。每个线程束都在同一个块内。以及该线程束中第一个线程的threadIdx。所以你不会看到偏移量,每个线程束之间的偏移量是32,因为线程束大小是32,你会看到这些是八组四组,只是为了视觉表示,但总共有32个线程。

CTA是块线程数组索引,thread显示该线程束中第一个线程的线程索引。

你可以做的另一件事是,如果你想要,你可以双击这些框中的任何一个,然后你可以转到那个线程,我们可以稍后再做。

这里的其他窗口是线程束监视,它与常规监视相同,只是你可以在上面添加CUDA变量,你会看到每个都有32行。所以你将显示整个线程束。如果我弹出这个。并填充这个。然后让我们回到内核,这样我可以添加一个变量,比如index。也许单步执行,以便它被赋值。现在你可以看到我们处于一个标准的索引方程中。所以这些值中的每一个都是唯一的并且现在被填充了。这就是线程束监视。

让我们看看还有哪些窗口。让我们进入通道,让我把它放在那里。

就像我之前说的,通道是硬件中线程的表示,所以你会看到每个都有线程索引,这将显示它们处于什么状态。在CUDA中,你可以有分支线程束,所以这是了解哪些线程处于活动状态、哪个线程有断点、哪些线程处于非活动状态的好方法。

这就是通道。让我们进入资源。这里面有几个选项卡。我要把它放在那里。我也要把这个放在这里。

在资源中,我们这里有几个下拉菜单,我将介绍其中一些。“设备”将显示它正在执行的设备的相同信息,就像我们在Nsight窗口系统信息中看到的那样,你也会在这里看到。

上下文,你可以有多个CUDA上下文用于多个线程。所以那将显示在大多数情况下,你只会有一个上下文。同样,流,你可以有多个流进行以提高效率和优化,我们将在课程后面讲到这个。

函数显示所有设备函数,这些包括CUDA全局内核和函数。所以你可以在这里看到名称,修饰名是它的完整定义,错名是目标代码中的名称,你可以看到每个块的线程数、它们使用的寄存器数量、字节数。我们还没有使用太多这些,但随着我们对CUDA理解的深入,你会开始看到这些也被填充。所以我要找一个我们当前正在使用的,即generateRandomPositionsArray

让我们进入资源,我按名称排序以便更容易找到。

generateRandomPositions。所以在这里我们可以看到,一旦我返回,你有一个index定义。你有1,2,3...3个变量作为函数参数传入。所以那是24字节。然后你还有一个向量指针,那将是额外的字节。然后我们有另一个4字节用于index,以及另一个在本地定义的vec3。所以如果我们回到资源,这告诉我们每个线程将使用29个寄存器和184字节的本地内存。

这是找出你编写的每个内核资源需求的好方法。

我现在要跳过图形,因为那是当你使用图形功能时,比如机器学习,现在调试不一定重要。

让我们看看我们还有哪些其他窗口。最后,我们当然有GPU寄存器,它给你所有的寄存器信息。这可能现在很难阅读,因为它都是十六进制和寄存器,但一旦你习惯了高级CUDA编程,你将能够相当容易地解读这个。

好了,我要关闭大多数这些。所以我要关闭GPU寄存器、通道、资源。我要保留线程束信息和线程束监视。我要分割窗口,这样我们可以看到两边。

调试内核中的变量

我已经添加了index到线程束监视中,只是为了向你展示。我还要添加timescale,因为这些都是输入的静态变量。所以这些对于所有线程、所有线程束都是相同的。所以我添加timescale在那里。你可以看到这些都将是相同的。所以我要再次删除这些,因为它们不一定重要。

我要添加blockIdxthreadIdx。现在,虽然这些是三组件结构体,但监视窗口将相当容易地显示它们。所以你不必做blockIdx.x,尽管如果你愿意,你可以这样做。所以我可以向你展示blockIdx.x。那将是唯一的。你不必这样做。你可以将它们作为三组件结构体。所以,让我们单步执行。

现在我们可以看到,我们即将使用这个函数生成一个随机数。所以如果我们在这里查看定义,我们看到这是一个__host__ __device__函数,意味着它可以在主机端或设备端运行,并且它将生成三个随机分量,并为此返回一个vec3

所以我要关闭那个,并且...让我想想。现在记住,这是一个GLM vec3,它来自GLM库,所以这可能不会直接显示出来。所以正如我们所看到的,这里有一个局部变量rand,但调试器无法看到正确的值,那么我们如何看到这个呢?我们可以做的是,打开双括号,做__local__,我们做local是因为rand在这里是内核的局部变量,它不是作为全局内存传入的,也不是共享内存,它是局部的。所以我要这样做。我要做float*来类型转换它。我要放一个&来获取它的指针。我也要关闭那个括号。所以一旦我按回车,现在你看到这是一个内存地址。然后我添加[0]索引。这应该转换为值。好了。所以现在所有的值都填充了所有的线程。

我好奇为什么这些是零,但我假设GPU还没有发送回那个信息。

我们可以对...做同样的事情。哦,你知道,这些是零是因为...不是所有...寄存器可能不是所有线程都使用相同的指针,或者可能...这就是为什么。所以它使用了来自线程0的rand地址,但没有为其他线程正确填充。

另一种方法是,如果你想复制这个并把它放到我们的监视窗口中,那也有效。所以在这里我们可以...我要再次复制那个。所以我犯了一个错误,我做了[index],我应该实际上做了[0],因为rand是x, y, z。所以我们可以也使用0,1,2。这就是为什么它可能遇到垃圾。所以现在,如果我做[0],那么所有这些值都正确填充了。所以我们可以对[1][2]做同样的事情。

所以让我们把它挤回去。这就是你如何读取GLM向量的方法。所以现在让我们单步执行。

所以值被赋给了arr,这是全局数组。所以如果我输入arr[index],问题是arr[index]也是一个GLM vec3。所以我们会遇到同样的问题。然而,我们不能对arr使用这个局部描述符,因为它不是局部变量。它是一个通过函数指针传入的全局变量。

要可视化这个arr全局内存,你要做的是进入调试窗口,你会看到这个内存,你可以打开其中一个监视内存窗口。

然后你要做的是在这个地址字段中,获取arr的地址。所以如果我们高亮显示它,你可以看到它,或者你可以从这个框中复制它。所以我只复制了十六进制部分。然后把它放进去。

通常这被设置为1字节整数,我测试过,所以我切换到了32位浮点数。我们知道GLM vec3是32位浮点数,所以我们要将其设置为那个。

因为我们在主机端将其初始化为全零,这个全局内存全是零。所以现在如果我们单步执行下一行,你会看到值更新。

现在你可以看到一些值更新了,但它似乎没有任何不规则的模式。你们有谁能想到为什么吗?我给你三秒钟暂停这个视频,3, 2, 1。

记住,我们不是...我们只分配了X分量,而这些GLM向量是三组件结构体,对吧?所以你会得到X, Y, Z, X, Y, Z, X, Y, Z。所以如果你看到第一个值更新了,但两个零,然后一个值,然后2,0。所以1,2。所以我们可以做的是,因为我们知道这是一个vec3,我们可以将列数改为3。所以这填充到32。我要退格并输入3。现在你可以看到所有第一列的值都更新了,只有32个,因为我们正在运行一个线程束,所以32个线程已经执行并更新了值。

让我们再执行一行。所以现在你可以看到所有的Y值都更新了。如果我们再执行一行,那么它可能会退出内核,因为我们完成了。内核上的执行将结束。所以我们可能会退出这个。所以给你一个警告,在我们这样做之前。所以我要执行下一步。好了,我们已经更新了,并且它已经退出,所以很好。你可以看到红色的点已经改变了。

这是一种使用这种内存监视来可视化全局内存的方法。所以让我们...我要把它放在这里,这样我们就不会丢失它。

一旦我们有了那个,让我们...我要停止这个。另一种可视化全局数据的方法是使用float3。所以float3,或者我要输入这个。所以如果我输入1,你可以看到有float1float2float3float4。这些是CUDA中的内置变量。所以调试器和CUDA理解它们。所以我们可以做float3 tempValue = make_float3(arr[index].x, arr[index].y, arr[index].z)。所以如果你这样做,float3值将固有地在线程束监视和自动窗口中可见。所以这是另一种可视化数据的方法,而不必进入内存窗口,这可能令人困惑或可能有太多数据需要查看。

观察分支和线程束分化

我想向你展示的另一件事是关于分支。因为它可能会改变这个,我要先编译这个。

我们生成这些随机数的大小是5000。所以n是5000。但如果我们看我们的块大小和块线程,我们正在启动40个块,每个块128个线程,总共5120个线程。这意味着我们启动的线程比元素多。所以一些元素...一些线程将满足这个条件并退出。这被称为分支或提前退出。所以我要在这里放置一个条件。并添加表达式index == 4999,因为5000会超出这个条件,而4999将在其中。所以保存那个,然后让我们开始调试这个。

当我们查看内核时,我们命中断点。你可以看到这个黄色箭头标记了index是4999的位置。看看这个,所有这些都被划掉了,即使index被正确填充,所有这些线程都被划掉了。让我们看看通道。这也很有趣,所以所有这些线程都被标记为非活动状态。如果你看线程束信息,另一件要注意的事是,在此之前的所有其他线程束已经完成了它们的任务并退出。所以我们在这里看到一些绿色的,它们是活动的,这里的灰色本质上是非活动的。

那么非活动是什么意思?如果我们回到我们的内核,任何不满足这个条件的线程没有其他事情可做。所以它们正在退出。这被称为提前退出。这是优化内核的一种方式。现在,如果这里有一个else条件,如果index小于n,否则做其他事情,那将被称为分支。所以在这种情况下,线程束中的一些线程在做一件事,而其他线程在做另一件事。这被称为线程束分化,我们将在课堂上更多地讨论这个。所以如果我们单步执行这个,我们可以看到一些值更新,而其他线程没有做任何其他事情。

简单示例:向量加法

我已经关闭了项目一,现在我想从CUDA示例中打开一个简单的例子,即向量加法,向你展示一些更基本的东西,而不涉及GLM vec3等。所以我打开了2017版本的向量加法。让我们打开文件。编译它。

当那发生时,我要在这里放置一个断点。在这个内核中,我们有三个全局内存数组传入,都是浮点数。所以让我们看看如何可视化像这样的简单数组,而不必处理GL向量。

好了,让我们开始调试这个。好了,让我们添加...让我们单步执行。1,然后让我们添加blockIdx.xthreadIdx.x。如果你双击这些选项卡,它们会自动调整大小。所以我经常使用那个。然后我们想要i,然后我们找a[i]b[i]c[i]。现在,假设你的第一个问题是,嘿,为什么abc在这里工作,当它来自全局内存时,但当我们试图访问点示例中的arr时,它不行?那是因为arr是一个GLM vec3。而监视窗口不完全支持那个结构体。这就是为什么我们无法可视化它。但这里相当简单,只是因为这些是监视窗口识别的类型。

所以我们可以很容易地逐步执行这个,当我们单步执行时,我们看到值被更新。现在c被更新了,让我们看看当我们超出执行时会发生什么。在一个普通函数中,这将返回到下一个...它将退出并转到上一个函数的下一个语句。但这是一个CUDA内核。所以还有更多线程要执行。所以让我们进入局部变量或为threadIdx.xblockIdx.x添加一个监视。

我没有使用这个,因为这个也会改变。所以注意这个。所以threadIdx全是0。blockIdx全是0。所以让我们单步执行。那么,让我们看看会发生什么。

现在这是什么。所以threadIdx已经增加到32,这意味着我们在线程束1中。所以让我们回到这里,你可以看到我不能再向上滚动了,并且没有0,0,0,0,0,0用于第一个块第一个已经退出的线程束。所以现在我们正在看这个线程束。现在,我们如何能转到下一个块,而不是转到下一个线程束?例如,要做到这一点,进入Nsight...你可以在这里跳转线程束,如果你想要,如果它们是活动的。所以前一个线程束不再活动。所以这没有太大意义。但如果我们想要,比如说,跳转块,我们可以做的是进入冻结,并执行“调度锁定恢复块”。好了,现在注意这个。32,0,0,0,0,0,对吧?所以让我们逐步执行这个。

这些都没有改变,因为我们仍然在同一个当前线程上。所以现在发生了什么?让我们逐步执行那个。看看这个。现在,blockIdx增加了1,0,0,而threadIdx是0,0,0。这意味着我们跳转到了下一个块,而不是块零中的下一个线程束。

所以如果我们回到我们的线程束信息,现在,看看这个,我不能再向上滚动了,但所有0,0,0块都消失了。现在我们在块1。所以让我们再试一次。所以单步执行,单步执行。现在,你认为这里会发生什么?猜一下。所以这现在增加到二了。再次,我们停留在第一个线程束,因为我们本质上是在开始一个新块。所以只有块索引增加了。

让我们改变冻结模式为“恢复线程束”,让我们看看会发生什么,所以...我现在要单步执行。我们在第二个块,线程束0。让我们增加那个。现在我们转到32。所以我们改变了线程束。我们转到了下一个活动线程束。所以如果我们去这里看,那个的起始索引是32。

类似地,我们可以有不同的锁定和不同的线程束,所以请随时在你自己的方便时尝试这个,看看当你选择这些所谓的不同的冻结选项时会发生什么。

添加命令行参数

今天我想向你展示的最后一件事是如何在使用Nsight调试器时添加命令行参数。所以你进入这个按钮,Nsight单元属性,你可以使用这个工作目录和工作环境,并在这里添加命令行参数,这些通常是从Visual Studio调试器获取的。所以如果我们去项目属性,进入调试。所以如果你在这里添加任何参数,那将被获取。但如果你想添加特定的Nsight调试参数,那么你也可以在这里添加那些。所以这对你来说是另一个有用的工具。

总结

本节课中我们一起学习了CUDA调试的基础知识和高级技巧。我们从调试工具的高层概述开始,了解了Visual Studio调试器和Nsight调试器的区别与用途。我们深入探讨了如何使用Nsight查看系统信息、设备属性,以及如何在内核调试中查看和操作变量。我们还学习了如何可视化全局内存、处理复杂数据类型(如GLM向量),以及如何观察线程束分化和分支行为。最后,我们通过一个简单的向量加法示例,巩固了使用监视窗口和内存窗口进行调试的技巧。掌握这些工具和技术将帮助你更有效地诊断和修复CUDA程序中的问题,并为后续的性能优化打下基础。

003:CUDA核心概念深入(上)

在本节课中,我们将深入学习CUDA编程的核心概念,包括GPU架构演进、线程调度、内存优化以及如何利用共享内存来提升矩阵乘法等核心算法的性能。我们将从理论出发,逐步过渡到实际的代码优化策略。

🏛️ 架构演进概述

上一节我们介绍了CPU与GPU的基本区别以及CUDA程序的初步结构。本节中,我们来看看GPU架构,特别是NVIDIA CUDA GPU,是如何随着时间的推移而演进的。

CUDA于2007年左右发布,其首个架构被称为G80(或G80系列)。这是第一个通用统一着色器架构,所有着色器和通用计算都在相同的核心上运行。

  • G80架构:包含16个流式多处理器(SM),每个SM有8个流式处理器(SP),总计128个核心。每个SM最多可驻留768个线程。
  • 后续架构:随后经历了GT200、Fermi、Kepler、Maxwell、Pascal、Turing、Ampere等架构的迭代。其核心趋势是SM数量、每个SM的核心数量以及线程驻留能力的大幅提升。例如,现代消费级GPU(如基于Ampere架构的GPU)拥有数千个核心和强大的计算能力。

一个关键概念是:GPU可以通过零开销的上下文切换来运行远超其物理核心数量的线程。调度器以线程束为单位进行调度,而非单个线程。

🧵 线程调度与线程束

我们之前讨论了线程、块和网格这些逻辑概念。现在,我们来深入了解硬件是如何调度和执行这些线程的。

线程束:硬件的执行单元

  • 定义:一个线程束是32个连续线程的硬件分组。它是GPU上调度和执行的基本单位。
  • 执行方式:线程束中的所有32个线程同时执行相同的指令。这被称为SIMT(单指令多线程)执行模型。
  • 重要性:为了获得最佳性能,块大小最好是32的倍数。否则,最后一个线程束中会有部分线程空闲,造成资源浪费。

调度与延迟隐藏

GPU调度器的目标是始终保持SM处于忙碌状态,以隐藏操作延迟(尤其是全局内存访问的高延迟)。

  • 调度过程:当一个线程束因等待数据(例如,全局内存读取)而停滞时,调度器会迅速切换到另一个就绪的线程束执行。这种切换几乎没有开销。
  • 计算示例:假设一次全局内存读取需要200个时钟周期,而一次乘加运算需要4个周期。为了完全隐藏这200周期的内存延迟,我们需要足够的计算工作来填充这段时间。通过计算(200周期 / 4周期/指令 ≈ 50指令),并考虑到线程束的并行性,我们可以估算出需要足够多的并发线程束来“掩盖”内存访问的延迟。

线程束分化

线程束分化发生在线程束内的线程需要执行不同代码路径时(例如,if-else语句)。

  • 性能影响:GPU会序列化地执行所有不同的路径,导致执行时间增加。例如,一个32线程的线程束如果完全分化,理论上可能需要32倍的时间来执行该段代码。
  • 优化目标:在编写CUDA内核时,应尽量避免或减少线程束内的控制流分化。

🔗 线程同步

我们之前提到,块内的线程可以同步。这是通过 __syncthreads() 函数实现的。

  • 作用__syncthreads() 作为一个屏障,块内的所有线程必须都执行到此点,才能继续执行后面的代码。
  • 使用场景与注意事项:同步通常用于协调对共享内存的访问。在写入共享内存后、读取共享内存前,通常需要 __syncthreads() 来确保所有线程都已完成写入。同样,在读取共享内存后、覆盖写入之前也需要同步,以防止数据竞争。

🚀 优化主机与设备间内存传输

主机(CPU)和设备(GPU)之间的内存传输速度可能成为瓶颈,因为它受限于PCIe带宽。

  • 问题根源:默认的 malloc 分配的是可分页内存。GPU驱动在传输前需要先将数据复制到固定的锁页内存中,这增加了额外开销。
  • 解决方案:使用 cudaMallocHost 直接在主机上分配锁页内存。
    float *h_pinnedData;
    cudaError_t err = cudaMallocHost((void**)&h_pinnedData, dataSize);
    // ... 使用 h_pinnedData
    cudaFreeHost(h_pinnedData);
    
  • 性能提升:使用锁页内存进行 cudaMemcpy 可以显著提高传输带宽,更接近PCIe的理论极限。

🔢 矩阵乘法优化实战

上一节的简单矩阵乘法内核存在两个主要问题:1) 矩阵尺寸受限;2) 全局内存访问次数过多,导致性能低下。现在,我们来逐一解决。

步骤一:解除尺寸限制(分块计算)

为了计算任意大小的矩阵,我们可以将输出矩阵划分为

  • 核心思想:每个线程块负责计算输出矩阵的一个子块(例如,16x16的大小)。由于输出矩阵的每个元素计算都是独立的,这些块可以并行计算。
  • 实现变化
    • 内核中:计算线程的全局行、列索引时,需要考虑到块偏移:row = blockIdx.y * blockDim.y + threadIdx.y; col = blockIdx.x * blockDim.x + threadIdx.x;
    • 主机端:根据矩阵大小和块大小计算网格维度:dim3 dimGrid(ceil(width / tileWidth), ceil(height / tileWidth));

步骤二:减少全局内存访问(使用共享内存)

这是性能优化的关键。我们观察到,在计算一个输出块时,许多线程会重复读取输入矩阵的相同行和列。

  • 优化策略:使用共享内存作为可编程的缓存。我们将输入数据的一块“瓦片”从全局内存加载到速度更快的共享内存中,然后让块内的所有线程从共享内存中重复读取数据。
  • 滑动窗口算法:由于整个输入矩阵可能太大,无法一次性装入共享内存,我们采用滑动窗口的方式:
    1. 每个线程将输入矩阵的一个元素从全局内存加载到共享内存数组中。
    2. 使用 __syncthreads() 确保所有加载完成。
    3. 线程在共享内存的数据上进行一部分点积计算。
    4. 使用 __syncthreads() 确保所有线程完成计算,防止下一轮加载覆盖仍在使用的数据。
    5. 滑动窗口,加载下一块输入数据,重复步骤2-4,直到完成整个点积计算。

以下是优化后的内核代码框架:

__global__ void matrixMultiplyShared(float* d_M, float* d_N, float* d_P, int width) {
    // 声明共享内存
    __shared__ float s_M[TILE_WIDTH][TILE_WIDTH];
    __shared__ float s_N[TILE_WIDTH][TILE_WIDTH];

    int bx = blockIdx.x, by = blockIdx.y;
    int tx = threadIdx.x, ty = threadIdx.y;

    // 计算输出矩阵P中的坐标
    int row = by * TILE_WIDTH + ty;
    int col = bx * TILE_WIDTH + tx;

    float pValue = 0;

    // 循环遍历输入矩阵的“瓦片”
    for (int m = 0; m < width / TILE_WIDTH; ++m) {
        // 协作加载一个瓦片到共享内存
        s_M[ty][tx] = d_M[row * width + (m * TILE_WIDTH + tx)];
        s_N[ty][tx] = d_N[(m * TILE_WIDTH + ty) * width + col];
        __syncthreads(); // 等待所有线程加载完成

        // 在共享内存上进行部分点积计算
        for (int k = 0; k < TILE_WIDTH; ++k) {
            pValue += s_M[ty][k] * s_N[k][tx];
        }
        __syncthreads(); // 等待所有计算完成,再加载下一个瓦片
    }

    // 将结果写回全局内存
    d_P[row * width + col] = pValue;
}
  • 性能分析:通过使用共享内存,我们将每个元素所需的全局内存读取次数从 O(width) 减少到 O(width / TILE_WIDTH)。如果 TILE_WIDTH 为16,全局内存访问量就减少了约16倍,这直接转化为理论峰值性能的成比例提升。

如何选择块大小?

块大小(TILE_WIDTH)的选择需要在资源利用和并行度之间取得平衡:

  • 过小:可能导致SM利用率不足,无法有效隐藏延迟。
  • 过大:会占用更多共享内存和寄存器,可能减少每个SM上可同时驻留的块数量,同样会降低并行度。
  • 经验法则:通常需要根据GPU的共享内存大小、寄存器数量等硬件限制,通过实验来确定最佳值。例如,对于较早的GPU,16x16是一个常见的选择。

📚 本节课总结

本节课中我们一起深入探讨了CUDA编程的几个核心高级主题:

  1. GPU架构演进:了解了从G80到现代架构的发展,以及线程束和零开销调度的基本概念。
  2. 线程调度与执行:学习了线程束作为基本执行单元的工作原理、延迟隐藏的重要性以及线程束分化对性能的影响。
  3. 线程同步:掌握了如何使用 __syncthreads() 在块内协调线程,特别是在共享内存访问前后。
  4. 内存传输优化:认识了使用锁页内存来加速主机与设备间数据传输的方法。
  5. 算法优化实战:通过矩阵乘法的案例,学习了如何通过分块计算来支持任意大小矩阵,以及如何利用共享内存滑动窗口技术大幅减少昂贵的全局内存访问,从而极大提升内核性能。这为我们后续优化更复杂的并行算法奠定了坚实的基础。

004:并行算法 🚀

在本节课中,我们将学习一系列核心的并行算法,包括并行归约、扫描、流压缩、求和表和基数排序。这些算法是GPU编程的基石,能够将看似串行的任务转化为高效的并行计算。我们还将探讨如何优化项目文档(README)和图表,以更好地展示你的工作。


概述 📋

本节课分为两个主要部分。首先,我们将讨论如何撰写有效的项目README和制作清晰的图表,这对于向潜在雇主展示你的技术项目至关重要。其次,我们将深入探讨几种关键的并行算法,理解它们如何将串行逻辑转化为GPU友好的并行模式,从而大幅提升计算性能。


README撰写技巧 📝

一份优秀的README是项目的门面,尤其对于求职者而言,它能有效向招聘经理和工程师展示你的技术能力和项目成果。

README的核心作用

README应清晰传达项目信息。以下是其关键组成部分:

  • 项目概述与背景:简要说明项目目标、技术栈和所需背景知识。
  • 构建与运行指南:提供清晰、可执行的步骤。
  • 功能与成果展示:通过文字、图片、视频等方式展示实现的功能。
  • 性能分析与对比:包含图表和数据,量化你的优化成果。
  • 已知问题与待办事项:诚实地列出项目的局限性或未来改进方向。
  • 许可证信息:明确项目的使用许可。

提升README质量的实用技巧

为了让README更具吸引力,请考虑以下建议:

  • 提供上下文与视觉展示:在开头部分提供项目背景、你的联系方式,并附上一张具有代表性的图片或GIF动图,直观展示项目成果。
  • 使用图解说明功能:利用截图或架构图,逐步展示项目的不同功能模块。对关键部分进行标注,帮助非技术背景的读者(如HR)理解。
  • 展示调试过程与问题解决:包含调试截图或“花絮”(Bloopers),这能有力地证明你遇到了实际问题并成功解决了它,体现了你的问题解决能力。
  • 进行对比展示:使用并排对比图来突出你的优化效果或额外实现的功能,这能让你在众多项目中脱颖而出。
  • 制作演示视频:一段简短的演示视频可以动态地展示项目运行效果,非常吸引人。

制作有效图表的准则

清晰的图表对于展示性能数据至关重要。

  • 标注清晰的坐标轴:确保横纵坐标轴都有明确的标签和单位。
  • 选择恰当的尺度:根据数据范围选择合适的坐标轴尺度(如对数尺度),避免图表空间浪费。
  • 明确优劣方向:明确指出图表中“数值更高更好”还是“数值更低更好”。
  • 使用易于区分的视觉元素:使用不同的颜色或线型来区分数据系列,方便对比。

积极推广你的项目

不要害羞,积极在社交媒体或技术博客上分享你的项目成果。这不仅能获得更多关注和反馈,还可能为你带来意想不到的职业机会。


并行算法 🔄

上一部分我们讨论了项目展示的技巧,现在让我们转向技术核心,学习如何设计并行算法。

并行归约

归约操作接收N个输入,产生一个输出(如求和、求最大值)。串行归约的复杂度是O(N)。

并行归约算法通过类似锦标赛淘汰赛的方式,将加法操作分层并行化。第一层,所有相邻元素两两相加;第二层,将第一层的结果再次两两相加;依此类推。对于N个元素,共需log₂(N)层。虽然总计算量变为O(N log N),但由于高度并行,整体运行时间大幅缩短。

伪代码示例(求和):

for (int stride = 1; stride < n; stride *= 2) {
    if (threadIdx.x % (2*stride) == 0) {
        array[threadIdx.x] += array[threadIdx.x + stride];
    }
    __syncthreads();
}

扫描(前缀和)

扫描操作接收一个数组和一个二元运算符(如加法),为每个位置输出其之前所有元素的运算结果。排他扫描(Exclusive Scan)的第一个输出是单位元(如0),包含扫描(Inclusive Scan)的第一个输出是第一个输入元素。

朴素并行扫描算法通过多轮偏移相加实现。虽然算法复杂度为O(N log N),但能完全并行执行。

高效工作扫描算法将扫描分为两个阶段,将复杂度降低到O(N),减少了全局内存访问,从而提升性能。

  1. 上行阶段:执行一次并行归约,在过程中记录部分和。
  2. 下行阶段:通过一套特定的复制和加法规则,将部分和传播到所有元素,最终得到完整的扫描结果。

流压缩

流压缩的目标是根据条件(如布尔掩码)过滤数组元素,移除不满足条件的项,并保持剩余元素的原始顺序。它在路径追踪(剔除无效光线)和稀疏矩阵处理中非常有用。

并行流压缩算法分为三步:

  1. 生成掩码数组:并行地为每个元素计算条件判断结果(1满足,0不满足)。
  2. 执行排他扫描:对掩码数组进行排他扫描。结果数组的每个值表示“在此元素之前有多少个满足条件的元素”。
  3. 分散写入:每个满足条件的元素,根据扫描结果提供的索引,将自己写入输出数组的对应位置。扫描结果的最后一个值即为输出数组的长度。

求和表

求和表是一个二维数组,其中每个位置的值等于原始二维数组中该位置左上角所有元素之和。它可用于快速计算图像中任意矩形区域的和,在图像滤波中应用广泛。

并行求和表算法通过两次扫描完成:

  1. 行扫描:对每一行独立进行包含扫描。
  2. 列扫描:对上一步的结果矩阵的每一列进行包含扫描。经过这两步,每个位置都获得了正确的二维前缀和。

基数排序

基数排序是一种非比较排序算法,特别适合并行实现。它对整数按位进行排序,从最低有效位到最高有效位,每次根据当前位的值(0或1)将元素重新排列。

单线程块内的并行基数排序(以单次位排序为例):

  1. 提取位值:为每个元素提取当前排序位的值(0或1)。
  2. 计算写入位置
    • 对“位值为0”的掩码数组进行排他扫描,得到每个“0”元素应写入的输出索引(F数组)。
    • 每个“1”元素的输出索引可通过公式 T[i] = i - F[i] + totalFalses 计算,其中 totalFalses 是“0”的总数。
  3. 分散写入:根据计算出的索引(FT),将元素写入新的位置。重复此过程,对所有位进行排序。

跨线程块的扩展:要对超出单个线程块的大型数组排序,可先在各块内独立排序,然后使用归并排序(如双调归并)合并结果。跨块扫描也可通过类似“先块内扫描,再对块总和扫描,最后将偏移加回”的模式实现。


总结 🎯

本节课我们一起学习了如何通过优秀的README和图表来展示你的GPU编程项目,这对职业发展至关重要。在技术层面,我们深入探讨了并行归约、扫描、流压缩、求和表和基数排序等核心算法。这些算法的共同点在于,它们通过巧妙的分解和重组,将固有的串行依赖转化为可并行执行的任务,充分利用了GPU的大规模并行计算能力。理解并掌握这些算法模式,是进行高效GPU编程的关键。在接下来的项目中,请尝试应用这些算法和展示技巧。

005:GPU架构概述 🚀

在本节课中,我们将学习CPU架构的历史及其如何影响GPU的设计。我们将从CPU的基本原理出发,逐步构建一个简化的GPU模型,以理解现代GPU如何实现大规模并行计算。

课程概述 📖

大家好,欢迎来到今天的课程。由于紧急出差,我无法亲自到场,因此我将录制本次讲座供大家观看。本次讲座内容理论性较强,非常适合录播。我们将不讨论CUDA等具体框架,而是专注于理解CPU架构的历史及其对GPU设计的影响。我们将共同构建一个“CIS 565 GPU”模型。

性能衡量标准:FLOPS ⚡

首先,我们需要了解如何衡量计算性能。CPU和GPU的计算性能通常以FLOPS(每秒浮点运算次数)来衡量。这本质上是衡量处理器每秒能执行多少次乘法和加法运算。

现代GPU的计算能力已达到万亿次浮点运算级别,而超级计算机甚至达到了千万亿次级别。例如,几年前的高端CPU(如AMD Ryzen 9 3900X和Intel i9-10900K)的算力约为1-1.5 TFLOPS,而同期的GPU(如RTX 2080 Ti)则能达到16 TFLOPS以上,性能差距超过10倍。

这种性能差距不仅体现在独立显卡上,也体现在集成芯片(如NVIDIA的Tegra、Jetson系列或苹果的ARM架构芯片)上。GPU能够为并行计算算法提供远超CPU的算力。

性能趋势与限制 📈

自2008-2011年左右GPU开始崛起以来,其单精度和双精度浮点性能的增长速度远超CPU。内存带宽也在同步提升:CPU端有DDR4、DDR5,而GPU则采用了GDDR5、GDDR6甚至HBM(高带宽内存)技术。

连接CPU和GPU的PCIe总线带宽也在不断翻倍。从PCIe 3.0的约16 GB/s,到PCIe 4.0的翻倍,再到未来的PCIe 5.0和6.0,数据传输的瓶颈正在不断降低,使得使用GPU加速计算的额外开销越来越小。

然而,性能提升也面临限制:

  • 功耗与散热:尤其是在超薄笔记本或移动设备中,功耗和散热限制了芯片的时钟频率。
  • 芯片尺寸与成本:芯片的物理尺寸、制造成本和功耗在近年来显著影响了GPU的性价比分析。
  • 移动设备能效:在手机等电池供电的设备上,优化算法以节省电量并高效利用计算资源变得至关重要。

CPU架构基础 🖥️

为了理解GPU,我们首先需要回顾CPU的设计理念。CPU主要针对轻量级线程化的桌面应用进行优化,例如文本编辑器(Vim)或文件列表命令(ls)。这类应用的特点是:

  • 包含大量分支判断
  • 频繁进行内存访问
  • 实际用于计算的向量指令占比极低(通常不到1%)。

这意味着CPU的任务不仅仅是追求最高计算性能,还需要高效地管理分支预测和内存访问。

简单的CPU核心

一个简单的CPU核心包含五个阶段,构成一个指令流水线

  1. 取指:获取下一条待执行的指令。
  2. 译码:将指令解码为硬件可理解的操作。
  3. 执行:在算术逻辑单元中执行该操作。
  4. 内存访问:将结果写入寄存器或从内存读取数据。
  5. 写回:将最终结果写回寄存器或程序计数器。

在简单的设计中,一个时钟周期只能完成一条指令的所有阶段,硬件利用率很低。

流水线与指令级并行

为了提高利用率,现代CPU采用了深度流水线技术。它将指令执行过程拆分成更多、更细的阶段(如Intel Core 2有14级,Pentium 4有20级)。这样,不同阶段可以同时处理多条指令的不同部分,实现了指令级并行

公式吞吐量提升 ≈ 流水线级数(理想情况下)

流水线化显著提高了时钟频率和吞吐量,但也带来了挑战:

  • 增加延迟:单条指令完成所需的总时间可能增加。
  • 增加硬件复杂度:需要更多晶体管来控制流水线。
  • 依赖性与分支:需要处理指令间的数据依赖和条件分支带来的不确定性。

分支预测

条件分支(如if-else语句)会中断指令流的连续性。CPU无法在分支条件计算完成前知道下一条指令是什么。解决方案有两种:

  1. 停顿:等待分支结果,但这会降低性能。
  2. 预测:预测分支最可能走的方向并提前执行相应指令。如果预测错误,则丢弃已执行的结果并重新开始。

现代CPU的硬件和编译器协同工作,分支预测准确率可达90%以上,这极大地提升了性能和能效。但代价是:

  • 需要更多晶体管来实现复杂的预测逻辑。
  • 增加了取指阶段的延迟。
  • 曾引发过如 Spectre和Meltdown 这样的硬件安全漏洞,攻击者可能利用分支预测执行来访问本不该访问的内存。

内存层次结构

内存系统遵循一个原则:容量越大,速度越慢

  • SRAM:用作CPU的L1、L2、L3缓存。速度极快(纳秒级),但容量小(几MB到几十MB)。
  • DRAM:系统主存(如16GB DDR4)。容量大(GB级),但速度较慢(几十纳秒)。
  • 闪存/SSD:存储设备。容量巨大(TB级),但速度更慢(微秒到毫秒级)。
  • HDD:机械硬盘。容量大,但速度最慢(毫秒级)。

延迟主要来自寻址时间,而非实际的数据读写时间。缓存技术通过利用两种局部性来提升效率:

  1. 时间局部性:最近被访问的数据很可能再次被访问。
  2. 空间局部性:访问一个内存地址后,其邻近地址很可能也被访问。

CPU的缓存层次通常包括核心私有的L1指令/数据缓存、共享的L2和L3缓存,然后是主存和硬盘。

现代CPU架构剖析

观察现代CPU芯片的布局图可以发现,很大一部分晶体管面积(约25%)被L3缓存所占据。其余部分包括多个CPU核心、内存控制器、I/O控制器以及集成GPU/媒体处理单元。这凸显了CPU设计中对缓存和内存子系统的巨大投入。

从CPU到GPU的演进思路 🔄

上一节我们介绍了CPU如何通过流水线、分支预测和缓存来优化串行和轻度并行程序。本节中,我们来看看如何将这些思路推向极致,以支持GPU所需的大规模并行。

超越流水线:更多并行策略

CPU除了指令级并行,还采用其他技术:

  • 超标量:在一个时钟周期内,从同一指令流发射多条指令到多个执行单元(加宽流水线)。
  • 乱序执行:硬件动态分析指令间的依赖关系,重新排序执行,以填满流水线气泡。
  • 向量指令:单指令多数据。例如SSE(操作4个浮点数)、AVX(操作8个浮点数),对同一数据执行相同操作。
  • 线程级并行
    • 同时多线程:在单个核心上交错执行多个线程的指令,以隐藏单个线程的延迟。
    • 多核心:在芯片上复制多个完整的CPU核心。现代架构还采用“大小核”设计,兼顾高性能与高能效。

然而,即使采用这些技术,现代CPU也只能同时处理几十个线程。那么,GPU是如何实现成千上万个线程并行执行的呢?

GPU的工作负载特征

GPU的爆发式增长源于其擅长处理高度并行的工作负载:

  1. 图形渲染:对数百万个三角形顶点和屏幕像素应用相同的变换、光照和着色计算。
  2. 通用计算:矩阵运算、物理模拟、粒子系统、机器学习等。这些任务同样具有“单指令,多数据”的特征。

早期游戏(如《德军总部3D》、《毁灭战士》)完全由CPU软件渲染,证明了图形计算本质上是并行的。随着硬件发展,GPU不仅包含可编程的着色器核心,也保留了用于纹理过滤、光栅化等任务的固定功能单元,以在提供高性能的同时保持硬件效率。

构建“CIS 565 GPU” 🛠️

现在,让我们基于CPU的设计理念,一步步构建一个简化的GPU模型。

起点:一个简单的着色器核心

考虑一个在数百万像素上运行的片段着色器。其核心模式是:输入一个像素,执行一系列相同操作,输出处理后的像素。

最初,我们可以设计一个类似简单CPU的核心:取指/译码、ALU、执行上下文(寄存器)。但这样效率太低。

核心理念1:用晶体管换更多ALU

CPU将大量晶体管用于分支预测、乱序执行等控制逻辑以优化单线程性能。GPU的第一个设计转变是:削减复杂的控制逻辑,将节省出的晶体管用于增加算术逻辑单元的数量

首先,我们在一个核心内复制多个ALU。但每个ALU都有自己的取指/译码单元,而所有像素执行的是相同的指令,这是一种浪费。

核心理念2:共享取指/译码,SIMD执行

因此,我们让一组ALU共享一个取指/译码单元。所有ALU在同一时钟周期执行相同的指令,但操作不同的数据(来自各自的执行上下文)。这实现了硬件层面的SIMD

此时,一个“核心”包含1个取指/译码单元和多个ALU(例如8个),每个ALU有自己私有的寄存器上下文,同时它们还可以访问一块共享的存储空间。这开始类似于CUDA中的线程块概念:线程私有寄存器 + 共享内存。

核心理念3:复制核心,构建多级并行

接下来,我们在芯片上复制多个这样的核心。假设有16个核心,每个核心有8个ALU,那么就能同时处理 16 * 8 = 128 个片段。

不同核心可以执行不同的指令流,这提供了线程块级的并行。而核心内部的ALU组则提供了线程级的并行。

处理分支和内存延迟

在GPU中,分支和内存访问延迟会带来严重性能问题。

  • 分支分歧:当一个ALU组(例如8个ALU)中的线程遇到条件分支时,如果部分线程走if路径,部分走else路径,GPU会先执行所有走if路径的线程,让else路径的线程等待,然后再切换执行else路径。这会导致ALU利用率下降。最坏情况是每个线程都走不同路径,利用率降至 1/8
  • 内存延迟:从全局内存读取数据需要数百个时钟周期,如果线程在读取数据时只是空等,硬件将完全闲置。

核心理念4:零开销上下文切换,隐藏延迟

GPU的解决方案是大幅增加每个核心内可驻留的线程数量,并实现快速的上下文切换。

我们不再让一个核心只管理8个线程(与其ALU数量一致),而是让它管理更多线程(例如32个)。这些线程被组织成若干组(在CUDA中称为Warp)。

当一组线程因等待内存数据而停顿时,核心的调度器会立即切换到另一组就绪的线程,继续执行。由于线程上下文(寄存器状态)都保存在核心内的高速存储中,这种切换几乎零开销

通过这种时间切片的流水线式执行,虽然单个线程的完成时间可能略有增加,但整个核心的吞吐量得到极大提升,有效地隐藏了内存访问延迟。

“CIS 565 GPU”规格总结

根据以上理念,我们设计的GPU模型如下:

  • 16个流式多处理器:相当于我们模型中的“核心”。
  • 每SM包含8个ALU:共 16 * 8 = 128 个ALU。
  • 16个并行指令流:每个SM可独立执行不同指令。
  • 每SM支持4个Warp上下文切换:共 16 * 4 = 64 个并发交错的指令流。
  • 总计并行处理片段数64个Warp * 8线程/Warp = 512 个线程。
  • 理论算力:在1 GHz时钟频率下,可达约256 GFLOPS。

CPU与GPU架构对比 🆚

最后,我们来总结CPU与GPU架构的关键区别:

特性 CPU GPU
设计目标 优化低延迟、强通用性、复杂控制流的串行任务 优化高吞吐量、规则控制流的数据并行任务
核心结构 核心数量少(通常<32),但每个核心功能强大(深流水线、乱序执行、大缓存)。 核心数量极多(成千上万),但每个核心简单(顺序执行、小缓存),专注于计算。
晶体管用途 大量用于控制逻辑(分支预测、乱序执行)和缓存。 绝大部分用于ALU计算单元和寄存器文件。
内存系统 强调低延迟访问,大容量多级缓存。 强调高带宽访问,缓存层次相对简单,全局内存带宽极高。
并行模式 指令级并行、向量指令、多线程。 大规模线程级并行,硬件管理的SIMD/SIMT。
适用场景 操作系统、应用程序逻辑、数据库事务等。 图形渲染、科学计算、机器学习训练/推理等。

带宽限制与计算限制

GPU的性能并非总能完全发挥。考虑一个向量逐元素乘法的例子:

  • 计算需求:GTX 480显卡在1.4 GHz下每秒可进行约 1.4G * 480 = 6720亿 次乘法。
  • 带宽需求:为了保持所有计算单元忙碌,需要高达 7.5 TB/s 的内存带宽。
  • 现实带宽:GTX 480的实际带宽约为 177 GB/s

因此,该任务在GTX 480上的理论效率只有 177 GB/s / 7.5 TB/s ≈ 2%。但由于GPU的绝对算力远超CPU,即使效率很低,其速度仍然是CPU的8倍。

这类问题被称为带宽限制型问题。反之,如果内存供给数据的速度足够快,但ALU计算跟不上,则属于计算限制型问题。优化算法时需要识别并针对不同的瓶颈进行处理。

课程总结 🎯

本节课中,我们一起学习了从CPU到GPU的架构演进思路:

  1. 精简核心,增加ALU:将用于复杂控制逻辑的晶体管转化为更多的计算单元。
  2. 共享控制,SIMD执行:让一组ALU共享指令流,高效执行“单指令,多数据”任务。
  3. 零开销切换,隐藏延迟:通过驻留大量线程和硬件管理的快速上下文切换,掩盖内存访问等长延迟操作的影响。

这三大核心理念构成了现代GPU高性能并行计算的基础。GPU本质上是一个由大量简化计算核心、固定功能单元和高带宽内存系统组成的并行处理器,其调度器负责将海量计算任务映射到这些硬件资源上。

下次课程(周三)将是关于项目三“路径追踪器”的专题辅导课。这是一个深受同学们喜爱的项目,期待大家创造出优秀的成果。谢谢观看,我们周三再见。

006:NVIDIA Nsight Graphics 工具介绍 🚀

在本节课中,我们将学习NVIDIA Nsight Graphics工具。这是一个强大的图形应用调试和性能分析工具,能帮助我们深入理解GPU的行为,优化图形和计算工作负载。我们将重点介绍其两个核心功能:帧调试器(Frame Debugger)和GPU追踪(GPU Trace)。

工具概述与生态系统 🌐

上一节我们介绍了课程目标,本节中我们来看看Nsight Graphics在整个Nsight工具生态系统中的定位。

Nsight是NVIDIA为开发者提供的一系列工具套件,旨在帮助程序员更好地理解和优化其应用程序的性能,并进行调试。目前主要的独立应用包括:

  • Nsight Systems:用于分析CPU工作负载。
  • Nsight Compute:用于分析CUDA内核。
  • Nsight Graphics:用于分析和调试图形与游戏应用。

典型的工作流程是:首先使用Nsight Systems确定应用是受CPU限制还是GPU限制。如果确定是GPU限制,则根据具体用例(图形渲染或通用计算)选择使用Nsight Graphics或Nsight Compute进行更详细的分析。

Nsight Graphics主要有两个核心功能:

  1. 帧调试器:提供对应用程序单帧的逐绘制(draw-by-draw)回放。它允许你检查渲染错误、查看GPU上的资源和管线状态。这对于确保正确使用图形API和设置渲染管线非常有用。
  2. GPU追踪:一个性能分析工具,用于了解各项操作的执行速度、各硬件单元的吞吐量,并提供逐着色器代码行的性能信息。

帧调试器深度解析 🔍

上一节我们概述了Nsight Graphics,本节中我们来看看帧调试器的具体功能和使用方法。

启动与捕获流程

当你打开Nsight Graphics时,首先看到的是登陆页面。要开始新会话,请点击“连接”按钮。连接窗口允许你进行远程调试,但大多数情况下,你将从本地机器启动应用程序。你需要在此处填入应用程序可执行文件的路径、工作目录以及任何所需的命令行参数。

以下是可用的活动列表,帧调试器是其中之一。你还可以选择每个API的设置或通用的捕获方式。准备就绪后,点击“启动帧调试器”。应用程序启动后,按F11即可进行捕获。

捕获完成后,你将看到刚捕获的帧被逐绘制地回放出来。

用户界面导览

帧调试器的整体界面包含大量信息,我们将逐一介绍各个部分。

顶部的通用设置包括:

  • 断开连接:停止帧调试器,但程序继续运行。
  • 终止:停止程序并结束帧调试器会话。
  • 下一帧:分析下一帧。
  • 恢复:让应用程序继续运行,以便手动进行另一次捕获。
    你还可以将帧导出为C++捕获文件,以便独立地逐步执行。

核心功能面板

事件查看器
这是一个记录的API命令列表,也包括与设置命令队列相关的内容。你可以用它来控制和导航会话。点击某个事件,程序的其他部分会自动高亮显示。它按执行顺序提供了帧相关的所有命令列表。

事件查看器中的命令顺序与你编写的代码顺序基本一致。例如,在Vulkan三角形示例的渲染函数中,你会首先看到vkWaitForFences,然后是获取下一个渲染目标的vkAcquireNextImage,接着是重置围栏和命令缓冲区,最后是开始记录命令到缓冲区的vkBeginCommandBuffer

一旦开始命令缓冲区,你就可以开始记录命令。在开始渲染通道(vkBeginRenderPass)之后,你会设置视口、剪刀等命令,执行绘制索引(draw indexed),结束渲染通道。所有这些命令都会按顺序出现在这里。完成后,调用vkEndCommandBuffer

从代码角度看,你需要在将缓冲区提交到队列之前记录命令。然而,从GPU的角度看,它首先接收到队列提交,然后才开始执行缓冲区上的所有命令。这就是为什么在API列表中,这些命令都出现在你的队列提交调用之后。

擦洗器
这是事件查看器的水平视图,你可以通过点击事件ID来浏览所有事件。它提供了以队列为中心或以线程为中心的层次结构视图,你可以选择任意一种查看方式。例如,如果一个应用程序先使用了计算着色器,然后使用了图形管线,你会看到先在计算队列上做了一些工作,之后在图形队列上使用了这些结果,并按队列分开显示。

当前目标视图
显示当前正在输出到屏幕的内容,并显示输出目标,如颜色缓冲区、深度缓冲区和模板缓冲区。

事件详情
允许你查看传递给每个API调用的参数。你还可以点击这些链接来检查每个参数本身。

API检查器
这是大部分信息的来源。例如,如果你的命令在图形管线上,你会在左侧看到输入装配器、视口、顶点着色器、光栅化、片段着色器等选项卡。每个选项卡都提供了相应阶段的信息。

以下是各选项卡提供的信息:

  • 管线信息:显示你设置的管线状态,是检查设置是否正确的好地方。
  • 渲染通道:所有设置阶段都应与此处匹配。
  • 输入装配器:在此阶段应看到顶点信息输入。例如,链接到缓冲区的顶点绑定可能包含三角形顶点的位置和颜色数据。
  • 视口:大多数情况下只告诉你宽度和高度。
  • 顶点着色器:告诉你预期的输入和输出,以及使用的任何统一变量。这是检查你是否正确传递和使用这些变量的好地方。
  • 光栅化状态:包括多边形绘制模式、正面、深度偏置等设置。
  • 片段着色器:告诉你输入和输出,也可以在此查看源代码。
  • 像素操作:显示你设置的像素操作方式。
  • 上下文信息:提供关于物理设备、逻辑设备、所在队列、当前命令缓冲区、管线布局和渲染通道的信息。

如果你使用的是计算着色器或在计算队列上,则不会看到上述图形管线信息,而是看到专门的计算着色器信息,例如管线着色器阶段为“计算”,可以查看源代码和计算模块等。

对于包含曲面细分控制和曲面细分评估的图形管线,它们也会显示在这里,并提供类似的信息。

API统计信息
提供CPU时间和GPU时间的粗略概览。虽然这不是进行性能分析的主要地方,但可以从此开始思考性能问题。它会告诉你哪些API调用执行了多长时间。

几何视图
允许你检查应用程序是否试图绘制所有正确的三角形。

所有资源
允许你查看绑定到GPU的资源。例如,这可以代表等待绘制的不同管线帧,以及变换矩阵、顶点数据等。它还提供了你所选资源的每个修订版本的视图。

像素历史
你可以检查这些资源上单个像素的历史记录。打开像素历史会带你回到资源选择器,选择要查看的资源后,会看到所有不同的修订版本。点击任意一个版本,然后选择目标像素,它将给出该单个像素的精确修订历史,以及是哪些绘制调用或调用修改了这些像素。

如果你有加速结构,它也会作为资源的一部分显示出来。点击它会带你进入加速结构视图。

着色器分析器
着色器分析器是GPU追踪和帧调试器共有的功能,但帧调试器版本显示的信息较少。不过,帧调试器版本允许你实时编辑和编译着色器。

例如,导航到源代码视图,选择你感兴趣编辑的着色器。会弹出一个小窗口,左侧是原始视图,右侧是你可以编辑的区域。假设你有一个Vulkan计算粒子示例,你决定不希望颜色是彩虹色,而希望它们都是蓝色,你可以在此处编辑,然后按“编译”。随后的回放将显示你刚刚所做的更改。

关于修订版本
修订版本是指图像发生的任何更改。它不一定对应每一帧,因为在单帧内你会看到多次绘制。它更像是每次资源被修改(可能由不同的API调用引起)时创建一个新的修订版本。

实时编辑的限制
帧调试器只能捕获一帧。你对着色器所做的任何更改都只应用于当前帧的回放。当你按“下一帧”捕获下一帧时,它将使用原始应用程序的数据,而不会读取你新修改的着色器信息。实时编辑功能主要是为了快速原型设计,减少代码修改、构建、部署和查看结果所花费的时间。

GPU追踪与性能分析 ⚡

上一节我们深入了解了帧调试器,本节中我们来看看GPU追踪工具,它用于分析应用程序的性能。

GPU追踪本质上是一个性能分析工具,它允许你查看GPU上的单元吞吐量和线程束使用情况,并提供着色器性能分析信息。其动机在于,GPU追踪允许你观察GPU上随时间运行的工作负载,而帧调试器只显示事件索引。它还能让你看到哪些工作负载花费了最多时间和资源,以及它们具体发生在帧的哪个位置。这样,我们可以确定性能限制因素,最终目标是减少帧或工作负载的运行时间,或提高FPS。

捕获与GPU概念

首先,我们谈谈如何捕获追踪。在设置中选择GPU追踪分析器,你可以设置每个GPU的设置(通常能自动检测),在“追踪设置”中,你可以选择手动触发或等待一定帧数或提交次数。建议打开“实时着色器分析器”以获取着色器信息。

与帧调试器类似,准备好后按F11。但与帧调试器不同,GPU追踪不是基于回放机制构建的,因此不会有逐绘制的回放。

在深入细节之前,我们需要了解一些GPU概念:

  • GPU饥饿:意味着你没有给GPU足够的工作。
  • 延迟限制:意味着GPU完成某些操作需要很长时间。
  • 吞吐量限制:意味着单元一次可以执行的最大操作数已超出。
  • 延迟:考虑的是时间
  • 吞吐量:考虑的是数量

你需要关注的关键指标包括:

  • GPU活跃度:表示GPU的利用率。
  • 单元吞吐量:显示GPU上哪些单元正以其最大性能运行。
  • SM指令吞吐量:是单元吞吐量的逻辑下一步,进一步细分SM单元的性能。
  • SM线程束占用率:告诉你平均在任何给定时刻,一个SM上有多少个活跃的线程束,以及它们是哪种类型的线程束。

“单元”指的是硬件中的各个独立单元。

像素着色器执行概览

简单回顾一下像素着色器在SM上执行时通常发生的情况:

  1. SM开始发出指令,线程开始活跃。
  2. 当线程请求的信息不在本地时,它会向L1缓存发出请求;如果不在L1,则请求L2;如果还不在,则访问VRAM(全局内存)。
  3. 最后,信息返回给SM,进行计算,然后像素通过ROP单元输出到屏幕。

P3性能分析方法论

我们将讨论P3方法,即峰值性能百分比方法。这是由NVIDIA的开发者技术工程师Louis Bavoil在2019年GDC上提出的,是开始优化工作负载的一个非常好的方法。

方法步骤如下:

  1. 查看GPU活跃度:如果低于95%,转到Nsight Systems,找出你没有给GPU足够工作的原因。如果大于95%,则开始查看顶部单元吞吐量。
  2. 查看顶部单元吞吐量
    • 如果低于60%,意味着该单元没有完成很多操作,可能是你没有给它足够的工作,或者它完成当前工作花费了很长时间。例如,如果该单元是VRAM,你可能需要减少VRAM访问。
    • 如果高于80%,但工作负载仍然很慢,那么向该单元添加更多工作不一定会让它更快。此时,你可以尝试将工作从这个单元转移出去,放到其他单元上,以便其他单元能更快地完成这些工作。

案例分析:粒子群模拟优化

我们通过一个案例来实践P3方法。这是2022年的一个粒子群模拟项目,需要从均匀的内存访问模式转变为连贯的内存访问模式。

在调试模式下,对包含50万个粒子的均匀网格进行追踪,CUDA内核执行了约252毫秒,帧率极低。切换到连贯网格后,内核时间下降到约200毫秒,改善不大。然而,在发布模式下,差异变得非常显著:均匀网格约38毫秒,而连贯网格约2.7毫秒,性能提升超过10倍。

我们使用P3方法分析均匀网格版本:

  1. GPU引擎活跃度大于95%,符合在Nsight Graphics的GPU追踪中分析的条件。
  2. 查看顶层吞吐量:发现L2和VRAM吞吐量很高,但SM吞吐量异常低,尽管正在执行内核。
  3. 查看SM线程束占用率:大多数活跃线程束是CUDA线程束,这很正常。
  4. 查看平均线程束延迟:这个指标衡量绝对着色器性能,显示一个线程束的平均存活时间约为120万个周期,这相当长。
  5. 查看SM线程束在发出阶段停滞的原因:顶部指标是“长记分牌”,平均约有72%的线程束因为等待长记分牌而无法启动。

理解记分牌

  • 短记分牌:指不导致SM停滞的可变延迟指令,例如平方根、超越函数等。
  • 长记分牌:指导致SM停滞的可变延迟指令,例如纹理读取、缓存读取等。

从分析中我们了解到:

  • 大部分时间花在获取数据上,而不是执行指令。
  • 单个线程束的平均寿命非常长。
  • 依赖于数据的指令可能导致SM停滞。

可能的原因包括:

  • 缓存颠簸:当一个粒子读取其邻居的位置或速度时,每次访问都会带入一个缓存行(大约128字节)。如果下一个邻居的数据在内存中不相邻,这个缓存行就需要被替换,导致不断向全局内存发出请求,从而引起缓存颠簸。
  • 内存延迟:随机内存访问效果不佳,因为内存控制器难以预测和预取数据。

优化措施
通过着色器分析器查看热点,可以发现访问位置向量的代码行占据了大量样本。样本数量可以近似代表执行时间。因此,优化措施是:根据位置对粒子的位置和速度数据进行排序,使得在空间中靠近的粒子(更可能相互读取)的数据在内存中也靠近存放

优化结果
优化后,性能指标发生了显著变化:

  • SM吞吐量上升至83%。
  • L2吞吐量下降至18%。
  • VRAM吞吐量下降至约4%。
  • 内核执行时间从38毫秒降至2.7毫秒。
  • 平均线程束延迟从120万个周期降至约8.5万个周期。
  • 因长记分牌而停滞的线程束比例从约68%降至20%。

用户界面与功能导览

GPU追踪的界面与帧调试器相似,但时间线现在会显示每项操作花费的时间。图形和计算队列的工作会分开显示。

主要视图和指标包括:

  • 时间线:显示各项操作的持续时间。
  • GPU活跃度:显示GPU或复制引擎活跃的持续时间。
  • 顶层吞吐量:我们之前查看过的各单元吞吐量。
  • SM指令吞吐量:进一步细分SM本身各单元的性能,如指令发出阶段吞吐量、ALU(整数运算)、FMA(浮点运算)等。
  • SM线程束占用率:如前所述,显示典型活跃SM上平均有多少线程束活跃,以及它们的类型。
  • 平均线程束延迟:着色器性能的绝对测量值,显示在选定时间段内线程束的平均寿命。
  • 线程束发出停滞:显示如果SM无法发出线程束,原因是什么(例如,在等待什么)。
  • 指标信息:显示时间线信息中所有离散值的平均值,可以针对选定的帧、持续时间或整个报告进行计算。

注意事项:机器是否连接电源会影响帧的持续时间,因为它会影响性能。因此,为了获得可比较的结果,请确保在相同电源状态下进行分析。

API事件列表
与帧调试器类似,但也有所不同。在GPU追踪的事件列表中,你可以看到命令缓冲区对应于哪个命令队列,绘制调用对应于哪个命令缓冲区,提供了一个层次结构视图。对于同时执行的API,它们会分组显示为一个标签,而不是两个独立的任务。

实时着色器分析器
这与帧调试器中的呈现方式略有不同。

  • 指令混合:告诉你着色器中哪种类型的指令占据了大部分。
  • 热点:提供每个源代码行的细分,显示哪行代码花费了最多时间。
  • 火焰图:以执行顺序显示所有着色器函数,每个条形的长度代表大约花费的时间(样本数),上面的后续条形代表调用的任何子函数。
  • 自上而下/自下而上视图:函数排序视图,你可以看到函数被调用的上下文。
  • 着色器管线/对象/源代码:应用程序中所有着色器的综合视图。灰色的着色器表示未检测到活动。你可以按管线对象、着色器对象或着色器源代码查看。
  • 着色器源代码视图:此处不能编辑着色器,但会提供源代码视图与中间语言视图的对比。例如,对于Vulkan,你会看到GLSL视图和SPIR-V视图并排。

追踪比较
允许你比较优化前后的追踪文件。在优化前进行一次追踪,优化后再进行一次追踪,然后比较两者的性能,它会给出两个追踪文件之间所有指标的比率和对比。

总结与职业建议 💡

本节课中我们一起学习了NVIDIA Nsight Graphics工具的两个核心部分。

对于帧调试器,我们主要探讨了:

  • 渲染问题排查。
  • 管线状态检查。
  • 资源检查。
  • 实时着色器编辑。

对于GPU追踪,我们讨论了:

  • 使用着色器分析器进行性能分析。
  • 使用P3方法分析GPU工作负载的性能。
  • 通过粒子群模拟项目的案例,实际演练了性能分析过程。

最后,分享一些职业和生活建议:

  1. 从零开始构建项目:亲自动手搭建框架,这能让你在众多求职者中脱颖而出。
  2. 与同伴和他人共度时光:在共同学习和解决问题的过程中相互学习,分享经验。
  3. 聪明地工作,善用工具:利用调试器和其他工具提高效率,从长远看可以节省大量时间。
  4. 掌握小众知识:学习他人不会的技能,或者付出额外的努力,这能真正让你与众不同。
  5. 保持现实的期望:求职市场充满变数,优秀的候选人也可能被拒绝。尽你所能做到最好。
  6. 学习永无浪费:花时间学习新事物,这些知识终将在未来以某种方式回馈你。

希望这些工具的介绍和职业建议对你有所帮助。记住,在学习和使用这些强大工具的过程中,你积累的每一分经验都是宝贵的。

007:流压缩与扫描算法教程 🚀

概述

在本教程中,我们将学习如何实现流压缩与扫描算法。与之前的项目不同,本项目要求实现具有实际计算功能的并行算法。我们将从CPU实现开始,逐步过渡到GPU实现,并探讨相关的优化技巧。

项目简介与CPU实现的重要性

上一节我们介绍了项目的整体目标,本节中我们来看看CPU实现的重要性。

本项目与项目一不同,项目一主要展示视觉效果以建立编程信心,而项目二要求实现实际的并行算法。首先实现CPU算法是一个良好的实践,这有助于理解算法逻辑,并能在编写CUDA代码时预见可能遇到的问题。

项目二的输出是确定性的,结果应始终保持一致。因此,确保CPU实现正确至关重要,因为它将作为GPU实现正确性的对比基准。如果CPU实现错误,将无法判断GPU实现是否正确。

以下是实现步骤:

  1. 在单独的C文件中实现朴素算法和高效算法。
  2. 每个C文件中的CPU函数将被测试文件调用,以验证程序正确性和获取性能结果。
  3. 在实现GPU版本前,务必确保CPU版本正确。

GPU实现与测试

在完成CPU实现后,我们可以开始进行GPU实现与测试。

接下来,可以测试扫描和流压缩算法的CUDA库函数,这相对简单,只需调用相应的API即可。

此外,还可以尝试实现使用共享内存的额外优化。在实现了高效的共享内存扫描后,可以进一步尝试避免共享内存的银行冲突。银行冲突的概念将在后续课程中介绍。

项目中已在 common.ccommon.h 文件中提供了一些辅助函数。可以先查看这些函数的签名,尝试实现它们,这些函数将在整个实现过程中被频繁调用。

最后是性能计时部分。可以在函数调用中使用GPU或CPU计时器。这里我们使用标准库测量CPU时间,并使用CUDA事件来跟踪CUDA程序的执行时间。CUDA事件就像书签,可以记录特定事件的时间点,功能非常强大。

实现技巧与注意事项

上一节我们讨论了GPU实现的基础,本节中我们来看看一些具体的实现技巧和需要注意的事项。

以下是实现时需要关注的要点:

  • 测量时间时,确保排除内存操作(如分配或复制)的时间,我们只关心内核的实际运行时间。
  • 如果发现初始的GPU实现比CPU实现慢,可以检查内核调度。确认是否有大量线程闲置,从而减少实际调度的线程数量。
  • 可以向现有项目添加新文件,但当前的构建系统不支持从一个文件的核函数调用另一个文件中的设备函数。设备函数只能被同一文件内的核函数调用。
  • CUDA内核是异步执行的。可能需要使用 cudaDeviceSynchronize() 来等待内核执行完成,以获得一致的结果。
  • 可以使用许多CUDA内部函数,例如 __powf(),这是CUDA内部的浮点幂函数,可以获得更好的性能。

在完成所有实现后,建议花时间思考扫描算法与归约算法的异同。不要只专注于实现细节,理解并行算法背后的设计思想同样重要。思考“为什么这个算法要这样设计”可以帮助你获得更深入的理解。

始终在发布模式下进行测试。这一点非常重要。

常见问题解答与算法理解

在掌握了基本实现后,我们来解答一些常见问题并深化对算法的理解。

关于“散射”操作:在流压缩中,散射是根据扫描结果和标志位,将有效数据重新排列到输出数组的操作。CPU版本的散射实现将作为GPU输出的黄金标准进行对比测试。

关于测试:建议生成大量随机数据(例如百万级元素),在CPU上运行得到标准结果,然后在GPU上运行并对比,以验证正确性。CPU版本几乎总是比GPU版本更容易实现和调试。

关于算法实现细节:项目中可能需要实现两种算法变体。请参考课程幻灯片和《GPU Gems》等书籍。在复制代码前务必先理解其原理。

关于性能基准测试:不要只运行一次就记录结果。应该运行多次迭代并取平均值。在迭代过程中,可以通过交换指针而非复制内存块来高效地交换输入/输出缓冲区。

关于同步:在GPU编程中,始终需要考虑CPU和GPU之间的同步问题。

总结

本节课中我们一起学习了流压缩与扫描算法的实现。我们从CPU实现的重要性开始,讨论了其作为正确性基准的作用。然后,我们过渡到GPU实现,介绍了使用CUDA库、共享内存优化以及性能计时的基本方法。我们还探讨了实现过程中的关键技巧,如排除内存操作计时、优化线程调度和理解异步执行。最后,我们通过解答常见问题,加强了对算法核心“散射”操作以及测试、性能评估方法的理解。记住,在追求代码实现的同时,深入理解并行算法的设计思想同样重要。

008:CUDA性能优化 🚀

在本节课中,我们将深入探讨如何提升CUDA程序的性能。我们将从理解性能的基本方程开始,逐步分析如何通过优化线程利用率、内存吞吐量和指令吞吐量来最大化GPU的计算能力。课程将涵盖并行归约的优化、内存访问模式、存储体冲突、SM资源分区、数据预取、循环展开和线程粒度等核心概念。


性能基础方程 📈

上一节我们介绍了并行算法的基础,本节中我们来看看如何从性能角度优化这些算法。

高效的GPU性能可以概括为一个基本方程:将高效的数据并行算法与能够提供超强并行性的优化GPU架构相结合,就能最大化性能。

核心公式性能 = 高效数据并行算法 + 优化GPU架构 + 超强并行性

我们的整个讲座都将围绕如何实现这个方程展开。


重温并行归约

还记得我们上节课讨论的并行归约吗?我们有一个包含八个元素的向量,通过两两相加的方式进行归约求和。

以下是该算法的一些特性:

  • 算法是原地操作的。
  • 随着归约进行,越来越多的线程变得空闲。例如,在第一步,八个线程中就有四个不工作。
  • 算法要求元素数量是2的幂,通常需要对向量进行填充。
  • 索引模式是固定的,结果最终存储在最后一个元素。

如果我们翻转这个操作的方向,算法本身不会改变,但会为我们后续的性能改进奠定基础。

以下是该算法的核心代码逻辑(在块内使用共享内存):

unsigned int t = threadIdx.x;
for (int stride = 1; stride < blockDim.x; stride *= 2) {
    __syncthreads();
    if (t % (2 * stride) == 0) { // 注意:避免直接使用`%`操作
        sdata[t] += sdata[t + stride];
    }
}

随着步长stride增加,越来越多的线程无事可做。对于一个有N个元素的归约,我们实际上只需要N/2个线程开始工作。这种对GPU的利用率非常低,我们可以改进它。


优化归约:减少线程浪费与分支分化

如何改进这个实现呢?关键在于改变索引模式。

优化后的思路是:让活跃的线程连续排列,而不是交替出现。这样,在每一步,整组的线程(即线程束)可以更早地结束工作并被调度器释放,从而让其他等待的线程块更快开始执行。

优化后的算法图示显示,非工作线程是相邻的。这意味着在拥有大量线程时,我们可以成组地(以线程束为单位)淘汰线程,而不是零星地淘汰。

以下是优化后的循环实现:

for (int stride = blockDim.x / 2; stride > 0; stride >>= 1) {
    __syncthreads();
    if (t < stride) {
        sdata[t] += sdata[t + stride];
    }
}

主要变化:

  1. 循环从最大步长开始,每次减半。
  2. 判断条件从取模运算t % (2*stride) == 0变为简单的比较t < stride

重要提示:在GPU上,取模运算%是代价非常高的操作(可能需要数百个时钟周期),而比较运算<则快得多(大约4个周期)。应尽量避免在CUDA内核中使用取模运算。

这种优化不仅通过更早地释放线程束来提高了线程利用率,还显著减少了线程束内的分支分化。


线程束分区与分支分化

要理解分支分化,首先需要了解线程束是如何在块中划分的。

核心规则是:每个线程束由32个连续的线程ID组成。无论你的线程块维度如何(1D、2D或3D),线程在展平为一维数组后,线程束总是由连续的32个线程构成。

分支分化发生在同一个线程束内的线程需要执行不同代码路径时(例如,通过if语句)。在这种情况下,GPU会序列化地执行所有路径,导致性能下降。

在我们的归约例子中,未优化的版本(使用取模判断)会导致严重的分支分化,因为相邻线程(如线程0和1)会进入不同的分支。而优化后的版本(使用t < stride)在大部分步骤中,同一个线程束内的线程会执行相同的操作,从而避免了分支分化。

通过将非工作线程集中在左侧,我们使得整个线程束可以更早地被判定为“全部空闲”从而退役,进一步提高了SM的资源利用率。


内存访问优化:合并访问

给定一个行主序矩阵,什么样的线程访问模式能实现最高的内存带宽利用率?

关键在于:让相邻的线程访问相邻的内存地址。这种访问模式被称为“合并访问”,它允许GPU在一次内存事务中读取一大块连续的数据,供整个线程束使用,从而最大化总线利用率。

考虑一个简单的矩阵访问。如果线程0访问A[0],线程1访问A[1],以此类推,那么这些访问是连续的,效率很高。如果线程0访问A[0],线程1访问A[N](即下一行的开头),那么访问就是跨步的,效率很低,因为一次内存读取操作获取的数据中只有一小部分被实际使用。

核心思想:设计你的内核,使得线程束中的线程访问全局内存时,地址尽可能连续。

全局内存访问速度很快,但前提是访问连续的地址。如果访问模式是随机的,性能会急剧下降。L1和L2缓存以特定大小的块(如128字节或32字节)传输数据。即使你只需要一个4字节的float,GPU也必须读取整个块。因此,访问模式决定了有效带宽的百分比。

合并访问是GPU算法中最重要的性能优化点之一,而共享内存是解决非合并访问问题的常用工具。


共享内存与存储体冲突

共享内存是每个SM上的高速可编程缓存。为了进一步提升速度,共享内存被组织成多个存储体。

关键点:

  • 每个存储体每个时钟周期只能响应一个地址的访问请求。
  • 存储体冲突发生在同一个线程束内的多个线程同时请求同一个存储体中的不同地址时。这会导致访问被序列化,从而降低性能。
  • 如果同一个线程束内的所有线程访问同一个存储体中的同一个地址,这称为“广播”,是高效的,不会导致冲突。

存储体冲突的严重程度取决于同时访问同一个存储体的线程数量,这被称为“N路存储体冲突”。

在矩阵转置等算法中,存储体冲突是一个常见问题。因为写入操作通常具有跨步访问模式,可能导致同一个线程束内的多个线程访问同一个存储体,造成严重的存储体冲突(如32路冲突)。理解并解决存储体冲突是优化诸如矩阵转置等内存密集型操作的关键。


SM资源分区与占用率

每个流多处理器拥有的硬件资源是有限的,主要包括:

  1. 线程块槽位:可同时驻留的线程块数量。
  2. 线程槽位:可同时活跃的线程总数。
  3. 寄存器文件大小。
  4. 共享内存大小。

这些资源共同决定了GPU的“占用率”,即硬件上同时活跃的线程束数量与最大可能数量之比。

例如,一个SM可能支持最多8个块和768个活跃线程。如果你启动的每个线程块有256个线程,并且每个线程使用10个寄存器,那么你可以同时运行3个块(共768线程),使用约7680个寄存器,这在8K寄存器的限制内。但如果每个线程使用15个寄存器,总需求将超过限制,SM将减少同时运行的块数,从而降低占用率。

高占用率有助于隐藏全局内存访问延迟(因为当一些线程束等待数据时,其他线程束可以执行计算)。然而,占用率并非越高越好。有时,为了使用更多寄存器来展开循环或预取数据,接受较低的占用率反而能获得更高的整体性能。这需要根据具体算法进行权衡。

开发者可以使用CUDA占用率计算器或查询cudaDeviceProp来了解硬件限制并优化内核启动配置。


指令级优化:数据预取

数据预取是一种通过重叠计算和内存访问来隐藏延迟的技术。

其思想是:在需要数据之前,提前发出加载指令。由于GPU的加载操作是异步的,线程可以在等待数据从全局内存加载的同时,执行一些不依赖于该数据的独立计算。

例如,在矩阵乘法中,我们可以:

  1. 将当前数据块从寄存器存入共享内存。
  2. 执行同步以确保共享内存就绪。
  3. 在计算当前数据块的乘加运算的同时,预取下一个数据块到寄存器中。

这样,计算和下一次内存加载就重叠了起来。这通常需要增加一些寄存器来保存预取的数据。

前提:预取的计算必须独立于正在加载的数据。编译器也会尝试进行指令重排以实现预取,但手动优化可以更精确地控制。


循环展开

循环展开是一种减少循环开销的经典优化技术。在循环中,每次迭代除了进行实际计算外,还需要进行索引递增、条件比较等操作。

通过循环展开,可以减少这些开销指令的比例,使计算更加密集。在CUDA中,可以使用#pragma unroll指令提示编译器展开循环。

注意事项

  • 循环边界必须在编译时已知。
  • 可能导致代码体积增大,增加指令缓存压力。
  • 可能增加寄存器使用量。

循环展开通常用于追求极致性能的场景,是“最后1%”优化的一部分。


线程粒度

线程粒度指的是每个线程所完成的工作量。

在并行归约中,一个线程可能只加两个数,也可能加八个或十六个数。在矩阵乘法中,一个线程计算一个输出元素,涉及大量乘加运算。

调整线程粒度是一种重要的优化手段:

  • 更粗的粒度:每个线程处理更多数据。这可以减少线程总数,从而降低线程创建和管理开销,也可能减少全局内存访问次数(如果数据可重用)。但可能降低并行度和占用率。
  • 更细的粒度:每个线程处理较少数据。这增加了并行度和占用率,有助于更好地隐藏延迟,但可能增加开销。

选择最佳线程粒度需要在并行度、占用率、内存访问模式和指令开销之间取得平衡。对于计算密集型的核函数,较粗的粒度可能更有效;对于内存访问密集型的核函数,较细的粒度可能有助于隐藏延迟。


总结 🎯

本节课中我们一起学习了CUDA性能优化的多个核心方面。

我们从理解性能的基本方程出发,首先学习了如何通过重构并行归约算法来优化线程利用率和减少分支分化。接着,我们探讨了内存合并访问的重要性,以及如何通过合理的线程访问模式来最大化内存带宽。

然后,我们深入研究了共享内存的组织方式及其带来的存储体冲突问题,并了解了SM资源分区如何影响内核的占用率和最终性能。我们还介绍了指令级优化技术,如数据预取和循环展开,这些技术可以帮助我们隐藏延迟并提高指令吞吐量。最后,我们讨论了线程粒度的概念,以及如何根据算法特点调整每个线程的工作量。

记住,优化是一个迭代过程:先实现一个正确但朴素的内核,然后使用性能分析工具定位瓶颈,再应用本节课学到的知识进行针对性优化。在下一讲中,我们将把这些理论应用到实际案例中,通过性能分析实验室来深化理解。

希望本教程能帮助你构建起CUDA性能优化的知识框架!

009:路径追踪器项目辅导 🎨

在本节课中,我们将学习如何实现一个基于GPU的路径追踪器。我们将从路径追踪的基础知识开始,然后深入探讨如何在GPU上高效实现,并介绍一系列可以提升渲染效果和性能的额外功能。

路径追踪基础 🧠

上一节我们介绍了课程概述,本节中我们来看看路径追踪的基本原理。

在现实世界中,光线从光源(如灯、太阳)发出,在场景中的各种表面和材质上多次反弹,最终到达我们的眼睛或相机。光的颜色由它沿途击中的物体决定。

然而,在路径追踪中,我们反向模拟这个过程。我们从相机发射光线,让光线在场景中反弹,直到击中光源。通过这种方式,我们可以计算出图像中每个像素的最终颜色。

渲染方程

光线传输使用渲染方程计算。以下是其核心公式:

Lo(p, ωo) = Le(p, ωo) + ∫Ω f(p, ωi, ωo) Li(p, ωi) |cosθ| dωi

让我们分解这个公式:

  • Lo(p, ωo): 从点 p 沿方向 ωo 出射的辐射亮度(最终颜色/亮度)。
  • Le(p, ωo): 点 p 自身发射的辐射亮度(对于光源大于零)。
  • ∫Ω ... dωi: 对以表面法线为中心的半球 Ω 上所有入射方向 ωi 的积分。
  • f(p, ωi, ωo): 双向散射分布函数(BSDF),决定光线如何从方向 ωi 散射到方向 ωo
  • Li(p, ωi): 从方向 ωi 入射到点 p 的辐射亮度。
  • |cosθ|: 余弦项(Lambert余弦定律),考虑了光线以掠射角照射表面时接收到的光更少。

什么是BSDF?

BSDF定义了光线如何从表面反射或透过表面传输。它接收入射方向和出射方向,并输出一个比例因子。BSDF是一个统称,可以细分为:

  • BRDF: 用于描述表面反射。
  • BTDF: 用于描述表面透射(如玻璃)。

以下是几种常见BSDF的例子:

以下是几种常见材质类型:

  • 漫反射材质: 光线均匀地向所有方向反射。例如未加工的木材、混凝土。
    • 核心概念:f_lambert = albedo / π
  • 镜面反射: 像镜子一样,光线根据法线精确反射。可以带有颜色色调。
  • 折射/透射: 光线穿过材质时方向发生改变,遵循斯涅尔定律。常与镜面反射结合来模拟玻璃等材质。

蒙特卡洛积分

由于不可能对无限多的路径进行积分,我们使用蒙特卡洛积分来估计渲染方程的值。

蒙特卡洛估计器的公式如下:

<F> = (1/N) * Σ [f(Xi) / p(Xi)]   (i=1 to N)

其中:

  • f(x) 是待积分的函数(在这里是渲染方程的积分部分)。
  • N 是样本数量。
  • Xi 是从概率分布 p(X) 中抽取的样本。

随着样本数量 N 的增加,估计值 <F> 会接近真实的积分值。

随机采样抗锯齿

在路径追踪中,抗锯齿几乎是“免费”的。我们不需要进行超级采样,只需在每个像素内随机抖动光线的起始位置。随着样本数量的累积和平均,锯齿状的边缘自然会变得平滑。

递归与迭代实现

路径追踪的核心逻辑可以通过递归或迭代的方式实现。

以下是递归实现的伪代码:

Color traceRay(Ray ray, int depth) {
    if (depth >= MAX_DEPTH) return BLACK;
    Intersection hit = findClosestIntersection(ray);
    if (!hit.hasHit) return BLACK; // 或返回环境光
    if (hit.material.isLight()) return hit.material.emission;

    // 采样BSDF,获取新的光线方向和BSDF值
    (newDirection, bsdfValue, pdf) = sampleBSDF(hit.material, hit.normal, ray.direction);
    Ray newRay(hit.position, newDirection);

    // 递归追踪,并加入蒙特卡洛权重
    Color incoming = traceRay(newRay, depth + 1);
    return bsdfValue * dot(hit.normal, newDirection) * incoming / pdf;
}

迭代实现则使用循环,并维护一个“吞吐量”变量来累积光线在多次反弹中的颜色衰减:

Color pathTrace(Ray initialRay) {
    Color throughput = WHITE;
    Color radiance = BLACK;
    Ray ray = initialRay;

    for (int depth = 0; depth < MAX_DEPTH; depth++) {
        Intersection hit = findClosestIntersection(ray);
        if (!hit.hasHit) break;
        if (hit.material.isLight()) {
            radiance += throughput * hit.material.emission;
            break;
        }
        // 采样BSDF
        (newDirection, bsdfValue, pdf) = sampleBSDF(hit.material, hit.normal, ray.direction);
        // 更新吞吐量和光线
        throughput *= bsdfValue * dot(hit.normal, newDirection) / pdf;
        ray = Ray(hit.position, newDirection);
    }
    return radiance;
}

迭代方法避免了递归调用,更适合GPU并行架构。

GPU实现策略 ⚙️

上一节我们介绍了路径追踪的基础算法,本节中我们来看看如何将其高效地映射到GPU上。

路径追踪有一个显著的优点:每个像素的光线追踪计算是相互独立的。这被称为“令人尴尬的并行”问题,非常适合GPU处理。

朴素方法及其问题

最直接的想法是为每个像素启动一个线程(一个内核),每个线程独立完成完整的路径追踪循环(迭代方法)。伪代码如下:

__global__ void pathTraceKernel(Color* image, ...) {
    int x = blockIdx.x * blockDim.x + threadIdx.x;
    int y = blockIdx.y * blockDim.y + threadIdx.y;
    if (x >= width || y >= height) return;

    Color pixelColor = BLACK;
    for (int s = 0; s < samplesPerPixel; s++) {
        Ray ray = generateRay(x, y, randomJitter);
        pixelColor += iterativePathTrace(ray); // 调用上面的迭代函数
    }
    image[y*width + x] = pixelColor / samplesPerPixel;
}

然而,这种方法存在两个主要问题:

  1. 高发散性与低一致性: 当光线开始反弹后,同一个线程束(Warp)中的不同线程可能执行完全不同的代码路径(例如,一些击中光源提前退出,一些击中漫反射表面继续反弹)。这严重降低了GPU的执行效率。
  2. 线程提前终止: 一些线程可能很快击中光源并完成工作,但必须等待同线程束内其他长时间反弹的线程,造成计算资源浪费。

解决方案:分段路径追踪与流压缩

为了解决上述问题,我们采用“分段路径追踪”策略。我们将一个庞大的、包含循环的内核,拆分成多个按顺序执行的小内核阶段,并在阶段之间对活跃线程进行管理。

以下是核心的四个阶段:

  1. 光线生成: 为每个像素生成初始光线。
  2. 求交测试: 测试所有活跃光线与场景中物体的最近交点。
  3. 着色与采样: 根据交点材质计算颜色,并采样生成下一次反弹的光线方向。
  4. 最终聚集: 将完成追踪的光线颜色累加到最终图像上。

关键优化在于流压缩。在每个阶段(尤其是求交和着色后),我们会有一批光线终止(例如击中光源、达到最大深度或射出场景)。我们可以使用类似thrust::copy_if的函数,将仍然活跃的光线数据压缩到一起,移除已终止的光线。这样,下一个内核启动时只需要处理更少的、密集排列的活跃线程,极大地提高了Warp利用率和缓存效率。

按材质排序

即使进行了流压缩,相邻线程处理的材质仍可能不同(例如一个处理漫反射,一个处理镜面反射),导致着色内核内部仍然存在分歧。

进一步的优化是按材质对活跃光线进行排序。这样,在执行着色内核时,相邻线程极有可能执行相同的材质着色代码,大大提高了线程一致性,从而提升性能。

以下是优化后的管线流程示意图:

生成所有光线 (活跃)
    |
    v
[求交测试] -> 压缩掉未命中的光线
    |
    v
[按材质排序] (可选但推荐)
    |
    v
[着色与采样] -> 生成新光线,压缩掉终止的光线
    |
    v
否 -> [是否达到深度或无活跃光线?]
|                             |
是                            |
    v
[最终聚集] <------------------

项目结构与核心任务 📁

上一节我们探讨了GPU实现的优化策略,本节中我们具体看看项目的代码结构和必须完成的核心任务。

项目布局

基础代码已经提供了框架,你需要重点关注以下文件:

  • pathtrace.cu: 核心文件。包含了四个内核的框架(generateRayFromCamera, computeIntersections, shadeFakeMaterial, finalGather)。你的主要工作将集中在完善computeIntersections和将shadeFakeMaterial改造成真正的shadeMaterial
  • interactions.h: 包含材质计算辅助函数,例如calculateRandomDirectionInHemisphere
  • intersections.h: 包含几何体求交函数(如球体、包围盒)。如需添加三角形或其它图元,需在此修改。
  • scene.cpp: 场景加载逻辑(基于JSON)。如需支持自定义模型加载,需在此修改。
  • preview.cpp: 包含OpenGL显示和ImGui控件代码。你可以在此添加交互参数和性能统计显示。

核心要求

项目分为两个主要部分:

第一部分(人人必须完成):

  1. 实现漫反射完美镜面反射材质。
  2. 实现分段路径追踪,并集成流压缩
  3. 实现按材质对路径段排序的功能。
  4. 实现随机采样抗锯齿

第二部分(需完成至少10分的功能):
你需要从项目README列出的功能列表中选择并实现,以赚取至少10点积分。例如:

  • 直接光照与多重重要性采样:显著降低噪点。
  • 加载自定义网格模型:使场景更丰富。
  • 空间加速结构:如BVH,用于加速网格求交。
  • 基于物理的材质:微表面模型、纹理贴图、次表面散射等。
  • 物理相机:景深、鱼眼镜头、运动模糊。
  • 降噪器:如Intel Open Image Denoise,能极大减少所需采样数。
  • 程序化内容和更多功能

重要提示

  • 所有实现的功能都必须在你的项目README.md中清晰展示和说明,否则将无法获得分数。
  • 鼓励创新,如果你想实现未列出的功能,请在Ed讨论区申请预审以确定其积分价值。

使用ImGui

项目中已集成ImGui库,你应该利用它来:

  • 暴露渲染参数(如相机FOV、光圈、最大深度)。
  • 显示性能统计数据(如每帧时间、各阶段耗时、活跃线程数)。
  • 提供功能开关(如切换是否启用材质排序),以便进行性能对比分析。

额外功能与灵感 ✨

上一节我们明确了项目的核心要求,本节中我们将浏览一些可以大幅提升作品质量的额外功能,并从中获取灵感。

关键额外功能详解

  1. 直接光照与多重重要性采样:这是减少噪点最有效的方法之一。与其等待光线随机击中光源,不如在每次着色点时直接对光源进行采样。结合BSDF采样,使用多重重要性采样来平衡两种策略,能在各种材质和光照条件下都获得稳健的结果。
  2. 自定义网格与加速结构:加载外部3D模型(如GLTF、OBJ格式)能极大扩展场景可能性。随之而来的挑战是性能。为复杂网格实现一个包围盒层次结构(BVH)是至关重要的,它能将对所有三角形的遍历减少为对树形结构的遍历。
  3. 降噪Intel Open Image Denoise 是一个易于集成的、基于AI的降噪库。它可以直接在CUDA内存上操作,并能利用法线、反照率等辅助缓冲区提升质量。仅需几十个样本,就能得到近乎干净的图像,能节省大量渲染时间。
  4. 高级材质与相机
    • 微表面模型:实现粗糙度参数,让反射变得模糊,更接近真实金属、塑料。
    • 纹理贴图:为模型添加颜色、法线、粗糙度等纹理,增加细节。
    • 景深:模拟真实相机的聚焦效果,让画面更具电影感。
    • 参与介质:渲染雾、烟、云等体积效果。

往届作品画廊

看看往届学生的优秀作品可以获得很多灵感:

  • 复杂场景渲染:包含自定义导入的模型、环境光照和高级材质。
  • 创意构图:使用景深、鱼眼镜头等相机效果营造特定氛围。
  • 技术展示:同时展示降噪前后对比、BVH可视化、性能分析图表等。
  • 艺术化表达:精心布置的光照、色彩和模型,创造出具有美感的渲染图。

请记住:你的封面图不应只是一个简单的Cornell盒子。努力创造一个独特、美观、能展示你技术实力的场景。

实用建议与截止日期

  • 开发策略:建议先快速完成核心要求,确保基础路径追踪器能工作。然后再有策略地选择额外功能,先实现那些性价比高(如降噪器)或你特别感兴趣的功能。
  • 第三方代码:对于像BVH构建这样的核心算法,直接使用库可能无法获得积分。但对于像Open Image Denoise这样的复杂库,则是允许且鼓励的。如有疑问,请在Ed讨论区提问。
  • README的重要性:README是你项目的门面。它应该包含精美的渲染图、功能说明、性能分析、实现细节、引用来源等。投入时间制作一个优秀的README至关重要。
  • 截止日期政策
    • 代码截止日期:10月1日(周二)晚11:59。
    • README截止日期:10月3日(周四)晚11:59。
    • 这两天间隔是专门用于完善README和生成最终渲染图的,不应再提交新代码。
    • 迟交天数可以应用于代码截止日期或README截止日期。

总结 🎯

本节课中我们一起学习了GPU路径追踪项目的完整流程。

我们从路径追踪的基础原理出发,理解了渲染方程、BSDF和蒙特卡洛积分。接着,我们探讨了如何在GPU上高效实现路径追踪,分析了朴素方法的缺陷,并引入了分段追踪、流压缩和按材质排序等关键优化策略。然后,我们梳理了项目代码结构,明确了核心实现要求可选额外功能。最后,我们通过往届作品获得了灵感,并了解了一些实用的开发建议和截止日期政策

现在,你已经具备了开始这个激动人心项目所需的基础知识。记住,这是一个展示你创意和技术能力的绝佳机会。从基础做起,逐步迭代,勇于尝试新功能,并最终呈现一个令人印象深刻的作品。祝你编程愉快!

010:性能分析与优化实战

在本节课中,我们将学习如何利用性能分析工具(如NVIDIA Nsight Compute)来诊断和优化GPU内核。我们将通过两个核心案例——矩阵转置(Transpose)和归约(Reduction)——来演示从基础实现到高度优化的完整流程。你将学会如何解读性能分析器的建议,并应用关键的优化技术来显著提升内核性能。

概述:性能分析的重要性

上一节我们介绍了GPU内存层次结构和共享内存的基本概念。本节中,我们来看看如何将这些理论知识应用于实际优化。性能分析器是我们的“导航仪”,它能精确指出代码的性能瓶颈所在,例如内存访问模式不佳、存储体冲突(Bank Conflict)或线程束分化(Warp Divergence)。我们将遵循“分析-优化-验证”的循环,逐步提升内核效率。

矩阵转置优化案例

矩阵转置是一个典型的内存密集型操作。我们的目标是使其性能尽可能接近纯内存拷贝(cudaMemcpy)的速度,这代表了设备内存带宽的理论上限。

基准测试:朴素实现

首先,我们建立一个朴素的转置内核作为性能基准。该内核为输出矩阵的每个元素启动一个线程,直接从输入矩阵的对应位置读取并写入。

// 朴素矩阵转置内核示例
__global__ void naiveTransposeKernel(const float* input, float* output, int width, int height) {
    int x = blockIdx.x * blockDim.x + threadIdx.x;
    int y = blockIdx.y * blockDim.y + threadIdx.y;
    if (x < width && y < height) {
        output[x * height + y] = input[y * width + x]; // 转置操作
    }
}

使用性能分析器(Nsight Compute)分析此内核,我们可能会看到如下关键指标:

  • 内存工作负载分析提示:“对L1的全局存储访问模式可能不是最优的,平均每个线程只利用了每个扇区传输的32字节中的4字节。”这明确指出了非合并内存访问的问题。
  • 线程束状态统计显示:内核大部分时间都在等待全局内存读写,存在大量延迟。

优化步骤一:引入共享内存

问题在于写入output时,内存访问是跨步的(Strided),无法合并。解决方案是使用共享内存作为临时缓冲区来重新组织数据。

核心思路

  1. 线程块将数据从全局内存顺序读取到共享内存的二维瓦片(Tile)中。
  2. 在共享内存内部进行转置(交换x, y索引)。
  3. 将转置后的共享内存数据连续写入到全局内存。

以下是优化后的代码框架:

__global__ void sharedMemTransposeKernel(const float* input, float* output, int width, int height) {
    __shared__ float tile[TILE_DIM][TILE_DIM];
    int x = blockIdx.x * TILE_DIM + threadIdx.x;
    int y = blockIdx.y * TILE_DIM + threadIdx.y;

    // 1. 从全局内存顺序读取到共享内存
    if (x < width && y < height) {
        tile[threadIdx.y][threadIdx.x] = input[y * width + x];
    }
    __syncthreads();

    // 2. 计算转置后的全局索引
    int transposedX = blockIdx.y * TILE_DIM + threadIdx.x;
    int transposedY = blockIdx.x * TILE_DIM + threadIdx.y;

    // 3. 从转置后的共享内存连续写入全局内存
    if (transposedX < height && transposedY < width) {
        output[transposedY * height + transposedX] = tile[threadIdx.x][threadIdx.y];
    }
}

此优化应能带来显著的性能提升,因为写入操作现在已是合并访问。

优化步骤二:解决共享内存存储体冲突

然而,分析器现在可能报告新的问题:“共享内存加载/存储存在存储体冲突,平均导致N路冲突。”在转置共享内存(tile[threadIdx.x][threadIdx.y])时,同一线程束内的所有线程可能访问同一存储体中的不同地址,导致严重的序列化。

解决方案:对共享内存数组进行填充(Padding)。将共享内存声明为tile[TILE_DIM][TILE_DIM + 1]。这会在每行的末尾添加一个“幽灵”元素,改变数据在存储体中的映射关系,从而将冲突的访问路径分散到不同的存储体中。

__shared__ float tile[TILE_DIM][TILE_DIM + 1]; // 添加填充以消除存储体冲突

公式解释:在具有B个存储体(通常为32)的GPU上,共享内存地址addr映射到的存储体索引通常为 bank_index = (addr / 4) % B。填充改变了地址的线性布局,从而改变了存储体索引的计算结果,避免了冲突。

应用此优化后,性能分析器中的存储体冲突警告应消失,性能进一步接近理论带宽。

优化步骤三:循环展开与增加每线程工作量

即使解决了内存问题,分析器可能仍提示计算强度低、占用率(Occupancy)不足。我们可以通过循环展开(Loop Unrolling) 来增加每个线程的工作量,从而更好地隐藏内存延迟并提高指令吞吐量。

核心思路:减少每个线程块启动的线程数量,但让每个线程处理多个数据元素。例如,原本一个32x32的瓦片需要1024个线程,现在可以只启动256个线程,每个线程在一个循环中处理4个元素。

template <int TILE_DIM, int BLOCK_ROWS>
__global__ void unrolledTransposeKernel(const float* input, float* output, int width, int height) {
    __shared__ float tile[TILE_DIM][TILE_DIM + 1];
    int x = blockIdx.x * TILE_DIM + threadIdx.x;
    int y = blockIdx.y * TILE_DIM + threadIdx.y;

    #pragma unroll
    for (int k = 0; k < TILE_DIM; k += BLOCK_ROWS) {
        if (x < width && (y + k) < height) {
            tile[threadIdx.y + k][threadIdx.x] = input[(y + k) * width + x];
        }
    }
    __syncthreads();

    x = blockIdx.y * TILE_DIM + threadIdx.x; // 转置后坐标
    y = blockIdx.x * TILE_DIM + threadIdx.y;

    #pragma unroll
    for (int k = 0; k < TILE_DIM; k += BLOCK_ROWS) {
        if (x < height && (y + k) < width) {
            output[(y + k) * height + x] = tile[threadIdx.x][threadIdx.y + k];
        }
    }
}

通过以上三步优化,矩阵转置内核的性能通常可以达到甚至超过纯内存拷贝带宽的90%以上。

归约优化案例

归约(如求和、求最大值)是另一种常见模式,它既是内存密集型,也包含一定的计算。我们的优化目标是最大化内存带宽利用率。

基准测试:交错寻址法

朴素的归约(称为“交错寻址”)在每个归约步骤中,让第i个线程与第i + stride个线程相加。这会导致严重的线程束分化和非合并内存访问。

// 阶段0:交错寻址(低效)
for (int stride = 1; stride < blockDim.x; stride *= 2) {
    if ((threadIdx.x % (2 * stride)) == 0) {
        sdata[threadIdx.x] += sdata[threadIdx.x + stride];
    }
    __syncthreads();
}

性能分析器会指出:指令开销大(模运算%)、线程束分化严重、内存访问模式不佳。

优化步骤一:消除模运算

首先,用更高效的索引计算替换昂贵的模运算%

// 阶段1:去除模运算
for (int stride = 1; stride < blockDim.x; stride *= 2) {
    int index = 2 * stride * threadIdx.x;
    if (index < blockDim.x) {
        sdata[index] += sdata[index + stride];
    }
    __syncthreads();
}

优化步骤二:顺序寻址与解决存储体冲突

将交错寻址改为顺序寻址。让前半部分线程主动从后半部分读取数据并相加,这样线程束内的条件判断更加一致,减少了分化。

// 阶段2:顺序寻址
for (int stride = blockDim.x / 2; stride > 0; stride >>= 1) {
    if (threadIdx.x < stride) {
        sdata[threadIdx.x] += sdata[threadIdx.x + stride];
    }
    __syncthreads();
}

分析器此时可能提示共享内存的存储体冲突,但相比之前已有改善。

优化步骤三:加载时多次相加

在将数据从全局内存加载到共享内存时,就让每个线程多加载几个元素并立即相加。这减少了所需线程总数,增加了每线程工作量,提升了占用率。

// 阶段3:每个线程加载并累加多个元素
unsigned int tid = threadIdx.x;
unsigned int i = blockIdx.x * blockDim.x * 2 + threadIdx.x;
sdata[tid] = 0;
// 假设每个线程处理2个元素
if (i < n) sdata[tid] += g_idata[i];
if (i + blockDim.x < n) sdata[tid] += g_idata[i + blockDim.x];
__syncthreads();
// ... 后续进行顺序寻址归约

优化步骤四:展开最后一个线程束

当活跃线程数小于等于线程束大小(32)时,后续的归约步骤只发生在一个线程束内。此时,我们可以使用volatile关键字修饰共享内存,并编写一个无循环、无条件分支的归约函数,让编译器生成更高效的指令。

// 阶段4:展开最后一个线程束的归约
if (threadIdx.x < 32) {
    volatile float* vmem = sdata;
    vmem[threadIdx.x] += vmem[threadIdx.x + 32];
    vmem[threadIdx.x] += vmem[threadIdx.x + 16];
    vmem[threadIdx.x] += vmem[threadIdx.x + 8];
    vmem[threadIdx.x] += vmem[threadIdx.x + 4];
    vmem[threadIdx.x] += vmem[threadIdx.x + 2];
    vmem[threadIdx.x] += vmem[threadIdx.x + 1];
}

volatile 防止编译器对这段关键顺序操作进行重排序优化。

优化步骤五:完全展开循环与递归归约

对于已知的线程块大小(如256或512),可以使用模板在编译时完全展开归约循环。此外,对于大规模数据,单次内核启动可能产生多个部分和。可以在GPU上启动第二次归约内核来处理这些部分和,而不是拷贝回CPU处理,这称为递归归约,能进一步减少数据传输开销。

总结与最佳实践

本节课中我们一起学习了如何系统性地分析和优化GPU内核:

  1. 确立基准与目标:首先实现一个朴素版本并测量其性能,同时明确理论性能上限(如内存带宽)。
  2. 善用性能分析器:Nsight Compute等工具能精准定位瓶颈,如非合并访问、存储体冲突、线程束分化、指令开销等。
  3. 应用模式化优化
    • 对于内存密集型操作(如转置),使用共享内存来重组数据以实现合并访问。
    • 注意并消除共享内存存储体冲突,常用方法是填充数组。
    • 通过循环展开增加每线程工作量来提高占用率和计算强度,隐藏延迟。
    • 对于归约类操作,采用顺序寻址加载时相加展开最后线程束等技术。
    • 考虑使用递归内核调用来处理中间结果,避免不必要的CPU-GPU数据传输。
  4. 使用编译辅助:合理使用 constrestrict 等限定符帮助编译器优化。对于关键路径,可使用 volatile 防止编译器重排序。
  5. 迭代验证:每次优化后都重新分析性能,确保优化有效且没有引入新问题。

记住,优化是一个迭代和权衡的过程。理解底层架构原理,结合分析器给出的数据驱动决策,你就能有效地将GPU的性能潜力释放出来。

011:光线追踪入门与实时渲染技术 🚀

在本节课中,我们将学习光线追踪的基本概念、发展历史、核心算法及其在现代实时渲染中的应用。我们将从光线追踪的起源开始,逐步探讨其如何从离线渲染技术演变为实时图形应用的核心,并了解支持这一转变的硬件和软件技术。


光线追踪的历史与核心概念

上一节我们介绍了课程概述,本节中我们来看看光线追踪的基础和历史发展。

光线追踪是一种模拟光线在场景中传播的渲染技术。其核心思想是从摄像机(或人眼)发射光线,追踪这些光线与场景中物体的交点,并根据交点处的材质、光源等信息计算像素颜色。

传统的光栅化渲染主要处理三角形,而光线追踪的“原子操作”是与各种几何图元(如球体、三角形、曲面)求交。如果能为一种物体计算光线交点,就可以对它进行光线追踪,这提供了极大的灵活性。

光线追踪的发展有几个关键里程碑:

  • 光线投射 (Ray Casting):从相机发射光线,找到最近的物体,用于确定可见性。
  • 经典光线追踪 (Classical Ray Tracing, 1980):Turner Whitted 引入了递归反射和折射光线,模拟镜面和透明效果。
  • 分布式/随机光线追踪 (Distribution/Stochastic Ray Tracing, 1984):Rob Cook 等人提出通过向一个方向发射一簇光线来模拟模糊反射、软阴影等效果。
  • 路径追踪 (Path Tracing, 1986):James Kajiya 提出了渲染方程,并通过随机采样路径来模拟全局光照,成为电影行业离线渲染的基石。

渲染方程是理解光线传播的核心公式:

Lo(p, ωo) = Le(p, ωo) + ∫Ω fr(p, ωi, ωo) Li(p, ωi) (n·ωi) dωi

其中:

  • Lo 是出射光亮度。
  • Le 是自发光。
  • 积分项表示从所有入射方向 ωi 收集的光,经过 BRDF fr 和几何项 (n·ωi) 加权后求和。

路径追踪通过随机采样来近似求解这个复杂的积分。


光线追踪能实现的效果

上一节我们回顾了光线追踪的历史,本节中我们来看看利用这项技术可以实现哪些具体的视觉效果。

光线追踪不仅能模拟物理上精确的光照,还能实现一些非物理但视觉效果很好的技法。

以下是光线追踪可以实现的部分效果:

  • 软阴影:通过向面光源区域发射多条阴影射线并平均结果,可以生成具有半影区的柔和阴影。
  • 环境光遮蔽:通过发射短距离射线并检测遮挡情况,来近似模拟角落和缝隙处的自阴影效果,增强场景的深度感。
  • 景深:通过抖动相机镜头位置的光线原点,模拟真实相机镜头的光圈效果,使焦点前后物体模糊。
  • 运动模糊:通过在帧时间范围内随机采样物体的位置,将多个时间点的样本累积起来,形成物体运动的动态模糊效果。
  • 大气散射:使用光线步进技术,沿视线方向积分计算光线与空气中微粒的散射,生成体积光、上帝光等效果。
  • 焦散:模拟光线通过反射或折射物体(如玻璃、水)后聚焦产生的明亮图案。这可以通过从光源发射光线(光子映射)或使用特定技巧来实现。

硬件加速:从通用计算到专用核心

上一节我们了解了光线追踪的多种效果,本节中我们来看看硬件是如何加速这一复杂过程的。

随着摩尔定律的放缓,通过并行化提升性能变得至关重要。幸运的是,光线追踪是“令人尴尬的并行”任务,每条光线的追踪可以独立进行。

NVIDIA 在 GPU 中引入了专用硬件单元来加速光线追踪:

  • RT Core:专门用于加速光线与包围盒层次结构(BVH)的遍历和光线-三角形求交计算。
  • Tensor Core:用于加速AI运算,如DLSS中的深度学习超采样和降噪。

与光栅化的即时模式不同,现代图形API(如DirectX Raytracing, Vulkan Ray Tracing)将光线追踪定义为可编程的管线,允许着色器递归地发射新光线。

以下是光线追踪管线的主要着色器阶段:

  1. 光线生成着色器:生成初始光线。
  2. 相交着色器(可选):用于自定义几何图元的求交。
  3. 任意命中着色器:在找到(不一定是最近的)交点时调用,常用于处理透明贴片(如树叶)。
  4. 最近命中着色器:在确定最近交点后调用,进行着色并可能发射新的反射/折射光线。
  5. 未命中着色器:当光线未击中任何物体时调用,通常返回背景色。

BVH是加速光线追踪的关键数据结构。它将场景物体组织成层次化的包围盒(如轴对齐包围盒AABB)。光线首先与根节点包围盒测试,若命中则继续测试其子节点,若未命中则忽略整个子树。这极大地减少了需要求交的图元数量。


实时渲染的挑战与解决方案:采样与降噪

上一节我们介绍了硬件加速,本节中我们来看看在实时帧率下实现高质量光线追踪的主要挑战和解决方案。

电影级路径追踪每像素可能需要数千次采样以消除噪声,但这在实时渲染中无法实现。实时渲染通常每像素只能分配1次或少数几次采样,导致图像噪声严重。

解决方案的核心是:使用极少的采样,然后通过强大的降噪器重建高质量图像

现代降噪器(如NVIDIA的DLSS)大量依赖人工智能和时间性信息:

  • 输入:低采样数的噪声图像、运动矢量、上一帧画面等。
  • 过程:利用训练好的神经网络,结合时间累积(复用上一帧信息)和空间滤波,从噪声输入中重建出清晰、稳定的图像。
  • 输出:视觉质量接近高采样“真实解”的最终图像。

这种“重建”而非“完全渲染”的范式,使得在实时帧率下实现柔和阴影、全局光照和反射等效果成为可能。其他如RTXDI(智能采样重要光源)、RTXGI(探针式全局光照)等技术,也旨在用最少的计算量获得最大的视觉收益。


光线追踪的广泛应用

上一节我们探讨了实时渲染的降噪技术,本节中我们来看看光线追踪技术在不同领域的广泛应用。

光线追踪的原理不仅限于视觉渲染,其“发射射线并检测相交”的核心思想可应用于许多需要模拟波或粒子传播的领域。

以下是光线追踪的一些应用方向:

  • 媒体与娱乐:电影、动画的高质量离线渲染;游戏中的实时反射、阴影和全局光照。
  • 设计与仿真:建筑可视化(光照分析、能耗模拟)、工业设计(产品外观评审)、光学系统设计(望远镜、镜头)。
  • 科学可视化:渲染大规模粒子数据(如原子模型)、体积数据(如医学CT扫描)。
  • 波传播模拟:无线电波传播预测、声学仿真(室内音效设计)、地震波模拟。
  • 碰撞检测:在物理引擎中,通过发射射线来检测物体间的潜在接触。
  • 数字孪生与机器人:为自动驾驶、机器人训练创建逼真的模拟环境。

总结与职业建议

本节课中我们一起学习了光线追踪从基础理论到实时应用的完整路径。

我们首先回顾了光线追踪的历史与核心算法,如路径追踪和渲染方程。接着,探讨了GPU硬件(如RT Core)如何加速BVH遍历和求交计算。针对实时渲染的核心挑战——采样不足导致的噪声,我们了解了基于AI的降噪技术(如DLSS)如何成为关键解决方案。最后,我们看到了光线追踪技术在娱乐、设计、科学仿真等领域的广泛应用。

作为补充,嘉宾Eric Haines分享了一些职业发展建议:

  • 建立个人品牌:创建并维护个人网站,展示你的项目和作品。
  • 勇于提问:在团队中,不要害怕暴露知识盲区,及时提问能有效提升效率。
  • 保持开放与合作:寻求多样化的反馈,团队协作比单打独斗更容易成功。
  • 持续学习与贡献:关注行业博客(如Graphics Weekly),参与开源项目或社区,积累经验与声誉。
  • 享受过程:职业生涯是一场马拉松,专注于解决有趣的问题,而不仅仅是追逐头衔。

光线追踪既简单到可以写在一张名片上,也复杂到足以让人穷尽整个职业生涯去探索。它已经从“未来的技术”变成了驱动当今实时图形创新的核心力量。

012:图形渲染管线与实时渲染技术

在本节课中,我们将学习传统的图形渲染管线,了解其如何映射到硬件,并探索实时渲染中的两种关键技术:正向渲染与延迟渲染。我们将从最基本的顶点处理开始,逐步深入到像素着色,并理解GPU架构如何演变以支持这些计算密集型任务。

概述:渲染的目标与挑战

渲染的主要目标是将三维场景转换为二维屏幕上的像素图像。这涉及两个核心问题:可见性判断(哪些物体/部分可见)和着色计算(可见部分应呈现何种颜色)。实时渲染需要在极短的时间内(例如每秒60帧)完成这些计算。

传统的图形管线提供了一种流水线化的方法来解决这些问题,我们将逐一剖析其各个阶段。

图形渲染管线详解

顶点装配 (Vertex Assembly)

图形管线的第一步是顶点装配。在这个阶段,我们需要将三维模型中的顶点数据(即空间中的点)组织成GPU能够高效处理的格式。

每个顶点通常包含以下属性:

  • 位置 (Position): 顶点的三维坐标 (X, Y, Z)。
  • 法线 (Normal): 顶点所在表面的朝向,用于光照计算。
  • 纹理坐标 (Texture Coordinates): 用于从纹理图像中采样颜色的二维坐标 (U, V)。
  • 切线 (Tangent)副法线 (Binormal): 这两个向量与法线一起构成一个局部坐标系(切线空间),主要用于法线贴图等高级着色技术。

顶点装配的任务就是将这些属性打包到连续的缓冲区中,以便GPU读取。一个高效的技巧是,通常只需存储法线和切线,因为副法线可以通过两者的叉积快速计算得出:binormal = cross(normal, tangent)

顶点着色器 (Vertex Shader)

上一节我们介绍了如何准备顶点数据,本节中我们来看看如何处理这些顶点。顶点着色器是管线中第一个可编程的阶段,其核心任务是对每个顶点进行坐标变换。

每个顶点着色器线程独立处理一个顶点,主要执行以下操作:

  1. 模型变换 (Model Transformation): 将顶点从局部模型空间转换到全局世界空间。公式为:world_position = model_matrix * local_position
  2. 视图变换 (View Transformation): 将顶点从世界空间转换到以摄像机为原点的观察空间。公式为:view_position = view_matrix * world_position
  3. 投影变换 (Projection Transformation): 将顶点从观察空间转换到裁剪空间(一个标准化立方体)。这模拟了透视效果(近大远小)。公式为:clip_position = projection_matrix * view_position

这三个变换通常合并为一个模型-视图-投影矩阵 (MVP Matrix) 传递给着色器。顶点着色器还可以进行其他计算,例如基于时间的顶点动画(如脉动效果)或从纹理中读取数据用于置换贴图。

图元装配与光栅化 (Primitive Assembly & Rasterization)

经过顶点着色器处理后,我们得到了变换后的顶点。但这些顶点仍然是独立的点,没有形成几何形状。图元装配阶段将这些顶点连接成基本的图形单元,如三角形。

接下来是光栅化,这是一个固定功能阶段。它的任务是将屏幕空间中的三角形转换为一系列片段 (Fragments),你可以将其理解为候选像素。这个过程包括:

  • 裁剪 (Clipping): 丢弃完全在屏幕外的三角形,并将部分在屏幕内的三角形切割为新的、完全在屏幕内的三角形。
  • 片段生成: 确定哪些屏幕像素被三角形覆盖,并为每个被覆盖的像素生成一个片段。

在光栅化过程中,顶点属性(如颜色、纹理坐标)会在三角形内部进行插值,为每个片段提供平滑过渡的值。这里引出一个重要的性能考量:三角形与片段的比例。对于一个简单场景(如一个立方体),片段数量远多于三角形,计算负载集中在后续阶段。而对于一个复杂场景(如包含数百万三角形的城市),顶点处理可能成为瓶颈。这种不平衡催生了GPU架构的演变。

片段着色器 (Fragment Shader)

一旦我们知道了屏幕上哪些像素需要被绘制,下一步就是确定它们的颜色。这是片段着色器(在DirectX中称为像素着色器)的职责,它通常是渲染管线中最耗计算资源的阶段。

片段着色器接收来自光栅化阶段的插值后数据(如位置、法线、纹理坐标),并输出该片段的最终颜色。其核心工作包括:

  • 纹理采样: 从纹理图像中获取颜色或其它信息(如漫反射颜色、法线方向、粗糙度)。
  • 光照计算: 根据材质属性和光源信息计算颜色。一个经典的简化光照模型是冯氏光照模型:
    color = ambient + diffuse * max(dot(normal, light_dir), 0) + specular * pow(max(dot(reflect_dir, view_dir), 0), shininess)
    
  • 高级效果: 实现凹凸贴图、环境反射、雾效、卡通渲染等。

片段着色器非常强大,但计算成本高昂,因为每个屏幕像素都可能执行复杂的纹理查找和数学运算。

逐片段测试与混合 (Per-Fragment Tests & Blending)

在片段着色器输出颜色后,管线还会执行一系列测试来决定最终是否以及如何更新帧缓冲区。

以下是主要的测试类型:

  • 模板测试 (Stencil Test): 根据一个模板缓冲区来丢弃或保留片段,常用于实现轮廓、反射等效果。
  • 深度测试 (Depth Test): 使用深度缓冲区(Z-Buffer)来确保只有离摄像机最近的片段被保留,这是解决可见性问题的关键。
  • 混合 (Blending): 将当前片段的颜色与帧缓冲区中已有的颜色进行混合,用于实现透明效果(如玻璃、烟雾)。

完成所有这些步骤后,最终的颜色被写入帧缓冲区,显示在屏幕上。

GPU架构的演变:从固定功能到统一着色器

回顾整个图形管线,我们看到了可编程阶段(顶点、片段着色器)和固定功能阶段(光栅化、测试)。这种划分深刻地影响了早期GPU的设计。

早期GPU(如1999年的NVIDIA GeForce 256)拥有独立的、专用的硬件单元来处理顶点变换和像素填充。这带来了一个问题:如果场景顶点密集而像素简单(如线框渲染),像素处理器就会闲置;反之,如果场景像素复杂而几何简单(如全屏特效),顶点处理器就会闲置。

为了解决这种负载不平衡问题,现代GPU采用了统一着色器架构(如2006年引入的架构)。在这种架构中,大量的通用计算核心(CUDA Core)可以动态分配用于顶点着色、片段着色或通用计算任务。这极大地提高了硬件的利用率和灵活性,也为CUDA这样的通用计算框架铺平了道路。

现代渲染技术:正向渲染与延迟渲染

传统的管线通常被称为正向渲染 (Forward Rendering)。它对于每个物体,遍历所有光源来计算其光照并直接输出到屏幕。这种方法简单直接,但当场景中有大量光源时,计算量会急剧增加(物体数量 × 光源数量)。

为了高效处理大量光源,延迟渲染 (Deferred Rendering) 技术应运而生。它将渲染过程分为两个主要阶段:

  1. 几何阶段 (Geometry Pass): 遍历所有物体,但不计算光照。而是将表面的各种属性(如位置、法线、漫反射颜色、镜面反射系数)渲染到多个缓冲区中,统称为G缓冲区 (G-Buffer)
  2. 光照阶段 (Lighting Pass): 忽略场景几何,直接对屏幕上的每个像素,根据G缓冲区中存储的信息,遍历所有光源进行光照计算。

延迟渲染的优势在于,光照计算的复杂度只与屏幕像素数量和光源数量有关,而与场景几何复杂度脱钩,非常适合拥有大量光源的场景(如夜间城市、有许多灯光的室内)。其缺点是需要更高的内存带宽来存储G缓冲区,并且对透明物体的处理比较困难。

总结

本节课我们一起学习了实时图形渲染的核心流程。我们从传统的正向渲染管线出发,详细了解了从顶点装配、坐标变换、光栅化到片段着色和最终合成的每一个步骤。我们看到了图形管线的设计如何影响了GPU硬件的演变,从专用的固定功能单元发展到如今灵活的统一着色器架构。最后,我们探讨了为应对大量光源挑战而出现的延迟渲染技术的基本原理。

理解这些基础概念,将帮助你更好地进行Project 4的WebGPU开发,并让你在编写CUDA内核时,也能洞察到其设计思想与图形编程的深厚渊源。

013:WebGPU 入门教程 🚀

概述

在本节课中,我们将学习 WebGPU,这是现代 Web 图形编程的新标准。我们将了解它为何被创建,其核心概念,并通过一个简单的“Hello Triangle”示例来学习其基本用法。课程内容基于宾夕法尼亚大学 CIS 5650 课程中 Google Chrome GPU 团队工程师的客座讲座。


为什么需要 WebGPU?🤔

上一节我们回顾了图形 API 的历史。本节中我们来看看为什么需要一个新的 Web 图形 API。

WebGL 是 WebGPU 的前身,它基于 OpenGL ES,而 OpenGL 的设计理念可以追溯到 1992 年。虽然 WebGL 应用广泛(例如 Google Maps),但其底层架构已无法充分利用现代 GPU 的硬件特性。

在 2014 年至 2016 年间,行业推出了新的原生图形 API:

  • Metal (Apple, 2014)
  • Direct3D 12 (Microsoft, 2015)
  • Vulkan (Khronos Group, 2016)

这些“显式”API 的设计更贴近现代 GPU 的并行架构,能提供更好的性能和更低的开销。Web 平台需要跟上这一趋势,因此 WebGPU 应运而生。它是一个全新的、跨浏览器的 API,旨在抽象 Metal、D3D12 和 Vulkan,同时更好地融入 Web 平台和 JavaScript 生态。

与 WebGL2 相比,WebGPU 引入了关键的新特性,例如:

  • 计算着色器 (compute shaders)
  • 间接绘制 (indirect drawing)
  • 渲染包 (render bundles)
  • 更灵活的 Canvas 集成
  • 更完善的错误提示和调试功能

最重要的是,它移除了 OpenGL 中复杂的全局状态机,使得 API 更易于学习和使用。


WebGPU 核心概念 🧠

理解了 WebGPU 的诞生背景后,本节我们来深入探讨其核心架构和对象模型。

WebGPU 的 API 设计围绕几个核心对象展开,它们共同协作以向 GPU 提交命令。其复杂程度介于 WebGL 和 Vulkan 之间,旨在教会你适用于所有现代图形 API 的核心概念。

以下是初始化 WebGPU 并绘制一个三角形所涉及的主要步骤和对象:

1. 适配器 (Adapter) 和设备 (Device)

这是初始化的第一步。Adapter 代表系统上的一个物理或逻辑 GPU 实现。通过它,你可以查询硬件的能力(特性和限制)。

const adapter = await navigator.gpu.requestAdapter();
const device = await adapter.requestDevice();

Device 是 WebGPU 的主要接口,用于创建所有资源(缓冲区、纹理等)并获取用于提交命令的队列 (Queue)。

2. 画布上下文 (Canvas Context)

与 WebGL 不同,WebGPU 的上下文与设备绑定,而不是与画布 (Canvas) 一对一绑定。

const context = canvas.getContext(‘webgpu’);
context.configure({
    device: device,
    format: navigator.gpu.getPreferredCanvasFormat(),
});

3. 缓冲区 (Buffer) 与数据上传

缓冲区用于在 CPU 和 GPU 之间传递数据(如顶点数据)。创建时需要指定大小和用途,且创建后不可更改。

// 创建顶点数据
const vertices = new Float32Array([...]);
// 创建 GPU 缓冲区
const vertexBuffer = device.createBuffer({
    size: vertices.byteLength,
    usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
// 将数据从 CPU 写入 GPU 缓冲区
device.queue.writeBuffer(vertexBuffer, 0, vertices);

4. 着色器与 WGSL

WebGPU 使用一种新的着色语言 WGSL (WebGPU Shading Language)。它的语法类似 Rust,并且单个模块可以包含多个入口点(如顶点和片段着色器)。

// 示例:简单的顶点着色器
@vertex
fn vs_main(@location(0) position: vec4<f32>) -> @builtin(position) vec4<f32> {
    return position;
}

着色器模块通过 device.createShaderModule 创建。

5. 渲染管线 (Render Pipeline)

这是 WebGPU 的一个关键概念。在 WebGL 中,混合、深度测试等状态是动态设置的。而在 WebGPU 中,几乎所有渲染状态都必须在管线创建时预先定义并打包成一个不可变的对象。

const renderPipeline = device.createRenderPipeline({
    vertex: {
        module: shaderModule,
        entryPoint: ‘vs_main’,
        buffers: [/* 顶点缓冲区布局 */]
    },
    fragment: {
        module: shaderModule,
        entryPoint: ‘fs_main’,
        targets: [{ format: canvasFormat }]
    },
    primitive: { topology: ‘triangle-list’ },
    layout: ‘auto’ // 管线布局
});

这种方式允许驱动进行大量预编译和优化,提升了运行时效率。

6. 命令编码与提交

WebGPU 采用命令缓冲模式。你首先创建一个 CommandEncoder,然后在其中记录命令(如复制缓冲区、开始渲染通道),最后将这些命令编码并提交到队列中执行。

// 创建命令编码器
const commandEncoder = device.createCommandEncoder();
// 开始一个渲染通道
const renderPass = commandEncoder.beginRenderPass({
    colorAttachments: [{
        view: context.getCurrentTexture().createView(),
        loadOp: ‘clear’,
        storeOp: ‘store’,
        clearValue: [0, 0, 0, 1],
    }]
});
// 设置管线、顶点缓冲区,并发出绘制命令
renderPass.setPipeline(renderPipeline);
renderPass.setVertexBuffer(0, vertexBuffer);
renderPass.draw(3); // 绘制 3 个顶点(一个三角形)
// 结束通道并完成编码
renderPass.end();
// 提交命令到 GPU 队列
const commandBuffer = commandEncoder.finish();
device.queue.submit([commandBuffer]);

这个过程是异步的,命令被记录并提交后,由 GPU 在适当的时候执行。


数据绑定:如何将资源传递给着色器 🔗

上一节我们介绍了如何配置管线并发出绘制命令。本节中我们来看看如何将缓冲区、纹理等资源(数据)传递到着色器中供其使用。

在 WGSL 着色器中,你可以通过 @group@binding 属性来声明需要绑定的资源。

@group(0) @binding(0) var<uniform> myUniforms: MyUniformStruct;
@group(0) @binding(1) var myTexture: texture_2d<f32>;
@group(0) @binding(2) var mySampler: sampler;

在 JavaScript 端,这个过程分为两步:

1. 创建绑定组布局 (Bind Group Layout)
它定义了着色器中一个绑定组(@group)的结构,即每个绑定槽(@binding)期望的资源类型(如只读缓冲区、可写存储纹理等)。

const bindGroupLayout = device.createBindGroupLayout({
    entries: [
        {
            binding: 0,
            visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
            buffer: { type: ‘uniform’ }
        },
        {
            binding: 1,
            visibility: GPUShaderStage.FRAGMENT,
            texture: { sampleType: ‘float’ }
        },
        // ... 其他绑定
    ]
});

2. 创建绑定组 (Bind Group)
它根据布局,将具体的资源(如缓冲区对象、纹理视图)绑定到对应的槽位上。

const bindGroup = device.createBindGroup({
    layout: bindGroupLayout,
    entries: [
        { binding: 0, resource: { buffer: uniformBuffer } },
        { binding: 1, resource: myTextureView },
        // ... 其他资源
    ]
});

在渲染通道中,你只需设置整个绑定组即可:

renderPass.setBindGroup(0, bindGroup);

这种将相关资源分组绑定的设计,减少了需要调用的 API 次数,并符合现代 GPU 的工作方式。


调试与最佳实践 🐛

掌握了基本绘制和数据绑定后,开发中难免会遇到问题。本节我们来看看 WebGPU 提供的调试工具和一些性能优化的最佳实践。

调试 (Debugging)

WebGPU 设计了比 WebGL 更友好的错误信息。当验证失败时,浏览器会提供包含上下文信息的错误,指出问题出在哪个调用、哪个资源上。

// 示例错误信息
Error: Buffer (Player Vertices) usage doesn’t include VERTEX.
    While encoding [RenderPass “Main Render Pass”].
    While calling [CommandEncoder “Frame”].beginRenderPass.

为了获得更清晰的错误信息,请为你创建的所有对象(缓冲区、纹理、通道等)添加标签 (label)

const buffer = device.createBuffer({
    label: ‘Player Vertex Data’, // 添加标签
    size: ...,
    usage: ...
});

此外,你还可以使用 调试组 (Debug Group) 来在开发者工具中结构化你的命令流,便于定位问题。

commandEncoder.pushDebugGroup(‘Main Render Loop’);
commandEncoder.pushDebugGroup(‘Render Scene’);
// ... 记录渲染命令
commandEncoder.popDebugGroup(); // ‘Render Scene’
commandEncoder.popDebugGroup(); // ‘Main Render Loop’

性能最佳实践 (Best Practices)

以下是几条重要的性能优化建议:

1. 尽量减少管线数量
管线的创建和切换都有成本。尽量复用管线,或通过超级着色器(使用分支或常量)来减少管线变体的数量。

2. 使用异步管线创建 (createRenderPipelineAsync)
管线编译可能很耗时。使用异步版本可以避免在管线就绪前阻塞提交,并且浏览器可能利用此提示进行并行编译。

const pipeline = await device.createRenderPipelineAsync({...});

3. 在管线间共享绑定组
如果多个管线使用相同的资源绑定布局(例如相机 Uniform 缓冲区),可以创建并共享同一个绑定组,以减少 setBindGroup 的调用。

4. 使用正确的画布格式
查询并使用系统首选的画布纹理格式(通常是 ‘bgra8unorm’),可以避免一次额外的格式转换,提升性能。

const format = navigator.gpu.getPreferredCanvasFormat();
context.configure({ device, format });

5. 利用渲染包 (Render Bundles)
对于静态或重复渲染的对象序列,可以将其预记录到 RenderBundle 中。之后每帧只需一个调用即可重放所有命令,极大减少了 JavaScript 端的开销。

// 创建阶段
const bundleEncoder = device.createRenderBundleEncoder({...});
// ... 在 bundleEncoder 中记录绘制命令
const renderBundle = bundleEncoder.finish();

// 渲染阶段(每帧)
renderPass.executeBundles([renderBundle]);

总结

本节课中我们一起学习了 WebGPU,这是下一代 Web 图形 API。我们从其诞生的背景开始,了解了它为何要取代 WebGL。接着,我们深入探讨了其核心对象模型,包括适配器、设备、缓冲区、管线以及命令编码流程。我们学习了如何通过绑定组和绑定组布局将数据从 JavaScript 端传递到 WGSL 着色器。最后,我们介绍了 WebGPU 强大的调试支持功能,并总结了几条关键的开发与性能优化最佳实践。

WebGPU 的设计更贴近现代 GPU 架构,虽然初始设置比 WebGL 稍显复杂,但其显式的、状态集中的设计消除了许多隐藏的陷阱,使得应用更健壮、性能更可预测,并且所学的概念能直接迁移到其他现代图形 API(如 Vulkan、D3D12)中。

014:从CUDA到WebGPU的SAXPY计算教程 🚀

在本节课中,我们将学习如何将一个简单的CUDA计算任务——SAXPY(单精度a乘X加Y)——移植到WebGPU平台上。我们将通过对比CUDA和WebGPU的API,理解图形API如何用于通用计算,并亲自动手实现一个完整的WebGPU计算流程。

概述 📋

SAXPY是一个基础的向量运算,公式为:z = a * x + y。在CUDA中,我们熟悉其编程流程:分配主机与设备内存、复制数据、启动内核、取回结果。WebGPU作为一个新兴的、跨平台的图形与计算API,其工作流与CUDA有相似之处,但也存在显著差异,特别是在初始化、资源绑定和着色器调用方面。

上一节我们回顾了CUDA中SAXPY的实现,本节中我们来看看如何在WebGPU中完成相同的任务。

从CUDA到WebGPU的思维转换 🔄

CUDA是专为NVIDIA GPU设计的“计算优先”API。你可以直接启动内核进行计算。而传统的图形API(如OpenGL)即使要进行通用计算,也需要经过帧缓冲、光栅化管线等图形流程。WebGPU在一定程度上弥合了这种差异,它既可以用于图形渲染,也可以像CUDA一样用于纯计算任务。

WebGPU的设计考虑了Web环境的安全性、隐私性和跨平台需求,使其成为在浏览器中进行客户端机器学习推理等任务的绝佳选择。

项目结构与CPU基准实现 💻

我们的教程将遵循一个清晰的流程。首先,我们设置一个简单的HTML页面作为包装器,并实现CPU版本的SAXPY作为验证基准。

以下是项目的基本HTML结构:

<!DOCTYPE html>
<html>
<head>
    <title>WebGPU SAXPY</title>
    <style> /* 一些美化页面的CSS */ </style>
</head>
<body>
    <h1>WebGPU SAXPY 测试</h1>
    <script type="module" src="webgpu_saxpy.js"></script>
</body>
</html>

接下来,我们在JavaScript中实现CPU版本的SAXPY逻辑,用于生成测试数据并验证GPU结果。

// 初始化随机向量和标量
function initSaxpy(size) {
    const x = new Float32Array(size);
    const y = new Float32Array(size);
    const z = new Float32Array(size); // 初始化为0
    const a = Math.random();
    for (let i = 0; i < size; i++) {
        x[i] = Math.random();
        y[i] = Math.random();
    }
    return { size, a, x, y, z };
}

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/upenn-cis5650-gpu-prog/img/20cb9720d4b8b89b9cfd6e59e05c448f_41.png)

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/upenn-cis5650-gpu-prog/img/20cb9720d4b8b89b9cfd6e59e05c448f_43.png)

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/upenn-cis5650-gpu-prog/img/20cb9720d4b8b89b9cfd6e59e05c448f_45.png)

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/upenn-cis5650-gpu-prog/img/20cb9720d4b8b89b9cfd6e59e05c448f_47.png)

// CPU版本SAXPY
function cpuSaxpy(size, a, x, y, z) {
    for (let i = 0; i < size; i++) {
        z[i] = a * x[i] + y[i];
    }
    return z;
}

初始化WebGPU设备 ⚙️

在WebGPU中开始计算之前,我们必须先初始化并获取GPU设备。这与CUDA中设置设备的概念类似。

async function initWebGpu() {
    // 1. 检查浏览器是否支持WebGPU
    if (!navigator.gpu) {
        throw new Error('WebGPU not supported');
    }

    // 2. 请求GPU适配器(Adapter)
    const adapter = await navigator.gpu.requestAdapter();
    if (!adapter) {
        throw new Error('No GPU adapter found');
    }
    // 获取适配器信息(类似CUDA的deviceProp)
    const adapterInfo = await adapter.requestAdapterInfo();

    // 3. 请求GPU设备(Device)
    const device = await adapter.requestDevice();
    if (!device) {
        throw new Error('No GPU device found');
    }

    console.log(`GPU: ${adapterInfo.vendor} ${adapterInfo.architecture}`);
    return { adapter, adapterInfo, device };
}

requestAdapter获取的是GPU的抽象信息,而requestDevice才是我们后续操作(如创建缓冲区、管线)所依赖的具体设备对象。这个过程是异步的,我们使用async/await语法来处理。

在GPU上分配与传输内存 📦

在CUDA中,我们使用cudaMalloccudaMemcpy。在WebGPU中,我们使用device.createBuffer来创建缓冲区,并通过不同的方式填充数据。

以下是创建和填充输入缓冲区x的两种方法:

async function webgpuSaxpy(device, size, a, x, y) {
    // 方法一:创建时映射(MAP),在CPU端填充后取消映射
    const xBuffer = device.createBuffer({
        label: 'x buffer',
        size: x.byteLength,
        usage: GPUBufferUsage.STORAGE, // 用作存储
        mappedAtCreation: true, // 创建时映射到CPU
    });
    // 获取CPU端的ArrayBuffer视图并填充数据
    new Float32Array(xBuffer.getMappedRange()).set(x);
    xBuffer.unmap(); // 取消映射,数据上传至GPU

    // 方法二:先创建缓冲区,再用queue.writeBuffer写入
    const yBuffer = device.createBuffer({
        label: 'y buffer',
        size: y.byteLength,
        usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
    });
    device.queue.writeBuffer(yBuffer, 0, y); // 直接写入GPU缓冲区

    // 创建用于存储结果的Z缓冲区
    const zBuffer = device.createBuffer({
        label: 'z buffer',
        size: x.byteLength,
        usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
    });

    // 创建Uniform缓冲区,用于传递标量a和大小size
    const uniformBuffer = device.createBuffer({
        label: 'uniforms',
        size: 2 * Float32Array.BYTES_PER_ELEMENT, // 两个float
        usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
    });
    const uniformData = new Float32Array([a, size]);
    device.queue.writeBuffer(uniformBuffer, 0, uniformData);
}

GPUBufferUsage标志定义了缓冲区的用途:

  • STORAGE: 用作计算着色器的存储。
  • COPY_DST: 可作为数据拷贝的目标(CPU->GPU)。
  • COPY_SRC: 可作为数据拷贝的源(GPU->CPU)。
  • UNIFORM: 用作统一变量(Uniform)缓冲区。

编写计算着色器(Compute Shader)🖥️

计算着色器相当于CUDA中的内核(kernel)。WebGPU使用基于WGSL(WebGPU Shading Language)的着色器。

// WebGPU 计算着色器 (WGSL)
@group(0) @binding(0) var<uniform> uniforms: ScalarStruct;
@group(0) @binding(1) var<storage, read> x: array<f32>;
@group(0) @binding(2) var<storage, read> y: array<f32>;
@group(0) @binding(3) var<storage, read_write> z: array<f32>;

struct ScalarStruct {
    scale: f32,
    size: f32,
};

@compute @workgroup_size(256) // 定义工作组大小(线程块大小)
fn computeMain(@builtin(global_invocation_id) global_id: vec3<u32>) {
    let index = global_id.x;
    // 边界检查
    if (index >= u32(uniforms.size)) {
        return;
    }
    // SAXPY 核心计算
    z[index] = uniforms.scale * x[index] + y[index];
}

关键概念解析:

  • @group@binding: 定义了资源(缓冲区、采样器)在着色器中的绑定位置和顺序,类似于函数参数列表。
  • @workgroup_size(256)在着色器内部定义每个工作组(Workgroup,相当于CUDA的线程块)包含的线程数。这是与CUDA的一个主要区别,CUDA的线程块大小是在主机端调用内核时指定的。
  • @builtin(global_invocation_id): 内置变量,等价于CUDA中的 blockIdx * blockDim + threadIdx
  • var<storage, read>: 声明一个只读的存储缓冲区变量。

创建管线与资源绑定 🔗

在WebGPU中,我们需要显式地创建管线布局(Pipeline Layout)和绑定组(Bind Group),将GPU缓冲区和着色器中的绑定点关联起来。

// 1. 创建着色器模块
const shaderCode = `...`; // 上一节的WGSL代码字符串
const shaderModule = device.createShaderModule({
    label: 'SAXPY shader',
    code: shaderCode,
});

// 2. 创建绑定组布局(Bind Group Layout)
// 描述着色器中@binding的顺序和类型
const bindGroupLayout = device.createBindGroupLayout({
    label: 'SAXPY bind group layout',
    entries: [
        { // @binding(0) uniforms
            binding: 0,
            visibility: GPUShaderStage.COMPUTE,
            buffer: { type: 'uniform' }
        },
        { // @binding(1) x
            binding: 1,
            visibility: GPUShaderStage.COMPUTE,
            buffer: { type: 'read-only-storage' }
        },
        { // @binding(2) y
            binding: 2,
            visibility: GPUShaderStage.COMPUTE,
            buffer: { type: 'read-only-storage' }
        },
        { // @binding(3) z
            binding: 3,
            visibility: GPUShaderStage.COMPUTE,
            buffer: { type: 'storage' }
        },
    ]
});

// 3. 创建绑定组(Bind Group)
// 将具体的缓冲区对象绑定到布局指定的位置
const bindGroup = device.createBindGroup({
    label: 'SAXPY bind group',
    layout: bindGroupLayout,
    entries: [
        { binding: 0, resource: { buffer: uniformBuffer }},
        { binding: 1, resource: { buffer: xBuffer }},
        { binding: 2, resource: { buffer: yBuffer }},
        { binding: 3, resource: { buffer: zBuffer }},
    ]
});

// 4. 创建计算管线(Compute Pipeline)
const pipelineLayout = device.createPipelineLayout({
    label: 'SAXPY pipeline layout',
    bindGroupLayouts: [bindGroupLayout],
});
const computePipeline = device.createComputePipeline({
    label: 'SAXPY pipeline',
    layout: pipelineLayout,
    compute: {
        module: shaderModule,
        entryPoint: 'computeMain', // 对应WGSL中的函数名
    }
});

绑定组布局定义了“槽位”的规格,而绑定组则将实际的资源填入这些槽位。这种设计允许复用布局和资源,提高灵活性。

调度计算与读取结果 🚦

一切准备就绪后,我们通过命令编码器(Command Encoder)来录制并提交执行命令。

// 1. 创建命令编码器
const encoder = device.createCommandEncoder({ label: 'SAXPY encoder' });

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/upenn-cis5650-gpu-prog/img/20cb9720d4b8b89b9cfd6e59e05c448f_55.png)

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/upenn-cis5650-gpu-prog/img/20cb9720d4b8b89b9cfd6e59e05c448f_57.png)

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/upenn-cis5650-gpu-prog/img/20cb9720d4b8b89b9cfd6e59e05c448f_59.png)

// 2. 开始计算通道(Compute Pass)
const pass = encoder.beginComputePass({ label: 'SAXPY compute pass' });
pass.setPipeline(computePipeline);
pass.setBindGroup(0, bindGroup); // 设置绑定组,0对应@group(0)
// 调度计算!计算网格大小 = ceil(size / workgroup_size)
const workgroupCount = Math.ceil(size / 256);
pass.dispatchWorkgroups(workgroupCount); // 启动工作组
pass.end();

// 3. 创建用于回读的暂存缓冲区
const stagingBuffer = device.createBuffer({
    size: zBuffer.size,
    usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
});

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/upenn-cis5650-gpu-prog/img/20cb9720d4b8b89b9cfd6e59e05c448f_61.png)

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/upenn-cis5650-gpu-prog/img/20cb9720d4b8b89b9cfd6e59e05c448f_63.png)

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/upenn-cis5650-gpu-prog/img/20cb9720d4b8b89b9cfd6e59e05c448f_64.png)

// 4. 复制结果到暂存缓冲区
encoder.copyBufferToBuffer(
    zBuffer, 0,        // 源缓冲区
    stagingBuffer, 0,  // 目标缓冲区
    zBuffer.size
);

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/upenn-cis5650-gpu-prog/img/20cb9720d4b8b89b9cfd6e59e05c448f_66.png)

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/upenn-cis5650-gpu-prog/img/20cb9720d4b8b89b9cfd6e59e05c448f_68.png)

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/upenn-cis5650-gpu-prog/img/20cb9720d4b8b89b9cfd6e59e05c448f_70.png)

// 5. 提交命令到GPU队列执行
device.queue.submit([encoder.finish()]);

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/upenn-cis5650-gpu-prog/img/20cb9720d4b8b89b9cfd6e59e05c448f_72.png)

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/upenn-cis5650-gpu-prog/img/20cb9720d4b8b89b9cfd6e59e05c448f_74.png)

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/upenn-cis5650-gpu-prog/img/20cb9720d4b8b89b9cfd6e59e05c448f_76.png)

// 6. 将暂存缓冲区映射到CPU并读取数据
await stagingBuffer.mapAsync(GPUMapMode.READ);
const zResult = new Float32Array(stagingBuffer.getMappedRange().slice());
stagingBuffer.unmap(); // 使用完毕后取消映射

// 7. 与CPU结果对比验证
let success = true;
for (let i = 0; i < size; i++) {
    if (Math.abs(zResult[i] - cpuZ[i]) > 1e-5) {
        success = false;
        break;
    }
}
console.log(`WebGPU SAXPY ${success ? '成功' : '失败'}`);

注意:

  • dispatchWorkgroups(workgroupCount) 指定了要调度的工作组数量,相当于CUDA中内核启动的网格大小。
  • WebGPU命令是异步的。mapAsync 确保了在数据可用之前,CPU端的读取操作会等待。
  • 每个命令编码器在调用 finish() 并提交后就不能再使用。

注意事项与限制 ⚠️

在移植过程中,需要注意WebGPU的一些特性和限制:

  1. 工作组数量限制: WebGPU对每个维度上可调度的工作组数量有上限(例如65535)。对于大规模计算,可能需要使用多维调度(2D/3D dispatch)来规避。
    // 1D 调度可能超出限制
    // pass.dispatchWorkgroups(100000); // 可能错误
    // 2D 调度
    const xGroups = Math.ceil(size / 256);
    const yGroups = 1;
    pass.dispatchWorkgroups(xGroups, yGroups);
    
  2. 资源绑定: 着色器中声明的每个 @binding 都必须在绑定组中有对应的资源,即使它是一个未使用的占位符,否则管线创建会失败。
  3. 内存映射生命周期: 缓冲区在映射(mappedAtCreation: truemapAsync)后,必须在使用完CPU端数据后调用 unmap(),才能被GPU命令使用或再次映射。
  4. 平台差异: 不同GPU适配器的限制(如最大工作组大小、存储缓冲区绑定大小)可能不同,可通过 adapter.limits 查询并据此调整代码。

总结 🎯

本节课我们一起学习了将CUDA SAXPY示例移植到WebGPU的完整过程。我们了解到:

  • WebGPU提供了一个跨平台的、可用于通用计算的现代API。
  • 其核心流程包括:初始化设备、创建与填充缓冲区、编写WGSL计算着色器、设置管线与绑定组、调度计算以及回读结果。
  • WebGPU与CUDA在概念上有很多对应关系(如缓冲区对应设备内存、工作组对应线程块),但API设计更显式化,特别是资源绑定和管线状态管理。
  • 虽然WebGPU的初始化代码比CUDA更冗长,但其结构清晰,并且强大的绑定模型为复杂应用提供了灵活性。

通过这个从CUDA到WebGPU的映射练习,我们掌握了在Web环境中利用GPU进行高性能计算的基础。这种知识可以应用于浏览器内的机器学习、科学模拟或任何需要并行计算的任务中。

015:项目一基础与渲染技术概述

在本节课中,我们将学习课程第一个项目的基础设置、三种核心渲染技术(前向渲染、前向+渲染、延迟渲染)的原理,以及项目代码结构。我们将了解如何优化光照计算,并熟悉WebGPU项目环境。

🛠️ 项目环境与工具配置

为了顺利完成项目,你需要配置以下开发环境。

以下是推荐的工具和配置步骤:

  • Node.js:项目需要在本地Web服务器上运行。安装Node.js是必需步骤。
  • 浏览器:推荐使用Google Chrome。它支持所有必需的WebGPU扩展,并内置了调试和分析工具。
  • 代码编辑器:推荐使用Visual Studio Code。它可以方便地配置语法高亮(例如WGSL着色器语言)并设置linter来捕获错误。

项目的基础代码已经搭建完毕。你只需要下载项目,运行 npm install 安装依赖,然后运行 npm run dev 即可在浏览器中自动启动项目。当你修改文件并保存时,页面会自动刷新。

上一节我们介绍了项目的基本环境配置,本节中我们来看看项目的核心任务与渲染技术演进。

🎯 项目任务概览

项目包含三个主要部分,引导你从基础实现逐步优化渲染流程。

以下是三个部分的简要介绍:

  1. 基础前向渲染:你需要填充一些代码空白来完成基础的渲染管线。这部分完成后,你将能看到彩色的灯光效果,但性能会很慢。
  2. 前向+渲染:此部分通过光照簇技术优化光照计算,只检查影响当前片段的灯光,从而大幅提升性能。
  3. 延迟渲染:此部分通过使用G-Buffer来避免过度绘制问题,为每个像素只计算一次片段。

💡 核心渲染技术详解

基础前向渲染的瓶颈

在提供的基础(Naive)前向渲染代码中,每个片段(像素)都会检查场景中的每一个灯光。这会导致巨大的性能开销,尤其是在有多个物体层叠的场景中,那些最终会被覆盖的片段也进行了不必要的灯光计算。

前向+渲染与光照簇

前向+渲染的核心优化是引入光照簇。它只检查当前片段所在簇内的灯光,避免了全局遍历。

光照簇是3D空间中的一部分,与2D屏幕上的平铺区域相对应。它们不是与场景对齐的轴对齐包围盒,而是与相机对齐的视锥体平截头体。这意味着当你旋转相机时,这些簇的边界在世界空间中也会移动。

在实现时,通常更容易计算片段在视图空间中的轴对齐包围盒来进行相交测试,但簇的实际形状是平截头体。

以下是实现光照簇的主要步骤:

  1. 计算簇边界:使用一个计算着色器,为每个簇计算其在屏幕空间(2D)的边界以及起始与结束深度。最大深度可以基于场景硬编码或简单计算。
  2. 转换到视图空间:将屏幕空间坐标转换为视图空间坐标,这需要用到相机矩阵。
  3. 分配灯光到簇:对于每个灯光,检查其是否与簇的包围盒相交。如果相交,则将该灯光加入该簇的灯光列表,并记录该簇内的灯光数量,直到达到预设的最大值。

延迟渲染

前向+渲染仍然存在过度绘制的问题。延迟渲染通过将渲染分为两个阶段来解决此问题。

以下是延迟渲染的两个核心阶段:

  1. 几何处理阶段:将每个物体的材质属性(如反照率、法线、位置)渲染到多个纹理中,这些纹理统称为 G-Buffer
  2. 光照计算阶段:从G-Buffer中读取数据,每个像素执行一次光照计算,合成最终图像。

这意味着无论场景复杂度如何,每个像素只执行一次片段着色器(光照计算)。项目中的延迟渲染可以复用前向+渲染中编写的光照簇计算逻辑。

📁 项目代码结构导览

了解代码结构将帮助你高效地开展工作。主要工作集中在 src 目录下。

以下是关键文件与目录的说明:

  • src/main.ts:应用主入口,负责初始化WebGPU、加载场景、设置灯光和相机。你可以在这里修改GUI控件。
  • src/renderers/:存放渲染器类。
    • base-renderer.ts:所有渲染器的基类,包含场景、灯光、相机等通用属性和 onFrame 方法。
    • naive-renderer.ts:基础前向渲染器,大部分已实现,你需要根据 // TODO 注释完成剩余部分。
    • forward-plus-renderer.tsdeferred-renderer.ts:需要你完整实现。
  • src/shaders/:存放所有WGSL着色器文件。common.wgsl 会被自动附加到每个着色器前,用于定义共享的结构体和常量。
  • src/stages/:包含核心功能类。
    • camera.ts:相机类。你需要在此处向相机Uniform缓冲区添加视图投影矩阵等数据。所有Uniform应打包到一个缓冲区中一次性上传,格式需与着色器中的结构体定义匹配。
    • lights.ts:灯光类。doLightClustering 方法是实现光照簇计算的核心位置,其中的逻辑可被前向+和延迟渲染器复用。

💎 实用技巧与额外挑战

在实现过程中,以下技巧和挑战可能对你有帮助。

内存对齐

WGSL/WebGPU中的内存布局可能有别于你的预期。例如,一个包含 vec3<f32> 的结构体后可能会插入填充字节以满足对齐要求。你可以使用在线工具来验证和计算结构体的内存布局。

额外挑战

完成基本要求后,你可以尝试以下挑战以获得额外加分:

  • 实现后处理效果:如泛光、色调映射等。
  • 优化G-Buffer
    • 将数据打包到 vec4 中。
    • 使用双分量法线(因为法线长度为1,可重建第三分量)。
    • 使用八面体法线编码将整个法线打包进一个 u32
    • 通过深度和相机矩阵重建世界位置,减少G-Buffer属性。
  • 实现可见性缓冲区:这是一种更极致的优化,只存储物体ID和三角形ID,在着色阶段手动获取顶点数据并计算光照。
  • 支持其他光源类型:如聚光灯、方向光等。

📝 总结

本节课中我们一起学习了第一个项目的全貌。我们从配置开发环境开始,逐步深入探讨了三种渲染技术:基础但性能低下的前向渲染、通过光照簇优化性能的前向+渲染,以及能有效解决过度绘制问题的延迟渲染。我们还快速浏览了项目的代码结构,明确了主要的工作区域。最后,了解了一些实用的调试技巧和可供探索的额外挑战。现在,你可以开始动手填充代码,将黑色的屏幕变为动态的光影世界了。

016:WebGPU计算与CUDA高级主题

在本节课中,我们将学习WebGPU计算管线的核心概念,并将其与CUDA进行对比。我们将从简单的SAXPY操作开始,逐步深入到矩阵乘法,并探讨WebGPU中的共享内存使用。随后,我们将转向CUDA的高级主题,包括统一内存、零拷贝和流,以理解如何优化GPU程序的并发执行。

WebGPU SAXPY 实现

上一节我们介绍了课程概述,本节中我们来看看如何在WebGPU中实现基础的SAXPY操作。SAXPY是一个向量运算:Z = a * X + Y。在CUDA中,这很简单,但在WebGPU中,我们需要显式地管理内存和管线。

内存分配与拷贝

以下是内存分配与从主机(CPU)拷贝到设备(GPU)的关键步骤。

  • 创建缓冲区:使用 device.createBuffer 创建GPU缓冲区。usage 标志至关重要,它定义了缓冲区的用途(例如,COPY_DST 用于从CPU拷贝到GPU,STORAGE 用于计算着色器读写)。
  • 映射与拷贝:与CUDA的 cudaMemcpy 不同,WebGPU需要先将主机内存映射到GPU可访问的范围,然后执行拷贝,最后取消映射。这类似于CUDA中的固定内存(pinned memory)操作。
  • 统一值(Uniforms):像标量 a 和数组长度这样的参数,在WebGPU中需要通过统一缓冲区传递。它们对GPU上的所有线程是只读的,类似于CUDA的常量内存。创建时使用 COPY_DST 标志,因为数据是从主机拷贝到GPU。

计算着色器

内存准备就绪后,我们需要定义在GPU上执行的计算逻辑。

  • 着色器模块:将WGSL(WebGPU Shading Language)代码字符串包装成 device.createShaderModule
  • 工作组大小:一个关键区别是,工作组(对应CUDA的线程块)的大小是在着色器内部定义的,而不是在CPU调用时指定。这通过 @workgroup_size 属性完成。
  • 内置变量@builtin(global_invocation_id) 提供了全局唯一的线程ID,相当于CUDA中的 blockIdx * blockDim + threadIdx
  • 核心计算:SAXPY计算本身与CUDA内核非常相似:获取全局索引,检查边界,执行 Z[i] = a * X[i] + Y[i]

绑定组与管线

为了将GPU缓冲区和着色器连接起来,我们需要创建绑定组和计算管线。

  • 绑定组布局:定义了着色器参数(绑定)的结构和类型。它相当于一个蓝图,说明“第0个绑定是统一缓冲区,第1个是只读存储缓冲区”等。
  • 绑定组:将具体的缓冲区实例(如 xBuffer, yBuffer)按照绑定组布局指定的顺序进行绑定。这告诉管线:“当运行这个着色器时,使用这些特定的缓冲区”。
  • 管线布局与创建:将绑定组布局和着色器模块组合起来,创建计算管线。这里需要指定入口点函数名(例如 computeMain)。

命令编码与执行

一切设置完成后,我们需要编码并提交命令以执行计算。

  • 命令编码器:创建一个 commandEncoder 来记录一系列GPU命令。
  • 计算通道:开始一个计算通道,设置管线(用哪个着色器)和绑定组(用哪些数据)。
  • 调度工作组:调用 dispatchWorkgroups 来启动计算。这里传入的是工作组(块)的数量,而不是线程总数。线程总数是 工作组数量 * 着色器内定义的工作组大小
  • 提交命令:通过 device.queue.submit 提交编码的命令列表以供GPU执行。

结果回读与验证

计算完成后,我们需要将结果从GPU读回CPU进行验证。

  • 暂存缓冲区:通常不能直接从用于计算的存储缓冲区映射到CPU。需要创建一个具有 MAP_READ 用途的暂存缓冲区。
  • 拷贝到暂存区:在同一个命令编码器中,添加一个从结果缓冲区到暂存缓冲区的拷贝命令。
  • 异步映射与读取:提交命令后,异步地将暂存缓冲区映射到CPU内存,然后从中读取数据。
  • 取消映射:数据读取完毕后,必须调用 unmap(),否则缓冲区可能无法重用。

WebGPU 性能计时

上一节我们完成了SAXPY的完整流程,本节中我们来看看如何在WebGPU中进行精确的性能测量。WebGPU的计时机制比CUDA更复杂,因为它是异步的,并且需要显式查询。

以下是设置和使用计时器的核心步骤。

  1. 启用计时功能:在请求GPU设备时,必须在 requiredFeatures 中包含 "timestamp-query"。注意,此功能可能需要在Chrome Canary等测试版浏览器中才能使用。
  2. 创建查询集:使用 device.createQuerySet 创建一个查询集,type"timestamp"count 为需要的采样点数(例如,开始和结束两个点)。
  3. 创建解析缓冲区:创建一个GPU缓冲区(usage: QUERY_RESOLVE),用于存储查询集解析后的时间戳。
  4. 创建结果缓冲区:创建另一个GPU缓冲区(usage: COPY_SRC | MAP_READ),用于将解析后的时间戳拷贝到CPU。
  5. 记录时间戳:在命令编码器的计算通道中,使用 passEncoder.writeTimestamp 在需要计时的代码段前后写入时间戳。
  6. 解析与拷贝:在计算通道结束后,使用 commandEncoder.resolveQuerySet 将查询集的时间戳解析到解析缓冲区。然后,将解析缓冲区拷贝到结果缓冲区。
  7. 读取时间差:提交命令后,映射结果缓冲区到CPU,读取两个时间戳,计算差值(注意时间戳单位通常是纳秒)。

WebGPU 矩阵乘法

理解了基础流程后,我们可以实现更复杂的算法。矩阵乘法(C = A x B)让我们有机会探索多维索引和性能优化。

从一维到二维

从SAXPY到矩阵乘法的主要变化是维度的提升。

  • 工作组大小:在着色器中,将 @workgroup_size 定义为二维,例如 (16, 16)
  • 调度调用:在CPU端调用 dispatchWorkgroups 时,也需要传入二维的工作组数量 (Math.ceil(M / 16), Math.ceil(N / 16))
  • 全局索引:在着色器中使用 global_id.xy 来获取二维的全局线程ID,分别对应输出矩阵C的行 i 和列 j
  • 内核逻辑:朴素矩阵乘法的核心是三重循环:对于输出C的每个元素 (i, j),累加 A[i][k] * B[k][j] 对于所有k的和。

使用共享内存(平铺矩阵乘法)

朴素矩阵乘法存在大量的全局内存访问。为了提高性能,我们可以使用共享内存(在WebGPU中称为 workgroup 内存)来实现平铺算法。

以下是平铺矩阵乘法在WebGPU中的实现要点。

  • 声明共享内存:在着色器中,使用 var<workgroup> tileA : array<f32, TILE_SIZE*TILE_SIZE>; 声明工作组共享的数组。WebGPU的共享内存目前只支持一维数组
  • 获取局部索引:除了 global_invocation_id,还需要 local_invocation_id(线程在块内的ID)和 workgroup_id(块的ID)来计算平铺的偏移量。
  • 数据平铺加载:每个线程协作将全局内存中A和B矩阵的一个“瓦片”加载到共享内存 tileAtileB 中。
  • 屏障同步:在共享内存加载完成后以及每次计算子块累加后,必须调用 workgroupBarrier() 来同步工作组内的所有线程。WebGPU要求屏障调用必须在所有线程的统一控制流中,这意味着不能在有提前返回的分支内调用屏障。
  • 计算与累加:在双重循环中,每个线程使用共享内存中的数据来计算其负责的输出元素的部分和,最后写回全局内存。

CUDA 高级主题:统一内存与零拷贝

现在我们将视角转回CUDA,探讨一些可以简化编程或提升性能的高级内存管理技术。

统一内存

统一内存提供了一个统一地址空间,系统自动在CPU和GPU间迁移数据。

  • 概念:使用 cudaMallocManaged 分配的内存,既可以从CPU访问,也可以从GPU访问。程序员无需显式调用 cudaMemcpy,数据迁移在遇到同步操作(如 cudaDeviceSynchronize 或内核启动)时自动发生。
  • 优点:简化了编程模型,特别适合初学者或原型开发。
  • 缺点:对于追求极致性能的程序,自动迁移可能引入不可预测的开销,且可能创建不必要的同步点。建议在性能关键的代码中使用显式内存拷贝

零拷贝内存

零拷贝内存允许GPU直接访问CPU的固定内存,避免了显式的设备端拷贝。

  • 概念:使用 cudaHostAlloc 分配固定(pinned)且映射(mapped)的主机内存。然后通过 cudaHostGetDevicePointer 获取该内存对应的设备指针。GPU内核可以直接使用这个设备指针读取或写入主机内存。
  • 适用场景
    • 集成GPU/SoC系统:CPU和GPU共享物理内存,此时零拷贝性能最佳,完全避免了PCIe拷贝开销。
    • 桌面离散GPU:数据通过PCIe总线访问,速度较慢,通常只适用于数据重用率低或设备内存不足的特殊情况。
  • 代码流程:分配映射主机内存 -> 获取设备指针 -> 内核使用设备指针 -> 释放主机内存(无需释放设备内存)。

CUDA 高级主题:流

为了充分挖掘GPU的并行潜力,我们需要在任务级别实现并发。CUDA流就是用于实现这种并发的机制。

流的概念与创建

流是一个在GPU上顺序执行的操作序列(如内存拷贝、内核启动)。不同的流可以并发执行。

  • 默认流:所有未指定流的操作都在默认流(stream 0)中执行,它是同步的,会阻塞其他流。
  • 创建非默认流:使用 cudaStreamCreate 创建新流,使用 cudaStreamDestroy 销毁。
  • 异步操作cudaMemcpyAsync 和内核启动(<<<..., stream>>>)是支持流的主要异步操作。

计算与拷贝重叠

流的首要优势是实现计算与内存拷贝的重叠,从而隐藏延迟。

  • 前提条件:GPU硬件需要支持并发内核执行和异步拷贝引擎(通常至少有一个拷贝引擎用于 HostToDevice,一个用于 DeviceToHost)。
  • 基本模式:将一个大任务分解为多个子任务,分配到不同的流中。每个流执行 H2D拷贝 -> 计算 -> D2H拷贝。通过合理安排流的启动顺序,可以让一个流的计算与另一个流的拷贝同时进行。
  • 同步:使用 cudaStreamSynchronize(stream) 来等待特定流中的所有操作完成,这比同步整个设备(cudaDeviceSynchronize)更精细。

事件

事件可用于精确计时和流间同步。

  • 计时:创建开始和结束事件(cudaEventCreate),在流中记录它们(cudaEventRecord),然后使用 cudaEventElapsedTime 计算时间差。这是GPU端计时的标准方法。
  • 同步cudaEventSynchronize(event) 会阻塞主机线程,直到该事件被记录完成。它可以用于更灵活的跨流同步。

CUDA 高级主题:图

CUDA图提供了一种新的工作提交模式,将一系列操作及其依赖关系定义为一个图,然后一次性启动整个图。

  • 动机:对于需要重复执行相同操作序列的应用,使用流时,每次提交操作都有CPU开销。图可以将这个序列定义为静态图,然后高效地重复启动。
  • 创建方式
    • 显式创建:使用 cudaGraphAddKernelNode 等API逐个添加节点(操作)和边(依赖)。
    • 流捕获:在常规流操作代码周围使用 cudaStreamBeginCapturecudaStreamEndCapture,可以自动将流中的操作记录为一个图。
  • 实例化与启动:创建图后,需要实例化(cudaGraphInstantiate)为可执行的图实例,然后使用 cudaGraphLaunch 启动。
  • 优势:减少了CPU开销,允许驱动进行更深层次的优化,特别适合深度学习推理等固定流水线场景。

总结

本节课中我们一起学习了WebGPU计算管线从内存管理、着色器编写到命令执行的完整流程,并通过SAXPY和矩阵乘法示例进行了实践。我们还探讨了WebGPU中共享内存和性能计时的特殊用法。随后,我们转向CUDA,深入了解了统一内存和零拷贝这两种简化或优化内存访问的模型。最后,我们重点学习了CUDA流和图的强大功能,它们通过实现任务级并行和预定义执行图,能够显著提升复杂GPU应用程序的吞吐量和效率。掌握这些高级主题对于编写高性能、可扩展的GPU程序至关重要。

017:Vulkan实验一与高斯溅射项目介绍

在本节课中,我们将学习两个核心内容。首先,我们会介绍课程项目五——基于WebGPU的高斯溅射渲染。其次,我们将开始Vulkan图形API的实验课程第一部分,学习其初始化、资源创建和管线设置等基础知识。


🎯 项目五:3D高斯溅射渲染概述

上一节我们介绍了本节课的整体安排,本节中我们来看看项目五的具体内容。3D高斯溅射是一种不同于传统三角形渲染的点云渲染技术。

核心思想:该技术源于统计学中的高斯分布。我们不是渲染三角形,而是渲染一系列点(称为“溅射”)。每个点周围有一个类似高斯分布的区域,具有颜色变化等属性,然后将所有点的颜色混合在一起形成最终图像。

数学基础:在3D空间中,一个高斯分布由均值(点的中心位置)和协方差矩阵定义。协方差矩阵描述了数据点相对于中心点的变化程度,在渲染中即表示颜色等属性围绕中心点的变化范围。

协方差变换:我们可以像在常规图形管线中一样,通过旋转矩阵和缩放矩阵来变换协方差,从而改变高斯分布的形状。

项目流程:在实际论文中,该技术需要从不同相机角度训练场景并采样点云。但在我们的项目中,我们只关注渲染部分。我们将使用预训练的场景、相机文件和点云文件。

以下是渲染管线的核心步骤:

  1. 加载数据:加载提供的3D高斯数据。
  2. 预处理:将3D高斯数据投影到2D屏幕空间。这包括视锥体裁剪、计算3D协方差矩阵并将其投影到2D、评估球谐函数获取颜色,以及根据深度对溅射点进行排序(因为我们需要像处理透明纹理一样从后向前渲染)。
  3. 渲染:将每个高斯溅射视为一个面向视图方向的四边形实例进行渲染。在片段着色器中,根据2D高斯方程计算颜色和不透明度,然后混合所有颜色。

2D投影公式:3D协方差到2D的投影类似于我们为三角形渲染所做的MVP变换,公式涉及视图变换矩阵和雅可比矩阵:
Σ' = J * W * Σ * W^T * J^T
其中,Σ是3D协方差矩阵,W是视图变换矩阵,J是投影的雅可比矩阵。

颜色计算:在片段着色器中,我们使用2D二次方程来计算不透明度。颜色从中心呈指数衰减,中心点的颜色系数由球谐函数得出。

项目代码结构:主要需要实现 GaussianRenderer 和相关的计算着色器(预处理)与渲染着色器。PointCloudRenderer 已基本实现,用于辅助调试。

注意事项

  • 数据传输到GPU时使用半精度浮点数以节省带宽。
  • 在GPU预处理阶段,需要使用原子操作来更新实例计数和派发参数。
  • 必须确保每一帧都根据深度对溅射点进行排序,以实现正确的从后向前混合。

⚙️ Vulkan实验一:初始化与资源管理

上一节我们介绍了高斯溅射项目,本节中我们开始学习Vulkan实验的第一部分。Vulkan是一个现代的、显式的、跨平台的底层图形API。

核心特点:与OpenGL等传统API相比,Vulkan没有全局状态,需要程序员显式管理几乎所有资源状态。这带来了更大的控制权和优化空间,但也增加了代码复杂度。

初始化Vulkan

以下是创建Vulkan实例和逻辑设备的基本步骤:

  1. 创建实例:实例管理应用程序的全局状态。创建时需要填写 VkInstanceCreateInfo 结构体,指定应用信息、启用的扩展和验证层。

    VkInstance instance;
    VkInstanceCreateInfo createInfo{};
    createInfo.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
    createInfo.pApplicationInfo = &appInfo;
    // ... 设置扩展和层
    vkCreateInstance(&createInfo, nullptr, &instance);
    
  2. 选择物理设备:枚举系统中的GPU(物理设备),并根据属性(如是否为独立显卡)选择最合适的一个。

    uint32_t deviceCount = 0;
    vkEnumeratePhysicalDevices(instance, &deviceCount, nullptr);
    std::vector<VkPhysicalDevice> devices(deviceCount);
    vkEnumeratePhysicalDevices(instance, &deviceCount, devices.data());
    // ... 遍历 devices 并选择
    
  3. 创建逻辑设备:通过选择的物理设备创建逻辑设备,这是与GPU交互的主要接口。在此步骤中,我们需要启用设备特性、扩展,并创建命令队列。

    VkDevice device;
    VkDeviceCreateInfo deviceCreateInfo{};
    deviceCreateInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO;
    deviceCreateInfo.queueCreateInfoCount = static_cast<uint32_t>(queueCreateInfos.size());
    deviceCreateInfo.pQueueCreateInfos = queueCreateInfos.data();
    // ... 设置特性和扩展
    vkCreateDevice(physicalDevice, &deviceCreateInfo, nullptr, &device);
    

队列:队列用于向GPU提交命令缓冲区(绘制、计算、内存传输)。队列属于队列族,不同的族支持不同类型的操作(如图形、计算、传输)。使用异步计算队列可以帮助平衡内存密集型与计算密集型任务,提高GPU占用率。

创建资源:图像与缓冲区

资源(如图像和缓冲区)在Vulkan中需要显式创建并绑定到设备内存。

创建图像:图像可以表示最多三维的数据数组。创建时需要指定用途(如用作存储图像、采样器或渲染目标)、格式和布局。

VkImageCreateInfo imageInfo{};
imageInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
imageInfo.imageType = VK_IMAGE_TYPE_2D;
imageInfo.format = VK_FORMAT_R8G8B8A8_UNORM;
imageInfo.usage = VK_IMAGE_USAGE_STORAGE_BIT | VK_IMAGE_USAGE_SAMPLED_BIT;
// ... 设置其他参数
vkCreateImage(device, &imageInfo, nullptr, &image);

图像布局:图像布局表示图像数据在内存中的排列方式,不同的管线阶段可能需要不同的布局。Vulkan要求程序员使用管线屏障显式地进行布局转换,而WebGPU等API会自动处理。

创建缓冲区:缓冲区相对简单,本质是一块内存。创建时需要指定大小和用途。

VkBufferCreateInfo bufferInfo{};
bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
bufferInfo.size = bufferSize;
bufferInfo.usage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT;
vkCreateBuffer(device, &bufferInfo, nullptr, &buffer);

绑定内存:图像和缓冲区本身不持有内存,需要先查询内存需求,然后分配设备内存并与之绑定。

VkMemoryRequirements memRequirements;
vkGetImageMemoryRequirements(device, image, &memRequirements);
VkMemoryAllocateInfo allocInfo{};
allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
allocInfo.allocationSize = memRequirements.size;
allocInfo.memoryTypeIndex = findMemoryType(memRequirements.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
vkAllocateMemory(device, &allocInfo, nullptr, &imageMemory);
vkBindImageMemory(device, image, imageMemory, 0);

从主机更新数据:如果CPU需要更新缓冲区数据,通常的流程是:

  1. 创建一个主机可见的暂存缓冲区。
  2. 将数据复制到暂存缓冲区。
  3. 创建一个设备本地的目标缓冲区。
  4. 通过命令缓冲区,将数据从暂存缓冲区复制到目标缓冲区。
  5. 销毁暂存缓冲区。

描述符与管线布局

上一节我们创建了资源,本节中我们来看看如何将这些资源绑定到着色器。在Vulkan中,这通过描述符和描述符集完成。

描述符:描述符描述了资源如何绑定到着色器的一个资源槽。

描述符集:描述符被分组到描述符集中。创建描述符集前,需要先创建描述符集布局,它定义了该集合中所有绑定的格式。

VkDescriptorSetLayoutBinding layoutBinding{};
layoutBinding.binding = 0; // 与着色器中的 binding 对应
layoutBinding.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE;
layoutBinding.stageFlags = VK_SHADER_STAGE_COMPUTE_BIT;
VkDescriptorSetLayoutCreateInfo layoutInfo{};
layoutInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO;
layoutInfo.bindingCount = 1;
layoutInfo.pBindings = &layoutBinding;
vkCreateDescriptorSetLayout(device, &layoutInfo, nullptr, &descriptorSetLayout);

更新描述符集:分配描述符集后,需要将具体的资源(缓冲区或图像视图)写入其中。

VkDescriptorImageInfo imageInfo{};
imageInfo.imageView = storageImageView;
imageInfo.imageLayout = VK_IMAGE_LAYOUT_GENERAL;
VkWriteDescriptorSet descriptorWrite{};
descriptorWrite.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
descriptorWrite.dstSet = descriptorSet;
descriptorWrite.dstBinding = 0;
descriptorWrite.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE;
descriptorWrite.pImageInfo = &imageInfo;
vkUpdateDescriptorSets(device, 1, &descriptorWrite, 0, nullptr);

最佳实践:建议根据资源的更新频率和用途将描述符分组到不同的描述符集中,例如一个集合用于每帧更新的数据,另一个用于静态数据。

创建图形与计算管线

有了描述符集布局,我们就可以创建管线布局,并最终创建管线。

着色器模块:Vulkan着色器通常用GLSL/HLSL编写,但必须编译为SPIR-V中间表示才能使用。

VkShaderModuleCreateInfo createInfo{};
createInfo.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;
createInfo.codeSize = codeSize;
createInfo.pCode = reinterpret_cast<const uint32_t*>(code);
vkCreateShaderModule(device, &createInfo, nullptr, &shaderModule);

图形管线:图形管线的创建非常复杂,需要定义一系列固定功能和可编程阶段的状态,例如:

  • 顶点输入状态(绑定描述、属性描述)。
  • 输入装配状态(拓扑类型,如三角形列表)。
  • 光栅化状态(多边形模式、剔除模式)。
  • 视口状态、多重采样状态、深度/模板测试状态、颜色混合状态等。

计算管线:计算管线的创建则简单得多,主要需要指定计算着色器和管线布局。

渲染通道:渲染通道是Vulkan图形管线特有的概念,它定义了一组附件(如颜色附件、深度附件)及其在渲染过程中的使用方法。子通道是渲染通道内的子部分,可以优化Tile-Based架构GPU上的带宽。

帧缓冲区:帧缓冲区包装了图像视图,使其能够被渲染通道使用。

创建管线布局与管线:管线布局组合了该管线将使用的所有描述符集布局。最后,将所有状态信息填入 VkGraphicsPipelineCreateInfo 来创建图形管线。

VkPipelineLayout pipelineLayout;
VkPipelineLayoutCreateInfo pipelineLayoutInfo{};
pipelineLayoutInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
pipelineLayoutInfo.setLayoutCount = 1;
pipelineLayoutInfo.pSetLayouts = &descriptorSetLayout;
vkCreatePipelineLayout(device, &pipelineLayoutInfo, nullptr, &pipelineLayout);

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/upenn-cis5650-gpu-prog/img/3a23afc523800bfaacb053f8b5862fcf_86.png)

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/upenn-cis5650-gpu-prog/img/3a23afc523800bfaacb053f8b5862fcf_88.png)

VkGraphicsPipelineCreateInfo pipelineInfo{};
pipelineInfo.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO;
pipelineInfo.stageCount = 2; // 顶点和片段着色器阶段
pipelineInfo.pStages = shaderStages;
pipelineInfo.pVertexInputState = &vertexInputInfo;
pipelineInfo.pInputAssemblyState = &inputAssembly;
pipelineInfo.pViewportState = &viewportState;
pipelineInfo.pRasterizationState = &rasterizer;
// ... 设置其他状态
pipelineInfo.layout = pipelineLayout;
pipelineInfo.renderPass = renderPass;
vkCreateGraphicsPipelines(device, VK_NULL_HANDLE, 1, &pipelineInfo, nullptr, &graphicsPipeline);

📝 总结

本节课中我们一起学习了两个主要部分。

首先,我们深入了解了项目五:3D高斯溅射渲染。我们学习了其基于高斯分布的核心原理、从3D点云到2D屏幕的渲染管线,包括数据加载、预处理(投影、排序)和基于四边形的渲染混合过程。

其次,我们开始了Vulkan实验课程的第一部分。我们学习了Vulkan的初始化流程,包括创建实例、选择物理设备、创建逻辑设备和队列。我们探讨了如何创建和管理关键资源,如图像和缓冲区,并理解了图像布局转换的重要性。最后,我们介绍了将资源绑定到着色器的核心机制——描述符和描述符集,并概述了创建复杂图形管线与简单计算管线所需的步骤。

Vulkan是一个显式且强大的API,虽然入门曲线陡峭,但能提供深度的硬件控制和优化潜力。在接下来的实验中,我们将继续学习命令缓冲区、同步和渲染循环等内容。

018:Vulkan命令记录与同步 🔧

在本节课中,我们将要学习Vulkan中命令记录的核心概念以及至关重要的同步机制。Vulkan是一个高度异步的API,这意味着开发者需要手动处理几乎所有同步操作,以确保GPU和CPU之间的正确协作。

命令缓冲区 📝

上一节我们介绍了Vulkan的基本架构,本节中我们来看看命令缓冲区。命令缓冲区是GPU执行命令的载体。为了使命令缓冲区对GPU有用,我们需要将其提交到GPU的某个队列中。

命令缓冲区可以记录一系列命令,这些命令可以被提交到任何队列执行。但请注意,记录的顺序并不等同于实际的执行顺序。不同命令之间的执行完成顺序可能是任意的。如果你想确保不同命令之间的执行顺序,需要在命令缓冲区记录阶段插入一些屏障。

命令缓冲区从VkCommandPool中分配和释放,不能直接创建。我们可以在vkBeginCommandBuffervkEndCommandBuffer之间记录所有命令,无论是绘制命令还是管道屏障。命令缓冲区的概念与WebGPU中的命令编码器非常相似。

以下是构建图形管道命令缓冲区的核心代码示例:

vkCmdBeginRenderPass(...); // 开始渲染通道
vkCmdSetViewport(...);
vkCmdSetScissor(...);
vkCmdBindDescriptorSets(...); // 绑定描述符集
vkCmdBindPipeline(...); // 绑定管道
vkCmdDraw(...); // 执行绘制命令
vkCmdEndRenderPass(...); // 结束渲染通道

在我们的示例中,我们绘制一个覆盖整个屏幕的三角形,这可以作为一个全屏通道使用。

命令缓冲区的分配通常如下所示,但实际使用的核心是记录在其中的命令:

vkAllocateCommandBuffers(device, &allocInfo, &commandBuffer);

对于计算着色器,命令记录更为简洁。以下是计算管道命令缓冲区的示例:

vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_COMPUTE, computePipeline);
vkCmdBindDescriptorSets(commandBuffer, VK_PIPELINE_BIND_POINT_COMPUTE, ...);
vkCmdDispatch(commandBuffer, 16, 16, 1); // 调度计算工作

这里的数字16是X和Y方向的工作组大小。

渲染通道与绘制 🎨

在图形管道中,在绘制任何内容之前,你总是需要开始一个新的渲染通道。你可以在一个渲染通道中使用多个子通道。

开始渲染通道的调用如下:

VkRenderPassBeginInfo renderPassInfo{};
renderPassInfo.renderPass = renderPass; // 使用之前创建的渲染通道
renderPassInfo.framebuffer = swapChainFramebuffers[imageIndex];
renderPassInfo.renderArea.offset = {0, 0};
renderPassInfo.renderArea.extent = swapChainExtent;
// 指定要写入的附件的清除值(例如颜色和深度模板)
std::array<VkClearValue, 2> clearValues{};
clearValues[0].color = {{0.0f, 0.0f, 0.0f, 1.0f}};
clearValues[1].depthStencil = {1.0f, 0};
renderPassInfo.clearValueCount = static_cast<uint32_t>(clearValues.size());
renderPassInfo.pClearValues = clearValues.data();

vkCmdBeginRenderPass(commandBuffer, &renderPassInfo, VK_SUBPASS_CONTENTS_INLINE);

之后,绑定描述符集和管道,并执行绘制命令。vkCmdDraw的函数签名如下:

void vkCmdDraw(
    VkCommandBuffer commandBuffer,
    uint32_t vertexCount,    // 要绘制的顶点数量
    uint32_t instanceCount,  // 实例数量
    uint32_t firstVertex,    // 第一个顶点的偏移量
    uint32_t firstInstance); // 第一个实例的偏移量

完成所有绘制(包括UI)后,结束渲染通道:

vkCmdEndRenderPass(commandBuffer);

Vulkan同步机制 🔄

现在我们来探讨Vulkan同步的核心部分。之前我们介绍了队列的概念,每个记录的命令缓冲区都可以提交到单个队列。这些队列可以异步执行,并且不同队列上的命令也可以并发运行。因此,我们需要在队列内部以及队列之间进行同步。此外,CPU和GPU也并发工作,有时CPU需要等待GPU工作完成。

Vulkan提供了不同的同步原语:

  • Fence(栅栏):用于CPU和GPU之间的同步。
  • Semaphore(信号量):用于队列操作之间或不同队列之间的同步。
  • Event(事件):用于在单个队列内进行非常灵活的同步。
  • Pipeline Barrier(管道屏障):功能强大,但只能在命令缓冲区记录阶段使用。

Fence(栅栏)

栅栏通过发出信号来确保CPU和GPU之间的同步,当GPU任务完成时通知CPU。栅栏有两种状态:已发出信号未发出信号

典型的用法是:首先将工作提交到GPU,在提交阶段标记一个栅栏以跟踪GPU工作的完成。一旦GPU完成工作,就会发出栅栏信号,然后CPU可以等待该栅栏。

在我们的示例中,使用两个栅栏来确保不同操作之间的同步。第一个栅栏用于防止计算命令缓冲区在上一次提交的工作完成之前被重复提交。

以下是相关代码示例:

// 提交计算工作,并关联一个栅栏
vkQueueSubmit(computeQueue, 1, &submitInfo, computeFence);

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/upenn-cis5650-gpu-prog/img/6cb40ed497ae887648330cfbd738ac69_64.png)

// 在下一轮迭代中,等待栅栏完成
vkWaitForFences(device, 1, &computeFence, VK_TRUE, UINT64_MAX);
vkResetFences(device, 1, &computeFence); // 重置栅栏状态

这与vkDeviceWaitIdle不同,后者会等待设备上的所有命令完成,而栅栏只等待特定的提交完成。

管道阶段

提交到Vulkan的每个命令都会经过一组阶段,每个阶段中的命令执行称为操作。例如,计算管道的dispatch命令只经过计算阶段,而绘制命令可能经过顶点阶段、片段阶段等多个阶段。阶段的定义是在管道创建时完成的。

对于GPU到GPU的同步,Vulkan允许程序员同步阶段一中的所有操作阶段二中的所有操作。这意味着我们可以确保在队列A的阶段X中提交的所有命令,在队列B的阶段Y中的命令开始之前完成。

这确保了操作顺序和范围内的内存顺序:

  • 操作顺序:第一组操作(阶段X)在第二组操作(阶段Y)开始之前完成。
  • 内存顺序:通过指定范围内存访问,可以确保第一组操作进行的内存访问能够被第二组操作看到。

Semaphore(信号量)

信号量主要用于管理GPU到GPU的同步,尤其是在不同队列之间。例如,如果我们希望图形队列等待计算队列完成工作,就需要使用信号量。

与栅栏类似,信号量也有已发出信号和未发出信号的状态。在向队列提交工作时,可以指定一个信号量,当该提交中的所有操作完成时,该信号量会被发出信号。同时,可以指定某个阶段等待该信号量被发出信号后再继续。

例如,在我们的光线追踪示例中,计算队列完成光线追踪后发出一个信号量。然后,图形队列的片段着色器阶段等待该信号量,因为它需要从计算队列写入的存储图像中采样。

以下是信号量使用的代码框架:

// 计算队列提交信息,设置完成后发出信号的信号量
VkSubmitInfo computeSubmitInfo{};
computeSubmitInfo.signalSemaphoreCount = 1;
computeSubmitInfo.pSignalSemaphores = &computeFinishedSemaphore;

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/upenn-cis5650-gpu-prog/img/6cb40ed497ae887648330cfbd738ac69_74.png)

![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/upenn-cis5650-gpu-prog/img/6cb40ed497ae887648330cfbd738ac69_76.png)

// 图形队列提交信息,设置需要等待的信号量及等待阶段
VkSubmitInfo graphicsSubmitInfo{};
graphicsSubmitInfo.waitSemaphoreCount = 1;
graphicsSubmitInfo.pWaitSemaphores = &computeFinishedSemaphore;
graphicsSubmitInfo.pWaitDstStageMask = &waitStage; // 例如,片段着色器阶段

对于交换链,获取下一个图像的操作是异步的。我们需要一个信号量在该操作完成时发出信号,以便颜色输出阶段可以等待图像可用后再开始。

Pipeline Barrier(管道屏障)

管道屏障用于单个队列内部的同步。它是一个以vkCmd开头的命令函数调用,因此只能在命令缓冲区记录阶段使用。

管道屏障可以帮助你同步单个提交内的不同命令或操作。你可以指定第一组操作和第二组操作,以及它们的内存访问范围。屏障确保第一组操作在第二组操作开始之前完成,并且第一组操作的内存访问结果对第二组操作可见。

管道屏障的一个重要用途是图像布局转换。例如,在着色器中用作存储图像的图像需要处于VK_IMAGE_LAYOUT_GENERAL布局,而用作颜色附件的图像需要处于VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL布局,呈现到屏幕的图像需要VK_IMAGE_LAYOUT_PRESENT_SRC_KHR布局。

以下是使用管道屏障进行布局转换的示例:

VkImageMemoryBarrier barrier{};
barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED;
barrier.newLayout = VK_IMAGE_LAYOUT_GENERAL;
barrier.srcAccessMask = 0;
barrier.dstAccessMask = VK_ACCESS_SHADER_WRITE_BIT;
// ... 设置图像、子资源范围等

vkCmdPipelineBarrier(
    commandBuffer,
    VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, // 源阶段
    VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, // 目标阶段
    0, 0, nullptr, 0, nullptr,
    1, &barrier);

渲染通道也可以自动处理布局转换。在创建渲染通道时,你需要指定附件的初始布局、最终布局以及每个子通道内的布局。

资源所有权转移

当资源(如图像或缓冲区)以VK_SHARING_MODE_EXCLUSIVE模式创建,并被多个队列使用时,需要进行显式的资源所有权转移。例如,存储图像被计算队列用于光线追踪,然后被图形队列的片段着色器采样,这期间就涉及所有权在队列间的转移。

所有权转移通过管道屏障实现,但必须与信号量配合使用,因为管道屏障只能同步队列内部,而信号量可以同步不同队列。

转移分为两步:

  1. 释放屏障:在源队列的命令缓冲区中,释放资源的所有权。
  2. 获取屏障:在目标队列的命令缓冲区中,获取资源的所有权。

在释放屏障中,你主要关心源访问掩码和源阶段;在获取屏障中,你主要关心目标访问掩码和目标阶段。

在我们的示例中,有一个从图形队列到计算队列的释放屏障,以及一个从计算队列到图形队列的获取屏障,它们与信号量一起确保了正确的同步和所有权转移。

子通道依赖

子通道依赖本质上是渲染通道内的管道屏障。它们由驱动程序转换为管道屏障,但允许你为附件自动处理内存依赖关系。

在创建渲染通道时,你可以指定子通道依赖关系,以同步不同子通道之间或渲染通道与外部操作之间的内存访问。每个依赖关系都适用于所有附件,但可以通过阶段掩码和访问掩码来限制同步的范围。

以下是子通道依赖的示例:

VkSubpassDependency dependency{};
dependency.srcSubpass = VK_SUBPASS_EXTERNAL; // 表示渲染通道之前的所有操作
dependency.dstSubpass = 0; // 第一个子通道
dependency.srcStageMask = VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT;
dependency.dstStageMask = VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT;
dependency.srcAccessMask = VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;
dependency.dstAccessMask = VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT;

这个依赖关系确保在深度附件上的前一次写入完成之后,当前渲染通道才能开始读取它。

Event(事件)

事件提供了更精细的控制,允许你在管道屏障之间执行一些工作。你可以设置一个事件,执行一些工作,然后等待该事件。不过,事件的使用相对复杂,在此不做深入讨论。

资源销毁与调试 🗑️

Vulkan没有垃圾回收机制,甚至没有智能指针。因此,你需要手动销毁所有创建的对象。这虽然繁琐但并不困难,只需为每个创建函数调用对应的vkDestroyXXXvkFreeXXX函数即可。

调试可以通过启用验证层来实现。Vulkan SDK提供了丰富的调试功能,帮助你捕获错误和性能问题。

总结 📚

本节课中我们一起学习了Vulkan命令记录与同步的核心内容。我们首先了解了命令缓冲区的创建、记录与提交过程。然后,深入探讨了Vulkan中复杂的同步机制,包括栅栏、信号量、管道屏障和事件。我们学习了如何使用它们来管理GPU与CPU之间、不同GPU队列之间以及单个队列内部的操作顺序和内存可见性。此外,还涵盖了图像布局转换、资源所有权转移以及渲染通道内的子通道依赖等高级主题。掌握这些同步原语是编写正确、高效Vulkan程序的关键。最后,我们简要提到了资源销毁的必要性和调试方法。

019:高级CUDA概念 🚀

在本节课中,我们将学习CUDA编程中的几个高级概念,包括原子函数、线程束(Warp)函数、动态并行、独立线程调度以及协作组。这些工具可以帮助我们编写更高效、更复杂的GPU程序。


原子函数 ⚛️

上一节我们讨论了流和事件,本节我们来看看原子函数。在并行编程中,有时需要确保对同一内存地址的操作是串行化的,以避免数据竞争。原子操作就是为此设计的。

原子操作的必要性

考虑以下代码片段在设备内核中的执行:

int count = 0;
count = count + 1; // 由多个线程并行执行

由于多个线程可能同时读取和写入count,最终结果是不确定的,可能在1到线程数之间。为了确保每个线程都能正确地将count递增1,我们需要使用原子操作。

CUDA提供了内置的原子函数,例如atomicAdd

atomicAdd(&count, 1); // 原子地将count增加1

当使用这样的原子操作时,任何试图访问count的线程都必须等待当前操作完成,这可能导致严重的性能下降,因为操作本质上变成了串行。

原子操作的实现原理

虽然CUDA提供了内置原子函数,但理解其原理有助于构建更复杂的操作。一个简单的、基于锁的原子加操作实现可能如下:

__device__ int atomicAdd_locked(int* address, int val) {
    int old;
    bool lock_success = false;
    while (!lock_success) {
        lock_success = lock(address); // 假设的锁API
        if (lock_success) {
            old = *address;
            *address = old + val;
            unlock(address);
        }
    }
    return old;
}

这种方式会导致线程在锁上循环等待,造成线程束内线程的串行执行和性能损失。

一种更高效的方式是使用“比较并交换”原语atomicCAS,它无需显式锁:

__device__ int atomicAdd_cas(int* address, int val) {
    int old = *address;
    int assumed;
    do {
        assumed = old;
        old = atomicCAS(address, assumed, assumed + val);
    } while (assumed != old);
    return old;
}

atomicCAS比较address处的当前值是否等于assumed,如果相等,则将其替换为assumed + val。每个线程可以基于自己读取的旧值进行尝试,减少了线程的显式等待,但核心操作仍然是串行的。

原子函数的类型与使用建议

CUDA提供了多种作用域的原子函数:

  • atomicAdd:设备级原子操作。
  • atomicAdd_system:系统级原子操作(CPU与GPU之间)。
  • atomicAdd_block:块级原子操作。

使用原子函数的建议:

  • 谨慎使用:原子操作会序列化内存访问,严重影响性能。
  • 作用域最小化:优先使用寄存器或共享内存上的原子操作,避免在全局内存上使用。
  • 作为构建块:使用内置原子函数来构建更复杂的同步逻辑。
  • 应用场景:例如,在并行归约中,跨块的最终结果累加可以使用原子操作来完成。

线程束(Warp)函数 🔀

线程束函数允许线程束内的线程直接交换寄存器中的数据,无需通过共享内存,从而获得极高的性能。

线程束表决函数

这些函数对线程束内所有线程的谓词(条件)进行归约操作。

以下是主要的表决函数:

  • __all(predicate):如果线程束内所有活跃线程的谓词都为真,则返回1,否则返回0。
  • __any(predicate):如果线程束内任何活跃线程的谓词为真,则返回1,否则返回0。
  • __ballot(predicate):返回一个32位无符号整数,其中每个位对应一个线程(0-31)。如果对应线程的谓词为真且线程活跃,则该位设置为1。

__ballot函数非常强大,例如,可以用于统计活跃线程数,或在流压缩中判断是否整个线程束都已退出,以便提前终止执行。

线程束洗牌函数

洗牌函数允许线程束内的线程直接交换寄存器中的值。

以下是四种洗牌类型:

  1. 索引洗牌 __shfl(var, src_lane):从指定源车道src_lane的线程获取var的值。
  2. 向上洗牌 __shfl_up(var, delta):从车道号比当前线程小delta的线程获取var的值。对于前delta个线程,结果未定义。
  3. 向下洗牌 __shfl_down(var, delta):从车道号比当前线程大delta的线程获取var的值。对于后delta个线程,结果未定义。
  4. 异或洗牌 __shfl_xor(var, lane_mask):从车道号为(lane_id ^ lane_mask)的线程获取var的值。这对于实现蝴蝶交换模式非常有用。

洗牌操作是单指令、无需同步、且不依赖共享内存,速度极快。

洗牌函数的应用:归约优化

在并行归约中,最后一步通常在线程束内进行。传统方法使用共享内存,但利用洗牌函数可以完全在寄存器中完成,从而提升性能。

传统共享内存方式:

// 假设 warpSize = 32
volatile float* smem = shared;
int lane = threadIdx.x % warpSize;
int wid = threadIdx.x / warpSize;
smem[threadIdx.x] = value;
__syncthreads();
for (int offset = 16; offset > 0; offset /= 2) {
    if (lane < offset) {
        smem[threadIdx.x] += smem[threadIdx.x + offset];
    }
    __syncthreads();
}

使用洗牌函数优化:

int lane = threadIdx.x % warpSize;
int wid = threadIdx.x / warpSize;
for (int offset = 16; offset > 0; offset /= 2) {
    value += __shfl_down_sync(0xffffffff, value, offset);
}

通过洗牌,我们消除了共享内存的访问和同步操作,所有数据交换都在寄存器中完成,显著提高了性能。


动态并行 🌳

动态并行是CUDA的一项高级功能,允许内核在GPU上动态启动新的子内核,而无需返回CPU主机端。

核心概念

  • 父子关系:启动新内核的线程成为父线程,新启动的内核是子内核。父内核会隐式等待所有子内核完成后才继续执行。
  • 数据可见性:子内核继承父内核的全局内存和常量内存视图。但子内核的局部内存和寄存器对父内核不可见。
  • 流与事件:动态并行同样支持流和事件来管理依赖关系和并发。

示例与用途

一个简单的动态并行示例:

__global__ void parent_kernel() {
    if (threadIdx.x == 0) {
        child_kernel<<<1, 128>>>();
        cudaDeviceSynchronize(); // 等待子内核
    }
    // ... 父内核其他代码
}

动态并行的典型用途包括实现递归算法(如递归归约),它可以使代码更清晰,但并不会自动增加硬件并发度,且需要注意父子内核间的数据传递(主要通过全局内存)。


独立线程调度与协作组 🤝

随着Volta及更新架构的出现,CUDA的线程调度模型和同步机制变得更加灵活。

独立线程调度

在旧架构中,线程束是调度的基本单位,线程束内的32个线程以锁步方式执行同一指令(处理分支时会有部分线程停用)。独立线程调度允许线程束内的线程更独立地被调度,减少了线程束内部分线程因分支或长延迟操作而导致的空闲,更好地隐藏了延迟。

为了配合此特性,CUDA引入了:

  • __syncwarp():同步线程束内的线程。
  • __activemask():返回一个掩码,指示当前哪些线程是活跃的。
  • 线程束函数需要显式指定同步掩码,例如__shfl_sync(mask, var, src_lane)

协作组

协作组是一个更灵活、可扩展的线程同步和协作编程模型。它允许开发者定义任意大小的线程组(可以跨块)并进行同步。

协作组提供了不同层次的抽象:

  • 隐式组:如this_thread_block()(当前线程块)、this_grid()(当前网格)。
  • 显式组:可以从隐式组中划分出来,例如:
    cg::thread_block tb = cg::this_thread_block();
    cg::thread_block_tile<32> tile32 = cg::tiled_partition<32>(tb); // 将块划分为32线程的片
    cg::thread_block_tile<4> tile4 = cg::tiled_partition<4>(tb); // 将块划分为4线程的片
    
  • 集体操作:协作组支持丰富的集体操作,如sync()memcpy_asyncreducebroadcast等,这些操作可以应用于自定义的线程组上。

协作组的优势在于其模块化可扩展性。它允许更精细的线程控制,并为未来在多GPU系统上编写可扩展程序提供了更好的抽象。


总结 📚

本节课我们一起学习了CUDA编程中的多个高级主题:

  1. 原子函数:用于实现安全的并发内存访问,但需谨慎使用以避免性能瓶颈。
  2. 线程束函数:包括表决和洗牌函数,实现了线程束内寄存器级别的高效数据交换与通信,是性能优化的利器。
  3. 动态并行:允许内核启动子内核,简化了某些递归算法的实现。
  4. 独立线程调度:现代GPU架构的特性,允许更灵活的线程束内调度,需要配合新的同步原语使用。
  5. 协作组:提供了一个灵活、可扩展的线程分组与同步模型,支持更复杂的并行模式。

这些高级概念为你提供了更强大的工具来优化和设计复杂的GPU程序。在最终项目中,合理运用这些技术可以帮助你实现更高的性能和更优雅的代码结构。

020:机器学习基础入门 🧠

概述

在本节课中,我们将从零开始学习机器学习的基础知识。我们将涵盖神经网络的核心概念、训练过程、优化方法以及如何将机器学习应用于实际任务,特别是那些与GPU编程高度相关的任务。课程内容旨在让没有机器学习背景的同学也能理解基本原理。


神经网络基础

上一节我们概述了课程内容,本节中我们来看看神经网络的基本构成。

我们的任务是通过一个简单的例子来理解神经网络:手写数字识别。给定一个手写数字的图像,我们需要将其分类为0到9中的一个数字。

输入是一个长度为784的向量,代表28x28图像中每个像素的灰度值(0到1之间)。输出是一个K维向量(K=10),其中每个元素对应一个数字类别的“得分”。

上图展示了一个解决此问题的神经网络。我们将784维的输入向量送入网络,经过网络内部的计算,理想情况下,代表数字“7”的输出节点会被激活,从而将数字分类为7。

神经元与权重

网络中的圆圈代表神经元(或节点),本质上就是一个数字。连接线代表权重,每个权重代表一个乘法运算。

当输入一个数字时,被点亮的节点代表具有较高值的神经元。当我们乘以一个很大的权重时,相应的连接线也会被点亮。

权重值是从数据中学习得到的。在机器学习中,我们通常先连接所有权重,然后尝试从数据中学习这些权重的值。

对于一个输入大小为N、输出大小为K的层,其权重数量为 N × K。在基本的前馈神经网络中,如果每一层都与下一层全连接,那么参数数量就是各层维度相乘的结果。

非线性激活函数

然而,我们不能简单地无限堆叠线性层。因为线性代数是线性的,如果连续进行矩阵乘法,所有层最终会坍缩成一个等效的单层,这无法增加网络的表示能力。

为了训练具有多个层的网络,我们需要在层之间引入非线性函数,以防止这种模式坍缩。目前我们将使用Sigmoid函数,后续会讨论其他选择。

Sigmoid函数的公式是:

σ(x) = 1 / (1 + e^(-x))

它将任何实数映射到(0, 1)区间内。

单个神经元的计算

单个神经元(节点)的完整计算公式如下:

输出 = σ( Σ (权重_i × 激活_i) + 偏置 )

其中,σ代表Sigmoid函数,我们对所有输入进行加权求和,加上偏置项,然后通过Sigmoid函数。

偏置项的作用是调整加权和的范围。如果加权和的结果非常大(例如100),Sigmoid函数在该区域的梯度会非常小,使得学习变得困难。偏置项可以将Sigmoid函数的“零点”移动到当前层激活值的均值附近,帮助模型学习正确的参数化。

全层矩阵表示

当我们要表示整个神经网络层,而不是单个神经元时,可以使用矩阵方程:

A^[l] = σ( W^[l] · A^[l-1] + b^[l] )

其中:

  • σ 是逐元素应用的Sigmoid函数。
  • W^[l] 是第l层的权重矩阵(维度:n_输入 × n_输出)。
  • A^[l-1] 是上一层的激活值向量(输入)。
  • b^[l] 是第l层的偏置向量。
  • A^[l] 是第l层的输出激活值向量。

可学习的参数就是权重W和偏置b。从GPU程序员的角度看,这具有高度的并行性,非常适合GPU计算。


如何学习权重?

上一节我们介绍了神经网络的结构,本节中我们来看看如何通过数据来学习网络中的权重和偏置。

损失函数

首先,我们需要一个函数来衡量神经网络在给定输入和参数下的表现有多“差”。这个函数称为损失函数成本函数。学习正确权重的问题就简化为寻找使这个函数值最小化的参数集。

对于手写数字分类任务,一个简单的损失函数是网络输出与正确输出之间的差异。正确输出是一个“one-hot”向量:正确类别位置为1,其余为0。

我们可以计算差值并取平方(均方误差),这样无论差值是正是负,都能很好地衡量我们距离真实值有多远。

均方误差损失 = (预测值 - 真实值)^2

梯度下降

我们如何最小化这个损失函数?我们使用一种称为梯度下降的方法。

我们计算损失函数关于权重(我们唯一能控制的参数)的偏导数。然后,我们沿着该导数的反方向(即梯度下降的方向)前进一小步。

我们必须指定一个步长(学习率),因为我们只知道方向,不知道应该走多远。学习率是一个超参数,不能通过梯度下降本身来学习,因为学习梯度下降需要步长,这会导致循环依赖。

通过多次迭代,我们最终会到达梯度接近零的区域,即一个局部最小值。然而,这并不能保证是全局最优解。神经网络的损失函数是非凸的,可能存在无数个局部最小值。从不同点(随机初始化权重)开始,可能会落入不同的最小值,这导致模型性能存在波动。

扩展到高维空间

在更高维度中,我们使用梯度,它是损失函数对所有参数的偏导数向量。梯度指向函数值上升最陡的方向,其反方向则是下降最陡的方向。

梯度下降的更新公式为:

W_new = W_old - η * ∇C(W_old)

其中 η 是学习率,∇C 是损失函数 C 关于权重 W 的梯度。

另一种理解梯度的方式是:它表示轻微扰动某个权重会对特定输入的输出产生多大影响。对输出影响越大的权重,在更新时调整的幅度也应该越大。


反向传播:计算梯度

上一节我们介绍了梯度下降的概念,本节中我们深入探讨如何高效地计算梯度,即反向传播算法。

我们将计算一个简单两层神经网络中每个参数的梯度。虽然现代机器学习库会自动完成此过程,但若要优化这些库,理解其原理至关重要。

我们从最后一层(第L层)开始。该层的方程如下:

Z^[L] = W^[L] · A^[L-1] + b^[L]
A^[L] = σ(Z^[L])
C = (A^[L] - Y)^2

其中:

  • Z^[L] 是线性层的输出。
  • σ 是Sigmoid激活函数。
  • A^[L] 是激活值(Sigmoid的输出)。
  • C 是损失函数(这里用均方误差)。
  • Y 是真实标签。

我们需要计算损失 C 对权重 W^[L] 和偏置 b^[L] 的导数。我们使用链式法则从后向前计算:

  1. 损失对激活值的导数∂C/∂A^[L] = 2 * (A^[L] - Y)
  2. 激活值对其输入的导数∂A^[L]/∂Z^[L] = σ'(Z^[L]) (Sigmoid的导数:σ(z) * (1 - σ(z))
  3. 线性层对权重的导数∂Z^[L]/∂W^[L] = A^[L-1]

根据链式法则,损失对权重的梯度为:∂C/∂W^[L] = (∂C/∂A^[L]) * (∂A^[L]/∂Z^[L]) * (∂Z^[L]/∂W^[L])

对于更早的层,过程类似但更复杂,因为每个前一层的激活值都连接到后一层的所有输入。因此,在反向传播时,我们需要将来自后一层所有神经元的梯度贡献累加起来。这使得计算需要更多的累积操作。

如何使用梯度?

理论上,最准确的梯度下降应该对训练集中所有样本计算梯度,取平均值,然后更新一次权重。这被称为批量梯度下降,但速度很慢。

一个很好的近似是随机梯度下降:对每个样本计算梯度并立即更新权重。虽然针对单个样本的更新方向可能不稳定,但期望上会收敛到最小值。其更新路径看起来更加随机。

一个常见的折衷方案是小批量随机梯度下降:每次取一小批样本(例如32个),计算平均梯度,然后更新。这能平滑掉异常样本的影响,并且非常适合GPU并行计算——可以同时进行32次前向传播和反向传播,时间开销增加很少。

有趣的是,批量大小32之所以流行,是因为它正好等于CUDA中一个线程束的线程数(32)。


机器学习核心术语

在深入改进方法之前,我们先快速回顾一些核心的机器学习术语:

  • 激活函数:层之间的非线性函数,如Sigmoid、ReLU。
  • 损失/成本函数:衡量模型预测与真实值差异的函数。
  • 输入表示:如何将原始数据(如图像、文本)转换为输入向量的方法。
  • 参数:模型中可学习的数值,即权重和偏置。
  • 架构:神经网络中神经元和层的连接方式。
  • 优化器:执行梯度下降的具体算法(如SGD、Adam)。
  • 超参数:不是从数据中学习,而是预先设定的参数,如学习率、批量大小、网络层数。
  • 初始化:训练开始前权重和偏置的初始值。通常从一个小范围(如[-1, 1])内随机采样,而不是全零初始化。

数据集划分与过拟合

  • 训练/开发/测试集划分
    • 训练集(约80%):用于实际计算梯度下降,更新模型参数。
    • 开发集(验证集):用于调整超参数(如学习率),评估不同配置的效果。
    • 测试集:用于最终评估模型性能,确保结果没有过拟合到开发集。通常在最终阶段才使用。
  • 过拟合:当模型在训练集上训练过度,完美记忆了训练样本,导致在未见过的数据上泛化能力变差。解决方法包括早停(当开发集性能不再提升时停止训练)和使用正则化技术。

对基础设置的改进

上一节我们介绍了机器学习的基本框架,本节中我们来看看过去几十年中对这些基础组件的一系列重要改进。

1. 增加网络深度

使神经网络性能大幅提升的一个关键因素是使用更深的网络(更多层)。这通常被称为深度学习

直观上,更深的网络可以学习更复杂的函数。虽然理论上具有无限宽度的两层网络可以逼近任何函数,但在宽度固定的情况下,深度增加确实能提高表示能力。当然,这也带来了巨大的计算成本,而GPU的出现使得训练深度网络成为可能。

如今的大型模型拥有数十亿甚至数万亿参数,远超我们例子中的13,000个参数。

2. 改进损失函数:Softmax与交叉熵

对于分类任务,均方误差损失并不理想。网络输出是K个类别的得分,而标签是一个概率分布(one-hot向量)。

首先,我们使用Softmax函数将网络输出转换为概率分布:

Softmax(z_i) = e^(z_i) / Σ_j e^(z_j)

Softmax确保所有输出之和为1,并且通过指数运算,使得最高得分在最终概率中占据主导地位,让模型更专注于区分最可能的类别。

然后,我们使用交叉熵损失(本质上是KL散度)来比较两个概率分布。与均方误差相比,交叉熵在概率接近0或1时能提供更有意义的梯度,其导数形式也更简洁。

3. 改进激活函数:从Sigmoid到ReLU

Sigmoid函数存在梯度消失问题:当输入值很大或很小时,其导数接近于零,导致梯度更新非常缓慢,权重可能“卡住”。

ReLU(修正线性单元) 函数解决了这个问题:

ReLU(z) = max(0, z)

它在正区间的导数恒为1,避免了梯度消失。同时,它允许网络“关闭”不重要的神经元(输出为0)。虽然ReLU在z=0处不可微,但在实践中可以定义其导数为0或1。

ReLU的缺点是“死亡ReLU”问题:如果神经元初始化为负值且始终未被激活,则可能永远无法更新。后续的改进版本如Leaky ReLUParametric ReLUGELUSwish等试图缓解这个问题。

4. 正则化技术:Dropout

Dropout 是一种防止过拟合的正则化技术。在训练过程中,以一定概率(如10%)随机将网络中的神经元输出置零。

这迫使网络不能过度依赖任何一个特定的神经元或特征,必须将知识分散到整个网络中,从而提高了泛化能力。

5. 改进优化器:带动量的SGD与Adam

基本的梯度下降对学习率选择很敏感:太大容易震荡,太小收敛慢。

带动量的SGD不仅考虑当前梯度,还加入之前更新方向的一部分(动量项),帮助冲出狭窄的局部最小值。

Adam 优化器在此基础上更进一步,不仅考虑梯度的一阶矩(均值,即动量),还考虑二阶矩(未中心化的方差)。它自适应地调整每个参数的学习步长,对稀疏特征(不常出现的特征)给予更大的更新。自2015年以来,Adam及其变体已成为训练深度学习模型(尤其是大型语言模型)的事实标准。


改进网络架构:卷积神经网络

上一节我们讨论了组件级的改进,本节中我们来看看对网络整体架构的根本性改进,以处理更复杂的任务。

基础的全连接网络在处理像ImageNet这样的大型图像数据集(1000类,196608维输入)时,参数量会爆炸式增长(一层就接近2亿参数)。我们需要利用对数据本身的先验知识来减少参数量。

图像数据有两个关键特性:

  1. 平移不变性:物体在图像中的位置不影响其类别。
  2. 局部相关性:像素与其邻近像素的关系最密切。

全连接网络无法利用这些特性,它为每个像素位置独立学习权重。

卷积层

卷积神经网络 引入了卷积层。它使用一个小的滤波器(或卷积核,如3x3)在图像上滑动。同一个滤波器在整个图像上共享参数,这隐式地编码了平移不变性。滤波器学习检测局部特征(如边缘、纹理)。

卷积操作自然降低了数据的维度(取决于步长和填充方式)。通过堆叠多个卷积层,后面的层可以组合低级特征,形成更复杂的高级特征(如物体部件)。

池化层

最大池化 是另一个利用平移不变性的操作。它在局部区域(如2x2)内取最大值输出。这回答了“特征是否出现在这个区域”的问题,进一步降低维度,且没有可学习参数,计算开销小。

AlexNet:深度CNN的里程碑

将卷积层、池化层、ReLU激活函数、Dropout和带动量的SGD结合起来,就构成了深度卷积神经网络。2012年的AlexNet是这一领域的开创性工作。

AlexNet是一个8层网络(5个卷积层,3个全连接层),在GPU(GTX 580)上训练,并在ImageNet竞赛中以巨大优势获胜。这标志着深度学习革命的开始。值得注意的是,AlexNet的作者详细优化了其CUDA内核,充分利用了GPU并行计算能力,这正是GPU编程与机器学习交叉的典范。


机器学习在图形学中的应用与项目构思

上一节我们介绍了CNN这一强大的架构,本节中我们来看看如何将机器学习,特别是CNN,应用于图形学相关任务,并探讨可能的课程项目方向。

一个关键思路是:任何能够自行生成训练数据的任务,都非常适合应用机器学习

以下是几个经典且可行的方向:

1. 图像去噪

  • 任务:给定有噪声的图像,输出去噪后的清晰图像。
  • 训练数据:可以轻易获得。取清晰图像,人工添加噪声(如高斯噪声)作为输入,原图作为目标输出。
  • 网络:训练一个CNN来预测噪声图,然后从输入中减去预测的噪声得到清晰图像。预测噪声比直接预测清晰图像更容易。
  • 为何CNN有效:噪声通常是局部相关的,CNN的滤波器擅长捕捉和处理这种局部模式。

2. 图像超分辨率

  • 任务:给定低分辨率图像,输出高分辨率图像。
  • 训练数据:将高分辨率图像下采样得到低分辨率版本,配对即可。
  • 网络:训练一个CNN来预测高分辨率与低分辨率图像之间的残差,然后将残差加到输入上。这也可以看作一种去噪任务(噪声是下采样引入的信息损失)。

3. 帧插值

  • 任务:给定视频的连续两帧,生成中间帧以提高帧率。
  • 训练数据:从高帧率视频中,每隔一帧或几帧取一帧作为输入,被跳过的帧作为目标输出。
  • 网络:可以使用3D CNN(在空间和时间维度上进行卷积)来处理连续帧,预测中间帧。使用均方误差损失。
  • 挑战:纯CNN在建模时间动态和运动轨迹方面可能不如一些结合了光流或Transformer的混合架构,这留下了优化空间。

过往课程项目示例

  1. WebGPU图像超分辨率:在浏览器中使用WebGPU实现实时的图像超分辨率。
  2. 基于光流的帧插值:实现更先进的帧插值模型(如Flavr架构)。
  3. 实时路径追踪与ML去噪:将路径追踪作业加速到实时,并使用机器学习CNN进行降噪和提升画质,替代传统的A-Trous滤波器。

新项目构思建议

  • 语音处理:卷积网络在音频领域(如语音识别、去噪、合成)也很常用。可以尝试在边缘设备(如NVIDIA Jetson)上实现高效的实时语音处理管道,这是一个尚未有团队涉足的方向。
  • 底层优化:不一定要训练新模型。可以专注于:
    • 使用纯CUDA实现一个现有神经网络(如Transformer)的推理过程,并尝试超越PyTorch等框架的性能。
    • 针对特定硬件(如移动GPU、边缘设备)优化现有模型的推理速度。
  • 结合新硬件:在Jetson等边缘设备上部署和优化视觉或语音模型,专注于能效和实时性。

总结

在本节课中,我们一起学习了机器学习的基础知识。我们从最简单的神经网络和梯度下降开始,逐步了解了损失函数、反向传播、优化器等一系列核心概念。接着,我们探讨了如何通过增加深度、改进损失函数(Softmax+交叉熵)、更换激活函数(ReLU)、加入正则化(Dropout)和使用高级优化器(Adam)来提升模型性能。最后,我们深入研究了卷积神经网络如何利用图像的先验知识(平移不变性、局部性)来高效处理视觉任务,并探讨了机器学习在图形学中的多种应用场景和项目可能性。

下节课,我们将深入探讨如何在代码层面实现和优化神经网络,包括使用高级框架(如PyTorch)和直接使用CUDA进行底层优化。

021:在CUDA中实现和优化神经网络 🧠

概述

在本节课中,我们将深入探讨如何在CUDA中直接实现和优化神经网络。我们将从回顾机器学习基础开始,然后逐步构建一个简单的神经网络,并分析其CUDA实现代码。最后,我们将讨论更高级的优化技术,包括使用线性代数子程序库和现代深度学习框架。


第一部分:机器学习快速回顾 📚

上一节我们介绍了课程的整体结构,本节中我们来看看机器学习的基础概念。

神经网络本质上是高维函数。它们接收一个输入向量,通过大量权重和偏置参数化的函数,输出另一个向量。

为了训练神经网络,需要引入成本函数来衡量网络在给定输入下的表现好坏。这可以是均方误差或交叉熵,具体取决于任务。

使用该成本函数,通过梯度下降法学习所有参数,即沿着成本函数负梯度的方向迭代更新。

通过梯度更新权重的过程称为反向传播。这本质上是链式法则的应用,从最后一层开始计算偏导数,一直反向传播到网络的第一层参数。我们通常称此为反向传播,而前向传播是初始预测过程。

神经网络的改进主要在于增加深度,这也是“深度学习”中“深度”一词的由来。这虽然计算成本高昂,但效果显著。

以下是分类任务中的一些优化技巧:

  • 使用交叉熵损失。
  • 不要使用Sigmoid作为非线性激活函数,而是使用ReLU或其他能缓解梯度消失问题的激活函数,以实现更快的反向传播。
  • 在优化器更新步骤中使用自适应动量,这不仅为每个梯度更新添加了动量,还会更关注稀有特征并给予更大的更新幅度。
  • 别忘了选择一个好的学习率。尽管有这些高级优化器,最终仍需手动选择学习率,目前尚无法自动学习学习率。

卷积运算非常适合图像处理。它们利用了平移不变性,在图像分类任务优化中可以节省大量参数。我们还会使用最大池化来进一步降低滤波器输出的维度。

卷积神经网络基本上就是一系列卷积层,后接一系列传统的线性层。这些深度卷积神经网络在图像任务上表现非常出色。

卷积神经网络可以学习非常高级特征的滤波器。最初的几层学习低级特征,随着网络加深,可以组合这些层,最终学习到高维、高级特征的滤波器。

关键在于,在进行机器学习时,我们会使用不同的架构来利用输入或任务的某种对称性或属性。存在许多不同的神经网络变体,例如用于序列任务(如语音)的循环神经网络、Transformer网络、生成对抗网络等。但它们都利用了希望保留的输入的某种属性。

最后,CNN可以用于许多不同的任务。例如,可以将其应用于图形学中非常重要的任务,如图像去噪和分辨率提升。当你可以生成任意多的数据时,应确保充分利用这一点,因为这类任务非常适合训练机器学习模型。


第二部分:在CUDA中实现神经网络 ⚙️

上一节我们回顾了机器学习基础,本节中我们将深入探讨如何在CUDA中实际编写一个神经网络。

首先,我们必须定义任务。我将给出一个最简单但最具说明性的任务:给定一个点的坐标 (x, y),预测它位于第一和第三象限,还是第二和第四象限。这是机器学习中的一个经典任务。

为什么选择这个任务?为了完成这个任务,我们能否将其作为线性函数解决?答案是否定的。从视觉上看,没有一条直线能将这两组点分开。因此,这不是一个线性函数。单层神经网络无论进行多少训练都无法学习到这个函数,因为它是线性的。单层网络无法学习非线性。因此,这迫使我们使用带有中间非线性激活函数的两层网络,事实证明这样就有可能学习该函数。

我们的输入维度是2,输出维度是1(二元分类)。我们必须考虑维度,因为如果维度太大,会严重限制我们想要学习的神经网络类型。

我们将采用的神经网络架构是:线性层 -> ReLU -> 线性层 -> Sigmoid。为什么在最后使用Sigmoid而不是Softmax?对于二元分类,Sigmoid和Softmax在单一维度输出上是等价的。损失函数是二元交叉熵损失。

我们将使用随机小批量梯度下降。这意味着我们从生成器中采样M个输入,然后同时对它们进行梯度下降,并并行执行以加速训练。输入的维度是2,但如果采样M个小批量,实际输入维度将是 2 x M 矩阵,其中M是输入点的数量。我们需要并行计算所有示例的损失,输出维度是 M x 1,然后为所有M个示例并行反向传播并平均梯度。

我们需要以下类:

  • 矩阵类:用于存储权重、偏置、激活值和梯度。
  • 层类:Sigmoid、ReLU、线性层。
  • 成本函数类:二元交叉熵损失。

为所有这些组件创建类的原因是,我们需要在计算过程中存储梯度以进行反向传播。我们需要前一层的梯度来乘以当前梯度,如果没有类,存储所有这些信息会更加困难。


高层概览:前向传播

这是整个神经网络类的前向传播过程。对于每一层,我们只是将输入矩阵逐层传递。我们通常使用Z表示输入(这是惯例),对于每一层,我们对该层执行前向传播,然后将结果设置为Z,依次进行。最后,计算成本并将其传递回各层。

这里可能存在竞争条件,特别是在将成本向量传递回各层的同时更新权重。如果我们在计算梯度的过程中更新了权重,那么权重会改变梯度本身。因此,在每次更新权重之前,需要确保进行线程同步,以避免其他线程读取到已被反向传播更新的权重。


逐步分析CUDA代码

现在让我们逐步分析CUDA代码。理解每一行代码并不重要,我会给出更概括的概述。如果你感兴趣,这些幻灯片将会公开,我强烈建议你下载代码并自己运行。

首先,矩阵类。这个类的关键点在于,我们通常有一个布尔变量来跟踪该矩阵是在设备(GPU)上分配还是在主机(CPU)上分配。我们在同一个类中同时拥有主机和设备指针。这是几乎所有机器学习库的常见做法。原因是我们喜欢对资源进行即时分配。当我们加载一个非常大的模型并进行一次前向传播时,我们只希望实例化当前所需的内存。

通常,这些类也为梯度预留空间。在进行反向传播时,我们希望实例化所有梯度的空间。本质上,当我们拥有这些指针时,我们可以说“这个类存在但尚未分配”,然后当我们第一次使用它时,只需检查它是否已分配,如果没有,就为其创建空间。

激活层的命名约定是:输入是Z,输出是A(激活值)。反向传播时,我们显然对激活层的输出求导,并用它们来获得对输入的导数。

同样,对于线性层,激活的输入和输出是Z。

这是层的父类。我们必须定义前向函数和反向函数。前向函数接收A并输出Z,反向传播接收dZ并输出dA。

Sigmoid类继承自神经网络层父类,并重新实现了前向和反向传播函数。

以下是主机函数(而非内核)。它基本上检查内存是否已分配,如果没有则分配,然后调用Sigmoid激活前向内核。反向函数类似,检查内存是否已分配,如果没有则分配,然后调用反向内核。从现在开始,我将省略所有这些主机函数,但要知道,对于我们讨论的所有内核,这都是通用结构。

Sigmoid前向内核非常简单。它是一个逐元素函数,因此本质上是高度并行的。我们只需一个设备函数来实现Sigmoid,然后索引到矩阵中并更新 sigmoid(z)a

反向内核同样非常简单,因为它是逐元素的,并且Sigmoid的导数实际上是 sigmoid * (1 - sigmoid)。我们甚至不需要实现新的设备函数,可以直接再次调用相同的Sigmoid函数并获得正确的输出。这是最简单的一层。

ReLU的实现:前向传播使用 fmaxf(浮点最大值函数),取 z0 的最大值。反向传播类似。通常我们希望避免在内核中使用if语句,但这个没问题,因为这两行代码基本上只是将内存从一个地方移动到另一个地方。

现在我们来看线性层类。这个类稍微复杂一些。我们定义前向和反向函数,与之前一样。我们实现了获取权重矩阵和偏置向量的函数,这些是标准的getter,在进行完整的反向传播时很有用。

与之前一样,如果内存未分配,我们就分配内存,然后调用线性层前向内核。

以下是前向内核。对于每个线程,我们找到行和列,然后进行点积运算。我们循环遍历行和列的所有索引,计算点积,然后将其放入矩阵。

敏锐的学生可能会注意到这个实现中有一些我们可能想要改变的地方,特别是如果你关注过矩阵乘法优化讲座的话。这是一个示例,正是因为这些可以快速改变的地方。

理论上,可以不用这里的for循环吗?k 是权重矩阵的大小,它可能大于块大小。理论上,可以使用原子操作,但通常不值得,更好的方法是直接循环,而且如果内存访问正确,你只是读取一段连续的内存,所以速度相当快。不过,对于未来的其他函数,我们将会使用原子操作。

线性层反向传播代码首先计算并存储反向误差,然后更新偏置和权重。当然,这是在执行梯度更新。我们得到误差,计算导数,更新偏置,更新权重。先更新偏置还是权重并不重要,但重要的是我们在不同的内核中执行它们。

在计算实际导数时,我们需要计算三个不同的导数:一个针对权重,一个针对偏置,一个针对输入(因为需要将其传递给前一层)。这些只是基本的导数,都是线性的,只是涉及矩阵运算时导数会稍微麻烦一些。

提醒一下,M 是批处理的数量。

这是我们为前一层计算导数 dA(前一层激活值的导数)的过程。结构完全相同,只是我们使用权重矩阵的转置,而不是权重矩阵本身。

线性层更新权重的过程类似。需要注意的是,当我们进行这个点积运算时,我们计算的是 dZ * A^T,当我们得到完整的点积后,我们在这里直接更新权重。之前我说过,在更新权重时可能存在竞争条件。但只有在其他线程仍在计算梯度时更新权重才会发生竞争条件,因为如果你更新了权重,权重会改变梯度是什么。在这里,我们首先计算梯度,然后才更新权重,所以不应该有任何竞争条件,因为我们已经使用了所有权重。

我们在这里有学习率,所以执行 (1.0 / A.dim.x) * learning_rate 的更新。

偏置更新类似,这里使用了原子加操作。出于某种原因,这个实现没有选择在单个线程中进行一次累加,而是希望多线程化,因此他们对单个累加使用了原子加操作。可能没问题,但如果你做过微基准测试,在寄存器中计算然后传递下去可能更快,但这取决于维度大小。

前向传播只是存储所有不同输入的所有激活值。因此,我们不仅要存储权重是什么,还要存储每一层的实际输出,以便知道激活值是什么。然后,反向传播使用这些激活值和权重来计算梯度。

最后,我们需要讨论损失函数。这是我们的二元交叉熵损失,包含成本和成本导数。

成本导数的函数公式是 y / y_hat(自然对数的导数是 1/x,所以是 y / y_hat)。

前向内核使用了相同的原子加技巧:我们遍历所有M个不同的示例,计算每个示例的成本,然后原子加 cost / batch_size 到总成本变量。反向内核基本类似。


神经网络类与训练

现在我们已经完成了大部分细节,退一步看。

我们有了神经网络类。我们有一个层的向量,所以这里有 getLayers 函数返回神经网络层指针的向量。然后我们有矩阵 YdY。它们代表什么?为什么需要它们放在神经网络类中?

Y 是我们的输出,dY 是成本函数对每个输出的梯度。

前向传播函数应计算神经网络所有层的前向传播。它实际上会遍历神经网络的层并执行前向传播。

反向传播稍微复杂一点。首先要注意的是,我们有一个名为 error 的矩阵。这是我们定义的BCE成本函数,是成本函数的导数。我们有输出和真实输出,然后 dY 是我们保存所有导数信息的地方。

在反向迭代层的过程中,error 被反复写入。为什么 error 被反复写入?为了对某一层进行反向传播,你必须查看后面一层的误差。每一层在进行反向传播时,都会接收前一层的梯度,因此我们必须反复使用 error 来持续记住梯度是什么,然后在反向传播过程中一直传递回去。

最后是 cudaDeviceSynchronize。我不完全确定为什么这是必要的,甚至不确定它是否必要。但在某种程度上,我认为原因可能是为了确保在退出之前将所有内容写入所有权重、所有梯度更新。可能不是必需的,但这更多是一个CUDA编程问题。考虑到CUDA的异步特性,放在那里更安全。在某些低端GPU上可能不需要,但在具有多个计算引擎的高端GPU上,这是一个好主意。

最后,这是我们初始化神经网络的方式。我们首先创建类,然后添加不同的层:线性层、ReLU、线性层、Sigmoid。注意这里的形状:输入到线性层是2,然后是30,然后是30,然后是1。2和1是有意义的,但30看起来有点奇怪。

现在你意识到这很奇怪了,我们这样做的原因是机器学习方面的考虑。隐藏状态可以是任意大小,取决于函数的复杂性,你应该使隐藏状态更大或更小。显然,更大意味着更多权重,但隐藏状态越大,可以学习的不同特征组合就越多。有一个非常著名的定理指出,具有无限隐藏状态宽度的两层神经网络可以学习任何函数,因为你可以创建任意多的特征组合。显然30不是无限的,但为了学习这个异或函数,我们需要相当大的隐藏维度,因为它本质上不是一个线性函数。

选择30是有些随意的。很多机器学习是启发式的,很多是查看已发表的论文,看看他们做了什么,然后从那里开始,再调整看看30是否是一个合理的选择。这完全合理,可能也是另一个很好的优化点。

更大的层与更多层之间的权衡是什么?通常,线性层是 n x k,如果 n 是输入,k 是输出维度。如果增加输出的维度,成本会呈二次方增长,因为矩阵会变得越来越大。因此,通常在神经网络中,人们更倾向于深度而不是宽度。但这并不是说大多数神经网络的中间状态隐藏维度大约有2000,所以仍然相当大。主要的权衡在于你更看重准确性还是速度。如果你愿意为了准确性牺牲速度,那么可以使隐藏维度越来越大。更大的隐藏维度不会降低准确性,网络中添加的权重越多,准确性可能会提高。问题在于存在一种工程上的权衡曲线。在实践中,如果你真的关心这个参数,你会尝试10或15个不同的值,然后绘制结果准确性的图表,然后选择一个你认为合理的中间值。但隐藏状态维度应该多大,并没有理论依据。

我想提到的另一点是,在机器学习中,神经网络的维度在“理论上完成任务所需的最小维度”和“实际训练所需维度”之间存在区别。从技术上讲,由于我们只需要两条线来完成这个任务,你应该能够使用只有2维隐藏状态的神经网络,因为你只需要学习两条不同的线,并且是这两条线的组合。问题是,当没有足够多的不同参数尝试时,实际上很难通过梯度下降找到这两条线。如果你有30条潜在的线,神经网络可以尝试许多不同的线组合,以找出哪些是正确的。这是真实示例的一个简化版本,真实示例当然是高度过参数化的神经网络并不字面上需要所有参数,但过参数化程度越高,网络就越容易学习到一个好的局部最小值。

训练后,你可能只有两个节点真正激活。你绝对可以“剪枝”掉不重要的权重,这是一个巨大的机器学习优化子领域,通常称为剪枝,即去掉那些基本上始终为零的权重。我们将在讲座的后半部分讨论这个问题,但这是一个常见的优化。

为了训练我们的神经网络,幸运的是我们有一个可以生成数据的任务。对于神经网络,我们可以生成无限的数据。我们创建一个坐标数据集对象,构造函数生成一个包含21个批次的数据集,每个批次包含100个数据点。

训练函数如下:对于每个批次,我们对所有批次进行神经网络的前向传播,然后对所有批次进行反向传播,然后将成本累加到 cost 变量。我们这样做的唯一原因是打印出一次遍历所有数据的总成本。在ML术语中,一次遍历训练集中的所有数据称为一个周期。因此,我们目前在这个函数中运行100个周期,然后每100个周期打印一次所有批次的总成本。

幻灯片上的问题是,你可能会注意到在for循环中,我们循环直到 batches < num_batches - 1,这意味着我们只训练了20个批次。第21个批次发生了什么?我们需要一些未训练过的数据来实际获取神经网络的准确性。

接下来是计算准确性,这是在训练中留出的批次上进行的。我们所要做的就是进行一次前向传播,输出将是预测值,然后我们可以用真实值 y 计算准确性。不需要在测试集上进行反向传播,因为那将是训练。

将所有部分放在一起,我们看到成本在所有周期中下降。如果成本下降,那么你就成功了。通常,在调试机器学习模型时,成本保持不变、上升或随机波动,这时你就知道出了问题,模型没有真正学习。如果你看到训练集上的成本下降,通常就没问题了。注意,网络学习正确函数需要相当长的时间,大约在700个周期之后成本才开始下降。这只是意味着神经网络尝试了很多不同的方向,直到找到一条好的路径然后开始收敛。另一个可能的原因是,我们在输出层使用了Sigmoid,并且如果初始化不当,Sigmoid存在梯度消失问题,所以我们可能进行了数百个周期,但梯度几乎没有变化,因为我们都饱和在Sigmoid层。

最后一个问题:在所有这些之后,我们在这个异或任务上的准确率只有93%。你可能会认为,拥有如此强大的能力和十万次数据遍历,我们已经看到了20,000个训练点,为什么还没有完全学会这个任务?

可能的原因包括陷入局部最小值。当我们在这个神经网络中使用梯度下降进行优化时,是否使用了任何机制来跳出局部最小值?我们没有使用动量或自适应动量等可以帮助我们更快学习的机制。

另外,如果我们寻找的边界线是X轴和Y轴,但网络猜测的线可能略有偏移。如果你没有足够接近这些轴的数据点,神经网络将无法学习到正确的边界线。重要的是,如果测试集中有非常接近边界线的点,或者我们构建了一个对抗性困难的测试集(例如给出刚好偏离轴线的点),而训练中没有足够多的这些区域表示,网络就学不会正确处理。

还有一个原因可能是初始化的不幸。这可能只是一次不幸的运行,初始化时所有权重都随机采样自高斯分布,导致我们陷入糟糕的起点。因此,多次运行神经网络然后取平均值是一个很好的完整性检查方法。

最后我想提到的是,一个周期是对整个训练数据的一次遍历。我们有大约20个批次,每个批次100个点,所以是2000个点。我们在这完全相同的2000个点上训练了1000次。我们的网络可能对特定的训练数据过拟合。它可能在训练数据上获得很高的准确性,因为这是完全相同的2000个点,但它没有足够的数据来学习正确的函数。既然我们有一个生成器,我们应该生成全新的数据。我们不应该有1000个周期,而应该有一个包含100,000个点的周期,因为我们可以生成任意多的数据。这也是当你数据不多并过度采样(反复训练相同数据)时的常见情况,神经网络通常不会变得非常好。另外,在训练大型语言模型时,你只做一个周期,因为你拥有网络上的所有文本,数据多到用不完,所以你只遍历一次,永远不会看到相同的文本两次。


最终回顾

在CUDA中实现神经网络相当简单,尤其是处理基本的线性层时。对于每种类型的层,你需要做的就是实现一个前向函数和一个反向传播函数来计算导数。

对于完整的神经网络前向和反向传播,你只需遍历所有层进行前向和反向传播。训练时,你需要累积批次的总成本然后打印,但除此之外,如果你的实现正确,只需多次进行前向和反向传播。

最后要提到的一点是,一旦你实现了一个神经网络类,你就可以反复使用同一个类来学习任意函数,具有任意深度和任意大小。当然,你需要根据内存大小改变一些优化,但仅仅因为你写了一个线性神经网络来解决异或问题,你现在就可以用完全相同的代码解决手写数字识别等问题,所以你实际上只需要写一次。


第三部分:加速我们的神经网络 🚀

上一节我们实现了一个基础的神经网络,本节中我们来看看如何对其进行优化。

这个实现很好,它跨小批量并行化了梯度计算,也进行了并行的点积运算,但还有很多优化空间。一个简单的改进方法是优化矩阵乘法,因为它在我们进行的前向和反向传播的几乎所有计算中都会用到。

现在是时候重新引入内存合并优化了。这是我们之前在讲座中停下的幻灯片。这里的访问模式是什么?我们在索引 i 上循环。我们在这里的内存索引方面做得好吗?不好。问题在于我们每次跳过的步长是 A.dim.x 的大小。如果你有合并读取,显然比零散的读取要快得多。所以一个非常简单的优化就是转置,然后一切就连续了。

另一个快速的优化显然是使用共享内存。我们可以将数据加载到共享内存中,然后在共享内存中进行矩阵乘法。当然,如果你没有足够的共享内存来加载巨大的权重矩阵,那么就会变得棘手,你必须担心分块等问题。但就目前而言,至少对于我们的神经网络,这是一个非常快速的优化。

我还想指出,记得上节课我谈到AlexNet。AlexNet在很多看来是深度学习的开端,原因在于他们将网络放在了GPU上,因此能够使其比任何其他神经网络都更深。

AlexNet源代码。在准备这次讲座时,我想看看我能在AlexNet的源代码中找到哪些优化,于是我逐步查看,发现了大量优化。首先,滤波器函数是实际运行卷积滤波器的函数。每个块应用32个滤波器,我们为滤波器和图像都使用了共享内存。我们还并行复制图像和滤波器。在输出数组中使用 #pragma unroll 和合并写入。在写入输出图像之前等待同步线程以确保所有线程完成。很多优化讲座中讨论的优化实际上都在这里实现了,我很惊喜地看到这一点。大多数研究代码可能只做最基本的矩阵乘法,但这个实际上被优化到了极致,以成为当时的实现。

这个实现很棒,但我们能做得更好吗?答案是某种程度上可以。如果你直接编写所有代码,不太可能超越高度优化的库,除非你确切知道你的内存维度。一个可能更有用的是使用基本线性代数子程序。这些函数是一组用于快速矩阵运算的标准函数,通常由硬件制造商在特定机器上实现,以利用向量寄存器和SIMD等特性。有许多BLAS实现库,如果你见过像MATLAB、NumPy、GLM这样的科学计算库,它们的底层都是由这些基本线性代数子程序实现的。

BLAS函数通常分为三个级别:

  • 级别1:向量操作,如点积(称为AXPY)。
  • 级别2:向量-矩阵操作,通常称为GEMV(广义矩阵向量积)。
  • 级别3:最常用的是GEMM(广义矩阵乘法),即 A * B + C 矩阵与系数。

BLAS是一个被广泛接受和使用的标准,从大约60或70年代开始。当你制造新的CPU或GPU架构时,通常会以特定方式实现这些函数,因为你知道每个人都期望你将这些子程序实现得非常优化。可以把BLAS看作一个标准化的API。如果你使用具有特定参数的GEMM,无论运行在什么硬件上,你都会得到正确的结果和最佳性能。在底层,这取决于库如何以及在哪里运行它。很多时候,你必须根据拥有的硬件决定使用哪个库。例如,在Linux上,你会得到BLAS和类似的库,如FFTW或LAPACK。但如果你在Intel硬件上,你会想要MKL库。在CUDA上,你会想要cuBLAS,等等。这些库不一定直接利用硬件,但Intel会利用AVX等向量化指令,它们本质上是汇编级优化。

cuBLAS是NVIDIA编写的BLAS实现,使用CUDA。它是闭源的,所以我们不知道具体是如何实现的,但很可能直接用机器代码编写。每个主要的ML框架都在底层使用它来实现前向传播。如果你了解任务的细节,并非不可能击败它,但通常不要尝试。

cuBLAS中有许多不同的GEMM函数,它们取决于你的数据类型。我们有SGEMM用于单精度浮点数,DGEMM用于双精度浮点数,等等。不确定时,使用SGEMM。我认为很多网络使用单精度浮点数,所以没问题。我们没有在矩阵权重中使用复数。API命名法非常常见,如果你去MKL,你会看到相同的东西和相同的参数顺序,这很重要。

直接使用BLAS API有些繁琐,需要设置很多状态。有时在将矩阵放入BLAS函数之前转置是必要的。我找了一些直接使用BLAS函数的示例程序,幻灯片上有一个链接,如果你想查看如何操作,可以看看。示例运行SGEMM进行矩阵乘法,作为确保一切运行正常的完整性检查。

比cuBLAS更高一层的库是cuDNN。cuDNN是用于实现非矩阵乘法层的库。理论上,如果我们想使线性层非常快,那实际上就是矩阵乘法加上一些额外的状态保存。我们可以直接用cuBLAS函数代替CUDA内核,这样我们的第一段代码就会快得多。然而,如果你试图运行卷积神经网络、Transformer或循环神经网络,所有这些其他层不仅仅是矩阵乘法。cuDNN是NVIDIA另一个建立在cuBLAS之上的闭源库,它提供了卷积、最大池化、Softmax、Sigmoid、ReLU等层的前向和反向传播函数。它不提供线性层,因为显然你应该直接使用cuBLAS。

与cuBLAS类似,cuDNN也可能很繁琐,但这是为了性能付出的代价。至少从我上次检查来看,使用cuDNN的资源比使用cuBLAS多得多。使用cuBLAS和cuDNN的C++代码是ML性能的行业标准。因此,如果你要进行某种推理优化,这是非常值得学习的东西。很多人使用其他高级机器学习库,但如果你想获得推理的额外性能,这是必要的。

我想讨论一些cuDNN可以做的优化,这些优化不仅仅是更好的矩阵乘法,而且实际上利用了神经网络内部的一些特性。最明显的优化是在卷积中。我们周一讨论了卷积,但本质上,我们取一个滤波器,将其滑过图像,然后计算滤波器对图像中每个像素的激活。以下是cuDNN可能做的一个改进示例(虽然我不能完全确定,但很可能)。通常,要进行卷积,你需要迭代地应用滤波器。但是,如果你将滤波器要相乘的像素展开,同时也展开滤波器,你可以使用GEMM。如你所见,如果你有第一个补丁、第二个补丁等等,你只需将所有像素展开成一个长向量。然后如果你展开核,你可以直接进行点积,那就是核乘以图像中的所有像素。这样更快,你可以直接使用它们优化的矩阵乘法。

这种性能改进的缺点是什么?性能改进通常不是免费的。缺点包括复制数据需要时间和内存。你不仅复制数据,而且如果滤波器有任何重叠,你会多次复制某些像素。这意味着每个补丁向量中的大多数数字将从先前的一组像素中复制,因此实际上会导致更多的内存使用。你可能会将图像放大两到三倍。所以,如果你真的受内存限制,就不能做这种优化;但如果你不受限制,那就好得多。


快速回顾

  • BLAS是一组标准的线性代数例程,通常由制造商实现和优化。BLAS有三个级别,对我们来说最重要的是矩阵乘法。
  • cuBLAS是GPU上并行化的BLAS实现,高度优化。
  • cuDNN是一个建立在cuBLAS之上的库,专门用于深度学习目的,它们有针对不同神经网络层的优化版本,实现卷积、Softmax等。
  • 所有现代基于GPU的深度学习都建立在cuBLAS和cuDNN之上。当我们调用PyTorch代码进行推理时,它实际上被编译成cuDNN代码。

第四部分:超越cuBLAS和cuDNN 🏗️

上一节我们讨论了底层优化库,本节中我们来看看更易用的高级框架。

直接调用cuBLAS和cuDNN的C++代码几乎是你所能获得的最佳性能,但相当繁琐。如果我们不太关心预测速度,只想快速原型化新网络,有没有更简单的方法?当然有,那就是使用PyTorch或TensorFlow。这是当今深度学习研究人员使用的两个主要框架。PyTorch是目前更流行的一个,这只是因为他们在库生命周期早期做出的某些设计决策,TensorFlow后来在TensorFlow 2.0版本中不得不采纳。这两个框架都有C++和Python绑定,并且在运行时都在底层使用cuDNN和cuBLAS。因此,即使你用Python编写代码,训练时仍将使用cuDNN。

这两个库与目前讨论的所有内容相比都非常易于使用。几乎所有需要的层类型都有内置类,API非常简单,状态设置很少,资源丰富,博客文章众多,并且有很多内置模型。幻灯片上有一个截图,你能仅从截图判断这是什么神经网络吗?这是一个卷积网络,有Conv2D、ReLU、MaxPool,正如我所说,这就是CNN。这是AlexNet。首先,他们为你实现了这个,你可以直接下载AlexNet并运行。其次,仅通过查看前向传播,你通常就能很好地了解你所看到的神经网络架构是什么,而在许多其他实现中,可能到处都是样板代码,而这种语法非常清晰。

在PyTorch和TensorFlow中实现机器学习模型,你只需要实现前向传播。反向函数是根据前向函数在底层自动生成的。一旦你有了前向函数,只要你知道所有不同层的导数计算,你的反向函数就是确定的。当然,你不能使用PyTorch中尚未作为类的层。如果你想使用一个全新的层,那么祝你好运,你得编写原始的NumPy代码。但对于他们拥有的所有层,反向函数是自动生成的。例如,这个前向传播只是平均池化,然后他们会自动为你进行反向传播。

训练循环完整代码如下:使用神经网络函数获取输出,计算损失,然后执行 loss.backward()。这将保存所有梯度,将梯度一直反向传播,进行优化并更新权重。然后 optimizer.step() 最终根据你选择的优化器将梯度应用于权重。如果你有Adam或动量优化器,它会根据这些优化器计算如何改变梯度更新向量。

这大约四行代码就完成了我们之前所做的所有事情。

如果这还不够简单,那么请准备好接受下一部分。如果你想在这些库中将数据发送到GPU,你所要做的就是运行 .cuda()。我稍微更喜欢 .to(device) 语法,因为它更有意义,.cuda() 让我难以忍受。如果你想在GPU上训练模型,只要你对模型和输入数据都运行了 .cuda(),所有训练都会自动在GPU上进行,你的代码完全不需要改变。

这一切都太简洁了,肯定有陷阱,对吧?坦率地说,没有太多陷阱。对于训练和原型设计,这绝对是最好的方法。它实际上在底层运行cuDNN,所以并不慢。

然而,一旦你开始训练循环,就像我说的,它们相当快,除非你试图构建世界上最大的神经网络,训练时间相对便宜。原因是,假设我是Google,我训练了我的模型,我真正关心的性能是当我为1亿用户提供模型服务时。推理时间比训练时间成本高得多,因为它直接与延迟成正比,实际上随着服务的客户数量而扩展。训练时间我可以在后台运行,实际上不必太在意需要多长时间。无论如何,绝大多数ML研究人员和工程师使用PyTorch,并且已经投入了大量优化工作。但PyTorch中存在某些结构性设计,本质上不是最优的,这些是众所周知的权衡,但值得讨论。

为了更好地理解PyTorch和TensorFlow的弱点以及我们可以在哪些方面改进它们的性能,我们首先需要讨论计算图。计算图只是表示神经网络信息流的一种方式。它帮助我们理解导数在哪里反向传播。计算图中的每个节点将是网络的某种状态,每个箭头基本上是导数或前向传播的去向。例如,输出L代表损失,反向传播到加法,然后反向传播到与权重的乘法,然后一直反向传播回A,沿途都是偏导数。

计算图的一个很酷的地方是,它们可以用来观察神经网络,然后直接对计算图进行优化,因为如果我优化了它,那么我就在直接优化网络。

PyTorch和TensorFlow作为其核心设计的一部分,允许你对计算图进行动态即时更新。例如,通过条件语句添加层、添加调试打印、在训练中途停止然后添加一个全新的层再继续训练等。这些都是在训练模型时对计算图进行的更新。TensorFlow 1.0过去不允许这样做,他们要求你必须预先定义计算图,不能添加新东西,但2.0版本现在允许了,因为允许即时更新使得原型设计容易得多。

然而,这对于优化来说相当不利,因为我们可以做的许多优化都要求我们知道计算图永远不会改变。

我将讨论其中的一些优化。第一个是层融合。例如,在这个计算图中,我们有 y = A * x,然后之后加上偏置B。这可能会被分成两个不同的操作,可能是两个不同的内核:一个是矩阵乘法,然后是矩阵加法。在PyTorch中,因为我们允许对图进行即时更新,我们不能合并这两层。另一个经典例子是在卷积网络中,我们有Conv2D,然后有MaxPool。这两件事必须是分开的。如果我们将数据加载到GPU的共享内存中,计算矩阵乘法,为什么我们不直接在拥有GPU上所有不同内存的同时立即进行MaxPool?答案是你可以,但由于PyTorch的设计,你被迫将它们分开,因为你不知道我们是否想在卷积和最大池化之间放置调试语句。这就是PyTorch目前的致命弱点。

许多其他事情,如剪枝模型权重(我们之前讨论过)、权重量化,这些在PyTorch中实际上是结构上不可能的,因为我们不知道如果图可以随时改变,哪些层可以融合。


快速推理优化:TensorRT

考虑到所有这些,如果我们想在TensorFlow或PyTorch模型上进行快速推理,我们该怎么做?首先,你可以使用TensorRT或其他优化框架。这是一个用于生产中快速模型推理的软件开发工具包。

TensorRT的输入只是一个ONNX格式的模型,这只是模型的序列化计算图。一旦你序列化了计算图,我们就决定了这是最终版本,不能更改,然后将其加载到TensorRT中,允许库一次性进行任何类型的优化,例如合并层、剪枝不同的权重、更改内存分配结构等。

TensorRT会进行这些常见的优化:

  • 从32位浮点数量化到8位整数(现在甚至更低,可以到4位整数,但会损失很多精度)。
  • 层和张量融合。
  • 内核自动调优:有许多不同的cuDNN内核实现相同功能,但具有不同的内存分块方式。TensorRT实际上会基准测试许多不同的cuDNN内核,并为你的特定模型参数选择最佳的一个。
  • 调整内存大小以确保高利用率。

如果TensorRT这么好,为什么还要使用cuBLAS和cuDNN?TensorRT并不保证你的神经网络输出仍然良好。它尽力而为,但不会在你的特定测试数据上进行评估,只是使用自己的启发式方法。它会说“当我们量化时,权重的实际值变化很小,所以应该没问题”,但这并不总是适用于所有问题。也许权重的微小变化会产生巨大差异。所以它不保证准确性,并且可能不会进行你想要且你知道会有效的优化,因为它根据自己的启发式方法认为这不好。最主要的是,它是一个黑盒子,你无法控制它做什么。如果你要尝试优化机器学习推理,它是一个非常有用的基线,但如果你非常了解你的特定任务,你将能够击败它。


最终总结 🎯

本节课中我们一起学习了在CUDA中实现和优化神经网络的完整流程。

  • 核心实现:直接调用cuBLAS和cuDNN的C++代码是行业标准。我知道DLSS(深度学习超级采样)至少是直接使用cuDNN实现的,以确保它实际上比仅进行渲染通道更快。
  • 训练与推理:除非你试图突破规模极限,否则训练时间通常比推理时间便宜得多。因此,对于训练,PyTorch和TensorFlow是首选,有大量资源,易于原型设计,语法灵活清晰。
  • 框架局限性:然而,如果你试图进行模型推理,PyTorch和TensorFlow就不那么好了,因为它们允许动态更新计算图,因此无法进行常见的优化。
  • 中间方案:TensorRT是一个相当好的中间地带,你仍然可以获得一些自动优化,但如果你更了解实际任务,仍然可以用cuDNN击败它。

本节课中我们一起学习了:

  1. 机器学习基础概念的快速回顾。
  2. 如何在CUDA中从零开始实现一个简单的神经网络,包括矩阵类、各种层(线性、ReLU、Sigmoid)和损失函数。
  3. 神经网络前向传播、反向传播和训练循环的CUDA实现细节。
  4. 针对CUDA神经网络的核心优化技术,重点是内存合并和矩阵乘法优化。
  5. 利用高度优化的库(如cuBLAS和cuDNN)来提升性能,以及它们的工作原理。
  6. 高级深度学习框架(PyTorch/TensorFlow)的便利性及其在推理优化上的局限性。
  7. 专门用于推理优化的工具(如TensorRT)及其常见的优化手段(如量化、层融合、内核调优)。

通过从底层实现到高层框架的全面了解,你现在应该对GPU上的神经网络编程和优化有了坚实的基础。

022:移动GPU编程的约束与实践

在本节课中,我们将探讨移动设备(特别是智能手机)上GPU编程所面临的独特约束、挑战以及相应的优化实践。我们将从功耗、热限制、屏幕特性等基础约束开始,逐步深入到移动GPU的架构差异、图形API的选择以及现代图形技术的应用前景。

概述:移动设备的独特世界

上一节我们介绍了GPU编程的一般概念。本节中,我们来看看一个特殊的领域:移动GPU编程。移动设备,尤其是智能手机,因其便携性、电池供电和手持特性,为GPU设计和使用带来了一系列独特的约束。理解这些约束是进行高效移动图形开发的关键。

移动设备的物理约束

功耗:电池续航的挑战

移动设备的核心约束之一是功耗。与插电使用的台式机不同,手机必须依靠有限的电池电量持续工作一整天。我们的主要目标是最大化“使用天数”(DOU),这通常比拥有极致的峰值性能更为重要。

以下是典型的移动手机功耗场景分析:

  • 待机:约30毫瓦。
  • 网页浏览/TikTok:约1.5瓦。
  • 中度游戏(如《糖果粉碎传奇》):约2.5瓦。
  • 重度游戏:接近5瓦。
  • 通话(1分钟):约1瓦(相当耗电)。

功耗主要消耗在以下几个部件:

  • 显示屏:0 到 500+ 毫瓦(取决于亮度)。
  • CPU:50 到 1000+ 毫瓦。
  • GPU:10 到 1000+ 毫瓦。
  • 调制解调器:约800毫瓦(如果开启并工作)。
  • 内存访问:这是最耗电的操作之一。

旗舰智能手机通常配备约5000毫安时的电池。根据上述功耗计算:

  • 玩重度游戏(5瓦)可持续约3.5小时。
  • 玩中度游戏(2.5瓦)可持续约7小时。
  • 纯待机状态可持续约35小时。

内存访问的极高成本

在移动芯片上,不同操作的能耗差异巨大。访问外部DRAM的能耗比片上算术运算高出数个数量级

能耗对比示例(近似值):

  • DRAM写入:~16 纳焦耳
  • SRAM读取:~50 皮焦耳
  • 总线传输:~26 皮焦耳
  • 64位点积运算:~20 皮焦耳

公式对比
能耗(DRAM写入) >> 能耗(算术运算)

这意味着,有时过程化生成数据可能比从内存表中读取数据更加节能。

像素密度:多少像素才够用?

从第一性原理出发,人类视觉对像素的感知能力是有限的。过高的像素密度在移动设备上不仅无法提供显著的视觉提升,还会增加GPU的渲染负担和功耗。

基于观看距离的像素密度(PPI)感知极限:

  • 阅读书籍(1英尺):最大约300 PPI(Kindle的水平)。
  • 使用笔记本电脑(2英尺):约120 PPI。
  • 观看电影(6英尺):约50 PPI。
  • 电影院前排(30英尺):约10 PPI。

目前主流手机的屏幕像素密度已经达到或超过了在典型使用距离下的感知极限。因此,继续盲目增加像素数对用户体验的提升微乎其微,反而会消耗更多电量。

刷新率:多快才算流畅?

人眼并没有固定的“帧率”。我们感知到的运动基于一个约100毫秒的滑动视觉累积窗口。这被称为“布洛赫定律”。

然而,从实践经验来看:

  • 4帧/秒:看起来像翻页动画。
  • 8帧/秒:有所改善。
  • 24帧/秒:看起来相当平滑,是电影的标准帧率。
  • 30帧/秒:2D主机游戏的典型帧率。
  • 60帧/秒:3D游戏的流畅标准,对于绝大多数移动游戏和应用已经足够。

虽然更高的刷新率(如120Hz)在观看高速运动时有益,但对于大多数场景,60Hz在功耗和体验之间取得了良好平衡。此外,美学习惯也很重要,例如24帧/秒的电影感已经深入人心。

热限制:手持设备的烫手问题

手机是握在手中的设备,其表面温度必须控制在舒适范围内。根据NASA的一项研究:

  • 低于45°C:可以长时间手持而不会感到疼痛(但会觉得热)。
  • 略高于45°C:皮肤较厚的人可坚持约60秒,之后会产生痛感。
  • 达到70°C:将达到剧痛阈值,无法忍受。

因此,移动设备必须采用被动散热,并通过动态电压频率调节(DVFS) 等技术在芯片过热时主动降频,以防止设备过热,即使这意味着性能会暂时下降。

移动渲染架构

系统级芯片与统一内存

移动平台的核心是系统级芯片。与台式机使用独立显卡通过PCIe连接专用显存不同,SoC将CPU、GPU、调制解调器等多个组件集成在同一块芯片上。

最关键的区别之一是统一内存架构

  • 桌面(独立显卡):GPU拥有专用的VRAM,与CPU内存分离,数据交换需要通过PCIe总线,存在延迟和带宽限制。
  • 移动(SoC):CPU和GPU共享同一块物理内存。这使得零拷贝读写成为可能,大大减少了数据迁移的开销和功耗。

渲染器类型:IMR 与 TBDR

移动GPU的渲染架构与桌面GPU有显著不同,主要分为两种类型:

1. 立即模式渲染器
IMR就像直线加速赛车,管线直接了当。流程如下:

绘制调用 -> 输入装配 -> 几何处理 -> 图元装配 -> 光栅化 -> 片元处理 -> 像素操作 -> 输出

IMR的优点是简单、延迟低。但在处理复杂场景时,对缓存和内存带宽的压力较大。

2. 分块式渲染器
TBDR是移动设备上的主导架构。它先将整个帧缓冲区划分为多个小块(Tile),然后分两步处理:

  • 图元分档:分析所有几何体,确定它们会影响哪些分块,并将信息存入对应的“档位”中。
  • 分块渲染:逐个处理分块。每个分块都有专用的高速片上内存,用于存储颜色、深度等中间数据。处理完一个分块后,再将结果写回主存。

TBDR的核心优势是节能。通过将渲染工作限制在小的分块内,并充分利用超高速的片上内存,极大地减少了对高功耗的外部DRAM的访问次数。

为了直观展示两者的区别,有一个实验绘制了12个重叠的大三角形,并在渲染中途停止:

  • IMR:停止时,画面显示为清晰、部分的三角形,因为它正在按顺序绘制像素。
  • TBDR:停止时,画面看起来杂乱无章,因为各个分块正在并行处理不同三角形的不同部分,但最终完成时,结果与IMR一致。

移动GPU编程最佳实践

上一节我们了解了移动GPU的架构特点。本节中,我们来看看如何针对这些特点进行编程和优化。

应对平台碎片化

移动应用需要运行在成千上万种不同配置的设备上。因此:

  • 必须提供降级路径:例如,如果使用了光线追踪,必须同时提供传统光栅化的替代方案。
  • 避免基于硬件的条件判断:不要写“如果是XX GPU则执行YY”这样的代码。而应创建功能测试,根据功能是否可用(而不是硬件型号)来决定执行路径。这样当驱动更新修复问题后,你的应用能自动恢复正常。

着色器优化

着色器是图形性能的关键,在移动端优化尤为重要:

  • 慎用“超级着色器”:一个包含大量条件分支的巨型着色器会增加寄存器压力,降低效率,并增加编译时间。应尽可能拆分为多个专注的小着色器。
  • 多用编译时常量:减少运行时动态分支。
  • 显式使用融合乘加运算:不要完全依赖编译器优化。
  • 理解discard的代价:在片元着色器中使用discard丢弃像素,只有在整个SIMD波前中的所有片元都被丢弃时才能节省算力。否则,GPU仍需执行大部分指令。
  • 避免昂贵操作:双曲三角函数、除法、原子操作等开销很大。
  • 尝试手动展开循环:有时手动展开循环可以让编译器进行更好的指令调度,反而能降低寄存器压力并提升性能。

利用小屏幕的特性

移动屏幕尺寸有限,这带来了一些独特的优化机会:

  • 多重采样抗锯齿并非必需:MSAA会增加数倍的工作量和功耗,在移动端高PPI屏幕上收益相对较小。
  • 可变速率着色是好朋友:VRS允许对画面中不重要的区域(如暗部、远景)使用更低的着色率,然后通过插值填充像素,可以显著节省算力。艺术家或后处理算法(如基于亮度)可以决定VRS的应用区域。
  • 善用中精度:移动GPU对16位半精度浮点数有良好的支持。在保证精度的前提下使用mediump,可以获得近乎双倍的吞吐量。但切勿将其用于深度或位置计算,以免产生Z-fighting问题。

优化缓存与内存使用

内存访问是功耗大头,因此缓存友好性至关重要:

  • 明确内存标签:尽可能将缓冲区标记为只读。
  • 利用“存储时不关心”:如果不需要立即读取写入的数据,使用相应的标志告知系统,有助于缓存管理。
  • 注意流程控制:如果if-else分支中的代码路径差异极大,可能导致缓存抖动。考虑拆分成两个不同的着色器。
  • 使用数学技巧替代分支:有时可以用step()函数和混合运算来替代条件判断,避免分支开销。
  • 紧凑打包顶点数据:使用最小的原生格式(如用vec4而不是两个vec2),并确保数据对齐。

渲染流程优化

高效的渲染流程能极大提升性能:

  • 减少渲染通道:尽可能合并通道,例如将G-Buffer生成和光照计算合并。
  • 利用子通道:在Vulkan等API中,子通道允许在渲染通道内保留数据,避免昂贵的回写和重新读取操作。
  • 避免从帧缓冲/深度缓冲中读取:操作如glReadPixels或帧缓冲获取会破坏渲染管线并行性,造成流水线停滞。
  • 不要干扰硬件深度优化:避免在着色器中写入或进行依赖深度的读取,这会禁用早期的深度测试和层次化Z优化。

图形API现状:Vulkan 与 DirectX 12

在桌面端,低级、显式的图形API正在成为主流。DirectX 12 因其在Windows和Xbox生态中的统治地位而占据优势。Vulkan 作为跨平台API,在移动端(Android)是事实上的标准。

为什么显式API获胜?
它们将控制权交还给引擎开发者,消除了传统API(如OpenGL)中驱动状态管理的黑盒和开销。DirectX 11现在甚至作为DX12之上的一层兼容层运行。

Vulkan的学习曲线虽然陡峭,但收益显著
用Vulkan绘制第一个三角形可能需要上千行代码,远多于OpenGL的百行代码。然而,研究表明,深入学习Vulkan的学生对图形管线的理解更加深刻和准确。Vulkan的显式特性迫使开发者理解底层细节,就像学习计算机架构时从门电路构建ALU一样。虽然入门困难,但它是深入理解现代图形编程的绝佳途径。

对于实际项目,大多数开发者会使用Unity、Unreal等游戏引擎,这些引擎封装了底层API的复杂性。但理解其下的Vulkan或DX12原理,对于进行高性能优化和深度调试至关重要。

现代图形技术展望

超分辨率、帧生成与去噪

将超分辨率、帧生成和去噪结合在一个神经网络中是未来的趋势。然而,在移动端应用面临挑战:

  • 成本效益平衡:神经网络的运行开销(目前约8-10毫秒)必须低于其通过降低渲染分辨率所节省的时间。目前移动端的渲染复杂度尚未达到这个临界点。
  • 适用场景:这项技术对于光线追踪等极其耗时的渲染任务更具吸引力。随着移动端光追的普及,神经网络辅助渲染的优势将更加明显。

神经渲染

神经渲染(如神经辐射场)具有巨大潜力,但目前主要用于离线内容创作。在移动端实时运行仍过于昂贵。

  • 近期机会神经纹理压缩NeRF是两个有前景的方向。神经纹理压缩可以在保证质量的同时大幅减少纹理内存占用。
  • 硬件限制:即使手机拥有专用的NPU,将帧数据在GPU和NPU之间频繁传输的功耗也非常高。因此,相关的神经网络计算很可能仍需在GPU上进行,与传统的图形计算争夺资源。

总结

本节课中我们一起学习了移动GPU编程的核心内容。我们首先探讨了移动设备面临的严格约束:有限的电池续航、高昂的内存访问成本、已趋近极限的屏幕像素与刷新率,以及手持带来的热限制。这些约束塑造了移动GPU独特的分块式渲染架构,其核心思想是利用片上内存来减少功耗。

接着,我们深入了一系列移动GPU编程的最佳实践,包括应对平台碎片化、优化着色器、利用小屏幕特性进行可变速率着色、优化缓存使用以及精简渲染流程。我们还分析了当前图形API的格局,认识到Vulkan等显式API在赋予开发者控制权方面的价值,尽管其学习曲线较为陡峭。

最后,我们展望了超分辨率、帧生成和神经渲染等现代图形技术在移动端的应用前景与当前挑战。移动图形技术的发展是一场在性能、功耗和热设计之间的精妙平衡。理解这些基础约束和优化原则,是开发高效、流畅移动图形应用的关键。

posted @ 2026-03-26 12:23  布客飞龙III  阅读(17)  评论(0)    收藏  举报