JHU-GPU-编程笔记-全-
JHU GPU 编程笔记(全)
001:课程综述 🚀

在本节课中,我们将对约翰霍普金斯大学的GPU编程专项课程进行整体介绍。我们将了解课程结构、讲师背景以及本专项课程的核心学习目标。

欢迎来到《GPU并发编程导论》课程,这是GPU编程专项课程的一部分。

我是讲师帕斯卡·钱塞勒。我在约翰霍普金斯大学怀廷工程学院任教已有10年,同时也是一名拥有15年经验的软件开发人员。高性能计算、云计算、Web开发以及计算机视觉的应用都是我的兴趣所在。
接下来,我将为您概述Coursera平台上的GPU编程专项课程。
该专项课程包含四门循序渐进的课程。
以下是四门课程的简要介绍:

- 第一门课程:您当前所在的《GPU并发编程导论》。我将在下一张幻灯片中更详细地描述它。
- 第二门课程:《CUDA并行编程导论》。本课程重点介绍NVIDIA GPU和CUDA编程框架提供的基本硬件和软件功能。
- 第三门课程:《面向企业的规模化CUDA》。本课程深入探讨CUDA更复杂的功能,以及如何将其应用于超越程序员个人机器的企业级硬件上进行规模化计算。
- 第四门课程:《CUDA高级库》。本课程向学生介绍CUDA开发工具包中附带的一系列最流行且功能强大的库。

上一节我们了解了整个专项课程的框架,现在让我们聚焦于您即将开始的第一门课程。
处理海量数据是现代编程的基础,学生将在本课程中学习相关技术。课程的主要焦点是并发、并行或多线程编程,学生将接触到这类软件开发的基本方面。
CPU和GPU(也称为通用图形处理单元或GPGPU)允许多个线程同时运行。为了专注于CPU并行编程的挑战,学生将学习如何使用C++和Python 3来并发处理多个数据片段。
最后,学生将接触到领先的GPU编程硬件和软件架构:NVIDIA和CUDA。
本课程的学习目标如下:
- 理解核心原理:总结可扩展数据处理所需的硬件和软件核心原则。
- 掌握多线程开发:使用多种编程语言,开发能够在众多并行线程上运行的软件。
- 应用GPU与CUDA:利用GPU,应用NVIDIA CUDA软硬件架构,创建能够同时高效处理数千个数据点的代码。
- 进行GPU软件开发:最终,在软件开发中使用CUDA和GPU。

本节课中,我们一起学习了GPU编程专项课程的整体结构和第一门课《GPU并发编程导论》的核心内容。希望本次介绍能帮助您思考自己在本课程乃至整个专项课程中的学习目标。
002:课程期望 🎯
在本节课中,我们将介绍《GPU编程入门》课程的整体期望,涵盖课程材料、时间投入和技术要求三个方面。

课程材料与结构 📚
课程内容以模块形式组织。每个模块包含4到7节课。每节课将综合使用多种形式的教学材料。
以下是每节课可能包含的内容类型:
- 视频:以幻灯片或屏幕录制的形式呈现。
- 资源:例如,配有旁白的幻灯片视频会提供对应的PowerPoint文件;包含屏幕录制内容的视频会提供相关的操作命令。
- 讨论:鼓励学生协作并分享想法与经验。
- 测验:用于评估学生对课程内容的理解。
- 实验活动:在真实环境中探索技术主题。
- 作业:具有挑战性的编程任务。

时间投入预期 ⏱️

了解课程的时间安排有助于你更好地规划学习。对于每个模块,建议的时间分配如下。
以下是每个模块大致的耗时估算:
- 观看视频:最多30分钟。
- 非计分工作(包括讨论和实验活动):15到30分钟。
- 计分作业:30分钟。如果花费更长时间,这完全正常。
接下来,我们详细看看每项学习活动的具体时长。
各类学习活动时长
每节课都包含讲座和技术应用视频,这是深入理解课程主题所必需的。


- 视频:时长通常在5到7分钟之间。
- 实验活动:允许学生在实际环境中探索技术主题,通常需要10到15分钟来掌握其核心内容。
- 测验:用于评估理论或特定技术知识,每个模块的测验预计需要3到5分钟完成。
- 编程作业:每个模块建议预留15到30分钟来完成。
技术要求与实践应用 💻
本课程要求学生具备一定的编程基础,并将理论知识应用于实践。

学生关于硬件和软件能力及限制的实践知识将通过多种方式得到应用。通常,这些知识将应用于编程活动和作业中。
编程语言与库

课程中的编程任务将使用 C++ 和 Python 语言完成。挑战难度将从简单逐步过渡到复杂。

这些任务通常需要应用多线程库。
任务形式与目标
实验活动和作业将包含以下形式:

- 提供一个程序的基本框架,要求学生修改它以演示预期效果。
- 要求学生完成代码以实现一个经典算法。
通过完成这些实践,学生将能够利用CPU和GPU来完成复杂任务,同时学会在最优的硬件和软件配置下进行工作。

总结 ✨

本节课中,我们一起学习了《GPU编程入门》课程的总体期望。我们明确了课程材料的结构与形式,了解了需要投入的大致时间,并清楚了技术上的要求——即使用C++和Python,通过实践编程活动来掌握利用CPU与GPU进行并发编程的核心技能。准备好开始你的GPU编程学习之旅了吗?
003:Coursera实验作业总览 🖥️
概述
在本节课中,我们将学习如何在Coursera平台上完成GPU编程课程的实验活动和编程作业。课程将详细介绍从开始作业到最终提交的完整流程,确保你能够顺利地在浏览器环境中完成所有编程任务。
实验与作业完成步骤
以下是完成编程实验和作业所需的六个基本步骤,所有操作均在浏览器内完成。
- 选择作业:首先,确定你想要完成的实验或作业。
- 启动环境:点击名为“在浏览器中工作”的按钮,这将在Coursera实验环境中启动Visual Studio Code编辑器。
- 打开目录:如果需要,打开与你当前模块关联的目录。例如,本模块的目录可能名为
module1。 - 阅读说明:阅读该目录下的任何
README.md文本文件,以获取实验或作业的具体说明。 - 编辑代码:编辑适用的代码文件,这些文件通常具有
.cpp、.hpp或.py等扩展名。 - 编译运行:打开终端,使用命令行工具(如
sh或make)来清理、构建和运行你的代码,直到你对结果满意为止。
作业提交专项步骤
上一节我们介绍了完成实验的通用步骤,本节中我们来看看针对编程作业的额外提交步骤。
对于作业,还有第六个也是最后一个步骤:
- 修改用户信息:编辑
.user文件和主代码文件(例如assignment.cpp),确保其中的用户名变量值相同。 - 提交作业:然后,你可以通过屏幕底部的按钮提交作业。这些按钮会标明完成所有必需步骤的按压顺序。
作业页面详解
现在,让我们深入了解作业页面的具体布局和功能。
这是模块1编程作业的页面。我们将逐步讲解完成作业所需的所有步骤。页面上包含进度通知区域、启动实验活动的按钮、完成作业的说明以及提交结果。
现在,让我们点击“在浏览器中工作”按钮来开始我们的作业。


在VS Code中操作
接下来,你需要在VS Code中打开与活动或作业关联的目录。
如果你在页面顶部附近没有看到模块编号和名称,你需要从菜单栏中点击“文件”菜单选项,然后从下拉菜单中选择“打开”。这将在编辑器顶部附近弹出“打开文件或文件夹”对话框。
在对话框中,你可以通过点击“..”(双点号)来向上导航目录结构。对于本项目,你需要导航到 /home/coder/project/module1。
在对话框中找到需要打开的文件夹或文件后,点击“打开”按钮。文件或文件夹将在编辑器的左侧打开。现在,你将拥有开始当前模块实验或作业所需的所有文件。
阅读文件与修改代码
你应该做的下一件事是阅读 README.md 文本文件,其中包含背景材料和一系列作业说明。
你可以查看 run.sh 文件,这是一个用于执行作业所有步骤的脚本。Makefile 用于清理、构建和运行你的工作。你通常不需要修改这两个文件,而应该只修改代码和其他相关文件。
如果你正在处理作业,接下来应确保 .user 文件反映了你的用户名。用户名可以是任何名称或登录名,只要不包含特殊字符。
在开始编写作业其余部分的代码之前,另一个需要添加用户名的地方是你的主代码文件中。在本例的 assignment.cpp 中,你需要修改 user_name 字符串常量,使其与你在 .user 文件中放置的名称相同。
通过编辑这两个文件,你将确保你的提交满足作业的第一个要求:用户名验证。当你执行代码(无论是直接执行还是通过 run.sh 或 make 命令)时,都需要传递相同的用户名。
构建与提交作业
下一步是提交你的代码。点击页面底部的“构建提交(步骤1)”按钮。
你将在页面底部看到一个终端窗口,它会清理、构建并执行你当前的代码,并将输出到一个文件中,该文件用于作业评分。请注意终端中的任何输出。
然后,点击同样位于页面底部的“提交作业(步骤2)”按钮。

该按钮将提交该文件给评分器。然后,你可以在作业页面查看提交结果。在评分器运行完成之前,结果将显示为琥珀色。

当你打开提交结果时,可以看到分数、是否通过以及任何反馈信息。
如果遇到问题,你真的需要进一步查看,可以进入日志。


总结
本节课中,我们一起学习了在Coursera平台上完成GPU编程实验和作业的完整流程。我们从选择作业开始,逐步讲解了启动开发环境、阅读说明、编辑代码、编译运行,直到最后构建和提交作业的每一个关键步骤。掌握这个流程将帮助你高效地完成后续的所有编程任务。
004:现实世界并行编程实践

概述
在本节课中,我们将要学习现实世界中的并发编程实践。我们将探讨多处理、并发、并行和线程化编程这些核心概念,理解它们在现代计算中的作用,并了解操作系统如何管理线程与内存,以实现计算资源的最大化利用。
多处理、并发、并行与线程化编程
多处理、并发、并行和线程化编程这几个术语本质上是同义的。现代编程的核心目标在于最大化利用现代计算机的多处理器和多核心。实现这一目标的最佳途径就是运行多个进程和线程。
操作系统会在无需开发者干预的情况下自动完成这一过程。然而,通过编写能够独立处理数据的高质量线程代码,开发者有能力进一步提升软件效率。
一些高度复杂的任务,例如人工智能、音视频处理和信号处理,都可以在设计时就考虑到多处理。但请注意,如果处理不当,也可能适得其反。并行处理不仅是需要大量计算能力的软件基础,同时也允许与用户进行异步交互。
什么是线程?
线程是一系列独立、按顺序执行的编程指令集合。你可以将一个拥有多个独立线程的程序想象成多条火车轨道,它们在特定点分离,又在特定点汇合。
当轨道分开时,多列火车可以全速前进;但当轨道合并时,每列火车的速度都会受到其前方火车速度的限制。
现代CPU与线程调度
对于现代CPU(例如笔记本电脑或消费级台式机),通常拥有4到8个核心,每个核心可以由调度器管理1到4个线程。
任何操作系统多线程能力的核心都是调度器。它负责在不同的程序(包括操作系统任务)之间进行切换,将它们置于活动或非活动状态,并在核心之间移动它们,以及在缓存之间移动数据等。
计算机内存层次结构
计算机拥有从硬盘到核心间共享寄存器等多级内存。它们允许线程之间共享公共数据,甚至是同一程序中多个线程所执行的指令。

内存缓存的目标是减少因从速度更慢、距离更远的存储中检索数据或指令而导致的等待时间,这种等待会导致线程变为非活动状态。

线程调度与缓存失效
在上图中,我们看到一个CPU调度器在不同核心的线程之间进行切换。在此案例中,假设所有四个核心位于同一处理器上,并至少共享一个L2缓存。当线程因缓存未命中而状态改变时,调度器不希望浪费CPU周期等待数据或指令,因此它会寻找需要运行的任务并将其激活。
当然,在线程间切换会损失时间。因此,频繁切换本身也会带来成本。
内存缓存层次结构与原理
内存缓存本质上是层次化的。它们的工作原理基于一个事实:物理上更近的内存性能更高。
缓存不仅用于为CPU存储本地化的数据和/或指令,还用作核心之间的共享机制,以及作为一种存储数据和/或指令的方式,以限制访问RAM甚至硬盘空间的请求次数。
还存在L3缓存,但在我们讨论GPU时其应用会略有不同,此处不进行详细讨论。

总结
本节课中,我们一起学习了现实世界并行编程的基础。我们明确了线程作为独立指令序列的概念,了解了现代多核CPU如何通过调度器管理线程。我们还探讨了计算机的内存层次结构,特别是缓存对于减少数据访问延迟、提升多线程程序性能的关键作用。理解这些底层机制是编写高效并发与并行代码的重要前提。
005:并发编程陷阱解析 🔍


在本节课中,我们将探讨编写多线程程序时可能出现的各种问题。理解这些陷阱对于编写正确、高效且稳定的并发程序至关重要。
概述
多线程编程能显著提升程序性能,但若处理不当,也会引入一系列复杂问题。本节我们将逐一解析竞态条件、资源争用、死锁、活锁以及资源利用不充分等常见陷阱。了解这些问题及其成因,是构建健壮并发系统的第一步。
竞态条件 🏁
上一节我们概述了并发编程的挑战,本节首先来看看最常见的陷阱之一:竞态条件。
竞态条件发生在多个线程的执行顺序与程序员的预期不符时。这可能导致严重的、非预期的后果。例如,程序员可能希望线程A先完整执行其三条指令,然后线程B再执行。但如果指令被交错执行,共享变量的最终值就可能不是开发者所期望的。

需要明确的是,在这种情况下,每个线程对共享变量的操作本身可能是“有效”的,但交织在一起的整体执行顺序导致了错误结果。因此,最终值本身没有对错之分,而是程序逻辑被破坏。

核心概念:竞态条件的本质是非确定性的指令交错执行导致程序状态错误。
为了避免竞态条件,最佳实践是尽量减少共享或全局变量的使用。如果必须使用,则需要确保每个线程对共享资源的访问是原子性的(我们将在后续课程中详细讨论原子操作)。
资源争用 ⚔️
与竞态条件类似,但更侧重于内存而非执行顺序的问题是资源争用。
线程和共享资源越多,如果程序员未充分考虑,资源争用发生的频率就越高。这是竞态条件的一种更异步的版本,其范围可以从线程扩展到完全独立的机器。
问题在于,不同的独立线程需要以不同方式访问同一资源(如内存、文件等),这会导致冲突和对值的不断重写。


考虑数据库的例子:多个线程需要递增同一个计数器。当一个线程从数据库读取值、准备更新时,另一个线程可能也在访问同一行数据。在第一个线程读取和更新之间,可能有N个线程做了同样的事情,导致许多递增操作没有被计入,因为只有最后一个写入的值被保存。这是数据库中一个非常重要的问题,幸运的是,数据库系统提供了多种机制来确保一致性。
核心概念:资源争用是多个执行实体异步地、冲突地访问同一共享资源。
死锁与活锁 🔒
死锁与资源争用相似,但情况更为棘手:一个或多个线程或进程需要多个共享资源,并且只有在获得其所需的全部资源后才会执行操作。同时,它们不会释放已持有的资源。
在死锁中,每个参与者都持有一部分资源,同时又在等待其他参与者释放它们所需的资源。没有任何一个参与者能脱离这种僵局。简单的解决方法是让线程放弃已持有的资源,但这可能导致线程不断丢弃并疯狂重试获取所有资源,反而可能使情况恶化。
活锁类似于死锁,但每个线程都在“积极”地执行某些操作(例如不断重试),却永远无法完成需要多个资源的整体流程。
这种情况可能发生在一个编程循环中:它在进行最终修改前先测试资源访问权限,然后只休眠极短时间或不休眠。考虑一个do-while循环,它在能将值保存到资源之前,不会更新外部可见的显示。多个执行相同循环的线程可能同时通过测试,却都无法访问必要的变量,从而导致不断递增另一个变量。
这可能引发缓冲区或堆栈溢出,或者将一条指令执行数百万次甚至无限次,或者递归执行同一段代码并锁死程序。即使错误地使用某些编程语言的异步功能,也可能导致活锁,因此需要格外小心。
核心概念:
- 死锁:线程相互等待对方持有的资源,所有线程都被阻塞。
- 活锁:线程不断重试失败的操作,所有线程都在运行但无法推进。
资源利用不充分 📉
一个稍令人担忧但同样重要的问题是过度或不足地利用机器的计算能力。如果在强大系统或大型计算集群上发生,这可能造成巨大的资源浪费。
顾名思义,这发生在你创建了过多或过少的线程时。
- 线程过多:大量线程可能因无工作可做而在活跃与休眠状态间频繁切换。上下文切换的开销可能会抵消使用多线程带来的速度优势。
- 线程过少:少量线程可能持续处于高负荷状态。微小的内存泄漏或效率低下的问题会叠加放大,CPU利用率可能飙升,进而影响所有运行线程的性能。此外,如果一个线程需要处理大量数据或指令,缓存未命中的情况会更频繁发生,系统将因在缓存、内存甚至硬盘之间的数据传输而变慢。
解决这个问题的关键在于根据数据量或CPU利用率动态调整线程数量。
核心概念:线程数量需要与工作负载和硬件资源相匹配,以实现最佳性能。
总结
本节课我们一起学习了并发编程中的几个核心陷阱:
- 竞态条件:由非预期的指令交错执行引发。
- 资源争用:多个实体冲突地访问同一共享资源。
- 死锁与活锁:线程因资源依赖而无法推进,区别在于前者被阻塞,后者在空转。
- 资源利用不充分:线程数量与任务不匹配,导致性能下降或资源浪费。

理解这些问题是设计正确并发算法和数据结构的基础。在后续课程中,我们将探讨如何使用同步原语(如锁、信号量、屏障)和原子操作来避免这些陷阱,从而编写出安全高效的多线程程序。
006:并发编程问题与算法展示

在本节课中,我们将学习并行编程中的四个经典挑战及其对应的算法。这些挑战是理解并发编程核心问题的关键,掌握其解决方案有助于应对众多类似的编程场景。
🍽️ 哲学家就餐问题
上一节我们介绍了并发编程的概述,本节中我们来看看第一个经典问题:哲学家就餐问题。
每个哲学家需要两把叉子才能进食。他们一次只能执行一个动作:拿起一把叉子、进食或放下叉子。在一个简单的实现中,如果每位哲学家都先尝试拿起左边的叉子,再尝试拿起右边的叉子,然后进食,最后依次放下叉子,这很可能会导致失败。因为最终可能每位哲学家都持有一把叉子,从而陷入僵局。
这听起来是否熟悉?它与之前视频中遇到的几个问题有些相似。
- 资源竞争:叉子就是被竞争的资源。
- 死锁:如果哲学家们坚持持有叉子而不放下,就会发生死锁。
- 活锁:如果每位哲学家都放下已持有的叉子,转而去尝试获取另一把叉子,则可能发生活锁。
考虑解决此问题的方法:哲学家们可以相互沟通,或者由一个中央机构决定顺序。此问题存在多种解决方案,但并非总是简单易行。因此,在编写代码前请仔细思考。
📦 生产者-消费者模式
在了解了资源竞争问题后,我们来看看一个非常常见的工具:生产者-消费者(或称读者-写者)模式。
这是一种在现代编程中广泛使用的模式,常见于消息队列的实现。消息队列允许数据以流式或批处理的方式顺序或非顺序地添加。然后,用户拉取或订阅消息队列,数据将按照放入的顺序或可用性被返回。
一个或多个进程(称为生产者)可以添加数据,供消费者使用。如果数据量超过队列的处理能力,则根据数据的使用方式有多种策略。
以下是几种处理策略:
- 如果最新数据更重要,队列可以丢弃旧数据。
- 如果只需要数据子样本,可以从队列中随机丢弃数据。
- 如果所有数据都重要,则新数据可以暂存,待队列有空间时再存入。这可以视为速度较慢但成本较低的方式,并且可以告知消费者暂停处理。
如果数据结构或队列允许覆盖值,或者生产者的指针索引超过了消费者的索引,则可能发生竞态条件,导致生产者可能永远无法获得新数据。
💈 睡眠理发师问题
生产者-消费者模式关注数据流,而睡眠理发师问题则关注服务线程的调度优化。
睡眠理发师问题与生产者-消费者模式类似,但服务的顺序并不重要。线程(理发师)试图优化其工作。等候室就像上一张幻灯片中的队列或数据结构,但理发师一次只能服务一位顾客,并且等候室大小有限。
可能发生两种主要情况:
- 过度利用:如果理发师剪发慢且健谈,同时有大量潜在顾客,则可能发生类似活锁或过度利用的情况。顾客探头发现等候室已满后便会离开。
- 利用不足:如果没有顾客,理发师们就会闲坐、睡觉,谈论过去的日子。这是利用不足的情况。
此问题存在针对一个或多个理发师的解决方案。但思考此问题时,可以考虑将问题反转:让等候室中的顾客数量来决定活跃理发师的数量。
🔒 数据与代码同步
讨论了具体的并发问题后,我们来看看一个基础的并行编程机制:数据与代码同步。
许多(即使不是大多数)编程语言都提供了同步数据或代码的机制。这通常通过锁定对数据或代码的访问来实现,因此请谨慎使用。如果同步所有数据,最终可能导致死锁或活锁。
如果一个线程尝试访问一段同步数据,任何需要该数据的其他线程都必须等待。这在票务系统中很有用,你希望锁定对代码或数据的访问,直到前一个持有“调用票”的线程完全使用完毕。
信号量使用锁实现,但通过有限数量的状态来更间接地管理对数据或代码的访问,并允许并发多次访问。
可以将其类比为航班的座位等级。头等舱(1A)的乘客可以在经济舱(2)乘客之前登机。多个个体可以同时登机,这应能最大限度地减少被其他乘客阻塞的可能性。
这里的主要注意事项是,对于多线程问题,很少存在万无一失的解决方案。但你可以根据对数据一致访问的优先级以及对利用不足或过度利用的可接受程度,提出一个合理的解决方案。

本节课中,我们一起学习了并行编程中的四个经典问题:哲学家就餐问题、生产者-消费者模式、睡眠理发师问题以及数据与代码同步机制。理解这些问题及其解决方案的模式,是设计和编写健壮并发程序的重要基础。
007:可选课题-饥饿小鸡问题 🐔
在本节课中,我们将通过一个生动的“饥饿小鸡”比喻,来理解多进程环境中的资源竞争、调度和死锁避免等核心概念。
概述
我们将观察两种不同的场景:代表充足计算资源的“驴子”场景,以及代表资源受限、多进程竞争的“小鸡”场景。通过对比,我们将直观地理解进程调度、资源争用以及如何避免死锁和活锁。
上一节我们概述了本节课的核心对比。首先,让我们来看看资源充足的情况。
资源充足场景:两只驴子 🐴
这里展示的食物代表计算资源。两只大驴子代表两个大型进程。在CPU中,它们就像是两个核心。
以下是驴子场景的关键点:
- 两只驴子(进程)交替从不同位置获取食物(资源)。
- 它们有时会过来争夺你的注意力(资源),但关键在于存在两个核心和两只驴子。
- 理论上,应该有足够的处理能力让它们完成所需任务。
看完了资源相对充足的场景后,我们转向一个更为复杂和常见的情况:资源受限下的多进程竞争。
资源竞争场景:饥饿的小鸡 🐥
现在我们将看到小鸡的场景,这里有许多小鸡,但资源更为紧张。
在左侧是小鸡饲料,右侧是谷物。小鸡需要饲料生存,但它们真正喜欢的是高糖分的谷物。谷物被用来分散它们的注意力,防止它们互相伤害。
这一群小鸡,代表着一组等待获取某些资源的进程。
理解了场景设定后,我们来看看当资源分配不当时会发生什么。
资源分配不均与争用
当我走动时,你会看到不同类型的喂食器或资源访问点,以及水。
我现在所做的,是只在喂食器的特定槽位中放入少量资源。因此,每只扮演进程的小鸡都试图立刻获取资源集。一些小鸡看到可能随之而来的争斗不值得费力,便选择等待更多资源出现。
接下来,我将资源放入红色喂食器的其中一个槽位并关闭顶部。小鸡们随后会尝试从任何槽位获取资源,并且正如你看到一只压在另一只身上那样,它们将开始陷入实际的资源争用。
为了避免上述争用情况,我们需要改变资源分配策略。
增加资源点以避免死锁
现在,我在喂食器的每个槽位都放置了资源。你可能会认为这将有更多的进食机会,但并没有。从小鸡的视角看,资源仍然不足。
因此,我们需要进一步分散饲料,使得小鸡减少互相注视和争夺资源。此时,它们将继续寻找开放的槽位(例如绿色或红色的喂食器)。注意,因为它们必须彼此靠近,所以资源更易获取,但在此刻,我们已经避免了死锁。

避免了死锁并不意味着系统高效。为了优化整体性能,我们需要处理活锁或过度争用。

使用“诱饵”资源优化进程调度
为了避免活锁、对资源的过度争用,甚至进程的次优使用,我将把谷物撒在地上。这进一步分散了小鸡,使它们彼此接触更少,并且给了它们最想要的东西。
总结
本节课中,我们一起学习了多进程编程中的核心挑战。通过驴子与小鸡的比喻,我们直观地理解了:
- 资源充足时,多进程可以相对顺畅地交替执行。
- 资源受限时,进程间会发生激烈的争用,导致性能下降。
- 通过增加资源访问点(更多槽位)可以避免死锁。
- 通过引入“诱饵”或分散注意力机制(撒谷物),可以优化调度,减少不必要的争用(活锁),提升整体效率。
希望我的小鸡和驴子能帮助你以一种新的方式思考多进程处理。
008:并发编程模式解析 🧩

在本节课中,我们将学习并行编程中几种核心的设计模式。理解这些模式能帮助你更高效地设计程序,避免重复造轮子。
概述
并行编程的许多挑战都可以通过五种核心模式来解决:分治法、映射归约法、仓库模式、流水线与工作流,以及递归。识别并应用这些模式,可以显著提升程序的效率和可维护性。
分治法
上一节我们介绍了课程概述,本节中我们来看看第一种模式:分治法。
分治法是解决计算机科学问题最常用的方法之一,即使在不涉及并发的情况下也是如此。你可能在搜索和排序算法中见过它的应用。
其基本思想是将数据不断拆分,直到数据量足够小,可以轻松回答关于排序或相等性等更大规模的问题。每个线程处理小数据集并得到答案后,会将答案返回给调用者。调用者汇总所有小数据集的响应,从而以更少的工作量得出最终答案。这种方法最终将结果传回主调用上下文,其耗时通常少于进行两两比较的暴力方法。
但它并非总是最佳选择。例如,在一个未排序的集合中查找特定值可能需要N次比较。如果数据本身未排序,那么拆分和重组的过程会带来额外开销。此外,在CUDA等不支持或递归效率低下的环境中,应谨慎使用此模式。实现分治法的程序也需要精心设计,以维护所有数据拆分过程中的复杂状态。
映射归约法
了解了分治法后,我们来看看映射归约法,它可以被视为分治法的一个子集。
映射归约法的主要区别在于,每次运行映射归约周期时,它会立即将数据分解为独立的数据点,并对所有数据点应用相同的操作。每个映射器只返回一个值,而归约器的任务则是接收这些返回值,并将其合并为单个值。
例如,要测试一个值是否存在于集合中,每个映射器在接收到非目标值时返回0,在接收到目标值时返回1。归约器则将所有返回值相加,并将0转换为false,大于0的值转换为true。
这种方法的优点在于,无论数据规模大小或计算资源质量如何,它都能很好地扩展,前提是映射归约系统架构良好且通信不慢。映射器和归约器执行的操作可以更复杂,也可以串联成一系列相互衔接的映射归约任务。
以下是映射归约过程的简化表示:
# 伪代码示例:计算集合中目标值的出现次数
def mapper(data_chunk, target_value):
return 1 if data_chunk == target_value else 0
def reducer(results_list):
total = sum(results_list)
return total > 0 # 返回布尔值,表示是否存在
仓库模式


接下来,我们探讨当需要在多个线程或进程间维护状态时使用的仓库模式。
在该模式中,每个进程独立运行。当需要信息或想要更改整体状态时,它会向仓库发出请求。仓库需要确保其内部数据维护的原子性,而进程则负责基于数据一致性或陈旧性的考虑来维护自身状态。
由于一个进程可能处理数据一段时间后才尝试更新,而这可能会覆盖基于更新状态所做的更改,因此该系统可能需要根据情况鼓励更多或更少地使用仓库。
流水线与工作流
现在,我们来看看流水线与工作流模式,它们非常相似。
两者都可以表示为无环的有向图(尽管工作流系统允许循环,但数据仍会从节点流出)。流水线是工作流的一种,其中每个步骤从前一步骤获取输入,并输出到下一个单一的未来步骤。
工作流通常采用扇出和扇入模式:
- 扇出:同一输入被输出到不同的逻辑步骤。
- 扇入:数据被分割并发送到相同的逻辑代码进行处理。
需要注意的是,映射归约和分治法都可以使用工作流来实现,但这几种模式之间并非排他关系。许多编程语言,尤其是现在以更函数式编程的方式,都支持分治法和映射归约。
以下是工作流概念的图示:
[步骤A] -> (扇出) -> [步骤B]
-> [步骤C]
-> (扇入) -> [步骤D]
递归
最后,我们来学习解决许多复杂问题的关键方法:递归。
几乎任何问题都可以通过递归解决,尽管这并不总是最高效的方式。函数式编程语言以及如Lisp、Clojure和Lodash等框架,都在一定程度上围绕递归构建。在这些情况下,数据被分为“头部和尾部”或“第一个和剩余部分”。函数对当前数据执行操作,并使用剩余部分调用自身。
递归不一定总是以这种方式处理,它可以以多种方式划分数据,包括二分方式。递归需要状态管理,因为必须确保递归不会无限进行下去,这意味着递归算法需要有终止状态。通常,当函数输入只有一两个数据时,递归会终止。
在跨多个本地或分布式CPU(非同一处理器)处理大型数据时,或在GPU上,递归模式通常性能不佳,因此不建议使用。
总结

本节课中,我们一起学习了并行编程的五种核心模式:分治法、映射归约法、仓库模式、流水线与工作流以及递归。每种模式都有其适用的场景和优缺点。学会识别问题并应用合适的模式,是设计高效、可维护并行程序的关键。
009:串行与并行代码及Flynn分类法 🚀

在本节课中,我们将学习串行代码与并行代码的核心区别,并通过一个搜索算法的例子来具体说明。我们还将探讨Flynn分类法,这是一个用于描述计算机架构和程序并行性的经典模型。理解这些概念是选择正确编程模型和硬件的基础。
串行搜索 🔍
上一节我们介绍了课程主题,本节中我们来看看最简单的搜索实现方式:串行线性搜索。
以下是一个用于在数据集中查找特定值的简单顺序算法伪代码:
function serialSearch(data, searchValue):
for i from 0 to length(data)-1:
if data[i] == searchValue:
return i // 找到则返回索引
return -1 // 未找到
该算法的执行流程如下:
- 从数据集第一个元素开始,逐个进行比较。
- 如果当前元素等于搜索值,则立即返回其索引。
- 如果遍历完所有元素仍未找到,则返回-1表示未找到。


这种方法的优点是实现极其简单,并且无需对数据进行预先排序(排序本身可能成本高昂)。缺点是,在最坏情况下,可能需要检查数据集中的每一个元素,效率较低。线性搜索在需要执行大量搜索操作时并不理想。
并行搜索 ⚡
了解了串行搜索的局限性后,本节我们来看看如何利用并行性来加速搜索过程。
并行搜索的核心思想是将数据集分割成多个子集,并分配多个线程同时进行搜索。其伪代码逻辑如下:
function parallelSearch(data, searchValue, numThreads):
sliceSize = length(data) / numThreads
for each thread t in [0, numThreads-1]:
start = t * sliceSize
end = start + sliceSize
// 每个线程搜索自己的数据片
for i from start to end-1:
if data[i] == searchValue:
// 找到后,需通知其他线程停止并返回索引
signalOtherThreadsToStop()
return i
return -1
以下是并行搜索的关键点分析:
- 优点:无需预先排序数据。通过增加线程数量,理论上可以线性提升搜索速度(在理想情况下)。
- 挑战:线程管理较为复杂,需要合理分配数据。当找到目标值时,需要安全地终止其他线程,这需要精心设计的逻辑。如果线程数量远小于数据规模,其效率可能仅略高于低效的串行搜索。
并行搜索的潜力在于拥有大量线程的场景,这正是图形处理器(GPU)的用武之地。如果每个线程只需执行一次比较,并在找到时更新一个共享变量,那么搜索时间可以降低到常数级别,优于对数级别的二分搜索。
Flynn分类法 📊
在比较了串行与并行代码后,我们需要一个框架来对计算任务进行分类。Flynn分类法根据指令流和数据流的数量对计算模型进行划分。
该分类法由两个维度构成:
- 指令流(I):可以是单指令流(SI) 或多指令流(MI)。这指的是在所有数据上执行的是相同的逻辑还是不同的逻辑。
- 数据流(D):可以是单数据流(SD) 或多数据流(MD)。这指的是处理的数据是单个复杂对象,还是大量简单数据项。
由此组合出四种经典类型:
- SISD(单指令流单数据流):这是传统的串行处理器模型。单个处理器核心按顺序执行指令,处理单个数据流。例如运行一个复杂的模拟程序。
- SIMD(单指令流多数据流):同一指令同时作用于多个数据项。这是GPU和向量处理器的典型模式,非常适合对大量像素、顶点或数组元素进行相同操作。
- MISD(多指令流单数据流):多个处理器对同一数据流执行不同的操作。这种模型较为罕见,通常用于容错系统。
- MIMD(多指令流多数据流):多个处理器独立地执行不同的指令,处理不同的数据。现代多核CPU集群、分布式系统属于此类。
大多数基于CPU的顺序程序可归类为MISD,因为它们包含不同的处理步骤,作用于单个或少数复杂对象。而GPU等分布式编程方案通常是SIMD,旨在使用大量线程,对海量数据执行相同的逻辑。
总结 🎯
本节课中我们一起学习了串行与并行代码的差异。我们通过搜索算法的例子,对比了串行线性搜索、二分搜索和并行搜索的优缺点。随后,我们介绍了Flynn分类法,它从指令流和数据流的角度,将计算任务分为SISD、SIMD、MISD和MIMD四类。

理解你的数据特征和计算目标,并据此确定所属的Flynn类别,是选择合适编程语言、框架和硬件架构的关键第一步。对于处理大规模简单数据的任务(SIMD),GPU并行编程将展现出巨大优势。
010:Python3并行编程语法与模式 🚀
在本节课中,我们将学习如何在Python 3编程语言中进行并行编程。Python标准库提供了多个用于并发和并行编程的库,我们将重点介绍其中三个核心库:threading、asyncio和multiprocessing。通过学习它们的基本概念和使用模式,你将能够编写出更高效、响应更快的Python程序。
概述:Python 3中的并行编程库
Python 3标准发行版中主要包含三个用于并行编程的库。虽然可以下载许多其他功能类似的库,但我们只介绍这三个,因为它们开箱即用且支持良好。
_thread和threading库:它们是Python 3线程的低级和高级接口。asyncio库:它实现了异步执行代码的一系列核心功能。multiprocessing库:这个库旨在超越线程,为并发软件提供更多可能。
线程库:_thread与threading
上一节我们概述了三个核心库,本节中我们来看看用于线程操作的两个库。要使用_thread或threading库,只需在Python文件顶部添加导入语句,以便全局访问;或者在方法顶部导入,以便在方法级别访问这些库。
线程通过start_new_thread方法启动。你需要向该方法传递线程将要执行的函数名,以及该函数所需的参数和关键字参数。这相当于run和start方法的简写形式,可以减少样板代码。
以下是使用_thread库启动线程的示例代码:
import _thread
def print_numbers(name, count):
for i in range(count):
print(f"{name}: {i}")
# 启动一个新线程
_thread.start_new_thread(print_numbers, ("Thread-1", 5))
线程同步机制
使用threading库时,你可以利用多种机制来协调线程。以下是常见的同步原语:
- 锁(Lock):你可以获取
start_new_thread方法或类似函数的返回值(即已启动线程的句柄),然后对代码的关键部分进行acquire(加锁)和release(释放锁)操作。这是保护关键代码段的常用模式。 - 信号量(Semaphore):信号量可用于控制对变量的访问。默认情况下,一次只允许一个线程访问(二进制信号量)。有界信号量则允许预定数量的线程同时访问变量。
- 事件(Event):还有一种使用事件和事件处理在线程间进行通信的方案。
- 屏障(Barrier):可以在代码中设置屏障,以确保所有活动线程都执行到屏障处,但在所有线程都到达屏障之前,不会执行屏障之后的代码。这是在多个线程间同步数据的好方法,特别是当一个或多个变量需要在多个线程间保持一致性时。
异步编程:asyncio库
上一节我们介绍了线程库,本节我们转向asyncio库。asyncio是多种编程语言中常见的语言结构,它以一种非常简单的方式管理异步代码。导入后,函数可以使用async关键字来表明它们可以以异步方式执行。
可以将其想象成这样一种情况:你的代码需要从网络服务器获取信息,但你不需要让整个系统等待一行或多行代码的执行。asyncio库保证异步方法最终会完成,并会处理相关事宜。
执行异步函数
要调用函数并让其在自己的线程中执行,调用代码只需将异步方法包装在asyncio.run()方法中。或者,如果你希望代码等待异步方法的执行,则使用await关键字,这在让线程休眠时很常见。
以下是异步函数的基本用法:
import asyncio
async def say_hello():
await asyncio.sleep(1)
print("Hello, async world!")
# 运行异步函数
asyncio.run(say_hello())
高级功能:任务与并发
asyncio的一些更高级功能包括创建任务。与返回线程所调用方法的run调用不同,create_task不仅执行协程,还返回可用于检索结果的任务对象。
任务可以作为一系列调用的一部分,通过gather方法并行执行。sleep方法非常重要,因为它允许执行线程等待预定的时间。这在你的代码除了处理计算结果外还在做其他事情,但又想等待一个最终会返回的结果时非常方便。
此外,如果你希望线程拥有一个只要它们存在就不断执行的循环(例如,轮询文件系统以查找更改,然后执行计算或在应用程序窗口中显示文件内容),asyncio也能很好地支持。
多进程编程:multiprocessing库
上一节我们探讨了asyncio,本节我们来看看功能更强大的multiprocessing库。这个库非常强大,它借鉴了Python3上下文中线程独立执行的思想,并允许在当前调用进程之外,甚至在远程机器上执行代码。
创建进程的方法
spawn、fork和fork server都有三个通用目标:从当前进程创建新进程,尽管它们的结果不同。
spawn是最常见的版本,它会创建一个新的Python解释器进程,该进程与当前进程相似但不完全相同(基本上,Python解释器将拥有现有的库)。fork更像是当前进程的一个副本,它可以随时间而变化。fork server是最复杂的。它创建一个服务器,当被调用时,会fork出一个新进程,因此可以被重复调用。它还会处理可能随时间出现的任何死进程。
进程的创建与管理
进程的构造函数与其他库中创建线程的方式非常相似。你将关键字参数传递给要在进程中执行的目标函数,以及将传递给关联方法的参数。
在进程执行中,你调用start()。当你希望暂停调用线程的执行直到进程完成时,调用join()。
以下是创建和管理进程的示例:
from multiprocessing import Process
def worker(name):
print(f'Worker {name} started')
if __name__ == '__main__':
processes = []
for i in range(3):
p = Process(target=worker, args=(i,))
processes.append(p)
p.start()
for p in processes:
p.join()
进程间通信与数据共享


为了让进程能够相互通信,可以将队列(Queue)或管道(Pipe)作为参数传递给各个进程。队列是顺序访问的数据结构,而管道是用于在管道两端之间进行通信的双向机制。可以让两个以上的进程访问管道的同一端,但同时进行读写会破坏队列。因此,如果希望在父进程和子进程(或此情况下的多个进程)之间进行多次读写,使用多个队列可能更有意义。
与前面提到的两个线程库类似,Lock对象的acquire和release方法可用于管理对代码关键部分的访问。
要在多个线程之间共享数据,可以使用Value对象来共享单个值对象,使用Array来共享基本类型的集合。
进程池模式
如果你希望启动多个进程来持续完成工作,工作池(Pool)是一种常见模式。你可以创建一个包含指定数量进程的池对象。然后,每次你希望由池进行并行执行时,只需对池执行函数,例如map。map是一个函数,你向它传递要对值数组执行操作的函数。然后,工作进程池使用数组中的一个值执行该函数,直到数组被完全映射。
这种函数式编程形式正变得越来越普遍,编程语言也正在越来越高效地处理它。当然,也存在其他常见的原语,类似于thread和threading库中的那些,但它们执行的范围超出了当前进程,并跨越多个spawn或fork的进程。
总结


本节课中,我们一起学习了Python 3中进行并行编程的三种主要方式。我们介绍了threading库用于管理线程及其同步,asyncio库用于编写高效的异步代码,以及multiprocessing库用于利用多核CPU进行真正的并行计算。每种工具都有其适用的场景,理解它们的基本概念将帮助你为不同的任务选择最合适的并发模型,从而提升程序的性能和响应能力。
011:Python3实验项目结构 🐍

在本节课中,我们将通过视频讲座,探索Python 3并行编程实验和作业的项目结构。

项目概述
首先,我们打开Python 3并行编程实验活动。我们的重点是查看根目录下 Python examples 文件夹中的内容。
项目基本结构
Python III编程实验的基本结构包含一些必需但不重要的文件,例如 .init.py 和 requirements.txt。requirements.txt 文件用于安装项目所需的适当库。
核心功能分布在多个 .py 文件中,包括 core.py。根据你想要探索的实验活动或库,主要关注以下三个并行编程示例文件:
start_new_thread_example.pythreading_lock_acquire_release_example.pythreading_semaphore_example.py
core.py 文件的作用是存放一系列变量和函数,供其他三个示例Python文件共同使用。因此,所有这些示例都依赖于 core.py。
核心文件解析
现在我们已经打开了 core.py,接下来讨论它的两个主要部分。
在文件顶部,定义了两个静态的、公开可用的函数:
thread_functioncritical_section_acquire_release
thread_function 定义了线程在此案例中应该执行的操作:输出开始和结束信息,并在中间休眠一秒钟。
critical_section_acquire_release 函数执行以下操作:接收一个同步对象,获取其锁,执行一些工作(即调用 thread_function),然后释放锁。需要注意的是,这个同步对象实际上可以是锁(Lock)或信号量(Semaphore),我们稍后会详细讨论。
此外,core.py 中还定义了一个核心类及其函数。它有一个构造函数 __init__,用于解析参数,这对处理命令行参数非常有用。它还包含 parse_args 和 add_argparse_argument 函数。add_argparse_argument 函数的作用是告诉命令行参数解析器添加一个参数,它接收四个值:命令行中使用的标志(如 -h 代表帮助)、该参数的目标变量、任何默认值以及描述性文本。
并行编程示例详解
上一节我们介绍了项目的核心文件,本节中我们来看看三个具体的并行编程示例。
以下是三个主要示例文件的简要说明:
-
基础线程示例:位于
start_new_thread_example.py文件中。其构造函数设置核心对象来解析-n参数,该参数用于获取将并行执行的线程数量。run函数用于并发执行相同的代码(在本例中是core.py中的thread_function)给每个线程。parse_args方法设置该类的核心对象来处理传递的命令行参数,以获取线程数量变量。 -
锁的获取与释放示例:位于
threading_lock_acquire_release_example.py文件中。这段代码看起来应该与上一个文件相似,这是有意为之的设计。它旨在适应一种简单的模式,可以在不同示例之间保持函数组织连贯的同时进行更新或扩展。在run方法中启动的每个线程,所创建的线程对象都会并行执行,执行相同的临界区代码。锁对象专门用于确保没有两个线程同时进入同一个临界区代码。


- 信号量示例:
threading_semaphore_example.py包含了信号量示例代码。此处展示的信号量示例,根据信号量的大小,执行用户定义数量的线程,并允许预定数量的线程进入临界区代码。每个线程都执行相同的critical_section_acquire_release函数,但使用不同类型的同步对象。信号量是一种锁,它允许更多数量的线程同时执行同一段代码。
总结

本节课中,我们一起学习了Python 3并行编程实验的项目结构。我们了解了核心文件 core.py 的作用,它提供了公共函数和参数解析功能。我们还详细分析了三个关键示例:基础线程创建、使用锁进行线程同步以及使用信号量控制并发访问。这些示例基于Python的基础编程库 threading,为探索线程、锁和信号量提供了清晰的实践路径。
012:Python3作业项目结构详解 🏗️
在本节课中,我们将详细讲解课程作业的项目结构。理解这个结构对于正确完成后续的编程任务至关重要。

概述
我们将启动Coursera平台上的作业项目,并逐一解析其核心文件和功能。作业的结构与之前的实验项目有许多相似之处,但也有一些关键变化。
项目结构解析
现在,我们来启动Coursera上的作业项目。

与实验项目一样,许多文件的结构和名称保持不变。但你会注意到 Qua.py 文件发生了一些变化。
核心对象与变量
新的核心对象包含以下几个变量:
number_of_threads: 线程数量。user_name_file: 用户名文件。CI_argument_past_username: 传递给用户名的CI参数。Coursera_assignment_Part_I_D: Coursera作业的Part I D。
read_user_file 和 test_user_name_equality 这两个函数用于验证提交的代码。
参数解析与文件结构
parse_arguments 函数现在位于 quoda.py 文件中,而不是之前的 example.py 文件。它增加了用于指定线程数、用户名和作业部分的参数。
以下是关于作业文件结构的重要说明:
- 作业
IP_UI文件中预定义的类和函数结构不应被修改。 - 你需要在不涉及日志记录的代码部分,填充实现一个完整票务系统所需的功能性代码。
票务系统执行函数
execute_ticketing_system_participation 是一个在作业 A_Py 文件中任何代码都可以调用的函数。它的函数签名如下:
execute_ticketing_system_participation(ticket_number, current_assignment_part_I_D, ticketing_system_object)
该函数将执行必要的步骤,让多个线程按顺序执行关键代码。票务系统需要管理对票的访问,确保任意数量的线程都能安全地取票,并且线程执行代码的顺序必须与它们从票务系统中取票的顺序一致。
这个函数与作业类(assignment class)中的 managed_ticketing_system 函数配合使用,共同保证所有线程按其取票顺序执行代码。




Makefile 使用说明
这里展示的 Makefile 对于正确执行作业 W_PY 文件非常有用。
以下是使用步骤:
- 运行
make clean build来清除所有已编译的Python文件,并安装所需的Python包。 - 最后一步是运行
make run命令,并传递参数。命令格式为:
你需要设置make run ARGS="-N <线程数> -U <用户名> -P <作业部分ID>"numb_threads、username等环境变量,或者直接在make命令示例中用具体值替换它们。
通常,你不需要直接修改或执行代码,因为VS Code中会有一个按钮来完成这些操作。



总结

本节课中,我们一起学习了第二次作业的项目结构。我们分析了核心文件(如 Qua.py)的变化,了解了用于验证和参数解析的新函数,重点讲解了实现并发票务系统的关键函数 execute_ticketing_system_participation 及其与作业类的协作方式,最后介绍了如何使用提供的 Makefile 来构建和运行项目。理解这个结构是成功完成后续并发编程任务的基础。
013:C++并行编程语法与模式 🧵
在本节课中,我们将学习C++标准库中用于并行编程的四个核心类。这些工具是构建高效并发应用程序的基础。
C++标准库提供了四个主要的并发编程类,它们都位于std命名空间内。每个类的名称都与其用途相匹配,分别是:线程、互斥锁、原子变量和期值。对于开发背景较浅,尤其是对现代流行编程语言新特性不熟悉的开发者来说,后两者可能比较陌生。

线程类 (std::thread) 🚀
上一节我们介绍了C++并发编程的概览,本节中我们来看看第一个核心类:std::thread。线程类负责线程的创建、构造、连接与分离。
以下是线程类的基本使用模式:
- 创建线程:使用模式
std::thread 线程变量名(要执行的函数),在当前操作系统进程中创建一个线程实例。线程将独立于调用它的上下文开始执行。 - 连接线程 (join):
join方法会阻塞调用上下文(即创建线程的代码)的继续执行,直到与该线程变量关联的线程完成其任务。 - 分离线程 (detach):如果希望线程完全脱离调用上下文的控制运行,可以使用线程变量的
detach方法。这将允许线程持续运行直至其工作完成,或调用进程完全结束(通常通过SIGTERM或SIGKILL信号,在操作系统中可通过Ctrl+C、Ctrl+D等命令实现)。
#include <thread>
#include <iostream>
void my_function() {
std::cout << "Hello from thread!\n";
}
int main() {
std::thread t(my_function); // 创建并启动线程
t.join(); // 等待线程结束
return 0;
}

互斥锁类 (std::mutex) 🔒
了解了如何创建和管理线程后,我们面临一个新问题:如何安全地让多个线程访问共享资源?这就需要互斥锁。互斥锁为代码的特定部分提供了互斥访问的手段,可以将其视为对所有到来的线程设置的一道屏障。
以下是互斥锁的使用要点:
- 基本使用:互斥锁是传递使用的变量。通常在进入临界区代码前调用
lock方法以阻止其他线程并发访问同一段代码,在完成操作后调用unlock方法。 - 尝试加锁 (try_lock):如果希望线程尝试获取锁,若失败则继续执行而不等待,可以使用
try_lock方法。这适用于线程可以执行许多不相关任务以最大化其处理能力的情况,或者线程可以记录加锁尝试失败后进入“睡眠-重试”模式。正确使用可以减少对临界区的争用,但如果线程睡眠时间过长,则可能导致利用率不足。 - 注意事项:请注意,如果在锁内和锁外都可能修改变量,则无法保证这两次操作之间数据的一致性。
#include <thread>
#include <mutex>
#include <iostream>
std::mutex mtx;
int shared_data = 0;
void increment() {
mtx.lock();
++shared_data; // 临界区
mtx.unlock();
}
原子变量类 (std::atomic) ⚛️

互斥锁提供了粗粒度的同步控制,但有时我们需要更高效、更细粒度的数据同步。这就是原子变量的用武之地。原子变量允许对数据进行更具一致性的访问,意味着在当前对变量的操作完成之前,该变量不能被访问或修改。
以下是原子变量的关键概念:
- 作用:试想对一个变量进行递增操作,这至少涉及两个操作系统级别的操作:加法和保存值。如果没有原子性,两个交错的递增操作可能最终只产生一次有效的递增。
std::atomic确保了这些操作的不可分割性。 - 使用范围:C++中的大多数基本类型都可以应用
atomic关键字。但将所有变量都设为原子变量并非良好实践。如果你确信两个线程将访问同一个变量,并且希望确保所有操作都产生预期效果,那么这是一个好工具。 - 内存栅栏 (fence):此外,可以使用内存栅栏来确保所有线程都能访问一个原子变量,但在所有线程都到达栅栏之前不会继续执行。
std::atomic_thread_fence是一种获取和释放的机制,让所有线程都能访问栅栏变量,然后让它们表明希望释放栅栏并在适当时机继续执行。
#include <atomic>
#include <thread>
std::atomic<int> counter(0); // 原子整数
void safe_increment() {
for(int i = 0; i < 1000; ++i) {
++counter; // 原子操作,线程安全
}
}
期值与承诺 (std::future & std::promise) 🔮
最后,我们来看一个更现代的并行编程构造:期值。它允许线程在继续执行其职责的同时,能够在另一个线程的操作结果完成时获取该结果。

以下是期值与承诺的工作机制:
- 基本概念:在现代语言中,这常用于网络通信。当在当前线程上下文之外发出请求时,与其等待答复,不如做出一个“承诺”,在结果返回时进行响应。为了获取承诺操作的结果,会返回一个“期值”。
- 异步执行 (std::async):如果你更倾向于让线程等待结果,函数可以使用
std::async来表明一个函数将在调用上下文之外继续执行,并在稍后的时间点执行。 - 等待与获取:使用
wait可以让调用上下文暂停执行,直到被调函数返回结果。而get则用于最终获取该异步操作的结果。
#include <future>
#include <iostream>
#include <chrono>
int compute_answer() {
std::this_thread::sleep_for(std::chrono::seconds(2));
return 42;
}
int main() {
std::future<int> fut = std::async(std::launch::async, compute_answer);
std::cout << "Doing other work...\n";
int result = fut.get(); // 等待并获取结果
std::cout << "The answer is: " << result << '\n';
return 0;
}
总结 📝


本节课中我们一起学习了C++标准库中四大并发编程组件:std::thread 用于线程生命周期管理,std::mutex 用于保护临界区实现互斥访问,std::atomic 提供了无需锁的线程安全基本数据类型操作,而 std::future/std::promise 则提供了更高级的异步任务结果传递机制。理解并合理运用这些工具,是编写正确、高效并行C++程序的关键。
014:C语言实验项目结构 🧪

在本节课中,我们将学习约翰霍普金斯大学GPU编程课程中C语言实验项目的结构。我们将打开项目目录,逐一查看其中的关键文件,并理解每个示例代码的功能。
项目目录概览
首先,我们打开实验项目目录。

您在这里看到的目录名为 Project。
它位于 coders 用户的 project 文件夹下。您会注意到其中有四对C++源文件(.cpp)和头文件(.h)。
它们分别对应 threads、mutexes、futures 和 atomics 这四个主题。
头文件展示了诸如常量变量和函数签名等内容,而C++文件则包含了实际的代码实现。
另一个您将看到的重要文件是 Makefile,我们稍后会详细讲解它。
Makefile 描述了 make 命令的多种使用方式,用于改变文件、编译文件等操作。
Makefile详解
现在,让我们打开 Makefile。
这个C++示例的 Makefile 管理着代码的编译、相关可执行文件的运行,以及清理构建过程中产生的中间文件(如目标文件和可执行文件)。
对于四个示例中的每一个,都有一个 build 目标和一个 run 目标。
此外,还有用于构建和运行所有示例的总体 build-all 和 run-all 目标。
all 目标会执行所有适用的目标,即清理所有内容、构建所有内容,然后运行所有内容。
线程示例分析
打开 thread 示例文件。
您会看到左侧标签页是头文件(.h)。它包含了三个非主函数的函数签名。
在右侧标签页,您可以看到 thread_example.cpp C++代码。其中实现了三个函数。
以下是这三个函数:
do_work:该函数包含线程要执行的工作,在本例中是记录正在运行的线程。execute_threads:该函数并行执行一个预定数量(本例中为3)的线程,然后等待它们完成(join)。execute_and_detach_thread:该函数以分离模式执行一个线程,意味着它不再与调用它的上下文绑定。
互斥锁示例分析
接下来,我们讲解 Mutex 示例。
左侧标签页是互斥锁示例代码的头文件。它包含了共享的 mutex 变量和四个非主函数。
现在让我们查看这个C++代码。
您将看到四个函数。以下是这些函数:
do_work_with_mutex_lockexecute_threads_with_mutex_lockdo_work_with_mutex_trylockexecute_and_detach_threads_with_mutex_trylock
两个 do_work 函数(do_work_with_mutex_lock 和 do_work_with_mutex_trylock)负责单个线程针对共享互斥锁的工作。
两个 execute 函数(execute_threads_with_mutex_lock 和 execute_and_detach_threads_with_mutex_trylock)则根据传入的线程数量变量来执行对应的 do_work 函数。
Future示例分析
现在,让我们继续学习 futures 的示例代码。
左侧标签页 future_example.h 是该示例的头文件,它包含了四个非主函数。
其中实现了四个函数。以下是这些函数:
do_work_with_futuresex_threads_with_futuresdo_work_with_asyncex_with_async
两个 do_work 函数(在此处展开)负责单个线程针对 futures 和整数输入的工作。
两个 execute 函数根据预定的或传入的 future 数量来执行 do_work 函数,其中 execute_the_do_work_with_async 函数是异步执行的。
原子操作示例分析
最后,让我们学习 atomics 的示例代码。
atomic_example.h 头文件包含了四个非主函数。
atomic_example.cpp 包含了原子变量的示例代码。
其中实现了四个函数。以下是这些函数:
do_work_with_atomic_boolex_threads_with_atomic_booldo_work_with_atomic_thread_fenceex_with_atomic_thread_fence
两个 do_work 函数(在此处和此处展开)负责单个线程针对所使用的原子变量的工作,第一个处理原子布尔变量,第二个实现原子线程栅栏。
两个 execute 函数(execute_threads_with_atomic_bool 和 execute_with_atomic_thread_fence)根据传入的线程数量变量,并行执行其关联的 do_work 函数。


总结
本节课中,我们一起学习了C语言实验项目的整体结构。我们查看了项目目录,分析了 Makefile 的作用,并逐一探讨了 threads、mutexes、futures 和 atomics 四个核心并发编程概念的示例代码及其函数构成。这为我们后续动手实践这些并发编程技术打下了基础。
015:C++作业项目结构详解 🏗️
在本节课中,我们将详细讲解C++作业的项目结构。了解清晰的项目结构是高效完成编程作业的第一步。我们将逐一介绍核心文件及其作用,帮助你快速上手。
项目文件概览 📁
我们将首先在浏览器环境中进行工作。你需要了解两个与作业相关的基础文件。

核心头文件:assignment.h
第一个关键文件是作业的头文件 assignment.h。它包含了一系列静态变量,以及四个最重要的非主函数的函数签名。


以下是该头文件的核心内容:
- 静态变量:定义了作业模拟中使用的各种配置参数。
- 函数签名:声明了四个核心函数,包括
execute_ticketing_system_participation、run_simulation、get_username_from_user_file和manage_ticketing_system。
核心源文件:assignment.cpp
现在,我们来看右侧标签页中的 assignment.cpp 文件,这是作业的主要源代码文件。其中定义了四个我们感兴趣的非主函数。
以下是这四个函数及其简要说明:
execute_ticketing_system_participation:执行票务系统的参与逻辑。run_simulation:运行一次作业模拟。get_username_from_user_file:从用户文件中检索用户名。manage_ticketing_system:管理票务系统。
模拟运行流程 🔄
上一节我们介绍了核心函数,本节中我们来看看 run_simulation 函数的具体执行流程。该函数基于头文件中先前定义的静态变量执行一次作业模拟。
在 run 函数内部,执行顺序如下:
- 首先调用
get_username_from_user_file函数,从用户文件中获取用户名。 - 接着,根据线程数量,在分离的线程上执行
execute_ticketing_system_participation调用。 - 最后,
manage_ticketing_system函数负责管理这些独立线程与票务系统之间的交互。
注意:
manage_ticketing_system函数的目标是确保票务系统正常工作,使得线程按照它们从票务系统中取出的票的顺序来执行其工作。
辅助支持文件 🛠️
除了核心代码文件,完成作业还需要两个支持文件。
以下是这两个支持文件的说明:
Makefile:包含典型的构建(build)、清理(clean)、运行(run)和默认(all)目标,用于自动化编译和运行流程。run_out.sh:该文件与Python 3作业中的run_out.sh非常相似,区别在于传递参数时不需要选项或标志。

本节课中我们一起学习了C++作业项目的完整结构,包括核心头文件与源文件的作用、模拟运行的逻辑流程以及必要的辅助脚本。理解这个结构将帮助你更有效地定位代码和完成后续的编程任务。
016:集成GPU与独立GPU对比
在本节课中,我们将学习集成GPU与独立(专用)GPU的核心概念、能力差异以及它们各自适用的编程框架。理解这些硬件区别是进行高效GPU编程的基础。
概述:两种GPU类型


首先,我们来明确两种主要的GPU类型:集成GPU和独立GPU。集成GPU通常与CPU集成在同一芯片上,而独立GPU则是一块独立的硬件。
上一节我们介绍了课程模块,本节中我们来看看这两种GPU的具体定义和特点。
集成GPU
集成GPU是大多数消费级笔记本电脑和台式机的基础配置硬件。它们不支持特定的厂商编程框架,例如CUDA,但可以与通用或异构编程框架(如OpenCL和OpenACC)一起使用。
以下是集成GPU的一些关键特征:
- 制造:通常由制造该机器CPU的同一家公司生产。
- 集成含义:GPU与CPU位于同一芯片上,这就是“集成”的含义。
- 优点:
- 物理上靠近CPU,并共享系统内存。
- 发热量更低,功耗更小,有助于延长电池寿命。
- 缺点:性能远不如独立GPU,因为它们必须与CPU共存。
独立GPU
NVIDIA制造了最常用的可编程GPU。AMD也生产功能强大的GPU,但其在GPU上直接编程的普及程度不及NVIDIA。AMD GPU更侧重于视频和游戏应用。
以下是关于独立GPU的更多信息:
- 趋势:NVIDIA GPU正朝着以更低功耗和更少发热实现更多处理能力的方向发展。
- 常见设备:高性能笔记本电脑可能包含NVIDIA显卡,但它们更常见于台式机和服务器。
- 平台限制:无法在Mac电脑上获得NVIDIA GPU,只能使用AMD GPU。这可能与苹果对OpenCL和CUDA缺乏支持兴趣有关,他们更专注于自家的GPU编程框架Metal。
应用场景与编程框架
了解了硬件区别后,我们来看看它们各自的应用场景以及如何通过不同的编程框架来利用它们。
常见应用场景
集成GPU的一个常见应用场景是编程或异构工作流程不太明确的情况。因为GPU可能使用也可能不使用,所以硬件成本和额外功耗较低。
独立GPU在以下用例中得到了更广泛的应用:
- 加密货币挖矿与验证
- 自然语言处理
- 计算机视觉
在自然语言处理和计算机视觉领域,程序员通常直接或间接地使用神经网络,因为它们能很好地映射到NVIDIA硬件上执行。这涉及到许多处理器同时进行少量工作。
编程框架对比
如果一个程序需要利用不同类型的硬件来执行不同的任务,选择合适的编程框架至关重要。
以下是三种主流异构编程框架的简要对比:
- OpenCL:一种广泛使用的开源计算软件,其核心目标是支持尽可能多的硬件平台。OpenCL代码位于单独的文件中,或作为文本编译到C/C++代码中,然后发送到硬件上进行即时编译。
- OpenACC:OpenCL的后续框架,全称Open Accelerator。它取消了需要单独代码的要求,而是让开发者使用宏来标记可以利用加速器(如GPU和FPGA)的代码段。
- CUDA:应用最广泛的框架,为开发者提供了对NVIDIA硬件更深入的访问和控制能力。
总结

本节课中我们一起学习了集成GPU与独立GPU的核心区别。集成GPU功耗低、成本低,适合通用或轻量级异构计算;而独立GPU性能强大,是深度学习、科学计算等密集型任务的首选。同时,我们了解了OpenCL、OpenACC和CUDA这三大编程框架的定位,CUDA在NVIDIA生态中提供了最直接和强大的控制能力。理解这些内容是选择合适硬件和工具进行GPU编程的关键第一步。
017:使用GUI与CLI工具识别GPU硬件 🖥️🔍
在本节课中,我们将学习如何使用图形界面(GUI)和命令行界面(CLI)工具来识别您当前计算机上的GPU硬件及其规格。掌握这些工具是进行GPU编程和性能优化的第一步。
我们将介绍三类工具:操作系统自带的硬件分析工具、普遍可用的命令行工具,以及由GPU制造商提供的、能获取更详细硬件信息的专用工具。
操作系统自带工具 🖱️
首先,我们来看看操作系统本身提供的图形化工具,它们通常能提供最直观的硬件概览。
以下是几种常见操作系统下的工具:
- Windows设备管理器:其界面是一个大型的树状结构。您需要导航至“显示适配器”类别,其下会列出所有已安装的GPU硬件。
- Linux系统信息(
hardinfo):该工具默认安装在Ubuntu和Mint等Linux发行版中。在应用程序左侧,您可以看到CPU、系统(指操作系统)、GPU等类别。 - macOS“关于本机”:在兼容macOS的系统上,“关于本机”工具会在“显示器”标签页下显示GPU相关信息。
通用命令行工具 ⌨️
上一节我们介绍了图形界面工具,本节中我们来看看更强大、更灵活的命令行工具。它们允许您通过输入命令来快速获取信息。
以下是不同操作系统下的常用命令:
- Windows命令提示符/PowerShell:使用
wmic或Get-WmiObject命令可以显示大量硬件信息。配合wmic path win32_VideoController get标志,可以专门获取GPU信息。 - macOS系统概述:系统提供了一个名为
system_profiler的CLI工具。您可以传入SPDisplaysDataType数据类型来筛选出GPU和显示器的信息。 - Linux
lspci工具:大多数Linux发行版都提供lspci工具来列出PCI设备信息。像大多数Linux命令一样,lspci可以与其他命令组合使用。
lspci 命令常与 grep 命令结合,后者用于筛选出匹配特定模式的行。您可以在下面的代码中看到 grep 命令的用法。这里我添加了 --color 选项来使用颜色高亮,使输出更清晰。
lspci | grep --color -i vga
通过管道将 lspci 的输出传递给 grep 命令,现在您就只得到了显示GPU硬件规格所需的输出。

厂商专用分析工具 🏭

除了通用工具,GPU制造商还提供了功能更强大的专用分析器。这些工具对于深入了解硬件运行状态至关重要。
在独立GPU领域的两大主要厂商,AMD和NVIDIA,都拥有各自的性能分析工具。
这些工具对于确定哪些操作(例如内存拷贝、硬件流处理器等)在任意时刻被积极使用非常有帮助。它们非常适合用于分析正在运行的代码,并识别在任意时间点发生的情况,这可能是识别内存泄漏或性能低下代码的完美方式。




本节课中我们一起学习了识别GPU硬件的多种方法。我们从操作系统自带的GUI工具开始,了解了如何快速查看基本信息;然后探索了更强大的命令行工具,它们提供了更灵活的查询方式;最后,我们介绍了由AMD和NVIDIA提供的专业分析工具,这些工具能提供最深入的硬件运行时洞察,是进行GPU性能分析和调试的利器。掌握这些工具是高效进行GPU编程的基础。
018:NVIDIA GPU架构解析 🚀
在本节课中,我们将首次深入了解NVIDIA特定的硬件和软件能力。我们将回顾自2008年Tesla架构以来,NVIDIA推出的七代主要GPU架构,分析每一代在性能、功耗和编程能力上的演进。
架构演进概述 📈
上一节我们介绍了课程目标,本节中我们来看看NVIDIA GPU架构的发展历程。截至2021年初,共有七代主要的GPU硬件支持基于CUDA的编程。每一代硬件都在提升性能与软件能力的同时,致力于降低功耗。
以下是七代NVIDIA GPU架构的列表,每一代都是NVIDIA在大约两年时间内生产的主要架构。

- Tesla (2008)
- Fermi (2010)
- Kepler (2012)
- Maxwell (2014)
- Pascal (2016)
- Turing (2018)
- Ampere (2020)
在接下来的几页中,我们将每页介绍两代架构,覆盖大约四到五年的硬件发展。
早期架构:Tesla与Fermi (2007-2012) ⚙️
Tesla和Fermi架构的生产周期从2007年持续到2012年。
Tesla是首个支持协同编程(CUDA)的架构。它核心数量较少,且功耗较高。
对于Fermi架构,其最受欢迎的显卡型号(如GTX 480)的核心数量较Tesla翻了两番,内存容量翻倍,内存速度提升了33%,而功耗和发热量仅有小幅增加。
能效提升:Kepler与Maxwell (2012-2016) 💡
Kepler和Maxwell架构的生产周期从2012年持续到2016年。
Kepler架构的主要目标是增强GPU的可编程性,其核心数量和内存容量再次实现翻倍。内存速度有所降低,整体功耗也得到控制。
Maxwell架构的显卡则再次近乎翻倍了核心数量,同时提升了内存带宽并进一步降低了功耗。
在这两代架构中,你不仅能看到功耗的轻微下降,更重要的是,由于可用核心数大幅增加,每瓦性能实际上随着每一代更新而翻倍。
近期架构:Pascal与Turing (2016-2020) 🔄
在最新一代架构之前的两代硬件——Pascal和Turing,生产于2016年至2020年。
Pascal架构致力于使内存模型更加统一,从而减少对内存配置的顾虑。它还引入了NVLink技术,使得主机与设备之间的内存传输更快、更便捷。
核心数量有小幅增长(每代约增加50%),功耗改善在这些架构中放缓。显卡内存容量更高,速度几乎是Maxwell架构的两倍。
当前架构:Ampere (2020-) ⚡
Ampere是当前最新的硬件架构。它继承了Turing架构的张量核心和光线追踪核心,并创建了更多专用核心。
核心数量再次近乎翻倍,同时功耗减半,这意味着每瓦性能提升了四倍。内存带宽基本保持稳定,或根据具体安装的显卡型号略有降低。

本节课中我们一起学习了NVIDIA GPU从Tesla到Ampere共七代架构的演进历程。我们看到了核心数量、内存性能和能效比(每瓦性能)的显著提升,以及NVLink等关键技术的引入。理解这些硬件特性是进行高效GPU编程的基础。
019:CUDA Linux安装指南 🖥️
在本节课中,我们将学习如何在Linux系统上安装CUDA工具包。我们将使用Ubuntu发行版作为示例,并详细介绍从下载到验证安装的完整步骤。

概述
本节教程将指导你完成在Linux(特别是Ubuntu)系统上安装CUDA工具包的过程。我们将从访问NVIDIA开发者网站开始,选择合适的安装选项,然后通过一系列命令行步骤完成安装,最后通过编译和运行一个简单的CUDA程序来验证安装是否成功。
选择安装平台与版本
首先,我们需要访问NVIDIA的CUDA下载页面。在撰写本教程时,我们正处于NVIDIA GTC 2021大会前夕。在目标平台选项中,我们选择 Linux。

接下来,选择架构。对于大多数AWS EC2实例,我们选择 x86_64 架构。
然后,需要选择Linux发行版。本教程选择 Ubuntu,因为它非常常见且支持良好。我们选择一个长期支持版本,在录制时是 20.04。
选择安装包类型
最后一步是选择安装包类型。我们选择 Debian包管理器 版本,这种类型会通过网络拉取所有必要的文件。
点击选择后,页面会显示基本的安装命令。
执行安装命令



以下是需要在基于Debian包管理器的系统上执行的六个命令。


1. 下载安装程序
首先,使用 wget 命令获取二进制安装程序。
wget https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2004/x86_64/cuda-ubuntu2004.pin
2. 移动包管理器信息
接下来,将包管理器信息文件移动到APT命令的首选项目录。
sudo mv cuda-ubuntu2004.pin /etc/apt/preferences.d/cuda-repository-pin-600
3. 添加公钥
然后,添加该发行版的公钥。
sudo apt-key adv --fetch-keys https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2004/x86_64/7fa2af80.pub

4. 添加软件仓库
接着,将与公钥关联的软件仓库添加到APT命令中。
sudo add-apt-repository "deb https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2004/x86_64/ /"
现在,包含CUDA安装信息的软件仓库已可用。
5. 更新软件仓库列表
更新APT仓库,以获取新添加仓库的最新信息。
sudo apt-get update
6. 安装CUDA工具包
最后一步,告诉APT命令安装CUDA库。此命令也会安装许多依赖包。
sudo apt-get -y install cuda
在此命令执行完毕后,你将能够执行所需的命令,如 nvcc,并且将拥有必要的C库。
有时CUDA工具包可能不包含 nvcc 编译器。为了确保安装,可以运行以下命令:
sudo apt-get install nvidia-cuda-toolkit
这个命令总会安装 nvcc 命令。
验证安装
现在,让我们使用Linux中的 vim 编辑器查看测试CUDA文件 test.cu 的内容。



该文件顶部有一个使用 __global__ 修饰符定义的全局内核函数,其方法名为 saxpy,这是一个简单的数学运算。
文件中还有一个 main 函数,它执行了内存分配、双向数据拷贝以及运算操作。最后,它会打印出计算中观察到的最大误差。
接下来,我们使用 nvcc 命令编译 test.cu 文件,并输出可执行文件 test.exe。
nvcc -o test.exe test.cu
然后,使用 ./ 命令在Linux中运行 test.exe。
./test.exe
你将看到输出的最大误差,在这个例子中是 0,这表明安装成功且程序运行正确。
总结

在本节课中,我们一起学习了在Ubuntu Linux系统上安装CUDA工具包的完整流程。我们从选择正确的平台和版本开始,逐步执行了六个关键的APT命令来完成安装,最后通过编译和运行一个示例CUDA程序验证了安装结果。现在,你的Linux环境已经准备好进行CUDA编程了。
020:CUDA软件层级架构 🏗️
在本节课中,我们将深入学习CUDA开发者工具包,理解其软件层级架构。这对于决定如何使用CUDA至关重要。我们将从各软件层之间的通用通信开始,接着探讨通过NVCC命令编译CUDA代码的两种主要工作流,然后介绍顶层的应用层,最后讨论CUDA软件架构中一个非常重要的部分。
软件层交互概述
我们将使用CUDA软件层级图来展示开发过程中不同部分是如何交互的。




在本次讨论中,您开发的CUDA应用程序将与运行时API或驱动API进行通信。您选择让代码更直接地与底层硬件通信,还是通过更高级的接口,这个决定非常重要。它将定义您的代码形态、编译方式以及生成的中间文件。
CUDA代码编译工作流
上一节我们介绍了软件层的交互,本节中我们来看看编译CUDA代码的两种主要工作流。
以下是两种主要的工作流程:
-
运行时API工作流:如果您选择通过运行时API使用更高级的访问模式,那么您可以将GPU代码嵌入到主机代码中。为此,您需要通过NVCC命令编译这两种类型的代码。一旦主机代码链接到GPU的PTX或CUBIN文件,它们就可以协同工作。您仍然需要使用面向主机的编译器,如GCC或G++,来生成最终的可执行文件。幸运的是,这个工作流可以通过NVCC命令简化,让它默认输出基于主机的可执行文件。这是大多数用户最常用的方式,也是本课程中将使用的方式。
-
驱动API工作流:如果您目标是驱动API,则需要分别编译GPU和CPU代码,然后让主机可执行文件通过驱动API与GPU代码交互。这种方式较少使用,因为该过程更依赖于特定的硬件和编译器组合,而运行时工作流可以生成更通用的可执行文件。
应用层
了解了编译流程后,现在让我们聚焦于最顶层的软件层——应用层。您很快就要开始开发CUDA应用程序,所以让我们看看在开始编写代码时需要考虑什么。
您的应用程序将一部分基于主机,一部分面向GPU。这两部分代码可以共存于同一个文件,也可以是分开的。不过,任何包含CUDA代码的文件都应使用 .cu 或 .cuh 作为代码和头文件的扩展名。
您当然也可以通过特定语言的抽象层或API(如PyCUDA或JCUDA)用其他语言编写代码。它们内部会使用CUDA,但您可以按照该语言的开发模式编写常规代码。
两个非常流行的用于创建神经网络和其他数学结构的库是TensorFlow和PyTorch。当系统中存在NVIDIA GPU并安装了CUDA驱动时,这些框架也会使用CUDA。

CUDA库
上一节我们讨论了应用层的代码编写,本节中我们来看看如何利用现成的强大工具——CUDA库。
使用CUDA能力的另一种常见方式是使用NVIDIA CUDA开发者工具包中的库。


其中一些库旨在消除编写复杂CUDA代码的需要,或者让复杂的数据结构和算法能在您的CUDA代码中使用。它们几乎都由NVIDIA维护。随着新的用例或领域需要更强大或更可靠的选项,新的库会被不断添加。
总结

本节课中我们一起学习了CUDA的软件层级架构。我们了解了应用程序通过运行时API或驱动API与底层硬件通信,探讨了分别对应这两种API的NVCC编译工作流,认识了应用层代码的组织形式(.cu/.cuh文件),并介绍了可以简化开发的各类CUDA库。理解这个架构是有效使用CUDA进行GPU编程的基础。
021:CUDA代码编译原理 🚀
在本节课中,我们将学习如何使用NVCC编译器来编译CUDA代码。我们将深入探讨NVCC命令的各种选项,理解如何将设备代码和主机代码链接成一个可执行文件,以及如何针对特定的硬件架构进行编译。
编译过程概述
上一节我们介绍了CUDA编程的基本概念,本节中我们来看看CUDA代码的编译原理。理解NVCC编译器的编译过程对于高效地开发和调试CUDA程序至关重要。
NVCC命令选项
要理解NVCC编译器,我们需要研究该命令的各种可用选项。了解这些选项可以帮助我们更好地控制编译过程。
查看帮助内容

了解此命令各部分的最简单方法之一是使用 -H 选项,该选项表示您希望查看其帮助内容。
以下是使用 -H 选项查看帮助的示例:
nvcc -H
输入与输出文件格式
您首先会看到命令的使用模式。接着,您会看到对输入和输出文件格式的描述,以及用于处理它们的命令选项。

链接设备与主机代码

接下来需要研究的重要领域是如何将设备代码和主机代码链接成一个可执行文件。请务必注意链接和编译后的代码是否会创建可重定位的可执行文件。
编译为库文件
-lib 命令选项允许您将所有输出文件编译到一个库文件中,这对于大型或共享项目非常有用。
一步编译、链接与执行
-run 选项是一个很好的命令,可以一步完成编译、链接和执行代码。

调试、性能分析与目标架构
最后两类选项是用于调试、性能分析和输出架构的选项。默认情况下,输出的代码不允许对GPU代码进行性能分析或调试主机代码。-profile 和 -debug 选项允许在可执行文件中包含此类附加内容。
指定目标架构
如果您希望针对特定的硬件和软件架构,将需要使用 -arch 和 -code 选项。真实架构决定了代码的生成方式以及如何为特定的硬件架构进行编译,而虚拟架构则与不同计算版本相关的功能有关。
-arch选项:允许您确定代码将支持哪些GPU硬件架构。-code选项:可用于确定嵌入式PTX代码将如何编译,以供特定的真实和虚拟架构使用。-gencode选项:提供了对前两个选项的简化。


编译示例
现在,让我们将所有这些内容整合起来。在左侧,您将看到一些示例的“Hello World”代码,这些代码旨在与CUDA一起使用,并通过NVCC命令进行编译。
以下是两个NVCC编译执行的示例:
- 第一个命令旨在生成PTX文件。您还会注意到它没有指定任何架构,因此会看到关于为已弃用架构编译的警告。
- 第二个命令专门输出
.exe文件,并以特定的架构6.2为目标。

总结

本节课中我们一起学习了CUDA代码的编译原理。我们探讨了NVCC编译器的各种命令选项,包括如何查看帮助、处理输入输出文件、链接设备与主机代码、编译为库文件、以及进行调试和性能分析。我们还学习了如何通过 -arch、-code 和 -gencode 选项来指定目标硬件架构。最后,通过两个编译示例,我们看到了这些选项在实际应用中的效果。掌握这些编译知识将帮助您更有效地构建和优化CUDA应用程序。
022:CUDA帮助实验与作业指导 🧪
在本节课中,我们将学习如何完成与在Linux系统上安装CUDA相关的实验和作业。主要内容是使用NVCC编译器的帮助命令,并将输出结果保存到文件中。
作业与实验概述
首先,让我们打开作业说明。作业内容非常简单,实验部分也使用了相同的代码,你可以随意探索。
你需要完成的任务非常直接:运行带有帮助标志的NVCC命令(即NVIDIA交叉编译器),并将其输出的大量文本内容保存到一个名为output.txt的文件中。这一切操作将在你浏览器中的VS Code集成开发环境中完成。


查看项目文件
现在,我想给你一个机会查看项目中的文件。这里总有一个C语言源文件,我希望你能有机会接触并探索实际的代码,以及生成的output.txt文件。
这个实验和作业都需要一定程度的终端使用技巧。让我们打开一个新的终端窗口。默认情况下,终端会将你置于项目文件夹中,正如屏幕右侧所示。
执行NVCC帮助命令
如前所述,你需要执行的命令非常简单,格式如下:
nvcc --help > output.txt
这个命令使用nvcc编译器,加上--help标志(或选项),然后将输出重定向(>)到output.txt文件中。
分析输出文件
现在,让我们查看生成的文件。你将看到帮助命令的全部内容,可以随意浏览。虽然不要求你掌握所有细节,但其中一些内容非常重要,例如如何创建PTX文件或.bin文件等。
我们将在后续课程以及与作业相关的部分中更详细地讲解这些标志或选项。
提交作业
创建了这个长文件后,你只需点击“提交作业”按钮。系统会提示它将运行一个Python命令进行评分。

查看提交状态与反馈
在作业提交并等待评分时,作业旁边会显示琥珀色状态,并告知你提交时间。这是实际的评分输出结果。

反馈信息显示,系统在你的提交中找到了NVCC帮助命令的输出,因此你获得了100分(满分)。

关于实验部分
实验部分使用相同的代码和相同的命令,区别在于你无需提交实验部分。因此,请自由地在实验环境中进行探索。
总结

本节课中,我们一起学习了如何完成CUDA安装后的第一个实践任务。我们使用nvcc --help命令获取了CUDA编译器的详细帮助信息,并将其输出保存到文件。你成功提交了作业并获得了满分。实验部分为你提供了自由探索相同内容的机会。掌握查看编译器帮助文档是后续学习的重要基础。
023:CUDA运行时与驱动API 🚀
在本节课中,我们将聚焦于CUDA运行时API与驱动API。我们将回顾CUDA软件架构的各个层次,并深入探讨这两种API的具体细节与区别。
上一节我们介绍了CUDA软件架构的概览,本节中我们来看看其核心的编程接口。
我们将再次使用CUDA软件层次图来说明开发过程中不同部分的交互方式。


对于本次讨论,您开发的CUDA应用程序将与运行时API或驱动API进行通信。选择让您的代码更直接地与底层NVIDIA硬件通信,还是一个重要的决策,它将定义您的代码形态以及如何编译生成中间文件。

运行时API

运行时API是驱动API的一个抽象层,它将开发者从诸如模块初始化和上下文管理等底层任务中解放出来。
另一个简化之处在于,在运行时API中,所有编译到任何PTX或GPU代码中的内核函数对主机代码都是立即可用的,无需选择性地将模块和内核加载到当前上下文中。
要编写使用此API的代码,您需要使用C++语言。通常,这是最常见的开发形式。
以下是运行时API的主要特点:
- 它是一个高级别的、对开发者友好的接口。
- 自动管理上下文、模块和内核加载。
- 代码编写更简洁,开发效率更高。
驱动API

如果您希望开发更低级别的代码,以便对编译后的代码及其在GPU中的执行方式施加更多控制,那么CUDA驱动API是您的工具。
最有趣的特点是,尽管它是低级代码,但不仅可以用汇编语言编写,任何能够链接和执行CUBIN对象的语言都可以使用。
该API本身需要在主机代码中显式初始化,使用cuInit函数。
编写驱动API代码的主要复杂性在于,诸如设备、代码模块以及执行GPU代码的上下文等主要组件都必须由您的代码来管理。
因此,您需要考虑为了获得额外的控制能力而付出的这些开发成本是否值得,或者选择更简单的API(即运行时API)是否更有意义。
以下是驱动API的主要特点:
- 提供低级控制,灵活性更高。
- 需要显式管理设备、上下文和模块。
- 支持更多编程语言,不限于C++。

总结

本节课中我们一起学习了CUDA的两种核心编程接口:运行时API和驱动API。运行时API提供了更高级的抽象,简化了开发流程,是大多数应用的首选。而驱动API则提供了底层的精细控制能力,适用于需要深度定制或使用非C++语言的特殊场景。开发者应根据项目对控制力与开发效率的需求,在这两者之间做出合适的选择。
024:CUDA驱动与运行时API实验指导 🚀
在本节课中,我们将学习如何完成一个涉及CUDA驱动API和运行时API的编程实验。我们将分步讲解如何编译、链接和执行代码,并最终提交作业。
实验概述
本次实验包含三个主要部分:
- Fat Binary 部分:使用驱动API,将CUDA代码编译为
fatbin格式并执行。 - PTX 部分:同样使用驱动API,但将CUDA代码编译为
PTX中间代码格式并执行。 - 运行时API 部分:使用更简单的CUDA运行时API来执行一个向量加法核函数。
实验的核心在于理解驱动API需要手动管理模块加载和内核启动,而运行时API则将这些过程自动化。
第一部分:驱动API - Fat Binary 作业
上一节我们介绍了实验的整体结构,本节中我们来看看如何完成驱动API的Fat Binary部分。此部分需要手动编译CUDA代码并链接到主机程序。
以下是完成此部分的具体步骤:
- 进入
driver_api文件夹。 - 修改
driver_test.cpp文件,确保其指向正确的fatbin文件。 - 将
.cu文件编译为fatbin文件。编译命令需链接CUDA库。 - 编译
driver_test.cpp文件,生成目标文件driver_test.o。此文件将引用上一步生成的fatbin。 - 再次使用
nvcc编译,将目标文件链接为最终的可执行文件driver_test。 - 执行
./driver_test命令。程序会将输出写入项目文件夹下的output.fatbin.txt文件。
关键概念:在驱动API中,主机程序需要显式地加载编译好的GPU代码模块(如fatbin或PTX)。这通过cuModuleLoad函数实现,代码中需要指定模块文件的路径。

// 在 driver_test.cpp 中,加载模块的代码可能类似这样:
cuModuleLoad(&module, “mat_sum_kernel.fatbin”); // 指向 fatbin 文件
// 或
// cuModuleLoad(&module, “mat_sum_kernel.ptx”); // 指向 PTX 文件
注意事项:请确保所有相关文件(.cu、.cpp、.fatbin)位于同一文件夹中,以避免路径错误。生成的文件(如.o、.fatbin)是二进制文件,无需尝试查看其内容。
第二部分:驱动API - PTX 作业
完成了Fat Binary部分后,我们来看看PTX部分。其流程与第一部分几乎完全相同,主要区别在于编译CUDA代码时使用的标志和生成的文件格式。
以下是此部分与第一部分的区别:
- 在编译
.cu文件时,使用-ptx标志来生成PTX格式的中间代码文件(例如mat_sum_kernel.ptx)。 - 需要修改
driver_test.cpp文件,将cuModuleLoad函数指向新生成的.ptx文件,而非.fatbin文件。 - 执行流程不变,但输出文件将命名为
output.ptx.txt。
核心概念:PTX(Parallel Thread Execution)是一种低级的、类似汇编的中间语言。驱动API可以直接加载并执行PTX代码,这提供了更大的灵活性,但通常fatbin(包含多种架构二进制代码的“胖二进制文件”)是更通用的部署选择。
第三部分:运行时API 作业
上一节我们处理了较为复杂的驱动API,本节中我们来看看更简单的运行时API部分。运行时API封装了许多底层细节,让CUDA编程更加便捷。
以下是完成此部分的步骤:
- 退出
driver_api文件夹,进入runtime_api文件夹。 - 使用
nvcc编译器直接编译CUDA源文件。命令为:nvcc -o vector_add vector_add.cu。-o参数用于指定生成的可执行文件名称。 - 执行生成的可执行文件:
./vector_add。 - 程序将进行向量加法计算,并输出结果到
output.runtime.txt文件。
关键区别:运行时API无需手动加载模块或获取函数指针。核函数直接通过<<<...>>>语法启动,编译器会自动处理背后的模块加载和内核启动配置。
// 运行时API的核函数调用非常简单直接
vectorAdd<<<blocksPerGrid, threadsPerBlock>>>(d_A, d_B, d_C, numElements);
提示:使用-o为可执行文件命名是一个好习惯,可以避免默认生成的a.out文件造成混淆。
作业提交与评分
完成所有三个部分的代码并成功运行后,即可提交作业。
- 在课程界面底部找到“提交作业”按钮。
- 点击提交后,系统会自动处理并评分。
- 你可以查看每部分的评分详情。请注意,不同部分的权重可能不同。
- 通常需要获得总分的75%以上才能通过本次作业。

总结
本节课中我们一起学习了CUDA驱动API与运行时API的实验操作。我们掌握了:
- 使用驱动API时,如何将CUDA代码编译为
fatbin或PTX格式,并修改主机程序来加载和执行它们。 - 使用运行时API时,如何直接编译和运行CUDA程序,其过程更加简洁。
- 实验的完整流程:从代码编译、链接到执行,最后提交作业。

希望你能通过本实验加深对CUDA两种编程接口的理解。
025:目标环境检测与代码语法
概述
在本节课中,我们将学习如何将CUDA代码编译并执行。课程内容分为三个主要部分:首先介绍标准C++示例的编译与执行流程,然后探讨使用CUDA运行时API的完整周期,最后分析更底层的CUDA驱动API的使用方法。通过对比三种方式,您将理解在不同抽象层级上操作GPU代码的异同。
C++示例:编译与执行
上一节我们概述了课程结构,本节中我们来看看一个基础的C++示例代码。
该示例的主函数首先初始化两个数组X和Y,分别赋值为1.0和2.0。随后,它调用add函数对这两个数组中总计一百万个元素进行求和操作。操作完成后,代码会检查更新后的Y数组与预期值(应全部为3.0)之间的最大差异,理论上结果应为0。最后,程序释放X和Y数组所占用的内存并退出。
add函数是一个简单的循环包装,它将两个输入指针x和y所指向数组的前n个值相加,并将每次迭代的结果存回y指针。
核心概念:add函数的所有循环迭代都在单个线程中顺序执行,因此这是一系列非常顺序化的操作。
了解代码后,编译过程就是将源文件传递给交叉编译器。在上述例子中,使用的是GCC编译器(本例中同样可以使用G++),并将输出文件命名为example_code.exe。
执行则更为简单,在Linux系统中,使用点操作符运行当前目录下的可执行文件example_code.exe即可。
CUDA运行时API

上一节我们介绍了标准的C++顺序代码,本节中我们来看看使用CUDA运行时API的代码有何不同。
运行时API的内核代码与普通的C/C++顺序代码非常相似,主要区别在于使用了__global__关键字。
核心概念:__global__关键字指示编译器,该函数应被编译以便在GPU上执行。
要执行运行时API内核,您需要像其他C/C++程序一样,拥有一个main函数。
与任何C++或C程序类似,您需要分配内存。在本例中,需要为六个浮点指针分配内存:三对数组,分别用于主机(CPU)和设备(GPU)。
以下是后续步骤:
- 用数值初始化输入数组。
- 将主机内存中的数据复制到设备(GPU)内存。
- 执行内核。请注意,示例中展示的是最不理想的情况:每次只分配一个包含单个线程的块。通常,选择每个块的线程数至少应为32,并且由于某些架构约束,最好是32的倍数。
- 如果您要解决三维问题(例如地图或视频处理),可能需要根据问题规模确定每个网格中的块数。
- 将内存从设备复制回关联的主机指针。
- 释放所有内存。
这里并未展示所有内存操作,但以上概述能让您了解大致的流程。
CUDA驱动API
上一节我们探讨了运行时API,本节中我们来看看更底层的驱动API,这提供了更强的控制力但也更复杂。
驱动API代码与运行时API示例代码差异不大,您同样可以访问关于线程和块的信息。在本例中,我们明确声明此代码是为C语言开发的。
驱动API代码的复杂之处在于初始化。您需要初始化一系列内容以确保CUDA和驱动API能正确运行,这些操作被封装在下一张幻灯片所示的单个函数中。
与运行时API代码类似,需要分配主机和设备内存。但请注意,所有设备内存都需要指定为CUdeviceptr类型。
启动驱动API代码看起来更像普通函数调用,没有<<< >>>符号,只需传递函数指针、各种块和网格大小、内存指针以及输入内存的大小,这些参数以一个参数数组的形式传递。
使用完内存后,务必释放主机和GPU上的内存。
初始化CUDA相关属性提供了运行时API所没有的许多控制选项,但也更复杂。您需要确定并设置CUDA设备,这能提供关于CUDA主次版本号、设备名称等更多信息。
获取设备后,需要为设备创建上下文以便代码运行。
随后是一个复杂的过程:从指定路径加载模块(fatbin文件),根据函数名从模块中获取函数,最后将引用赋值给CUfunction变量。
在此,您将传入初始化函数的CUfunction指针与找到的函数连接起来。
是的,这非常复杂。让我重复一遍:您将传入此初始化函数的CUfunction指针与找到的函数相链接,使它们关联在一起。
编译与执行总结
本节课我们一起学习了三种不同的代码执行流程,最后我们来总结它们的编译与执行。
运行时API的编译可以很复杂,但通常与C或C++类似,区别在于您需要使用NVIDIA的nvcc交叉编译器,并将其链接到CUDA驱动程序库。
驱动API的编译分为两步:
- 为CUDA内核生成
fatbin二进制文件。 - 编译可执行文件。
请注意,初始化CUDA的函数需要知道fatbin文件的位置。因此,只有当fatbin文件与可执行文件位于同一目录,且代码中的路径正确时,此方法才有效。当然也存在其他策略,但这是最简单的一种。
驱动API的可执行文件编译过程可以与运行时API相同。


CUDA代码的执行与标准的C或C++程序执行方式相同。
026:CUDA关键字解析 🧠
在本节课中,我们将学习CUDA编程中的核心关键字。这些关键字对于控制代码在何处以及如何执行至关重要,是编写高效GPU程序的基础。我们将它们分为三类:函数执行位置、计算划分以及内存管理。
上一节我们介绍了CUDA关键字的重要性,本节中我们来看看第一类关键字,它们定义了代码的调用和执行位置。
这些关键字是函数签名的一部分,决定了函数是在CPU(主机)上执行,还是在GPU(设备)上执行,或者两者兼有。正确使用它们能确保代码在预期的硬件上运行。
以下是关于函数执行位置的关键字:
__host__: 此关键字修饰的函数在CPU上执行,并且只能从CPU调用。它是C/C++函数的默认行为。__global__: 此关键字修饰的函数(称为内核)在GPU上执行,但从CPU调用。调用时使用特殊的三角括号语法<<<...>>>来配置执行参数。__device__: 此关键字修饰的函数在GPU上执行,并且只能从GPU上的其他函数(通常是__global__或__device__函数)调用。它用于构建GPU内部的层次化代码结构。


了解了代码的执行位置后,我们来看看如何组织GPU上成千上万的线程进行计算。这涉及到第二类关键字,它们描述了计算的划分方式。
当从CPU调用一个 __global__ 内核时,我们需要指定线程的组织结构。这通过调用时的 <<<...>>> 配置来完成。
kernelFunction<<<gridDim, blockDim, sharedMemSize, stream>>>(arguments);
在这个配置中:
gridDim: 定义了网格的维度。网格是线程块(Block)的集合。blockDim: 定义了每个线程块的维度。线程块是线程(Thread)的集合。
以下是关于线程组织的关键概念:
- 线程: GPU上最基本的执行单元。每个线程独立运行内核函数的一份副本。
- 线程块: 线程的集合。块内的线程可以通过共享内存进行通信和同步。一个块通常应包含多个32的倍数(如128、256、512)的线程,以匹配GPU的硬件架构(Warp)。
- 网格: 线程块的集合。网格可以是一维、二维或三维的,这非常适合于处理具有相应维度的数据(如图像、视频或地图数据)。
最后,我们来探讨第三类关键字,它们与内存管理相关。高效地利用不同特性的内存是GPU编程性能优化的关键。
CUDA提供了多种内存空间,关键字用于指示变量应优先存放在哪种内存中。
以下是主要的内存类型及其关联的关键字:
- 寄存器: 速度最快,但容量最小。使用标准的C/C++基本数据类型(如
int,float)定义的局部变量,编译器会尽可能将其放入寄存器。 - 常量内存: 只读,缓存效率高。使用
__constant__关键字修饰的变量会被放入常量内存。 - 共享内存: 块内线程共享,速度极快。使用
__shared__关键字修饰的变量会被放入共享内存。但需要注意,如果声明的共享内存总量超过硬件限制,编译器可能不得不将部分数据放入全局内存。 - 全局内存: GPU上容量最大但延迟最高的内存。使用
__device__关键字修饰的全局变量会驻留在设备全局内存中。这是一个可选关键字,用于明确指示内存位于设备端而非主机端。


本节课中我们一起学习了CUDA编程的三类核心关键字。我们首先了解了控制函数执行位置的 __host__、__global__ 和 __device__ 关键字。接着,我们探讨了用于划分大规模并行计算的网格和线程块概念。最后,我们学习了管理不同内存空间(如寄存器、常量内存、共享内存和全局内存)的关键字。掌握这些关键字是编写正确且高效CUDA程序的基础。
027:简单CUDA实验与作业指导 🧪
在本节课中,我们将学习如何完成一个简单的CUDA编程作业。我们将了解作业的结构、需要修改的关键代码部分,以及如何构建、运行并最终提交作业。
作业结构与目标
首先,我们来快速浏览一下这个CUDA编程作业的结构和关联目标。请注意,有一个与该作业关联的实验,其中包含了所有相同的代码。
以下是完成此作业的三个步骤:
- 在运行前,你需要进行一系列修改。
- 当你认为一切就绪后,需要执行
make clean build命令,并且该命令需要成功完成。 - 接着,执行由上一步构建生成的
simple_dot.exe程序,并将其输出重定向到项目文件夹中的output.txt文件。最后,点击提交按钮。

探索VS Code环境
上一节我们介绍了作业的整体流程,本节中我们来看看具体的开发环境。让我们探索一下VS Code环境中有什么。

首先要做的是打开终端,这可能是我们在项目文件夹中最强大的工具之一。除了VS Code的一些元数据,这里还有一个 Makefile。请注意,许多文件被故意进行了轻微的混淆或破坏,以挑战你完成它们的能力。
例如 Makefile 文件。虽然其中大部分内容编写正确,但你需要使用的编译器在这里只是一个占位符。你必须知道我之前多次提到的、用于编译NVIDIA代码的交叉编译器是什么。
我已经运行并完成了这个作业,所以这里有一个正常情况下不会出现的 .txc 文件。有两个文件很重要:一个是 simple_dot.c,它包含了所有实际的代码,你需要修改的主要部分大多与其中的关键字有关;另一个你需要修改的地方是 headaway_execute_the_kernel 函数。
修改关键代码
这个函数的编写基本符合要求,除了其中使用了 X、Y 和 z 这些变量。此外,类似“网格中的块数”和“替换为块中的线程数”这样的文本提示,非常接近实际的代码行。你只需要复制适当的变量名即可。
同样,在某个地方你需要找到指向浮点基元的指针,并以适当的方式传递它们,以便能够正确执行向量的加法或乘法(本例中是乘法)。
当你完成这些修改后,就可以利用这里的 Makefile 了。修复后,你将能够运行 make clean 命令,该命令会清除之前创建的任何可执行文件。完成后,你就可以执行生成的可执行文件了。
执行方式类似这样,然后你需要将输出重定向到文件,按回车确认。完成后,点击提交按钮,系统会提示提交成功。
但这并不意味着你在这个作业中得了100分,你仍然需要查看提交后的结果。
查看提交结果

好了,我们收到了结果反馈。它显示我有一个100分的正方形,并且说我通过了。由于这是一个非常简单的作业,只有一个部分,你需要获得100%的分数才能通过。
现在让我们看看评分器的输出,它非常简单。它显示我运行了API部分(这是本次作业的唯一部分),并且你的成绩将是100分。


总结

本节课中,我们一起学习了如何完成一个基础的CUDA编程作业。我们了解了从探索开发环境、修改关键代码(特别是内核执行函数和Makefile中的编译器配置),到构建项目、运行测试并最终提交作业的完整流程。记住,成功的关键在于仔细阅读代码中的提示,并正确设置网格、块以及内存指针等核心概念。
028:CUDA IDE编程实践 🛠️
在本节课中,我们将学习用于CUDA开发的集成开发环境(IDE)和文本编辑器。我们将了解不同工具的特点,以及它们如何与构建工具配合,共同构成完整的CUDA代码开发和构建流程。

主流IDE选项 💻
上一节我们介绍了CUDA编程的基础,本节中我们来看看具体的开发工具。前三类IDE选项是CUDA开发中最先进和流行的环境。
以下是三种主要的IDE:
- CLion:这是一个通用的C/C++ IDE,不包含针对CUDA GPU代码调试和分析的特定功能。但它集成了CMake构建工具,并包含一个CUDA项目向导,可以创建包含CMake构建文件的基本CUDA程序结构。该IDE还提供代码补全、通用C/C++格式化等辅助开发功能。
- Eclipse:最初是一个Java IDE,拥有众多变体。其中一个变体通过安装插件,允许对CUDA项目进行分析和调试。该编辑器的一个主要优势是,可以将一个IDE应用于不同语言的不同项目,并且项目视图会根据你的目标自动切换。运行该IDE需要在机器上安装Java。
- Microsoft选项:微软提供两种选择。
- Visual Studio:这是一个功能齐全的IDE,包含许多CUDA开发的集成点,例如通过Nsight插件进行分析和调试。对于基于Windows的机器开发,它可能是最佳选择,因为功能最全面。
- VS Code:这是一个功能强大的通用文本编辑器,通过市场可以提供许多插件,以实现语法辅助、通用内存管理代码快捷方式等功能。它是一个非常灵活的编辑器,将在我们的实验中使用。

文本编辑器的作用 📝
虽然功能不如IDE丰富,但文本编辑器在CUDA开发中扮演着非常重要的角色。

以下是常见的文本编辑器类型:
- 命令行编辑器:根据你的偏好,Vim和/或Emacs是两个非常优秀的Linux命令行代码编辑工具。它们可以使代码替换、语法高亮和项目结构导航等任务变得更简单。
- GUI文本编辑器:Textmate及类似的图形界面文本编辑器适用于大多数操作系统,并且可以支持编辑项目中的各种文件。
文本编辑器本身通常不提供IDE的所有功能,但可以通过插件进行增强。
构建工具 🔨


尽管它们不是代码编辑器,但构建工具与IDE和编辑器结合,为你的CUDA代码构成了完整的开发和构建流程。
构建工具获取项目代码及其自身的配置文件,并根据目标编译代码。

以下是两种常见的构建工具:
- Make:使用一个单独的Makefile,其中包含Bash风格的环境变量声明、基于正则表达式的代码和对象文件识别,以及用于执行从简单到复杂的层次化构建的Bash脚本。
- CMake:这是一个更现代、更程序化的构建工具选项。在示例中,CMake被告知要使用哪个版本的CMake、C++的标准版本、要编译哪些文件以及如何编译代码。


本节课中我们一起学习了CUDA开发可用的各类IDE、文本编辑器以及构建工具。我们了解了CLion、Eclipse和Visual Studio等主流IDE的特点,也认识了文本编辑器在开发中的辅助作用,最后探讨了Make和CMake如何将代码编译成可执行程序。掌握这些工具是进行高效CUDA编程实践的重要基础。
029:CUDA项目结构与最佳实践 🏗️
在本节课中,我们将学习如何创建一个CUDA项目,并探讨如何编写易于理解和维护的代码。编写处理复杂问题的优秀CUDA代码并非易事,如果不考虑一些关键因素,让他人理解你的代码将是一个挑战。我们将从项目结构和最佳实践这两个主要方面入手,它们能有效提升代码的可读性和性能。值得注意的是,许多应采纳的实践普遍适用于所有C或C++项目,因为CUDA代码是这些语言功能的一个子集。
项目结构 📁

上一节我们介绍了项目结构的重要性,本节中我们来看看具体的结构设计。
简单项目的结构通常非常扁平,所有代码和头文件可以直接存放在项目文件夹下。

然而,大型项目可能包含许多不同的配置和支持文件。在所有语言的所有项目中,唯一一个必须包含的文件是README。
README可以是一个简单的文本文件,但更推荐使用Markdown文件,因为它支持更高级的格式,并且Git服务器可以直接解析它们。README至少应包含如何构建和运行代码的文档。

根据你希望使用的构建系统,你需要创建以下文件之一:
- Makefile:如果你使用
make。 - CMakeLists.txt:如果你使用
CMake。在本课程中,我们将主要使用make,因为它在开发构建配置文件时的开销较低。

当你拥有一个真正复杂的项目时,你需要使项目的完整结构能够反映其目的,并勾勒出源代码的模块化。
这意味着源文件、头文件和相关的测试代码应反映在任何导入语句中,并应划分到它们自己的文件夹中。你应该养成记录代码的习惯,既在代码本身中记录,也在单独的文件中记录,例如设计文档、用户文档,以及任何未被构建系统处理的复杂安装说明。
与之前简单项目结构相比,一个主要变化是将源代码和头文件分离到src和include文件夹中。此外,将所有被其他代码文件使用的代码组织到common目录下的文件中是一个好做法。
这样做的一个主要原因是管理循环依赖。如果你有复杂的CMake配置文件,则应将其放在单独的cmake文件夹中,以使主项目文件夹不被非源代码文件污染。
当然,如果你使用make,那么你只需要一个Makefile,它可以放在项目的根目录下。再次强调,始终创建一个README,至少包含如何构建和执行代码的文档。

性能最佳实践 ⚡
理解了项目结构后,我们来看看提升CUDA代码性能的一些核心实践。
强扩展性可以通过阿姆达尔定律来衡量。算法应易于并行化,这应是首要考虑因素。一个例子是将for循环展开,让每个线程执行一部分迭代。
弱扩展性则意味着增加处理器数量不一定能线性提升性能。当你需要多个线程处理相同的数据点时(例如图像滤镜),需要考虑这一点。更多线程意味着更快的性能,但你无法将数据划分到与线程数一一对应。
内存优化可以产生影响,但不同数据类型之间的速度提升差异并非数量级的,而且通常更快的存储器(如寄存器)受限于线程数量和可用资源。因此,使用内存优化使代码运行更快是个好主意,但不要指望它能让糟糕的代码或复杂的算法在常数时间内运行。
一个非常重要的事情是了解分支(如if、do、while等)的代价。这是因为构成一个半线程束(half-warp)的16个线程(或构成线程束的32个线程)会同步执行。当一个线程进入不同的分支时,半线程束中的所有其他线程的运行时间几乎都会翻倍。
因此,应避免随意使用分支。处理此问题的一种方法是预处理信息,使其沿着半线程束的边界对齐。这可以通过操作偶数或奇数数据的代码实现,将它们移动到输入数据的开头或结尾,从而减少这种边界跨越的发生。

开发与测试 🔍

以下是关于代码开发和测试的一些关键实践:
- 性能分析:分析代码是识别内存瓶颈和性能不佳代码段的好方法。
- 持续测试:在整个开发过程中测试代码可以更早地发现错误和其他问题,这可以挽救你的项目。
- 混合编程:如果你无法为所有代码段编写CUDA代码,或者作为完全迁移到CUDA之前的临时解决方案,可以考虑使用像OpenACC这样的编译器,使部分代码能立即使用GPU。它们通常使用
#pragma指令包裹for循环。 - 输出性能指标:最后但同样重要的是,将性能指标作为代码执行的一部分输出,可以显示你的代码在不同情况下的表现以及其工作效果。


总结 📝


本节课中我们一起学习了创建可维护CUDA项目的核心要素。我们首先探讨了从简单到复杂的项目结构设计,强调了模块化、文档化和使用README的重要性。接着,我们深入研究了性能优化的最佳实践,包括理解强扩展与弱扩展、进行内存优化以及避免代价高昂的分支操作。最后,我们介绍了开发过程中的关键实践,如性能分析、持续测试、利用OpenACC进行混合编程以及输出性能指标。遵循这些结构和实践准则,将帮助你编写出更高效、更清晰且更易于协作的CUDA代码。
030:复杂CUDA项目作业解析 🧩
在本节课中,我们将快速解析与复杂CUDA软件项目相关的编程作业。我们将概述完成此作业需要遵循的五个关键步骤,并重点说明代码组织、Makefile配置以及项目构建的核心概念。
作业步骤概述 📋
上一节我们介绍了课程背景,本节中我们来看看完成本次编程作业的具体步骤。以下是完成作业需要执行的五个主要步骤。
- 第一步是处理代码文件。我放入实验的代码几乎完全正确,但我移动了一些文件的位置,包括源文件和头文件。
- 第二步是修改Makefile。你需要修改Makefile,将适当的包含目录作为环境变量,供make命令使用。
- 第三步是构建项目。像之前一样,运行
make clean和make build命令。这将在你当前的项目文件夹根目录下构建一个名为complex的可执行文件。 - 第四步是运行并输出结果。执行
complex这个可执行文件,并将其输出重定向到output.txt文件中。 - 第五步是提交作业。点击提交按钮后,你将看到结果。系统基本上会报告它是否找到了预期的内容。
核心要点与目标 🎯
在了解了具体步骤后,我们需要明确本次作业的核心要点。作业过程中可能会有失败的情况,但你需要知道,本次作业的重点主要在于整合代码的不同部分(如头文件),并理解如何使用Makefile使整个项目正常工作。
我细分了代码结构,使其更清晰,并遵循了一些像谷歌这样的大公司所推行的实践规范。因此,本次作业更多是关于如何在项目中组织代码。虽然项目结构仍然相对简单,但足以让你入门。
总结 📝


本节课中我们一起学习了复杂CUDA项目作业的解析流程。我们概述了从代码处理、Makefile配置、项目构建、运行测试到最终提交的五个关键步骤,并强调了本次作业的核心目标是掌握代码组织与项目构建工具的使用,为后续更复杂的企业级开发实践打下基础。
031:课程介绍与概述 🚀
在本节课中,我们将学习约翰霍普金斯大学GPU编程专项课程的整体结构,并深入了解第一门课程《CUDA并行编程导论》的目标与内容。
大家好,欢迎来到CUDA课程并行编程导论的第一节视频讲座。本视频将聚焦于GPU编程专项课程。我是谁?我是Chancellor Pascal。我在约翰霍普金斯大学怀廷工程学院担任讲师已有十年。我拥有十五年的软件开发经验。高性能计算、云计算、Web开发以及计算机视觉的应用是我的兴趣所在。现在,我想为大家概述一下Coursera平台上的GPU专项课程。

专项课程结构 📚
以下是GPU编程专项课程包含的四门课程。
- 第一门课程:GPU并发编程导论:这是您当前所在的课程,我将在下一张幻灯片中详细描述。
- 第二门课程:CUDA并行编程导论:这门课程专注于NVIDIA GPU和CUDA编程框架提供的基本硬件与软件能力。
- 第三门课程:面向企业的规模化CUDA:这门课程深入探讨CUDA更复杂的功能,以及如何将其应用于超越程序员个人机器、扩展到企业级硬件上。
- 第四门课程:CUDA高级库:这门课程向学生介绍CUDA开发工具包中附带的一系列最流行且功能强大的库。
上一节我们了解了整个专项课程的框架,本节中我们来看看您即将开始学习的第一门课程。

第一门课程:《CUDA并行编程导论》详解 🎯
那么,我将如何描述《CUDA并行编程导论》这门课程呢?我们将帮助您,也就是学生,准备好开发能够处理大量数据的代码。我们谈论的是数十万甚至数百万个数据点。您将开发的GPU(图形处理器)代码,将使用CUDA框架,在从消费级到企业级的NVIDIA GPU上解决复杂问题。此外,在理解线程的同时,您还将对各种形式的内存有更深入的理解。
接下来,让我们明确本课程的学习目标。


课程核心目标 🎯
以下是本课程结束时您将能够掌握的核心技能。
- 分解复杂问题:您将能够将复杂的线性和多维问题分解成内核(kernel),这些内核可以在任一时刻在多达数千个线程上执行。
- 管理数据传递:您将在CPU(主机)和GPU(设备)内存之间传输数据。
- 利用GPU特定内存:您将学习利用GPU上的共享内存(shared memory) 和常量内存(constant memory)。这允许在线程和线程块之间传输静态和动态数据,旨在提升线程间的数据一致性以获得更好的性能。
- 使用寄存器内存:您将为数据的小子集使用寄存器内存(register memory)。

本节课中,我们一起学习了GPU编程专项课程的整体规划,并详细探讨了第一门课程《CUDA并行编程导论》的定位与具体学习目标。希望本次介绍能让您开始思考自己在本课程乃至整个GPU专项课程中希望达成的目标。
032:课程预期目标 🎯
在本节课中,我们将了解《使用CUDA进行并行编程》课程的整体结构、学习材料、时间投入以及技术能力要求。
课程材料与结构 📚
上一节我们介绍了课程概述,本节中我们来看看课程的具体安排。从材料角度看,每个模块将包含4到7节课,每节课配有视频、测验等内容。
以下是课程材料的主要形式:
- 视频材料将以幻灯片或屏幕录制的形式呈现。
- 带有旁白的幻灯片视频将同时提供PowerPoint幻灯片文件。
- 对于包含屏幕录制内容的视频,相关的操作命令也会提供。
时间投入与学习活动 ⏱️
从时间角度看,学生每周需要投入以下内容:
- 观看20到30分钟的视频。
- 完成15到30分钟的非计分作业,例如实验和讨论。
- 完成30到60分钟的计分作业,形式为编程作业和测验。
每节课将包含讲座视频和技术应用视频,这些是深入理解课程主题所必需的,时长通常在5到7分钟之间。

以下是不同类型学习活动的具体时间预估:
- 实验活动:允许学生在实际环境中探索技术主题,通常需要10到15分钟来掌握其核心内容。
- 测验:用于评估学生对理论或特定技术知识的掌握,每个模块的测验预计需要3到5分钟完成。
- 编程作业:对于每个模块,学生应预留15到30分钟来完成编程作业。

技术能力要求 💻
现在,让我们深入了解课程对技术能力的具体期望。你需要理解如何利用硬件和软件的约束与能力细节来优化程序。
以下是课程的核心技术目标:
- 你需要能够使用C++语言编写至少简单的CUDA代码。
- 我将提供一个基础的软件项目,在完成作业时,你需要识别在哪里放置你的代码,以及如何最高效地分配算法。
- 最后,基于你自己的硬件或Coursera实验平台的目标CPU和GPU,你将开发出能够以最优方式利用NVIDIA GPU的软件。
为了挑战和评估学生对技术主题的理解,大多数模块将使用编程实验活动和作业。
协作与评估 🤝
为了鼓励协作和分享想法与经验,讨论将是每个模块的一部分,学生应将其视为学习过程中不可或缺的环节。许多模块会包含测验,以评估学生对讲座材料的掌握情况。

本节课中我们一起学习了《使用CUDA进行并行编程》课程的整体框架。我们明确了课程提供的材料形式、需要投入的时间分布、以及需要掌握的核心技术能力,包括编写CUDA C++代码和优化GPU程序。准备好开始你的并行编程之旅了吗?
033:Coursera实验作业环境指南 🖥️
在本节课中,我们将学习如何使用Coursera的实验和作业环境进行CUDA编程。我们将详细介绍从打开环境到提交作业的完整流程。
概述
Coursera的GPU编程课程实验和作业开发流程包含六个核心步骤。我们将逐一讲解每个步骤的具体操作和注意事项。
开发流程详解
第一步:打开Coursera实验环境
首先,你需要在浏览器中打开Coursera的实验环境,该环境集成了VS Code编辑器。
第二步:导航并打开项目目录

接下来,你需要在VS Code中打开与实验或作业相关的项目目录。以下是具体操作步骤:
如果编辑器页面顶部没有显示模块编号和名称,你需要从菜单栏点击“文件”选项。在下拉菜单中选择“打开”,这将调出“打开文件或文件夹”对话框。
在对话框中,你可以通过点击“..”来向上导航目录结构。对于本课程的项目,你需要导航至 home/coder/project/module1 目录。
找到所需文件夹后,点击“打开”按钮。该文件夹将在编辑器左侧打开,其中包含开始当前模块实验或作业所需的所有文件。
第三步:阅读说明文件
打开项目后,你应该首先阅读 README.md 文本文件。该文件包含背景材料和一系列作业说明。
此外,你可以查看 run.sh 文件,这是一个用于执行作业所有步骤的脚本。Makefile 文件则用于清理、构建和运行你的代码。通常你不需要修改这两个文件,只需修改代码及其他相关文件。
第四步:编辑配置文件与代码
如果你正在进行作业,接下来需要确保 .user 文件中的用户名信息正确。用户名可以是任何不包含特殊字符的名称或登录名。
在开始编写作业其余部分的代码之前,另一个需要编辑的地方是你的主代码文件(例如 assignment.cpp)。你需要修改 USER_NAME 字符串常量,使其与你在 .user 文件中设置的用户名一致。
通过编辑这两个文件,你可以确保你的提交满足作业的第一个要求:用户名验证。当你通过 run.sh 脚本或 make 命令执行代码时,需要传递相同的用户名。
第五步:构建与执行代码
提交代码的下一步是点击页面底部的“构建提交(步骤1)”按钮。页面底部将出现一个终端窗口,它会清理、构建并执行你当前的代码,同时将输出写入一个用于作业评分的文件。请注意查看终端中的任何输出信息。
第六步:提交作业
然后,点击同样位于页面底部的“提交作业(步骤2)”按钮。该操作会将上一步生成的文件提交给评分系统。
查看提交结果

提交后,你可以在作业页面查看提交结果。结果在评分器运行完成前会显示为琥珀色。
打开提交结果后,你可以看到分数、是否通过以及任何反馈信息。如果遇到问题,你可以进一步查看日志以获取详细信息。
总结


本节课我们一起学习了Coursera GPU编程课程实验作业的完整操作流程。我们涵盖了从打开浏览器环境、导航项目目录、阅读说明文件、编辑必要配置和代码,到最终构建、执行并提交作业的每一个关键步骤。掌握这个流程是顺利完成后续编程实践的基础。
034:核函数执行原理 🚀
在本节课中,我们将学习GPU的高层架构、CUDA编程中软件与硬件的映射关系,以及核函数的执行原理。理解这些概念是编写高效并行程序的基础。
GPU高层架构 🏗️
上一节我们介绍了GPU编程的基本概念,本节中我们来看看GPU的高层架构。下图展示了Ampere架构的示意图。

在图的中央,被黄色框包围的多个方框代表了流式多处理器,它们是GPU计算的核心。图的左右两侧是内存控制器,它们帮助在GPU的不同类型内存之间移动和存储数据。
图的顶部是GigaThread引擎,它是主要的调度器。顶部和底部的接口分别是:顶部的通用IO总线,由CPU和GPU共享,数据在它们之间传输;底部的NVLink高速链接,主要用于加速GPU之间的数据传输。
深入流式多处理器 🔬
现在,让我们放大观察流式多处理器。下图是一个Ampere架构GPU中SM的示例。



在这个SM中,你会看到四个线程束。每个线程束包含:
- 一个L0指令缓存。
- 一个调度器。
- 一个寄存器集合,称为寄存器文件。
- 两个半线程束:一个能执行浮点或32位整数运算,另一个专用于32位浮点运算。它们具备加载和存储能力。
- 一个专属于该线程束的张量核心。
- 使用L1数据缓存作为共享内存的能力,我们稍后会详细讨论。
- 四个纹理核心。
- 一个光线追踪核心。纹理核心和光线追踪核心主要专用于图形处理。
软件与硬件的映射 🗺️
理解CUDA编程中软件与硬件的映射关系至关重要。以下是核心的映射概念:
- 线程:最小的计算单元,相当于一个核心。回想一下,在流式多处理器中提到的两个半线程束,分别执行浮点和整数运算,其中的每个执行单元就是一个核心。
- 线程块:一组线程的集合。通常,一个线程束包含32个核心,对应CUDA流式多处理器中的一个或多个分区。
- 网格:包含多个线程块。网格可以非常大,可以在同一个GPU上或跨多个GPU上重复执行。

下图直观地展示了这种映射关系。

核函数执行 🚦
现在,让我们运用所学的硬件、软件及其映射知识,来看看它们如何应用于核函数的执行。
一个重要的符号是 <<< >>>。这个符号告诉调度器和编译器,传入其中的参数描述了如何在GPU上划分工作。这是你在外部控制工作分配的主要方式。
以下是核函数启动配置中可以指定的参数:
- 网格与块维度:你可以指定网格中的块数量以及每个块中的线程数量。这两者的乘积通常与你处理的数据量相关。
dimGrid和dimBlock实际上可以指定为三维对象,以描述在三个维度上如何划分工作。我们将在后续课程中详细讨论。 - 共享内存:指定你打算使用多少硬件中存在的共享内存。
- 流:指定一个流,用于发送数据和事件,以实现更交互式的内核执行。
当然,你还需要传递核函数本身的参数,这些通常是进出核函数的数据。
核函数本身的定义遵循特定格式:
- 它以
__global__关键字开头(__是两个下划线符号)。 - 它不返回任何值,因此返回类型是
void。 - 然后是核函数名称及其输入参数。
在下面的示例中,核函数通过简单的索引计算,获取二维线程和块空间中的 i 和 j 坐标(即x和y坐标),从而映射到两个输入矩阵或数组中,将它们的值相加,然后将结果放入一个二维矩阵或数组中。

总结 📝

本节课中,我们一起学习了GPU的高层架构,包括流式多处理器和内存控制器等核心组件。我们深入探讨了CUDA编程模型中线程、线程块和网格与GPU硬件核心、SM之间的映射关系。最后,我们详细解析了核函数的执行原理,包括启动配置语法 <<< >>> 和核函数定义的关键要素。理解这些基本原理是后续进行高效并行GPU编程的基石。
035:分治法与GPU算法设计
在本节课中,我们将学习如何将一个为CPU精心调优的分治算法,转换为基于CUDA GPU的算法。我们将以归并排序为例,详细解析其CPU实现原理,并探讨如何将其核心思想迁移到GPU上,利用其并行计算能力。
概述:从CPU归并排序说起
上一节我们介绍了分治法的基本思想。本节中,我们来看看另一个经典的分治算法——归并排序。
归并排序的目标是得到一个有序的数组。其核心操作是递归地将输入数组对半拆分,直到每个子数组只包含一个元素(即叶子节点),然后通过合并有序子数组的方式,自底向上地构建出完整的有序数组。
CPU归并排序的工作原理
以下是CPU上单线程归并排序的典型步骤:
- 递归分解:算法首先创建一个递归调用树,将给定的输入数组不断对半拆分,直到每个子数组只包含一个元素。此时到达叶子节点,递归过程停止。
- 回溯与合并:递归调用开始返回。每个调用接收两个已排序的子数组,并将它们合并成一个更大的有序数组。这个过程逐层向上进行,直到回溯到最初的调用,此时整个原始数组已完成排序。
虽然这不是最高效的排序算法,但在单线程CPU上,它是一种非常有效的算法。
核心:合并步骤详解
从前面的图表中,最重要的步骤是“合并”。理解这一点至关重要。
可以想象,在整个递归过程中,算法操作的是同一个原始数组,并非在每一步都创建全新的子数组副本。算法通过指针来标记当前处理的数组片段的起始和结束位置。

以下是合并两个有序子数组 L 和 M 的具体过程:

- 创建临时数组
L和M(在实际优化实现中,可能通过指针操作避免完全复制)。 - 使用指针指向
L和M中当前最小的元素(因为L和M各自已排序)。 - 始终比较
L和M的当前最小元素。 - 将两者中较小的一个放入输出数组的正确位置。
- 当一个子数组(如
L)的所有元素都处理完毕后,将另一个子数组(M)的剩余元素全部按序填入输出数组的剩余位置。
这个合并操作在递归回溯的每一层都会被调用,它接收两个已排序的数组片段,并将它们合并成一个更大的有序片段,最终在栈顶完成整个数组的排序。
过渡到GPU:CUDA归并排序
与CPU上的归并类似,CUDA也提供了合并函数。需要注意的是,GPU上的合并操作不仅要处理单个元素(如线性搜索),还必须能够合并整个子数组。
CUDA合并函数的输入通常包括:
source array:内核执行开始时源数据的数组。destination array:用于存放合并结果的数组(因为GPU上常采用异地操作)。start,middle,end:定义了在源数组中需要合并的片段的起始、中间和结束位置。
其工作原理是遍历指定的片段(从 start 到 middle 和从 middle 到 end 这两部分),通过比较和交换,将有序结果输出到目标数组。这是一种“原地修改”思想的变化形式,只不过输出被放在了另一个数组中。
CPU与GPU实现的关键差异
CUDA归并排序在功能上与CPU版本相似,它也为GPU底层的合并操作设置起始、中间和结束位置。
但两者存在显著差异:
- 执行粒度:CPU版本通常处理大小相等的切片,并且一次只执行一个合并操作。
- 并行策略:GPU版本则需要考虑数据总量、线程数量以及偏移宽度。它会通过一个循环,动态决定如何将数组划分成多个小块交给大量线程并行合并。例如,如果数据量小但线程多,可能每个合并操作只分配一个线程,这在一定程度上是高效的。然而,在算法的某个阶段,它总是需要跨多个数据子集进行合并,这涉及到线程间的协作与同步。

总结

本节课中,我们一起学习了如何将CPU上的分治算法——特别是归并排序——的思路迁移到GPU编程中。我们剖析了CPU归并排序的递归分解与合并过程,并重点探讨了在CUDA环境下实现归并排序的关键考虑,包括合并函数的输入输出、以及GPU大规模并行执行模型带来的算法设计差异。理解这些核心概念,是将传统算法高效移植到GPU并行架构的重要基础。
036:实验总览教程 🚀

在本节课中,我们将一起学习约翰霍普金斯大学GPU编程课程第二周的编程实验。我们将详细解析实验代码的结构、核心函数的功能以及整个程序的执行流程。通过本教程,你将理解一个基础的CUDA搜索程序是如何工作的。
实验环境与构建 🛠️
首先,我们来看如何构建和运行实验代码。所有操作都通过终端完成。
以下是构建和运行代码的基本步骤:
- 使用
make命令编译代码。 - 使用
make clean命令清理编译文件。 - 使用
make run命令运行编译好的程序。
构建过程可能需要一些时间,并且如果源代码存在问题,可能会失败。成功运行后,你会看到一个名为 search.exe 的可执行文件。运行程序,你将看到一行输出,其中包含输入数据、搜索值以及是否找到该值的索引(如果找到的话)。
核心内核函数解析 ⚙️
上一节我们介绍了如何构建和运行程序,本节中我们来看看程序的核心——CUDA内核函数。
内核函数 search 定义在 search.cu 文件中。它的主要功能是在一个数组中并行地搜索特定值。
以下是该内核函数的伪代码描述:
__global__ void search(int *d_data, int *d_found_index, int search_value, int num_elements) {
// 1. 计算当前线程的全局索引
int tid = blockIdx.x * blockDim.x + threadIdx.x;
// 2. 边界检查:确保索引不越界
if (tid >= num_elements) return;
// 3. 从全局内存读取数据到线程私有变量
int my_element = d_data[tid];
// 4. 执行比较:检查当前元素是否为搜索值
if (my_element == search_value) {
// 5. 如果找到,将当前线程的索引写入结果位置
// 注意:如果搜索值出现多次,此位置会被多次覆盖
d_found_index[0] = tid;
}
}
这个内核函数展示了CUDA编程的基本模式:由大量线程并行执行相同的指令,每个线程处理数据的不同部分。
主机端辅助函数概览 🔧
理解了核心的内核函数后,我们来看看主机(CPU)端有哪些辅助函数来支持内核的执行。
以下是主机端主要辅助函数的列表及其简要说明:
allocate_random_memory:此函数负责生成随机数,为搜索内核准备输入数据。read_csv:此函数从CSV文件中读取输入数据。在本次实验中,你通常不需要修改它。allocate_device_memory:此函数在GPU设备上分配内存。实验不要求修改此函数,但你可以尝试探索其实现。copy_from_host_to_device:顾名思义,此函数使用cudaMemcpy将数据从主机内存(h_*)复制到设备内存(d_*)。这个函数非常稳定。execute_kernel:此函数是调用搜索内核的封装器。它负责计算每个块的线程数(threadsPerBlock)和网格的块数(blocksPerGrid),并处理可能出现的错误。copy_from_device_to_host:此函数功能简单,负责将搜索结果从设备内存(d_found_index)复制回主机内存(h_found_index)。deallocate_memory和cleanup_device:这两个函数负责释放内存和清理设备资源,你通常不需要关心它们。
这些函数共同搭建了主机与设备之间的桥梁,管理着数据传输和内核启动。
主程序执行流程 📋
最后,我们来梳理整个程序的入口——main 函数的执行流程。它协调了前面介绍的所有步骤。
main 函数首先解析命令行参数,以确定以下配置:
- 是否传入了输入数据文件。
- 是否需要对输入数据进行排序。
- 需要设定每个块的线程数,从而间接决定了处理的数据量。
- 需要传入要搜索的目标值。
解析完参数后,程序进入核心执行阶段:
- 复制主机数据到设备:调用
copy_from_host_to_device函数。 - 执行内核:调用
execute_kernel函数启动GPU上的并行搜索。 - 复制设备内存到主机:调用
copy_from_device_to_host函数获取搜索结果。 - 输出结果:将找到的索引(或未找到的提示)打印到终端。
总结 🎯

本节课中我们一起学习了GPU编程第二周实验的完整结构。我们从如何构建项目开始,深入分析了在GPU上并行执行搜索任务的核心内核函数 search。接着,我们概述了主机端一系列辅助函数的作用,它们负责内存管理、数据传输和内核启动。最后,我们梳理了 main 函数的执行流程,它像指挥家一样将各个部分有序地组合起来,完成从参数解析到结果输出的全过程。通过这个实验,你能够直观地理解一个典型CUDA程序的骨架和工作原理。
037:随机数据搜索作业指南 🎯
在本节课中,我们将学习如何完成第一个编程作业。这个作业的核心任务是:在随机生成的数据中搜索特定值。我们将使用一个已搭建好的程序框架,它能够接收不同的命令行参数来控制数据生成与搜索行为。
作业概述与要求 📋
现在,让我们开始第一个编程作业。在这个作业中,你将在一组随机数据中进行搜索,这与我们在实验课中看到的内容类似。
本作业的一个主要特点是,我构建了一个程序结构,它可以接收不同的命令行参数,用于指定:
- 数据是否需要排序。
- 将要创建的数据元素数量。
- 要搜索的目标值。
- 输入文件的文件名(如果使用文件输入)。
- 当前作业部分的ID(你通常不需要修改此项,因为它由评分工具使用)。
- 每个线程块中的线程数。
请注意,数据元素的数量应始终大于每个线程块的线程数。并非所有参数都必须提供,关于命令行工具的使用方法,在你编译程序后会进行讨论。
代码修改与调试指南 🔧
上一节我们了解了作业的整体要求,本节中我们来看看具体的代码修改和调试步骤。
不要修改任何打印语句。程序会输出输入数据、搜索值以及是否找到目标值等信息。我使用了预定义的数据,这就是为什么会有输入文件参数。在调试时,你可以随意添加额外的打印语句,但在完成后请确保将其移除。

你需要做的是进入项目文件夹(我们稍后会在编辑器中查看),然后修改搜索内核函数。之后,运行典型的 make clean 和 make build 命令来编译程序。编译完成后,你就可以根据指南使用这个工具了。
操作流程详解 🖥️
了解了代码修改要点后,接下来我们看看完整的操作流程。
你应该能在界面底部看到三个按钮:
- 构建代码:运行
make clean build。这需要一些时间,请等待其完成。 - 运行并记录:运行你的代码。该操作的第一步也会执行
make clean build,因此耗时相同。之后程序会运行,你会注意到生成了一系列以“output-”和作业部分ID命名的输出文件。这些文件包含了不同的数据输入、搜索值以及搜索结果。同时请注意,程序使用了不同的命令行参数组合。 - 提交:当你完成后,可以点击此按钮提交。
搜索算法实现示例 💡
在开始动手之前,我们先来看一个搜索算法的简单示例,这与你们之前所做的没有太大不同。
以下是实现搜索算法的主要部分。大部分其他代码(如分配设备内存和主机内存、在两者之间复制数据、将内容输出到文件等)已经为你开发好了。你不需要修改这些部分。如果你想探索一下,可以自由尝试,但请确保输出格式与要求一致:即显示输入数据(来自文件或随机生成)、搜索值以及目标值在输入中的位置。这些输出会被自动检查,因此不要随意填写数字。程序执行时使用的值也会变化。
// 搜索内核函数示例
__global__ void searchKernel(int* data, int searchValue, int* result, int N) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < N && data[idx] == searchValue) {
*result = idx; // 找到目标值,记录其索引
}
}
提交与评分 📤
最后,当你点击提交按钮后,系统会提交本次作业的五个不同部分。这些部分是独立评估的,你的最终成绩是各部分权重得分的总和。如果你没有完成某一部分,也可以执行提交,然后在提交记录中查看你的成绩。



本节课总结:在本节课中,我们一起学习了如何完成随机数据搜索编程作业。我们了解了作业要求、需要修改的代码部分(主要是搜索内核)、通过界面按钮进行构建、运行和提交的完整流程,并预览了一个简单的搜索算法实现示例。记住,确保输出格式正确,并利用好提供的框架代码是成功完成作业的关键。
038:线程与线程块概念解析 🧵
在本节课中,我们将学习如何确定在GPU上执行的线程块(Block)中的线程(Thread)数量。我们将从最简单的一维布局开始,逐步深入到二维和三维布局,并理解如何计算每个线程在数据中的索引。
一维线程块布局
上一节我们介绍了GPU编程的基本概念,本节中我们来看看最简单的情况:一维线程块布局。
在主机(Host)代码中,我们定义了一个全局变量 N,其值为 1618。这个变量在主机和设备(Device)代码中都可用,用于定义输入输出数据的大小并限制对数据的越界访问。
以下是如何定义一个仅包含一个块、每个块有32个线程的内核启动方式:
addKernel<<<1, 32>>>(A, B, C);
其中,<<<1, 32>>> 中的 1 表示网格(Grid)中有一个线程块,32 表示每个线程块包含32个线程。
设备内核函数使用 __global__ 关键字标识,它接收三个整数数组 A、B 和 C。为了获取输入和输出数组的索引,我们首先需要处理所在线程块的偏移量。
由于我们只有一个块,块索引 blockIdx.x 始终为0。因此,索引主要由线程在块内的索引 threadIdx.x 决定。计算索引的公式如下:
index = blockIdx.x * blockDim.x + threadIdx.x
在这个一维例子中,blockIdx.x 为0,blockDim.x 为32,所以索引简化为 threadIdx.x。
我们必须始终检查计算出的索引是否在数据边界内(即小于 N,本例中为1618)。然后,内核函数执行计算 C[index] = A[index] + B[index]。
二维线程块布局
理解了线性布局后,现在让我们处理二维线程块布局。
在这种情况下,我们使用 dim3 数据类型来定义二维布局。网格仍然只在X和Y方向上各有一个块,因此块偏移量始终为0。每个块现在由32x32个线程组成。
内核启动方式如下:
dim3 gridSize(1, 1);
dim3 blockSize(32, 32);
matrixMultiplyKernel<<<gridSize, blockSize>>>(A, B, C);
在设备代码中,我们需要计算每个线程在二维数据中的索引。以下是计算行(row)和列(col)索引的方法:
col = blockIdx.x * blockDim.x + threadIdx.x
row = blockIdx.y * blockDim.y + threadIdx.y
由于 blockIdx.x 和 blockIdx.y 均为0,索引再次简化为 threadIdx.x 和 threadIdx.y。
然后,我们需要将二维索引转换为一维数组索引。如果数组宽度为 WIDTH(例如512),则一维索引的计算公式为:
index = row * WIDTH + col
同样,必须检查 row 和 col 是否都小于数据维度 N(本例中为32),以确保索引有效。


三维线程块布局
最后,我们将复杂度提升一点,探讨三维线程块布局。
这里,我们需要将块布局指定为三维的,例如 16 x 16 x 16。网格仍然定义为 1 x 1 x 1,这意味着所有维度的块偏移量始终为0。
主机代码的更改主要在于 dim3 对象的初始化:
dim3 gridSize(1, 1, 1);
dim3 blockSize(16, 16, 16);
kernel3D<<<gridSize, blockSize>>>(A, B, C);
设备代码变得更为复杂。直接分解出X、Y、Z索引并分别加上偏移量的方法虽然可行,但效率较低且不易阅读。更直接的计算一维索引的公式如下:
index = (blockIdx.z * blockDim.z + threadIdx.z) * (blockDim.y * blockDim.x) +
(blockIdx.y * blockDim.y + threadIdx.y) * blockDim.x +
(blockIdx.x * blockDim.x + threadIdx.x)
由于所有 blockIdx 分量均为0,公式可以简化为仅与 threadIdx 和 blockDim 相关。但核心思想是逐层计算偏移:先计算Z平面的偏移,再计算该平面内Y行的偏移,最后加上X方向的偏移。
与之前一样,最后一步是确保计算出的 index 没有超出输入数组的边界(本例中为 16 x 16 x 16)。
总结

本节课中我们一起学习了如何确定GPU内核中线程块和线程的数量。我们从最简单的一维布局开始,了解了如何计算线程索引并进行边界检查。接着,我们探讨了二维布局,学习了如何将二维线程索引映射到一维数据数组。最后,我们分析了更复杂的三维布局,理解了计算三维索引的通用方法。无论维度如何,养成检查索引是否越界的习惯对编写正确、安全的GPU代码至关重要。
039:线程块与网格结构详解 🧩
在本节课中,我们将学习CUDA中线程、线程块和网格的布局结构。我们将从一维布局扩展到更复杂的二维和三维布局,并理解如何编写能够适应不同维度配置的通用内核代码。
上一节我们介绍了线程和线程块的基本概念,本节中我们来看看如何组织多维的线程块和网格。
网格与线程块的布局
在CUDA编程中,线程被组织成线程块,而线程块又被组织成网格。布局可以从一维扩展到三维,甚至在某些情况下可以支持到六维。虽然这听起来可能有些复杂,但请理解,这里展示的是所有可能性,在实际编码中可能只会用到其中的一小部分。我们将简化内容,专注于线程块和网格的布局。
一维线程块与二维网格
首先,我们来看一个简单的例子:线程块采用一维布局,而网格采用二维布局。
- 线程块布局:一维,每个块包含一个线程。
- 网格布局:二维,大小为
32 x 32。 - 数据规模:输入数据最大为512个元素。
需要注意的是,由于网格规模(32*32=1024个线程)大于输入数据规模(512个元素),很可能有一半的线程会访问到输入或输出数组的有效范围之外。虽然示例代码中没有进行检查,但在实际的设备代码中,必须添加边界检查。
在主机代码中,你只需要更改 dim3 类型的网格变量值,并使用相同的线程块配置来调用内核。设备代码中的关键变化在于计算块索引。
以下是计算全局线程索引的核心思路:
- 确定块索引:你需要根据线程在网格中的X和Y维度位置来计算当前线程块在网格中的唯一ID。
- 确定线程索引:在确定了块索引后,再计算线程在其所属块内的局部ID。这个计算方式与之前学过的类似。
这样做的目的是将复杂性转移到块索引的计算上,从而使线程索引的计算保持相对简单。当你使用更多维度时,则需要在两个部分都进行计算。
二维线程块与二维网格

接下来,我们看一个更通用的例子:线程块和网格都采用二维布局。


实际上,即使你声明网格是 1 x 1,它在技术上也是二维的。这里的线程块采用了二维布局。
与之前示例的唯一区别在于:在之前的简单一维线程块例子中,我们默认网格在Y维度的偏移量为0,因此无需考虑。但现在,我们需要显式地计算块在网格二维空间中的位置。
这种实现的优势在于其适应性。即使网格大小发生变化,这个内核依然可以工作,因为块索引是通过相同的通用公式计算的。这个公式与上一张幻灯片中的计算本质相同。
线程索引的计算现在稍微复杂一些,因为我们需要显式地处理X和Y两个维度。而在之前的一维线程块例子中,我们知道Y维度的偏移量始终为0,所以无需担心。
这种为网格和线程块都实现二维布局的方式,使其能够适应 1D x 1D、2D x 2D 或 2D x 1D 等各种布局组合。因此,这段代码实际上更有用,因为你不必为不同维度的实现编写不同的代码。
三维线程块与三维网格
现在,让我们进入最复杂的例子:线程块和网格都采用三维布局。

在主机代码中,你将网格和线程块都定义为 dim3 类型,并指定为三维。例如,网格可以是 2 x 2 x 2,而每个线程块可以是 16 x 16 x 16。

在设备代码中,定义块索引现在需要包含Z维度的索引作为乘数。
以下是计算全局线程索引的通用公式思路:
- 计算块偏移:现在增加了第三个维度(Z),你需要计算块在X、Y、Z三个维度上的整体偏移量。
- 计算线程偏移:你的线程ID使用块ID作为其偏移计算的一部分,然后分别乘以它在X、Y、Z维度上的大小,并加上线程在块内的局部索引。
对应的代码逻辑如下(概念性表示):
// 计算块在网格中的全局起始索引
int blockOffsetX = blockIdx.x * blockDim.x;
int blockOffsetY = blockIdx.y * blockDim.y;
int blockOffsetZ = blockIdx.z * blockDim.z;
// 计算线程在全局网格中的唯一索引
int threadGlobalIdX = blockOffsetX + threadIdx.x;
int threadGlobalIdY = blockOffsetY + threadIdx.y;
int threadGlobalIdZ = blockOffsetZ + threadIdx.z;
// 然后将 threadGlobalIdX, Y, Z 映射到你的数据数组索引
这看起来有些复杂,但你可以习惯它。这样做的好处是极强的适应性:如果你的网格在任何维度上都没有扩展(即大小为1),那么对应的块索引(blockIdx)将为0,这个公式依然成立。因为内核启动时的上下文总是会设置这些维度值(即使为1),所以这段代码可以适应任何维度的数据布局。
你可能需要花时间理解它,或者找到一种对你而言更易读的写法,并在整个编程过程中坚持使用。



总结

本节课中我们一起学习了CUDA中线程块与网格的多维布局结构。我们从简单的一维线程块与二维网格开始,逐步深入到二维和三维的通用布局。核心在于掌握计算全局线程索引的通用方法,即结合块索引和线程索引,并考虑各个维度的规模。通过编写这种具有适应性的内核代码,我们可以用同一段代码灵活处理不同维度的数据布局,而无需为每种情况单独编写代码。记住,始终在内核中添加边界检查,以防止线程访问越界。
040:多维高斯模糊核函数实例 🎬
在本节课中,我们将学习一个具体的GPU编程实例:一个用于视频处理的多维高斯模糊核函数。我们将探讨其设计思路、线程组织方式以及核心算法的实现。
概述
我们将分析一个执行三维高斯模糊的CUDA核函数。其中,三维指的是图像的空间维度(X和Y)以及时间维度(帧序列)。这个核函数会对视频中的每一帧图像进行模糊处理,同时考虑相邻帧的影响,从而实现跨时间的平滑效果。
核函数设计思路
上一节概述了多维高斯模糊的应用场景,本节中我们来看看其具体的设计模式。
核函数处理的是一个三维数据体:两维是图像的X和Y坐标,第三维是时间(视频帧序列)。X和Y维度通过线程块(blocks)和线程(threads)来并行执行,而时间维度则在网格(grid)层面进行处理。
其核心思想是:对于输出视频中的每一个像素点,我们不仅查看当前帧中该点周围3x3区域的像素,还会查看前一帧和后一帧对应位置的3x3区域。这形成了一个3x3x3的“立方体”滑动窗口。为了简化边界条件处理,我们会在输入图像的四周以及视频序列的首尾添加一圈“边框”像素(例如填充为白色或0值)。这样,核函数在访问相邻像素时,就无需额外判断是否越界,尽管边界处的模糊效果会不太理想。

线程与数据空间可视化
理解了设计模式后,我们需要明确线程是如何组织并映射到数据上的。
线程的组织方式如下:
- 在网格(Grid)层面:处理不同的视频帧(Z/时间维度)。
- 在线程块(Block)层面:处理单帧图像中不同区域的像素(X和Y维度)。
- 在线程(Thread)层面:每个线程负责计算输出视频中一个特定像素(在特定帧的特定位置)的新值。
可以想象,每个线程会聚焦于一个小的3x3图像区域,并且这个区域会随着时间(前后帧)延伸,形成一个3x3x3的数据立方体。该线程计算出的新像素值,就是这个立方体中心点的模糊结果。





主机端代码框架
接下来,我们看看启动这个核函数的主机端(CPU)代码需要做哪些准备工作。
主机端代码主要完成数据准备、内存管理和核函数启动任务。以下是关键步骤:
- 定义并加载数据:声明用于存储输入和输出视频数据的三维数组(例如
float input_data[Z][Y][X]),并将视频数据加载到输入数组中。 - 分配设备内存:使用
cudaMalloc在GPU上为输入和输出数据分配显存。 - 拷贝数据至设备:使用
cudaMemcpy将主机内存中的输入视频数据拷贝到GPU显存中。 - 定义执行配置:根据视频分辨率(如1920x1080)和总帧数,计算并定义
dim3类型的网格(grid)和线程块(block)布局,确保能覆盖所有像素。 - 启动核函数:调用多维高斯模糊核函数,并传入定义好的执行配置以及设备内存指针等参数。
- 取回并存储结果:核函数执行完毕后,使用
cudaMemcpy将GPU显存中的输出数据拷贝回主机内存,并保存为处理后的视频文件。
设备端核函数框架
现在,我们将焦点转移到在GPU上实际运行的设备端核函数代码。
核函数本身是三维的。它使用一个3x3x3的掩码(mask)或卷积核,其中中心点的权重最高,随着与中心点距离的增加,权重递减。这个掩码所有值的总和用于计算归一化的加权平均值。
在核函数内部,每个线程需要执行以下操作:
- 确定数据切片:根据线程的全局索引(对应输出图像的某个像素位置),获取输入数据中对应的3x3x3数据立方体。这是通过一个辅助函数(如
get3DSlice)完成的。 - 应用高斯模糊:将获取到的3x3x3数据切片与预定义的高斯权重掩码进行卷积运算。
- 计算输出:对加权求和的结果进行归一化(除以权重总和),得到该像素的模糊后值,并写入输出数组的对应位置。
核心算法伪代码详解
最后,我们来深入理解核函数中两个最核心部分的算法逻辑。
以下是关键方法的伪代码说明:
1. 获取3D数据切片 (get3DSlice)
此方法的目的是根据当前线程的坐标,从输入数据中提取一个3x3x3的立方体。
函数 get3DSlice(当前线程坐标 threadIdx, 输入数据 inputData):
初始化一个 3x3x3 的数组 slice
for dz in [-1, 0, 1]: // 时间维度(前后帧)
for dy in [-1, 0, 1]: // Y维度(上下行)
for dx in [-1, 0, 1]: // X维度(左右列)
计算源坐标 src_x = threadIdx.x + dx
计算源坐标 src_y = threadIdx.y + dy
计算源坐标 src_z = threadIdx.z + dz
将 inputData[src_z][src_y][src_x] 的值存入 slice[dz+1][dy+1][dx+1]
返回 slice
2. 执行3D高斯模糊 (apply3DGaussianBlur)
此方法对获取到的数据切片应用高斯权重并计算加权平均值。
函数 apply3DGaussianBlur(数据切片 dataSlice, 高斯掩码 gaussianMask):
初始化 加权和 weightedSum = 0
初始化 权重总和 totalWeight = 0
for z in 范围(3):
for y in 范围(3):
for x in 范围(3):
像素值 pixelValue = dataSlice[z][y][x]
权重 weight = gaussianMask[z][y][x]
weightedSum += pixelValue * weight
totalWeight += weight
// 计算加权平均值作为输出像素值
输出像素值 = weightedSum / totalWeight
返回 输出像素值
高斯掩码 gaussianMask 是一个3x3x3的数组,其值符合三维高斯分布,中心值最大,向边缘递减。所有权重之和应为1(或另一个常数,用于归一化)。

总结

本节课中,我们一起学习了一个三维高斯模糊CUDA核函数的完整实例。我们从其处理视频数据的应用场景出发,逐步分析了线程在多维空间(X, Y, 时间)的组织映射方式、主机端管理数据和启动核函数的流程,以及设备端核函数获取数据切片并进行加权平均模糊计算的核心算法。通过这个例子,我们看到了如何将经典的图像处理算法(高斯模糊)扩展到时域,并利用GPU的并行架构高效实现。在后续的实践环节中,我们将逐行分析具体的代码实现。
041:图像处理作业指南 🖼️
在本节课中,我们将学习第二个编程作业:使用GPU将彩色图像转换为灰度图像。我们将介绍作业要求、核心算法、评分机制以及代码实现的关键部分。
作业概述
作业要求你读取一张图像,将其RGB值转换为灰度值。最低要求是实现一个简单的平均算法。虽然作业不强制处理视频,但你可以将视频分解为图像序列并应用此过程。
作业结构与评分
以下是作业的常规流程:
- 你需要构建代码,执行
make、clean和run命令。 - 程序会运行生成的可执行文件,并输出一张灰度图像。
评分基于一个“平均差异百分比”的计算。系统会将你的GPU内核实现的结果与一个CPU版本的算法结果进行比较。
具体计算方式如下:
- 对于图像中的每个像素(坐标X和Y),比较GPU结果与CPU结果的差异值(范围0到1)。
- 对所有像素的差异值求平均。
- 将这个平均差异值映射到0-255的范围,并计算百分比误差。
评分标准如下:
- 如果误差在 100% 到 25% 之间,得分为 50%。
- 如果误差在 2.5% 到 0% 之间,得分为 100%。
只需实现简单的平均算法,你就能轻松获得80分。但要获得90到100分,需要调整图像数据在内存中行(Y)与列(X)的读取方式,否则输出图像右侧可能会出现重复的条纹。

核心算法:RGB转灰度
你的提交中最关键的部分是实现 convertToGray 内核函数。该函数接收三个 unsigned char 类型的指针,分别代表红(R)、绿(G)、蓝(B)通道,并输出一个 unsigned char 类型的灰度值。所有值的范围都是0到255。
其核心公式是计算RGB三个通道强度的平均值:
gray = (red + green + blue) / 3
每个GPU线程将执行一个内核,处理其对应位置的RGB值,通过上述平均计算得到该像素的灰度强度。虽然这不是最完美的灰度转换方法,但效果相当不错。
代码框架解析
上一节我们介绍了核心算法,本节中我们来看看提供的代码框架。
代码中已包含其他辅助函数:
- 一个函数用于比较GPU生成的灰度图像与CPU版本的结果。
- 一个函数用于执行内核。
- 内存释放和设备清理工作已处理,你无需担心。
- 命令行参数解析也已完成。
在用于评估的CPU版本(convert_to_gray_cpu)中,代码使用OpenCV读取图像并直接转换为灰度图,以确定目标行数和列数,并填充指针数组。其内存索引方式为:R * rows + C,其中 R 代表行(Y),C 代表列(X)。
主函数的逻辑清晰:
- 读取输入图像。
- 执行你的GPU内核。
- 写入GPU生成的灰度图像。
- 生成用于测试的CPU灰度图像。
- 计算并输出决定你成绩的“缩放平均差异百分比”。
总结


本节课中我们一起学习了第二个编程作业的完整指南。你了解了作业目标是将RGB图像转换为灰度图像,掌握了基于平均算法的核心转换公式 gray = (R+G+B)/3,熟悉了基于“平均差异百分比”的评分机制,并浏览了代码框架中已实现的部分与你需要完成的核心内核。这是一个简单但有趣的图像处理入门实践,祝你编码愉快!
042:NVIDIA GPU设备全局内存 🧠
在本节课中,我们将学习NVIDIA GPU设备上的全局内存。我们将了解其物理布局、不同架构下的容量演变以及内存类型。
全局内存的物理布局
上一节我们介绍了课程主题,本节中我们来看看GPU上全局内存的物理布局。下图展示了三种GPU的全局内存布局。在所有情况下,全局内存都位于示意图的左右两侧或实际显卡上。
图1和图2展示了采用安培架构的GPU及其内存控制器示意图。图3位于右上角,展示了一块实际的GPU。可以看到,共有16个内存控制器,位于示意图的左右两侧。在实际的GPU设备示意图中,通常不会明确标出全局内存,或者仅用通用方框表示。这是因为不同硬件实例的全局内存容量差异很大,厂商不希望为特定架构指定具体容量。

不同架构的全局内存容量

了解了物理布局后,我们来看看不同架构下全局内存的容量范围及其代际演变。大约每两年,CUDA就会推出一个新的架构。以下是各代架构的全局内存容量范围:
- 低端容量:指该架构下GPU的最小全局内存容量。
- 高端容量:指该架构下GPU的最大全局内存容量。
你会注意到,前三个架构的低端和高端容量都相对适中。直到麦克斯韦架构,内存容量出现了大约三倍的增长。这是因为从这个时期开始,出现了专门为服务器或GPU集群设计的GPU。
从那时起,你会观察到每一代架构的容量都在稳步增长,有时甚至每代翻倍,无论是低端还是高端容量。这意味着可用的全局内存越来越多,这允许你的应用程序在每个线程中处理越来越多的数据。请务必充分利用这一点。
需要注意的是,由于我们正处于安培架构的中期,因此未列出其高端容量上限。
内存类型
除了容量,内存类型也是关键因素。下图右侧标注了各代架构使用的内存类型:


以下是主要的内存类型:
- DDR2, DDR3, DDR4, DDR5, GDDR2-GDDR5:这些是不同类型的内存,与你在CPU上看到的内存非常相似。
- HBM / HBM2:这是高带宽内存。当你看到HBM时,这意味着这是专门为GPU设计的内存。它往往速度非常快,但有时带宽较低,意味着在任何时刻通过管道的数据量较少,但传输速度极快。
总结


本节课中,我们一起学习了NVIDIA GPU的全局内存。我们了解了全局内存通常位于GPU芯片的左右两侧,通过多个内存控制器进行管理。我们回顾了从早期架构到现代架构的全局内存容量增长趋势,特别是从麦克斯韦架构开始,为服务器设计带来了容量的大幅提升。最后,我们认识了不同的内存类型,从通用的DDR/GDDR系列到专为GPU设计的高带宽HBM内存。理解这些特性有助于你更好地为应用程序分配和利用GPU内存资源。
043:Linux命令行GPU设备识别 🖥️🔍
在本节课中,我们将学习如何在Linux命令行环境下,使用可用的工具来识别GPU设备的特定功能,其中之一是全局内存的容量与带宽。
识别GPU设备信息 🧐
上一节我们介绍了课程概述,本节中我们来看看具体的命令行工具。nvidia-smi 命令是NVIDIA提供的一个非常实用的工具,用于监控和管理GPU设备。
在不带任何参数的情况下运行 nvidia-smi 命令,它会列出系统中所有的NVIDIA GPU。对于每一块GPU,它会显示诸如内存容量、图形接口时钟频率、流式多处理器温度、处理器利用率等多项信息。


nvidia-smi 命令选项 ⚙️
了解了基本命令后,我们来看看 nvidia-smi 的一些常用选项,它们可以帮助我们获取更具体的信息。
以下是 nvidia-smi 的几个关键命令行选项:
-L或--list-gpus:此选项仅列出GPU及其UUID(通用唯一识别码)。-i或--id:此参数允许你指定一个特定的GPU ID,并只返回与该ID匹配的GPU信息。-q或--query:此选项用于指定你希望获取的统计数据类型,例如内存使用情况、利用率、时钟频率等。


使用 lspci 命令 🔧
除了NVIDIA专属工具,Linux系统还有一个通用的硬件信息查询命令 lspci。
lspci 命令用于列出所有连接到机器的PCI设备信息。这个命令可能并非在所有机器上都默认可用,但它能提供非常详尽的硬件信息。你可以通过管道和 grep 命令来筛选信息,例如使用 lspci | grep -i nvidia 来只显示NVIDIA设备。此外,你还可以选择不同的信息详细程度(verbosity),并为输出信息着色,使其更易于阅读和理解。

总结 📝

本节课中我们一起学习了在Linux命令行下识别GPU设备信息的方法。我们主要介绍了 nvidia-smi 命令及其常用选项(-L, -i, -q),用于获取NVIDIA GPU的详细状态和性能数据。同时,我们也了解了通用的 lspci 命令,它可以用来查看包括GPU在内的所有PCI设备信息,并通过过滤和着色功能使输出更清晰。掌握这些工具是进行GPU编程和系统管理的基础。
044:GPU全局内存研究
在本节课中,我们将学习如何获取GPU设备的标识符,并查明其全局内存容量及其他硬件能力。我们将通过多种工具和数据源来获取这些信息。
获取GPU设备信息
上一节我们介绍了课程目标,本节中我们来看看如何获取GPU的基本信息。一个直接的方法是使用NVIDIA提供的系统管理接口命令。
以下是使用 nvidia-smi 命令获取信息的示例:
nvidia-smi

该命令会输出连接到当前主机的所有NVIDIA GPU信息。在示例中,系统连接了一块GPU。输出信息中,产品名称是一个关键项,例如,它可能显示为“Tesla C2070”。

查阅在线数据库
除了命令行工具,我们还可以借助在线数据库来获取更详细的GPU规格信息。这有助于我们理解设备的硬件限制和潜力。
维基百科列表
维基百科上有一个详尽的NVIDIA GPU列表,涵盖了包括CUDA架构推出之前的历代产品及其相关统计数据。
以下是查找“Tesla C2070”的步骤:
- 访问维基百科的NVIDIA GPU列表页面。
- 在页面中搜索“Tesla C2070”。
- 在搜索结果中,可以找到该显卡的详细规格,例如高亮区域显示其拥有 6 GB的GDDR5显存。
TechPowerUp GPU数据库
另一个数据源是TechPowerUp网站,它提供了一个GPU数据库,可以更直观、便捷地查找特定显卡的所有信息。
该数据库提供的信息包括:
- 处理器规格
- 内存信息(再次确认是6 GB GDDR5)
- 内存带宽和总线宽度
- 关于显卡的各种详细信息
- 甚至提供相对性能比较
这是一个非常有用的工具,可以帮助开发者全面了解其GPU硬件。

总结

本节课中我们一起学习了如何研究GPU的全局内存。我们首先使用 nvidia-smi 命令获取了GPU的设备标识和基础信息。接着,我们介绍了两个重要的在线资源——维基百科的GPU列表和TechPowerUp的GPU数据库——来查询更详细的硬件规格,特别是全局内存的容量和类型。掌握这些信息对于进行GPU编程和优化至关重要。
045:NVIDIA GPU设备检测命令实验 🖥️
在本节课中,我们将学习如何使用NVIDIA系统管理接口命令来检测和获取GPU设备的详细信息。这是进行GPU编程前了解硬件环境的关键步骤。
概述与准备工作
上一节我们介绍了GPU编程的基础概念,本节中我们来看看如何具体地检测和了解你的NVIDIA GPU硬件。

首先,确保你的系统已安装NVIDIA驱动和CUDA工具包。准备工作完成后,打开一个终端窗口。
执行NVIDIA-SMI命令
以下是执行设备检测的核心命令。在终端中输入此命令以获取GPU信息。
nvidia-smi
执行该命令后,系统将打印出关于GPU的重要信息。这些信息对于后续的编程和性能调优至关重要。
解读命令输出信息
命令的输出包含多个部分,每一部分都提供了GPU的不同维度的状态数据。
- 头部信息:显示
nvidia-smi工具的版本、驱动版本以及CUDA版本。 - GPU标识:显示GPU处理器的具体型号(例如Tesla V100)。
- 功耗与温度:提供GPU的当前功耗、最大功耗限制以及当前温度。
- 内存使用情况:显示GPU显存的总量、当前使用量以及剩余量。
- GPU利用率:以百分比形式显示GPU的计算核心利用率。
- 进程信息:列出当前正在使用该GPU的所有进程及其资源占用情况。
总结

本节课中我们一起学习了如何使用nvidia-smi命令来检测NVIDIA GPU设备。我们了解了命令的输出结构,并学会了如何解读关键的硬件信息,如GPU型号、驱动版本、功耗、温度、内存使用率和运行进程。掌握这些信息是进行高效GPU编程和系统管理的基础。
046:主机内存分配机制 🧠
在本节课中,我们将学习如何为主机(CPU)分配内存,这是将数据传输到GPU的第一步。理解不同的内存类型及其分配方式,对于优化GPU程序的性能至关重要。
主机内存类型概述
在CUDA编程中,主机内存主要有四种类型:可分页内存、固定内存、映射内存和统一内存。可分页内存是默认类型。数据通常先在CPU上分配内存,然后传输到固定内存,最后再传输到设备内存。
上一节我们介绍了内存传输的基本流程,本节中我们来看看每种内存类型的具体特性和分配方法。
可分页内存
可分页内存是CPU程序的默认内存分配方式。它的分配速度最慢,因为在数据传输到GPU之前,需要先将数据复制到一个临时的“固定”缓冲区。这会增加一次额外的内存拷贝操作。
以下是分配主机可分页内存的方法,与标准C/C++程序中的做法完全相同:
- 对于指针,使用
malloc命令和size_t类型。基本方法是:将你想要的数组大小乘以你将要使用的原始类型(或结构体等)的大小。 - 对于数组,分配方案更为简化。
固定内存
固定内存(或称页锁定内存)的分配方式可以节省将数据从可分页内存复制到固定缓冲区所花费的额外时间。在分配时直接指定内存为固定内存即可。
以下是固定内存的分配语法:
cudaMallocHost(&pointer, size_in_bytes);
你需要传入想要分配内存的指针地址和内存大小。
映射内存
映射内存基本上将你的GPU内存映射到CPU地址空间上,因此CPU可以直接访问这块内存(实际上内存物理上位于GPU)。其分配语法与固定内存非常相似。
以下是映射内存的分配方法:
cudaHostAlloc(&pointer, size_in_bytes, cudaHostAllocMapped);
这个语法与固定内存分配类似。你需要传入指针地址、内存大小,并使用 cudaHostAllocMapped 标志来将其与其他内存类型区分开。
统一内存
统一内存本质上是在CPU和GPU内存之上的一层抽象。双方都认为访问的是同一块内存,但在底层实际上仍有数据传输发生。这使得编程变得更加容易。
以下是统一内存的分配命令:
cudaMallocManaged(&pointer, size_in_bytes);
同样,你需要传入指针地址和内存大小(例如元素数量乘以类型大小,本例中为 float)。
性能比较
不同内存类型的性能特点总结如下:
- 可分页内存最慢,因为它增加了一次额外的拷贝,内存传输时间较长。
- 统一内存第二慢,因为内核执行速度较慢,所有内存实际上仍存在于CPU端。
- 固定内存和映射内存速度相当且较快,因为它们要么减少了内存传输,要么降低了对内核执行的开销。
结构体与主机内存分配
与主机内存分配相关的一个概念是结构体。结构体与类模板非常相似,但更简单。它们只用于定义对象。
你可以这样定义一个结构体:
struct ImagePixel {
float r;
float g;
float b;
float a;
};
你指定结构体名称(例如 ImagePixel 或 Image),然后定义其属性的名称和类型。
在结构体内部,你也可以包含数组,并可以指定默认大小。这可以使你的代码更加清晰整洁。之后,你可以传递一个指向该结构体的指针,甚至可以将一个结构体数组或指向多个结构体的指针作为程序的一部分进行传递。
关于使用点符号与解引用等细节,你可以参考任何优秀的C或C++教程或课程来更好地理解。


本节课中,我们一起学习了CUDA中四种主要的主机内存类型:可分页内存、固定内存、映射内存和统一内存。我们了解了它们各自的特性、分配语法以及性能差异。同时,我们也简要介绍了如何在内存分配中使用结构体来组织数据。掌握这些知识是进行高效GPU编程和数据传输的基础。
047:设备全局内存分配策略 🧠
在本节课中,我们将学习如何在GPU设备上分配全局内存。我们将介绍核心的分配命令、数据拷贝方法以及不同类型内存(如映射内存和统一内存)的使用策略。理解这些概念对于编写高效的CUDA程序至关重要。
分配设备内存
上一节我们介绍了GPU内存的基本概念,本节中我们来看看如何具体地在设备上分配全局内存。最基础的设备内存分配命令是 cudaMalloc。
其基本语法如下:你传入一个设备指针和所需分配的大小(以字节为单位)。这个大小基于你想要创建的数组大小或输入数据的大小,以及将要填充的数据类型的大小。
- 你可以通过传入指针和基本数据类型的大小来分配基本类型的内存。
- 对于更复杂或更大的结构体指针,你需要传入指针,然后指定该结构体类型的大小乘以你希望填充的实例数量。
核心的分配命令可以用以下代码描述:
cudaError_t cudaMalloc(void** devPtr, size_t size);

数据拷贝命令
分配内存后,下一步通常是在主机(CPU)和设备(GPU)之间传输数据。接下来非常重要的命令是 cudaMemcpy。
这个命令可以双向拷贝信息。对于可分页内存和固定(Pinned)内存,你都需要指定源地址、目标地址以及一个表示拷贝方向的标志。
以下是 cudaMemcpy 的核心用法:
cudaMemcpy(dest, src, size, cudaMemcpyHostToDevice); // 从主机拷贝到设备
cudaMemcpy(dest, src, size, cudaMemcpyDeviceToHost); // 从设备拷贝到主机
参数按此顺序传递。这样可以将内存拷贝到设备,也可以从设备拷贝回主机。
使用映射内存
当使用映射内存(Mapped Memory,或称零拷贝内存)时,流程有所不同。你需要使用 cudaHostGetDevicePointer 函数。
该函数以主机指针作为第二个参数,设备指针作为第一个参数。然后你指定指针的大小,或者你打算在任一方向拷贝的数据量。
这种方法非常方便,因为它减少了一次数据拷贝。你只是直接将数据映射过去,使得操作更简单。
统一内存的优势
当你使用统一内存(Unified Memory)时,你不需要指定任何拷贝操作。设备和CPU都会表现得好像内存是本地的一样(尽管物理上可能不是)。你让CUDA运行时来决定数据的迁移。
这种方式更加简单,但性能可能有所不同。使用不同类型的内存时,需要仔细考虑应用程序的特点、成本以及计算模式。
- 如果有很多处理过程,也许前端或后端的一点额外成本并不重要,这比不断按需拷贝数据的成本要好。
- 或者,如果你有一个复杂的代码库,可能更倾向于使用更简单的方案,比如统一内存。它带来的轻微额外成本可能会被大量的计算分摊,这可能更有意义。
请根据你的具体应用场景仔细权衡。

总结

本节课中我们一起学习了GPU设备全局内存的分配策略。我们掌握了使用 cudaMalloc 进行内存分配,使用 cudaMemcpy 在主机与设备间传输数据,了解了映射内存如何通过 cudaHostGetDevicePointer 简化流程,并认识了统一内存的便利性。关键在于根据应用程序的计算模式、数据访问频率和代码复杂度,来选择最合适的内存管理策略。
048:主机与设备内存分配实验 🧪
在本节课中,我们将学习如何在CUDA编程中进行主机(CPU)与设备(GPU)之间的内存分配、数据传输以及内核执行。我们将通过一个具体的实验代码来理解这些核心步骤。
概述
内存管理是GPU编程的基础。CPU(主机)和GPU(设备)拥有各自独立的内存空间。为了在GPU上执行计算,我们必须:
- 在主机上分配和初始化数据。
- 在设备上分配内存。
- 将数据从主机内存复制到设备内存。
- 在GPU上执行计算内核。
- 将结果从设备内存复制回主机内存。
- 释放所有已分配的内存。

接下来,我们将通过分析实验代码来详细讲解每一步。
实验代码结构
首先,我们来看一下实验项目的构建和运行方式。项目使用一个Makefile来管理编译和清理过程。
以下是Makefile中定义的三个主要目标:
build:编译源代码,生成可执行文件。clean:删除生成的可执行文件。run:在编译后执行生成的可执行文件。
我们可以通过命令 make clean 和 make build 来清理并重新构建项目。
核心函数解析
上一节我们介绍了项目的构建流程,本节中我们来看看实现加法运算的核心CUDA内核函数。
内核函数:addKernel
这个函数在GPU上并行执行,将两个数组A和B的对应元素相加,结果存入数组C。
__global__ void addKernel(int *A, int *B, int *C, int numElements) {
int i = blockDim.x * blockIdx.x + threadIdx.x;
if (i < numElements) {
C[i] = A[i] + B[i];
}
}
代码解释:
__global__声明这是一个在GPU上运行的内核函数。- 参数
A,B,C是指向设备内存中数组的指针。 numElements是数组的元素总数。- 第一行计算当前线程处理的全局索引
i。 if (i < numElements)是一个边界检查,防止线程访问超出数组范围的内存。C[i] = A[i] + B[i];执行实际的加法操作。
主机端辅助函数
内核函数需要在主机代码的驱动下运行。以下是主机端完成准备工作的几个关键函数。
randomAllocHostMemory 函数:在主机(CPU)内存中为数组A、B、C分配空间,并为A和B填充随机数。
allocateDeviceMemory 函数:在设备(GPU)上为数组d_A、d_B、d_C分配内存空间。
copyHostToDevice 函数:使用 cudaMemcpy 函数将主机上A和B数组的数据复制到设备上对应的d_A和d_B中。其核心调用如下:
cudaMemcpy(d_A, A, size, cudaMemcpyHostToDevice);
executeAddKernel 函数:配置并启动GPU内核。它根据用户指定的threadsPerBlock和总的numElements来计算需要的网格大小(blocksPerGrid),然后调用内核。
int blocksPerGrid = (numElements + threadsPerBlock - 1) / threadsPerBlock;
addKernel<<<blocksPerGrid, threadsPerBlock>>>(d_A, d_B, d_C, numElements);
deallocateMemory 和 cleanupDevice 函数:分别用于释放所有已分配的主机和设备内存,以及重置CUDA设备。
parseCommandLineArguments 函数:解析命令行参数,例如使用 -n 指定数组元素数量,使用 -t 指定每个线程块的线程数。
主函数执行流程
了解了各个功能函数后,现在我们来梳理主函数 main 如何将它们串联起来,形成一个完整的工作流。
- 解析参数:首先,解析命令行输入的数组大小(
numElements)和线程块大小(threadsPerBlock)。 - 分配主机内存:调用
randomAllocHostMemory,在主机上为A、B、C分配并初始化内存。 - 打印输入数据:输出
A和B数组的内容,便于调试和验证。 - 分配设备内存:调用
allocateDeviceMemory,在GPU上为d_A、d_B、d_C分配内存。 - 复制数据到设备:调用
copyHostToDevice,将主机数据A和B复制到设备内存d_A和d_B。 - 执行内核:调用
executeAddKernel,在GPU上启动并行加法计算。请注意,数组C在主机分配,但指针d_C指向设备内存,因此内核直接操作设备内存。 - 复制结果回主机:计算完成后,使用
cudaMemcpy将设备上的结果d_C复制回主机内存的C中。cudaMemcpy(C, d_C, size, cudaMemcpyDeviceToHost); - 释放内存:调用
deallocateMemory释放所有主机和设备内存。 - 打印输出结果:输出结果数组
C的内容。 - 清理设备:调用
cleanupDevice进行最终清理。
运行与验证
现在,让我们按照流程构建并运行程序。在终端中执行:
make clean
make build
./cpu_gpu_memory.ex -n 10 -t 256
程序将输出两部分内容:
- 主机输入数据:显示初始生成的10个随机数对(数组A和B)。
- 输出结果:显示计算得到的10个和(数组C),这应该是A和B对应元素相加的结果。
通过对比输入和输出,我们可以验证GPU内核计算是否正确。
总结
本节课中我们一起学习了CUDA编程中主机与设备内存管理的完整流程。我们通过一个具体的向量加法实验,逐步分析了如何:
- 使用
cudaMalloc在设备上分配内存。 - 使用
cudaMemcpy在主机和设备间传输数据。 - 编写和启动一个简单的
__global__内核函数。 - 合理配置线程块和网格维度。
- 使用
cudaFree释放设备内存。

掌握这些步骤是进行更复杂GPU编程的基石。记住,清晰的内存管理对于避免内存泄漏和确保程序正确性至关重要。
049:编程作业指南 🚀
在本节课中,我们将学习如何完成第三周的编程作业。我们将详细讲解作业的各个部分,包括如何填充代码、构建项目、运行测试以及提交作业。通过本教程,你将掌握处理不同类型主机与全局内存分配的基本流程。
作业概述 📋
我们将首先查看内存分配的CUDA文件。你需要更新设备内存分配函数。请注意,文件中会有多处标记为“fill in”的注释,你需要在注释之间填入相应的代码。
接下来,我们将转移到内存复制部分。你需要寻找“从主机复制到设备”的注释,并在指定的位置填入代码。
第一部分:内存分配CUDA文件 🔧
首先,我们来看内存分配的CUDA文件。你需要更新allocate_device_memory函数。文件中会有多处标记为“fill in”的注释,你需要在注释之间填入相应的代码。
以下是需要完成的具体步骤:
- 更新设备内存分配函数:在
allocate_device_memory函数中,找到标记为“fill in”的注释,并在注释之间填入分配设备内存的代码。 - 检查注释位置:确保代码填入在正确的注释之间,避免语法错误。
第二部分:内存复制操作 🔄

上一节我们介绍了内存分配,本节中我们来看看内存复制操作。在内存复制部分,你需要寻找“copy from host to device”的注释,并在指定的位置填入代码。
以下是需要完成的具体步骤:
- 定位复制代码位置:在文件中找到标记为“copy from host to device”的注释。
- 填入复制代码:在注释之间填入从主机内存复制数据到设备内存的代码,例如使用
cudaMemcpy函数。
第三与第四部分:修复映射内存与页锁定内存分配 🛠️
接下来,我们将处理映射内存和页锁定内存的分配。这两个部分需要你修复相应的CUDA文件中的allocate_device_memory函数。
以下是需要完成的具体步骤:
- 修复映射内存分配:在
broken_mapped_memory_allocation.cuda文件中,修复allocate_device_memory函数。 - 修复页锁定内存分配:在
broken_page_pinned_memory_allocation.cuda文件中,同样修复allocate_device_memory函数。
第五部分:运行脚本与Make文件 📁
现在,我们将注意力转向运行脚本和Make文件。run_do_s脚本会执行清理、构建以及运行不同部分的测试。Make文件中包含了项目所需的各种构建和运行目标。
以下是需要了解的关键点:
- Make文件的作用:Make文件定义了清理、构建虚拟输出、生成测试数据等目标。
- 测试数据格式:测试数据以CSV格式提供,包含整数和浮点数。
第六部分:浏览器底部按钮操作 🖱️
最后,我们将介绍如何使用浏览器底部的按钮逐步完成作业。你需要依次构建每个部分,运行测试,然后提交作业。
以下是操作步骤:
- 逐步构建:点击对应按钮,依次构建Part 1到Part 6。
- 运行测试:构建完成后,运行测试以生成输出文件。
- 提交作业:最后,点击提交按钮,完成作业。
总结 🎯

本节课中,我们一起学习了如何完成第三周的GPU编程作业。我们从内存分配开始,逐步讲解了代码填充、内存复制、修复特定内存分配函数、使用Make文件以及通过浏览器界面完成构建、测试和提交的全过程。掌握这些步骤对于顺利完成CUDA编程任务至关重要。
050:NVIDIA GPU共享内存与常量内存 🎮
在本节课中,我们将深入学习NVIDIA GPU设备上的共享内存与常量内存。这两种内存类型对于优化CUDA程序的性能至关重要。
理解NVIDIA GPU架构 🏗️
上一节我们介绍了GPU编程的基本概念,本节中我们来看看NVIDIA GPU的具体架构。为了更好地理解NVIDIA GPU设备,让我们深入分析一些架构图。
在左上角,您可以看到一个熟悉的图表。它展示了NVIDIA GPU卡的整体布局。您可以看到七个不同的流式多处理器(SM),以及不同类型的内存和总线等。
在右侧,您可以看到一个新的图表。它展示了对一个SM(在本例中是SMX)的深入剖析。但从功能上讲,所有版本的SM或流式多处理器在功能上是等效的。
流式多处理器内部结构 🔬
在流式多处理器上,您将看到共享内存或L1缓存。这部分内存由同一线程块内的所有核心和线程共享。
您还会看到,在观察整个设备时,存在常量内存。现在,常量内存的大小通常上限为64KB。但有时,它可以被整合到L2缓存中。
因此,在决定使用多少常量内存之前,请查看您显卡的规格说明。




常量内存与L2缓存的发展 📈
以下是不同架构中与L2缓存相关的发展情况,L2缓存是分配的大部分内存。您会看到它的容量随着时间的推移而增长。
最初,几乎所有的L2缓存,即64KB的限制,都被常量内存分配或可用于常量内存分配。但后来,L2缓存的容量增加了,以至于现在已接近48MB。
请注意,如果存在大量的缓存读取未命中(这些未命中随后必须访问全局内存),那么使用L2缓存可能会产生显著的成本,并使其性能降低到与全局内存相当的水平。
因此,请尝试非常有效地使用您的缓存内存,特别是L2缓存。



共享内存的演变 🔄

共享内存,或L1缓存,并非在每个架构中都一直可用。基于设计决策,每个流式多处理器上的共享内存容量有增有减。
从安培架构开始,您可以看到其容量增加了三倍,但这是超过10年发展的结果。拥有这种缓存确实非常有用,但就像其他所有资源一样,有效地使用它、避免读取未命中,并利用它在线程之间进行通信而不引发竞态条件或类似问题,是非常重要的。

核心概念总结 📝
本节课中我们一起学习了NVIDIA GPU上两种关键的内存类型:
- 共享内存:位于每个流式多处理器内部,由同一线程块内的所有线程共享。其访问速度极快,常用于线程间通信和数据复用。
- 代码示例:在CUDA内核中声明共享内存数组:
__shared__ float s_data[BLOCK_SIZE];
- 代码示例:在CUDA内核中声明共享内存数组:
- 常量内存:位于设备内存中,但通常通过常量缓存(如L2)进行访问,适合存储所有线程只读的常量数据。容量通常较小(如64KB)。
- 代码示例:使用常量内存:
__constant__ float c_coeff[256];并在主机端使用cudaMemcpyToSymbol进行数据拷贝。
- 代码示例:使用常量内存:

理解并合理利用共享内存和常量内存,是优化CUDA内核性能、减少全局内存访问延迟的关键步骤。
051:GPU共享与常量内存研究 🔍

在本节课中,我们将学习如何调查GPU设备上共享内存和常量内存的容量与规格。了解这些信息对于优化CUDA程序的内存使用至关重要。
工具选择:NVIDIA SMI
上一节我们介绍了不同类型的内存。本节中,我们来看看如何获取设备的具体内存信息。调查设备信息最重要的工具是NVIDIA SMI工具。
以下是关于NVIDIA SMI工具的关键点:
- 如果你安装了CUDA开发工具包,你将获得NVIDIA SMI工具。
- 像
lspci或Windows/Mac系统工具不一定总是可用,因此建议尽可能依赖NVIDIA SMI。 - 使用
nvidia-smi命令会生成一个日志,列出所有连接的设备信息,包括产品名称(例如Tesla C2070)。
在线数据库与调优指南
获取设备名称后,你可以借助外部资源查找具体规格。我推荐使用一个特定的在线数据库来查找NVIDIA GPU设备的详细信息。
无论何时,基于你所知的显卡架构代次,查阅相关的调优指南都会非常有帮助。你会发现,NVIDIA会保留大约五到六代产品的调优指南。任何仍在轻度支持的设备都有相应的文档。
这些指南不仅适用于常量或共享内存,它们还提供了大量关于内存使用、不同类型内存间数据传输的信息,几乎涵盖了你为实现代码性能优化所需了解的所有内容。
专用GPU数据库
在之前关于全局内存的讨论中,我提到可以查阅维基百科的NVIDIA GPU设备列表。然而,这对于共享内存或常量内存的调查效果不佳,因为维基百科提供的相关信息很少。
TechPowerUp网站有一个GPU数据库。你可以从顶部开始,选择搜索特定显卡,或者按架构或设备类型浏览。找到你想调查的设备后,你会看到一个非常直观的仪表板,其中显示了关于处理器、各级缓存、常量内存等信息。它还会将该显卡的性能与其他显卡进行比较。因此,如果你需要在多个设备间切换,这个数据库非常有价值,建议你将其收藏备用。


本节课中我们一起学习了如何调查GPU的共享内存和常量内存。我们介绍了使用NVIDIA SMI命令行工具获取基础设备信息,并推荐了查阅官方调优指南和TechPowerUp在线数据库来获取更详细的内存规格与性能数据。掌握这些工具和方法是进行有效GPU内存优化的第一步。
052:GPU共享内存分配技术 🧠
在本节课中,我们将学习GPU编程中一个关键的性能优化工具——共享内存。我们将探讨如何在GPU设备上分配共享内存,理解其两种分配方式,并分析其优势与使用时的注意事项。
编译时分配共享内存
上一节我们介绍了共享内存的基本概念,本节中我们来看看如何具体分配它。第一种情况是在编译时已知内存大小。这种方式非常简单直接。
以下是静态分配共享内存的语法:
__shared__ T array_name[array_size];
在这个声明中,__shared__ 关键字用于指定共享内存,T 是数据类型,array_name 是数组名称,array_size 是编译时已知的常量大小。
运行时分配共享内存
然而,并非所有情况都能在编译时确定内存大小。如果共享内存的大小需要在运行时根据输入参数(例如块ID或变量)动态决定,我们就需要使用动态分配。


动态分配共享内存的语法与静态分配类似,但有关键区别:
extern __shared__ T array_name[];
这里使用了 extern 关键字,并且不指定数组大小。数组的实际大小将在启动内核时,通过第三个执行配置参数来指定。
共享内存的优势与挑战
共享内存是优化内核性能的宝贵工具。了解其特性对于有效使用至关重要。
以下是共享内存的主要优势:
- 速度更快:在理想情况下(没有读取缺失),共享内存的访问速度远快于全局内存。
- 线程间通信:它允许同一个线程块内的线程进行通信和协作,例如用于存储中间计算结果。
然而,使用共享内存也带来一个关键挑战。虽然代码在逻辑上是顺序执行的,但实际运行时,线程的执行速度可能因任务不同而各异。
为了保证数据一致性,必须在关键操作后使用同步屏障:
__syncthreads();
这个函数会创建一个屏障,确保块内的所有线程都执行到此点并完成相应操作后,才能继续执行后续指令。这可以防止在数据未准备就绪时发生读写冲突。
总结

本节课中我们一起学习了GPU共享内存的分配技术。我们掌握了在编译时使用 __shared__ T array[size] 进行静态分配,以及在运行时使用 extern __shared__ T array[] 进行动态分配的方法。我们认识到共享内存具有访问速度快和便于线程块内通信的优点,但也必须谨慎使用 __syncthreads() 来同步线程,以确保数据的正确性和程序的稳定性。正确使用共享内存是提升GPU内核性能的关键步骤之一。
053:GPU常量内存分配方法 🧠
在本节课中,我们将学习GPU常量内存(Constant Memory)的概念、分配方法以及如何将数据从主机(Host)传输到设备(Device)的常量内存中。常量内存是一种特殊的内存类型,其内容在GPU内核执行期间保持不变,所有线程都可以访问相同的值。
常量内存概述
上一节我们介绍了共享内存,本节中我们来看看常量内存。常量内存,顾名思义,其内容在定义后不会改变。一旦在GPU设备级别定义了常量内存,所有线程都可以访问这些相同的值。常量内存通常最大为64KB。
与共享内存不同,常量内存的分配和数据传输需要更多考虑。共享内存仅在设备上分配和使用,而常量内存需要在主机和设备之间进行数据传输。
常量内存的特性与分配
常量内存的分配不是在GPU内核内部进行的,甚至不是在特定函数内部定义或初始化的。这意味着常量变量通常被放置在C文件的顶部或函数上下文之外。它们只在某个函数内部被设置一次。
虽然常量内存的访问速度可能比全局内存快,甚至可以达到与寄存器内存相同的速度,但它可能因缓存未命中而变慢。因此,必须确保指定的常量内存大小在允许的最大64KB范围内。
以下是常量内存的关键特性列表:
- 不变性:在GPU内核执行期间,其内容保持不变。
- 全局可访问性:所有线程都可以访问相同的常量值。
- 容量限制:通常最大为64KB。
- 文件级作用域:在C文件级别(函数外部)声明和定义。
- 性能考虑:访问速度快,但需注意缓存未命中问题。

常量内存的使用方法
当你想使用常量内存时,需要使用 __constant__ 关键字来声明它。这必须在文件级别完成,而不是在函数内部。
__constant__ float constArray[1024];
然后,使用 cudaMemcpyToSymbol 函数将主机数据复制到设备的常量内存中。你需要传递设备上常量内存的指针、主机内存指针、数据大小、偏移量以及内存复制类型。
cudaMemcpyToSymbol(constArray, hostDataPtr, dataSize, 0, cudaMemcpyHostToDevice);
内存复制类型应始终为 cudaMemcpyHostToDevice。这是因为内存是常量,在内核中无法修改,因此永远不需要甚至不可能使用 cudaMemcpyDeviceToHost。
注意事项与最佳实践
在CPU上,常量内存使用 const 关键字。虽然技术上可以在CPU和GPU常量内存之间进行复制,但不要混淆它们。为了使代码更清晰,许多开发者会为设备的常量内存使用 __device__ 关键字(尽管 __constant__ 已隐含了设备属性)。
常量内存是一个非常强大的工具,当你拥有预定义的常量输入时应该使用它。例如,用于图像模糊的卷积核,或者以相同值对图像进行增亮或变暗的系数等。
在编写代码时,请将常量内存的使用记在心中,因为它可以加速程序执行,并使代码更简洁。
总结

本节课中我们一起学习了GPU常量内存。我们了解了常量内存不变和全局可访问的核心特性,其最大容量通常为64KB。我们掌握了使用 __constant__ 关键字在文件级别声明常量内存,以及使用 cudaMemcpyToSymbol 函数将数据从主机传输到设备常量内存的方法。最后,我们讨论了常量内存的适用场景,如图像处理中的固定卷积核或调整参数,正确使用它可以有效提升程序性能。
054:共享与常量内存图像处理实验教程 🖼️
在本节课中,我们将学习第四周的实验内容,具体是关于在图像处理中应用共享内存和常量内存的实验。我们将通过构建代码、执行程序并分析关键函数来理解如何在CUDA编程中高效地使用这些内存类型。

实验概述与构建
首先,我们来看实验的基本操作。你需要运行 make clean build 来构建你的代码,然后执行它。这与我们之前实验的操作流程一致。
以下是构建和运行的基本步骤:
- 运行
make clean来清理之前的构建文件。 - 运行
make build来编译当前代码。 - 编译成功后,即可执行生成的可执行文件。
分析Makefile与代码结构
上一节我们介绍了实验的基本操作,本节中我们来看看项目的构建文件。Makefile 通常包含 build、run 和 clean 等目标。之前提到过,你可以使用 make clean build 命令。这个命令会首先移除旧的可执行文件(例如 shared_constant_mem_lab.exe),然后尝试编译新代码。
现在,让我们查看核心的CUDA内核文件 shared_constant_mem_lab.cu。在这个文件中,cuda_kernel 函数里已经为你预留了注释,提示你在何处需要编写代码来初始化变量、同步线程以及进行性能计算。像线程ID(threadId)、块ID(blockId)等变量的计算已经为你完成,你只需要在指定位置填充代码即可。
设备内存管理与内核调用
理解了内核结构后,我们接下来关注主机端如何管理设备内存并启动内核。allocate_device_memory 函数使用 cudaMalloc 为设备的RGB通道分配内存。接着,对于图像的行数和列数,它会将它们复制到常量内存中。请注意,这些维度在 .hpp 头文件中已有定义。
以下是内存操作的关键步骤:
- 主机到设备复制:
copy_to_device函数接收主机端RGB值的无符号字符指针,并将它们传递给设备端指针。 - 内核执行:
run_kernel函数负责启动CUDA内核,对RGB值以及图像的行列维度进行处理。 - 设备到主机复制:
copy_from_device函数使用cudaMemcpy将处理后的设备内存RGB值复制回主机。 - 资源清理:
deallocate_memory和cleanup_device函数确保释放设备内存并使设备处于良好状态,以备下一次迭代使用。
主函数流程与图像处理
最后,我们来梳理整个程序的主逻辑。main 函数首先解析命令行参数,以获取输入图像文件名、输出图像文件名、分区ID以及每个块的线程数。接着,它根据输入文件名读取图像数据,并生成一个灰度图像指针。
以下是 main 函数的执行序列:
- 解析命令行参数。
- 读取输入图像。
- 为RGB数据分配设备内存。
- 将主机数据复制到设备。
- 在RGB值及行列数据上执行内核。
- 将结果从设备复制回主机。
- 释放内存并清理设备。
- 基于处理后的灰度像素创建PNG格式的图像(你也可以尝试使用RGB模式进行不同的实验,这里提供了一定的自由度)。
- 将图像写入输出文件。
- 返回0,程序正常结束。
课程总结

本节课中,我们一起学习了如何在CUDA图像处理实验中应用共享内存和常量内存。我们从构建代码开始,逐步分析了内核函数的结构、设备内存的分配与传输流程,以及主函数如何协调整个图像处理过程。通过本实验,你应能理解如何利用CUDA的特殊内存类型来优化数据访问,并掌握一个完整CUDA图像处理程序的基本框架。
055:使用常量与共享内存进行图像处理作业指南 🖼️
在本节课中,我们将学习如何完成第四周的编程作业。该作业的核心是使用CUDA的常量内存和共享内存,对一个RGB图像进行模糊处理,并将其转换为灰度图。我们将逐步解析作业要求、代码结构以及操作流程。
概述
本作业要求你编写一个CUDA内核函数,利用常量内存存储模糊滤镜的权重,并使用共享内存来优化图像像素的访问。你需要将处理后的GPU结果与一个CPU参考实现进行比较,并根据两者的差异百分比获得成绩。
作业操作流程
以下是完成此作业需要遵循的具体步骤。

- 编写内核代码:首先,你需要编写处理RGB值的CUDA内核代码。该代码需使用常量内存,并将输入的RGB图像转换为灰度图像。
- 构建与运行:完成编码后,点击“构建代码”按钮。接着,点击“运行并记录尝试”按钮。
- 查看输出:程序运行后,查看输出的TXT文件。其中会包含一个“平均差异百分比”,该值用于计算你生成的图像与CPU生成的参考图像之间的差异。
- 提交作业:点击“提交作业”按钮。系统将根据“平均差异百分比”来计算你的成绩。请注意,系统中存在一个已知的Bug,这可能导致你的最高成绩约为90%。修复这个Bug将作为练习的一部分留给你完成。
- 开始练习:点击“在浏览器中工作”按钮,即可开始编码练习。
核心代码解析
上一节我们介绍了作业的整体流程,本节中我们来看看需要实现的核心CUDA代码结构。
主内核函数是 apply_simple_linear_blur_filter。代码注释描述了每个步骤需要完成的任务。
内核函数步骤:
- 初始化变量:根据线程索引计算要处理的图像像素位置。
- 同步线程:由于使用了共享内存,在从全局内存加载数据到共享内存后,必须调用
__syncthreads()来确保所有线程的数据加载完成。 - 应用模糊滤镜:此步骤应使用常量内存中存储的权重值。同时,你也可以利用常量内存来存储图像的行数(
d_rows)和列数(d_cols),代码框架已为此设置好。
用于比较图像的 compare_color_images 函数,会计算两幅图像RGB通道的差异。
图像比较逻辑:
- 对于每个像素的R、G、B三个通道,分别计算其绝对差值。
- 将一个像素的三个通道差值进行平均,得到该像素的差异值。
- 将所有像素的差异值求和,再根据图像的总像素数进行平均,得到整幅图像的平均差异。
程序执行流程
了解了核心内核后,我们来看看整个主机端程序的执行流程。
- 分配设备内存:此步骤包括设置常量内存符号和分配共享内存。具体的实现需要由你完成。
- 执行内核:
execute_kernel函数是apply_simple_linear_blur_filter内核的封装调用器。 - 清理设备内存:内核执行完毕后,释放分配的GPU内存。
- 解析命令行参数:程序通过命令行参数获取输入/输出图像路径、当前设备ID以及每个块的线程数。
- 读取图像:使用OpenCV库从文件读取图像,并将其数据存储为一个包含RGB值的向量(
vector<Vec3b>),其大小由图像的行数(rows)和列数(cols)定义。 - CPU参考实现:
apply_blur_kernel函数是你要实现的GPU内核的CPU版本。它的基本逻辑是查看目标像素左、右、上、下的像素,并对这些像素的RGB值进行平均。 - 主函数流程:
- 解析命令行参数,获取输入/输出图像信息。
- 调用
execute_kernel执行GPU模糊处理。 - 读取GPU处理生成的图像数据,并写入到输出PNG文件。
- 对原始输入图像调用CPU版本的
apply_blur_kernel,得到用于对比的RGB向量。 - 最后,使用
compare_color_images函数,通过对比CPU和GPU生成图像的RGB值,计算出一个缩放后的平均差异百分比。
最终步骤
代码开发完成后,请按顺序执行以下操作:
- 点击“构建代码”按钮(这通常会执行
make clean和make build)。 - 点击“运行并记录尝试”按钮(这通常会执行
run.sh脚本)。 - 当结果输出到
output.txt文件后,点击“提交作业”按钮。
总结

本节课中,我们一起学习了第四周图像处理编程作业的完整指南。我们明确了作业目标是使用CUDA常量内存和共享内存实现图像模糊滤镜,并理解了从代码编写、构建运行到结果对比和提交的整个流程。你需要重点关注 apply_simple_linear_blur_filter 内核的实现,特别是共享内存的数据加载同步以及常量内存的使用。通过完成这个作业,你将加深对CUDA内存模型及优化策略的理解。
056:CUDA GPU寄存器内存机制 🧠
在本节课中,我们将要学习CUDA编程中最后一种设备内存类型:寄存器内存。我们将了解它的基本概念、工作原理、使用方法以及相关的性能考量。
概述
寄存器内存是GPU设备上速度最快的内存类型,位于每个流多处理器内部。它专属于每个线程,用于存储线程执行过程中频繁使用的局部变量。理解寄存器内存对于编写高效的CUDA内核至关重要。
上一节我们介绍了共享内存和常量内存,本节中我们来看看速度更快的寄存器内存。
寄存器内存的基本概念
让我们回顾一下GPU设备的通用布局图。一张是通用概览图,另一张是更详细的图像,展示了从主机到设备,再到流多处理器的数据流。
关于寄存器,可以将其视为一个文件。这个文件可以被流多处理器内的所有处理单元访问,包括构成线程的通用核心,以及执行单精度和双精度浮点运算的专用处理单元。
寄存器文件的大小是预定义的,通常为64 KB。流多处理器中核心数量的增加,并不意味着总的寄存器内存会增加。寄存器文件的大小在大多数架构中是静态的。
寄存器内存的特性
正如刚才提到的,在大多数架构中,每个流多处理器的寄存器内存量是静态的。早期的一些型号可能较少,而一些高端显卡可能拥有更多寄存器内存。
最佳实践是,除非你明确知道你使用的显卡有更多寄存器内存,否则应假定每个流多处理器只有64 KB的寄存器内存。
一个有趣的副作用是,每个线程可用的寄存器内存量会根据硬件而变化。虽然变化不会很大,但你可能需要找到一个安全的边界来规划你的寄存器使用。
如何使用寄存器内存
实际上,我们一直在使用寄存器内存。任何时候你在内核中创建一个变量,无论是基本数据类型、数组还是指针,它们通常都会被分配到寄存器内存中。
你并不能完全控制一个变量是否总是存储在寄存器中。如果你为每个线程分配的内存超过了可用寄存器容量,那么多余的部分将会被溢出到本地内存(实际上是缓存在L1缓存或全局内存中),这会导致访问速度变慢,但并非极其缓慢。
以下是使用寄存器内存的要点:
- 自动分配:在内核中声明的局部变量通常优先使用寄存器。
- 容量限制:每个线程可用的寄存器数量有限,由硬件和编译参数决定。
- 寄存器溢出:当所需寄存器超过限制时,编译器会将部分变量“溢出”到更慢的内存中。
核心概念与公式
一个关键的性能指标是每个流多处理器上可同时驻留的最大线程数,这受到寄存器总量的限制。其关系可以用以下公式描述:
最大驻留线程数 ≈ (每个SM的寄存器总数) / (每个线程使用的寄存器数)
这个公式表明,如果你的内核使用了大量寄存器,那么每个流多处理器上能够同时运行的线程块和线程数就会减少,这可能会影响GPU的占用率,从而影响性能。
在代码中,声明一个寄存器变量非常简单:
__global__ void myKernel(int* data) {
int tid = threadIdx.x + blockIdx.x * blockDim.x; // 变量'tid'通常存储在寄存器中
float temp = data[tid] * 2.0f; // 变量'temp'通常存储在寄存器中
// ... 其他计算
}
性能考量与最佳实践
寄存器内存的访问延迟极低,带宽极高。为了充分利用寄存器,应遵循以下原则:
- 最大化数据复用:将频繁访问的中间计算结果存储在寄存器中。
- 最小化寄存器使用:优化内核代码,避免声明过多不必要的局部变量,以允许更多线程同时活跃。
- 使用编译选项:可以通过
-maxrregcount编译器选项或__launch_bounds__指令来提示编译器每个线程使用的寄存器数量上限,以控制寄存器占用和线程占用率之间的平衡。


总结

本节课中我们一起学习了CUDA中的寄存器内存机制。我们了解到寄存器是GPU上最快的内存,专用于每个线程,容量有限但访问速度极快。我们讨论了它的自动分配特性、寄存器溢出的概念,以及寄存器使用量与线程占用率之间的重要权衡关系。合理利用寄存器是优化CUDA内核性能的关键步骤之一。
057:CUDA寄存器内存研究 🔍
在本节课中,我们将学习如何研究GPU卡上的寄存器内存数量,以及流多处理器中的核心数量如何决定可用的寄存器内存量。
工具与资源概览

与查看其他类型内存或显卡属性一样,NVIDIA SMI是一个非常有用的工具。我们不会逐一查看所有属性,但最重要的属性之一是产品名称。你将使用产品名称来确定流多处理器中的核心数量,这将帮助我们确定每个核心可用的寄存器内存。

架构与调优指南
对于NVIDIA仍在支持的所有架构(这些架构大约是在过去10年内生产的),NVIDIA提供了调优指南。这些调优指南提供了多种方法,使你的程序在特定的GPU卡上运行更高效。因此,这些指南始终是很好的参考资料,你应该查阅它们,以了解不同架构可用的不同功能。如果你愿意,可以为特定架构编译你的代码。
使用GPU数据库
然而,与其他类型的内存一样,TechPowerUp GPU数据库对于确定每个核心的寄存器内存量非常有价值。请注意,这个GPU数据库并没有具体说明寄存器文件的大小,你可能需要去其他地方查找。但关键在于,它告诉你每个流多处理器有多少个核心可用。
计算每个核心的寄存器内存
基于这个数字,你可以进行计算。通常显示的是总核心数,通常被称为“着色单元”。以示例中的GTX 970为例,它有448个着色单元和14个流多处理器。这意味着每个流多处理器有32个核心。
接下来,我们知道每个流多处理器有64 kB的寄存器内存。因此,每个核心关联的内存为 64 kB / 32 = 2 kB。
注意事项与最佳实践
请注意,可能存在分支或其他情况,导致变量未在内核中实例化,因此这个数字可能会更高或更低,但这是一个很好的经验法则。所以,在创建大量变量之前,请考虑你能在2 kB中放入多少数据。
如果你在内核中拥有更多的变量内存(这些内存试图成为寄存器内存),那么会发生的情况就像在CPU上一样:内存将根据需要在这些变量和寄存器内存之间来回移动。你希望避免这种情况,因为这会使那些变量和特定的访问速度降低到全局内存的速度。
总结

本节课中,我们一起学习了如何利用NVIDIA SMI工具和TechPowerUp GPU数据库来研究GPU的寄存器内存配置。我们了解了如何根据显卡的流多处理器和核心数量,计算出每个核心大致可用的寄存器内存量(例如,GTX 970上每个核心约2 kB)。最后,我们强调了优化内核变量使用以避免寄存器溢出到全局内存的重要性,这是保证GPU程序高效运行的关键。
058:CUDA GPU内存评估方法 🧠
在本节课中,我们将学习CUDA设备上不同类型内存的评估方法。我们将比较全局内存、常量内存、共享内存和寄存器内存的特性、优缺点及适用场景,并探讨如何根据编程需求选择合适的内存类型。
上一节我们介绍了CUDA内存模型的基本概念,本节中我们来看看不同类型内存的具体比较。
下表展示了不同类型设备内存的简单对比。
以下是不同类型内存的简要比较:
- 全局内存:容量最大。根据安培架构,典型容量可达24GB。优点是容量巨大,可以存储大量数据,例如多张图像。具体来说,如果您需要处理视频或其它形式的大型数据,全局内存是必要的。需要注意的是,所有线程都能访问相同的数据,因此尽管您可以根据线程ID进行映射,但必须考虑竞态条件的可能性。
- 常量内存:容量约为64KB,通常配有缓存以帮助减少未命中的影响。它适用于广播数据,因为其容量显著且所有线程都能访问相同的内存。优点是您可以向所有线程通知基础信息,或使用相同的内核(例如图像处理内核)。但其速度与全局内存一样慢,并且在编译后无法更改常量内存中的数据。
- 共享内存:在安培架构中,容量最高可达约192KB。优点是延迟低,这意味着数据进出速度快。它适用于在线程块内共享信息,这与适用于所有线程的常量内存不同。共享内存可用于共享数据,例如构成视频的一系列图像中的整张图像,或者如果您希望在线程之间进行异步通信。只要在同一个块内,这就是一个很好的机制。与全局内存相比,其容量相对较小。
- 寄存器内存:速度最快。它是线程安全的,因为每个线程只能访问自己的寄存器内存。缺点是容量可能非常小,您拥有的核心越多,每个寄存器的内存量就越低。它也是高度局部的,因此无法直接从寄存器共享信息。如果您有一个局部变量,您必须将其添加到共享内存、常量内存甚至全局内存中,才能进行进程间通信。
现在,让我们讨论一下设备上可用内存类型的一些使用场景。

首先请注意,所有非常量的内存传输都必须经过全局内存。因此,无论您创建寄存器还是共享内存,所有这些数据都必须从主机传输到设备的全局内存。这一点需要注意,这是所有内存传输的瓶颈,不仅体现在速度上,还体现在能通过全局内存的数据量大小上。因此,如果您需要处理的图像大于全局内存容量,则必须将其分解为多次运行。例如,可以使用处理更多数据的更大块大小,但该数据量受限于全局内存的大小。
因此,几乎所有用例都会用到全局内存,只是效果可能不理想。但它的一个绝佳用途是:获取大型数据,将其放入全局内存,然后根据需要移动到其他类型的内存中。
常量内存是向所有线程广播数据的好方法。它可以是一个预定义的图像、过滤器或内核。
共享内存允许同一块内的不同线程进行通信。这可以是全局内存中数据的子集,例如图像的一部分、视频的单个帧,或者与您的线程在二维、三维甚至更高维网格空间中的布局相匹配的局部数据。
如前所述,寄存器内存是线程安全的,因此您不必担心竞态条件等问题。它速度快,并且是显式定义的,因此您不必担心处理指针之类的事情。它是命名的,因此在编程上有许多优点。
但一个好的实践是:从全局内存中获取数据,如果块中的所有内容都需要访问,则将其放入共享内存。然后,如果您要修改共享内存中的数据,则将其放入寄存器内存,让所有线程读取,调用 __syncthreads() 同步,然后反向操作:写回共享内存,再写回全局内存。

本节课中,我们一起学习了CUDA中四种主要设备内存(全局内存、常量内存、共享内存、寄存器内存)的特性、优缺点和典型使用场景。理解这些内存类型的差异是进行高效GPU编程的关键。请记住这些指导原则,但也鼓励您亲自探索,以确定最适合您特定应用的最佳实践。
059:设备内存实验教程 🧠
在本节课中,我们将学习第五周的实验内容,该实验主要围绕CUDA设备内存展开。我们将详细解析实验说明、代码结构以及核心步骤,帮助你理解如何在CUDA编程中有效地使用不同类型的设备内存。
实验概述 📋
实验说明中有一个部分专门讨论了主机代码及其将执行的六个步骤。这些步骤涵盖了从数据准备到内核执行,再到结果输出的完整流程。
主机代码执行步骤 📝
以下是主机代码将按顺序执行的六个核心步骤:
- 创建数据数组:根据传入的每个线程块中的线程数量,创建一个大小为“每个线程元素数”的数组。这将作为你的输入数据。
- 分配设备内存:根据你将运行的内核类型,将数据适当地放入设备内存中。
- 启动计时器:启动一个计时器,以便了解内核执行所需的时间。
- 执行内核:根据内核访问的是全局内存、常量内存、共享内存还是寄存器内存,执行相应的内核函数。
- 停止计时器:停止计时器,记录内核执行的总时长。
- 输出统计信息:根据实验ID、内核参数、每个线程的元素数、线程数以及内核执行时间,将统计信息输出到一个CSV文件中。
内核函数设计 ⚙️
内核函数本身的设计遵循以下逻辑:
- 计算线程跨度:首先,内核需要确定每个线程处理的“跨度”。这通过将输入数据的总大小除以线程总数来计算。由于这两个值都是常量,这个计算应该非常快,但需要在每次内核执行时进行。
- 执行增值操作:接着,内核会对一个值进行递增操作。这样做的目的是确保内核在执行过程中,确实花费了时间在内存访问上,而不是在其他地方。
- 执行核心计算:然后,执行你的内核想要进行的任何操作。这部分由你决定,你可以根据需要使其变得复杂。
- 赋值输出:最后,将你搜索到的值或某些计算结果,赋值给输出变量或指针。
命令行参数解析 🛠️
程序支持三个命令行参数,用于灵活配置实验:
-M:指定每个线程处理的元素数量。这个值乘以线程总数,就得到了总的数据量。-N:指定每个线程块中的线程数量,范围可以从32到1024。-K:指定内核类型,即使用哪种内存(全局、常量、共享或寄存器)。
注意:由于常量内存和共享内存的容量有限,存在一个能确保程序正常工作的最大数据量。我计算这个值大约是32K。虽然理论上可以是64K,但这里我们留出一些余量。这意味着,例如,你可以设置每个线程处理256个元素,每个线程块有64个线程(总计16K数据)。你可以调整这些数字的组合,但请注意不要超过内存限制。
代码结构解析 💻
现在,让我们来看看代码的具体实现。代码中包含了四个内核函数,分别对应全局内存、共享内存、常量内存和寄存器内存。目前,这些内核函数内部基本上是空的,除了计算线程ID的部分。

// 示例:全局内存内核函数框架
__global__ void kernel_global(int *input, int *output, int elements_per_thread) {
int tid = threadIdx.x + blockIdx.x * blockDim.x;
int stride = gridDim.x * blockDim.x;
// ... 你的计算逻辑将写在这里
}
你可以自由决定在这些内核函数中实现什么功能,尽情发挥你的创意。
辅助函数与主流程 🔧
代码中还包含了一些关键的辅助函数:
- 分配随机内存:主机代码会分配一个值在0到255之间的随机内存数组。
- 分配设备内存:使用简单的
cudaMalloc为输入数据分配设备内存。你可以根据内核的需要修改这部分。 - 释放内存:执行完代码后,务必使用
cudaFree释放设备内存中的所有指针,这是良好的编程习惯。 - 解析命令行参数:如前所述,这个功能允许你动态设置线程数、元素数和内核类型。
- 主函数:作为C/C++程序的核心,主函数负责处理命令行参数、设置设备内存的输入变量、在主机和设备之间复制数据、围绕内核执行进行计时、释放内存、清理设备,并最终输出用于分析的CSV文件。
总结 🎯

本节课中,我们一起学习了第五周关于CUDA设备内存的实验。我们详细剖析了实验的六个执行步骤,理解了内核函数的设计逻辑,掌握了如何通过命令行参数配置实验,并浏览了代码的整体结构。这个实验为你提供了实践机会,去探索和比较不同类型设备内存(全局、常量、共享、寄存器)在性能和行为上的差异。请根据实验指导,填充内核函数的具体实现,并运行实验以收集和分析性能数据。
060:CUDA设备内存分析作业指南 🧠💻
在本节课中,我们将学习如何完成第5周关于CUDA设备内存分析的编程作业。我们将详细解析作业要求、代码结构、执行流程以及如何获得满分。
概述
本次作业的核心是分析不同CUDA内存类型(全局、共享、常量、寄存器)的性能。你的主机代码需要执行六个步骤,而算法部分包含五个步骤。评分脚本run.sh默认只运行全局、共享和寄存器内存的内核。若想获得100%的分数,你需要修改该脚本以包含常量内存的测试。
作业结构与要求
上一节我们介绍了作业的整体目标,本节中我们来看看具体的执行步骤和参数要求。
你的主机代码应执行以下六个步骤:
- 确定搜索目标的大小。
- 递增内存输入。
- 遍历输入数据。
- 在数据中查找目标值。
- 设置输出值。

作业通过命令行传递多个参数:
elements_per_thread:每个线程处理的元素数量。current_part_id:当前部分ID(不应修改,由run.sh使用)。threads_per_block:每个块的线程数(强制范围32到1024)。kernel_type:内核类型,可选值为global、constant、shared、register。

注意:请避免修改任何打印语句,否则可能导致作业评分失败。你可以添加调试用的打印语句,但最终输出格式应大致如下模式:part ID, elements, threads, kernel。此输出将被解析为CSV文件。

代码解析与实现要点
了解了作业框架后,我们深入看看需要你编写的核心代码部分。
代码中提供了四个内核函数的框架,分别对应全局、共享、常量和寄存器内存。每个内核内部已给出基本结构,你需要根据注释完成具体逻辑。内核的主要任务是正确处理线程ID并确保不会发生越界访问。
在主机端代码中,你需要:
- 分配可分页的随机内存。
- 分配设备内存。
- 将数据从主机复制到设备。
- 根据传入的
kernel_type参数,执行相应的内核。
特别需要注意的是,变量 thread_span 需要你计算并使用,它用于确定每个线程处理的数据范围,对于某些类型设备内存的访问模式至关重要。
对于常量内存,搜索值 symbol 已在代码中预设,这是为了防止在其他地方遗漏设置。
执行、评分与可视化

完成了代码编写,下一步就是执行、评分并生成可视化结果。

主函数 main 的运行逻辑与实验课类似。它封装了内核执行的时间测量,负责内存的分配与清理,并输出可被解析的CSV格式结果。

run.sh 脚本将输出用于运行程序不同迭代的命令行参数。如前所述,生成的CSV文件用于创建性能对比图。

以下是作业执行的完整流程:
- 修改
run.sh:将其中的循环修改为遍历global、shared、register和constant内存类型。 - 构建代码:执行
build_code。 - 运行与记录:执行
run_and_log_attempts来运行run.sh。 - 生成图表:使用
generate_graph并配合gnuplot生成性能图表。 - 提交作业:完成上述步骤后提交作业。
重要提示:run.sh 脚本会遍历不同的内核类型、每个线程处理的元素数量以及迭代次数,以生成平均值。Python脚本 parse_output.py 会读取输出的CSV文件并生成供 gnuplot 使用的数据文件。
总结

本节课中我们一起学习了第5周CUDA设备内存分析作业的完整指南。我们明确了作业的六个执行步骤和五个算法步骤,分析了四种内存类型(全局、共享、常量、寄存器)内核的实现要点,并梳理了从代码编写、修改评分脚本、执行测试到生成可视化图表的全流程。记住,若要获得满分,必须修改 run.sh 以包含常量内存的测试。
061:课程总览 🚀
在本节课中,我们将对约翰霍普金斯大学的GPU编程专项课程进行总览,了解整个课程体系的结构、目标以及你将学习到的核心内容。
大家好,欢迎来到《面向企业的规模化CUDA》课程。我们将基于你在前两个模块中学到的知识进行扩展,探索如何将其应用于处理更大规模的数据、更复杂的算法,以及利用CUDA提供的库。
我是Chancellor Pascal。我在约翰霍普金斯大学怀廷工程学院担任了10年的讲师,同时也是一名拥有15年经验的软件开发人员。高性能计算、云计算、Web开发以及计算机视觉的应用都是我的兴趣所在。现在,我想为你概述一下Coursera平台上的GPU专项课程。


课程体系结构
以下是GPU专项课程包含的四门课程:
- 第一门课程:《GPU并发编程导论》。这是你当前所在的课程,我将在下一张幻灯片中详细介绍。
- 第二门课程:《CUDA并行编程导论》。这门课程重点介绍NVIDIA GPU和CUDA编程框架提供的基本硬件与软件能力。
- 第三门课程:《面向企业的规模化CUDA》。这门课程深入探讨CUDA更复杂的功能,以及如何将其应用于超越程序员个人机器的企业级硬件,实现规模化计算。
- 第四门课程:《CUDA高级库》。这门课程向学生介绍CUDA开发者工具包中附带的一系列最流行且功能强大的库。
本课程具体内容
现在,让我们具体谈谈你正在学习的这门《GPU并发编程导论》课程。
上一节我们介绍了整个课程体系,本节中我们来看看本课程的核心学习内容。你将学习如何为以下配置进行编程:
- 单GPU配合多CPU
- 单CPU配合多GPU
- 两者结合(这是GPU安装的常见模式)
接下来,你将学习如何处理流(Streams)和事件(Events)。它们允许异步工作流,使你能够在不同数据上重复运行相同的内核(Kernel),并控制执行顺序。
最后,我们将重点介绍一些库和技术,这些最终能让你在GPU上实现诸如数据排序、使用CUDA库处理图像或信号等任务。
课程目标
本课程的学习目标有三方面:
- 环境利用:在给定多CPU和/或多GPU的环境中,开发能恰当利用这些资源的软件。
- 异步控制:使用CUDA流和事件与GPU及内核进行异步交互。
- 解决挑战:运用你对CUDA硬件、GPU能力、库和算法的理解,解决编程中的一些挑战,包括数据排序和图像处理。






本节课中我们一起学习了GPU编程专项课程的整体框架、本课程《GPU并发编程导论》的具体学习内容与目标。希望本次介绍能让你开始思考自己在本课程乃至整个GPU专项课程中希望实现的目标。本次介绍到此结束。
062:课程预期目标 🎯
在本节课中,我们将了解《面向企业的规模化CUDA编程》课程的整体预期目标。我们将从课程材料、时间投入和技术要求等多个维度,详细说明学习本课程需要完成的任务和达到的水平。
课程材料与结构 📚
从课程材料的角度来看,本课程的结构安排如下。
每个模块将包含4到7节课,每节课配有视频、测验等内容。
时间投入预期 ⏱️
从时间投入的角度来看,学生需要为以下内容预留时间。

以下是每周大致的课时分配:
- 每周将有20到30分钟的视频学习内容。
- 每周将有15到30分钟的非计分作业,例如实验和讨论。
- 每周将有30到160分钟的计分作业,形式为编程作业和测验。
此外,学生需要能够使用CUDA框架和C++语言,完成从简单到复杂的编程作业。

课程内容形式 📺
上一节我们了解了时间分配,本节中我们来看看课程内容的具体形式。
每节课将包含以幻灯片或屏幕录制形式呈现的视频材料。
- 配有旁白的幻灯片视频将同时提供对应的PowerPoint幻灯片文件。
- 对于包含屏幕录制内容的视频,其中使用的命令也将被提供。
许多模块会包含测验,用于评估学生对讲座内容的理解。
为了鼓励协作和分享想法与经验,每个模块都将包含讨论环节,学生应将其视为学习过程中不可或缺的一部分。
为了挑战和评估学生对技术主题的理解,大多数模块将使用编程实验活动和作业,来检验学生应用所学知识的能力。


各环节时长预估 🕒
了解了内容形式后,我们来预估完成每个环节所需的具体时间。
每节课将包含讲座和技术应用视频,这些是深入理解课程主题所必需的,每个视频时长在5到7分钟之间。
实验活动将允许学生在实际环境中探索技术主题,通常需要10到15分钟来掌握其核心要点。
对于更理论化或特定的技术评估,学生需要完成测验,每个模块的测验预计需要3到5分钟。
对于每个模块,学生应预留15到30分钟来完成编程作业。




技术要求与能力 🔧
前面介绍了课程的形式与时间,本节我们将深入探讨课程对学生的具体技术要求。
你需要理解如何利用硬件和软件的约束与能力细节来优化程序。
你必须能够使用C++语言编写至少简单的CUDA代码。
我将提供一个基础的软件项目,然后对于作业,你需要能够识别在何处放置你的代码,以及如何最高效地分配算法。
最后,根据目标GPU以及你自有硬件或Coursera实验平台的GPU,你将开发出能够以最优方式利用NVIDIA GPU的软件。


本课程的额外特色 🚀
与先前的课程相比,本课程还有一些额外的特色和期望。
以下是本课程特有的环节:
- 在模块3中,设有一个荣誉课程和一个同行评审作业。
- 课程将更侧重于介绍可用的、特定领域的CUDA库,以利用GPU进行计算。
- 最终作业将包含同行评审环节,并期望你能处理大量数据。

总结 📝

本节课中,我们一起学习了《面向企业的规模化CUDA编程》课程的详细预期目标。我们了解了课程的材料结构、每周需要投入的时间、各种学习环节的形式与时长,以及课程对学生提出的具体技术能力要求。此外,我们还明确了本课程相比之前课程的独特之处,包括荣誉课程、同行评审和对特定领域库的深入探讨。掌握这些信息,将帮助你更好地规划学习路径,为后续的深入学习做好准备。
063:Coursera实验与作业综述 🧑💻
在本节课中,我们将学习约翰霍普金斯大学《GPU编程》课程中,关于Coursera平台实验与作业的完整操作流程。我们将详细介绍从打开环境到最终提交作业的每一个步骤。
概述
Coursera平台上的实验与作业遵循一个清晰的六步流程。我们将逐一拆解这些步骤,确保你能顺利完成课程任务。
实验与作业开发六步流程
以下是完成Coursera实验活动的六个标准步骤。
- 打开Coursera实验环境:在浏览器中使用集成的VS Code编辑器。
- 打开模块目录并阅读说明:定位到
modules目录,仔细阅读README.md文件。 - 编辑源代码文件:根据任务要求,修改
.cu、.cpp、.h或.hpp等源代码文件。 - 在终端中执行命令:使用
make命令来清理、构建和运行你的代码。对于作业,可能需要多次迭代。 - (仅作业)编辑用户文件:按照
README.md中的说明,编辑.user文件并修改指定的代码变量。 - 提交作业:完成代码后,通过平台界面提交你的作业。
模块作业页面详解
上一节我们介绍了通用流程,本节中我们来看看作业页面的具体布局和功能。
这是模块1编程作业的页面。我们将逐步讲解完成作业所需的所有操作。

页面主要包含以下几个区域:
- 进度通知区:显示作业完成状态。
- “在浏览器中工作”按钮:点击此按钮启动实验环境。
- 作业完成指南:提供详细的步骤说明。
- 提交结果区:显示作业评分和反馈。
现在,点击 “在浏览器中工作” 按钮来启动我们的作业。

在VS Code中打开项目
启动环境后,你需要在VS Code中打开与实验或作业关联的目录。
如果你在页面顶部附近没有看到模块名称,请按以下步骤操作:
- 点击菜单栏中的 “文件” 选项。
- 从下拉菜单中选择 “打开”。
- 在弹出的“打开文件或文件夹”对话框中,你可以通过点击
..(双点号)向上导航目录结构。
对于本课程项目,你需要导航至以下路径:
/home/coder/project/module_1
在对话框中找到所需文件夹后,点击 “确定” 按钮。该文件夹将在编辑器左侧打开,其中包含开始当前模块实验或作业所需的所有文件。
关键文件说明
打开项目后,你应该首先阅读README.md文本文件,其中包含背景材料和具体的作业要求。
以下是项目中几个关键文件的作用:
README.md:核心说明文件,包含作业背景和详细指令。run.sh:运行脚本,用于执行作业的所有步骤。Makefile:构建文件,用于清理、构建和运行你的代码。
请注意:你通常不需要修改run.sh和Makefile这两个文件,只需专注于修改代码及其他相关文件。
作业提交前的关键配置
如果你正在完成一项作业,接下来需要确保用户名配置正确,这是提交的前提。
配置涉及两个文件:
.user文件:确保该文件内容是你的用户名(可以是任意名称或登录名,但不能包含特殊字符)。- 主代码文件:例如
assignment.cpp,你需要修改user_name字符串常量,使其与.user文件中的名称一致。
通过编辑这两个文件,你可以保证你的提交满足作业的用户名验证这一首要要求。当你通过run.sh或make命令执行代码时,也需要传递相同的用户名。
代码示例(在assignment.cpp中):
const std::string user_name = “YourUserNameHere”;
构建与提交作业
完成代码编写和配置后,即可提交作业。提交分为两个步骤:
- 点击构建提交(步骤1):点击页面底部的 “构建提交 步骤1” 按钮。页面底部将出现一个终端窗口,它会清理、构建并执行你的当前代码,并将输出结果保存到一个用于评分的文件中。请留意终端中的任何输出信息。
- 点击提交作业(步骤2):然后点击附近的 “提交作业 步骤2” 按钮,将生成的文件提交给评分系统。

查看提交结果与日志
提交后,你可以在作业页面查看提交结果。结果在评分器运行期间会显示为琥珀色(处理中)。

打开提交结果后,你可以看到:
- 成绩:你的得分。
- 是否通过:作业通过状态。
- 反馈:评分器提供的具体反馈信息。
如果你遇到问题或作业未通过,需要进一步排查,可以查看详细的日志文件以获取更多信息。



总结

本节课中,我们一起学习了在Coursera上完成《GPU编程》实验与作业的完整流程。我们从打开实验环境开始,逐步了解了如何导航项目目录、阅读说明文件、编辑代码和配置文件,最后完成了构建与提交作业以及查看评分结果的全过程。掌握这个标准化流程,将帮助你高效地完成后续的所有编程任务。
064:多CPU架构解析 🖥️
在本节课中,我们将学习如何在一个架构中利用多个CPU进行协同工作。我们将探讨CPU间通信的不同模式、技术选择以及设计多CPU系统时需要考虑的关键约束。
概述
为了在多个CPU之间实现通信,需要选择一种模式或技术。一个常见的选择,尤其是在过去,是使用文件系统。
通信模式与技术
以下是几种常见的CPU间通信模式与技术。
- 文件系统:通过锁文件或远程服务器上的文件进行通信。可以认为,万维网(World Wide Web)最初就是这样开始的。
- 套接字(Sockets):预定义的双向通信方式,允许点对点通信。
- 消息队列系统(Message Queuing Systems):包含一个缓冲区或队列,用于存储消息。另一端的处理系统会从中取出消息,并可能通过另一个消息队列进行回复。
- 更规范的协议:例如RPC(远程过程调用) 或 REST,它们通过已知的方法进行通信。
设计约束与考量
在设计多CPU通信系统时,需要考虑以下重要约束和因素。
- 同步与异步:通信是同步还是异步?是否需要立即得到响应,还是可以发送请求后稍后再查看结果?
- 数据一致性:数据一致性是否是一个问题?
- 数据传输模式:是批量发送大量数据,还是以流的形式持续发送少量数据?
- 连接与网络弹性:连接和网络的健壮性如何?
- 安全性:是否需要考虑安全性?不同的进程或用户是否需要不同的访问权限或角色?
常见的网络通信拓扑
上一节我们介绍了通信的技术与约束,本节中我们来看看三种常见的多台联网计算机之间的通信拓扑模式。


- 星型/集中式(Star/Spoke/Centralized):存在一个中心枢纽,所有通信都通过它进行。
- 网状/去中心化(Mesh/Decentralized):所有节点基本上都可以相互通信,或者与一定数量的邻居节点连接。
- 环型(Ring):邻居节点之间通信,但只与一跳之内的邻居通信,因此通信相对稀疏。
可以将网状网络想象成如今的Wi-Fi路由器。当你想最小化主题通信时,可以使用环型网络。
与GPU共享资源的结合
那么,当你想使用像GPU这样的共享资源时,这些模式如何工作呢?
如果你采用星型网络模式,可以将GPU放在那个中心枢纽上。反之,如果你知道某个节点有GPU,也可以将其设为中心枢纽。如果有多台机器都拥有GPU(但每台可能只有一个),那么可能更适合使用去中心化的网状网络。
通信速度或对响应的要求可能会影响你的决策。
- 你是需要大量部分答案,还是需要完整的答案?
- 你是否确定CPU之间会始终保持连接,还是连接可能是突发性的?如果是后者,批量处理(batch)可能更好,即在连接存在时发送批次数据。
- 如果接收大量小响应很重要,但允许丢失部分数据(类似于UDP通信协议),那么可以使用流(stream) 模式。在这种模式下,系统不断尝试连接并发送数据,即使有些数据没有完全送达也没关系。
这又回到了一个问题:你是需要一次性处理所有数据,还是可以处理部分数据?例如,对于图像,你可能不需要整张图像都在;对于视频,你可能不需要所有帧。你可以一次发送一帧视频或图像的一部分,然后让流来处理。
总结

本节课中我们一起学习了多CPU架构中的通信模式,包括文件系统、套接字、消息队列和RPC/REST等技术。我们探讨了设计时需考虑的同步性、数据一致性、传输模式和安全性等约束。同时,我们分析了星型、网状和环型三种网络拓扑,并讨论了它们如何与GPU等共享资源结合,以及如何根据对通信速度和数据完整性的要求选择批量或流式处理策略。这些模式对于处理多CPU、少量GPU的配置至关重要。
065:多CPU实验项目概述 🧪

在本实验中,我们将学习如何构建一个多CPU与GPU协同工作的异步通信系统。该系统包含Python脚本作为数据生产者,CUDA C++代码作为数据处理者,它们通过文件系统和锁文件进行同步。
实验文件结构 📁
以下是实验目录中的关键文件及其作用。
- 输入文件:
input_A.csv和input_B.csv。这些文件由Python脚本生成,包含待处理的浮点数数据。 - 锁文件:
input_A.lock和input_B.lock。这些文件不包含实际数据,仅作为同步信号,用于指示某个文件正在被读取或写入,以防止数据竞争。 - 输出文件:
output_A.csv和output_B.csv。这些文件由CUDA程序生成,包含处理后的结果。 - 输出锁文件:
output_A.lock和output_B.lock。功能与输入锁文件类似,用于同步输出文件的写入过程。 - Python脚本:
run_input_A.py和run_input_B.py。它们作为数据生产者,负责生成随机数并写入对应的CSV文件。 - CUDA C++代码:
cuda_to_file.cu。这是核心的数据处理程序,从输入文件读取数据,在GPU上进行计算,并将结果写入输出文件。
Python生产者脚本解析 🐍
上一节我们介绍了实验的整体结构,本节中我们来看看数据生产者——Python脚本的具体工作方式。
Python脚本 run_input_A.py 和 run_input_B.py 逻辑相同。它们接受两个参数:输入标识符(A 或 B)和要生成的元素数量。脚本的核心工作是循环执行以下步骤:
- 生成指定数量的随机数。
- 等待对应的输入锁文件消失(表示GPU程序未在读取)。
- 将随机数写入
input_[A|B].csv文件。 - 创建锁文件
input_[A|B].lock,通知GPU程序有新数据待处理。 - 等待GPU程序处理完毕(通过检查输出锁文件)。
- 从输出文件读取结果并打印。
其关键函数调用类似于:
# 示例性逻辑
write_data_to_csv(“input_A.csv”, random_data)
create_lock_file(“input_A.lock”)
CUDA C++处理程序解析 ⚙️
了解了数据如何产生后,我们现在进入核心环节:查看CUDA C++程序如何处理这些数据。
主程序 cuda_to_file.cu 的核心是一个无限循环,它不断检查输入锁文件,执行GPU计算,并输出结果。其主要流程如下:
- 内存分配:使用CUDA统一内存(Unified Memory)为输入和输出数组分配空间,这简化了主机与设备间的数据拷贝。
float *d_input, *d_output; cudaMallocManaged(&d_input, data_size); cudaMallocManaged(&d_output, result_size); - 数据检索:当检测到输入锁文件存在时,程序读取对应的CSV文件,将字符串解析为浮点数数组,并存入
d_input。 - 内核执行:配置线程块和网格,启动CUDA内核。内核的核心计算是求两个输入向量中对应元素的差值。
__global__ void vectorDiffKernel(float* inputA, float* inputB, float* output, int n) { int i = blockIdx.x * blockDim.x + threadIdx.x; if (i < n) { output[i] = inputA[i] - inputB[i]; // 核心计算:差值 } } - 结果输出:将GPU计算得到的差值结果(存储在
d_output)写入output_[A|B].csv文件,并管理输出锁文件。 - 同步信号:删除输入锁文件,通知Python生产者可以写入下一批数据;创建输出锁文件,通知生产者结果已就绪。
实验运行与观察 🚀
现在我们已经理解了各个组件的工作原理,接下来看看如何运行整个系统并观察其行为。
你需要打开三个独立的终端窗口或标签页,分别执行以下命令:
- 运行生产者A:
python run_input_A.py A 100 - 运行生产者B:
python run_input_B.py B 100 - 运行GPU处理程序:
./cuda_to_file
所有程序都将持续运行,直到你使用 Ctrl+C 手动终止。在运行过程中,你可以观察输出文件的内容。需要注意的是:
- 由于异步执行,
output_A.csv和output_B.csv中同一位置的值通常并不对应同一批输入数据的处理结果。 - 然而,从数学上看,
output_A中的值应是output_B中对应值的相反数。这是因为对于同一对数据(a, b),从A视角计算是a - b,从B视角计算是b - a,两者互为相反数。
总结 📝

本节课中我们一起学习了如何构建一个基于文件锁的多CPU/GPU异步协作系统。我们剖析了Python数据生产者与CUDA数据处理者的角色,理解了它们如何通过锁文件实现松耦合的同步。我们还查看了核心的CUDA内核函数,它执行了简单的向量差值计算。这个实验展示了在异构计算环境中实现任务级并行和数据流的一种实用模式。你可以尝试修改数据大小、计算逻辑或同步机制来进行更深入的探索。
066:多CPU编程作业详解 🖥️
在本节课中,我们将详细讲解模块2的多CPU编程作业。这个作业的执行流程与之前的实验非常相似,我们将一步步解析从代码开发到提交的完整过程,并重点说明作业中的核心任务与注意事项。
作业执行流程概述
上一节我们介绍了作业的整体背景,本节中我们来看看具体的执行步骤。作业流程主要包含几个关键环节,其顺序与实验课类似。
以下是作业的核心步骤:
- 开发你的核心代码。
- 运行代码。
- 生成提交文件。
- 提交作业。
- 查看提交结果。
需要注意的是,步骤二到步骤五与实验课一致。步骤一(代码开发)是独立的,但在调试时,你可能需要从步骤五返回到步骤一进行修改。你通常不需要修改提供的Python框架代码,但为了获得更好的控制,你可能希望先停止其运行。如果让Python代码持续运行,那么在修改后你需要从步骤三重新开始执行。
核心代码任务解析

在代码开发完成后并成功运行,接下来的两步是生成提交文件和正式提交。生成提交文件按钮会执行一系列底层脚本,将输出CSV文件的内容处理并整合,以展示所有运行结果的完整视图。
现在,让我们具体看看在编程环境中需要完成的任务。

在multiC代码文件中,已经包含了许多注释来指导你。你需要自行决定如何分配代码、从文件复制数据以及执行内核(kernel)。
作业的内核函数与实验课略有不同。实验课的内核计算的是差值,而本次作业的内核需要比较两个数组A和B在特定索引位置的值。
其逻辑描述如下:从数组A的值大于数组B的值的视角出发,需要返回不同的结果。
- 如果
A[i] > B[i],则返回1。 - 如果
A[i] == B[i],则返回0。 - 如果
A[i] < B[i],则返回-1。
你需要在代码中实现这个逻辑。我强烈建议你尝试探索不使用条件分支(如if语句)的实现方式,但这并非强制要求。如果你只能通过if语句来实现,那也是可以的。实际上,存在一些技巧可以避免显式的条件判断来实现此类比较。从长远来看,除非必要,应尽量避免在GPU内核中使用过多的条件分支。
提交与评分注意事项
在深入代码细节之前,我们先回顾一下操作按钮。前四个按钮用于创建提交文件,点击后会执行一系列底层Bash脚本,请不要随意修改这些脚本。最终会生成一个结果CSV文件,然后你可以点击提交按钮。
提交完成后,你可以返回查看当前的提交状态。

目前这里没有任何提交记录。我想强调一个重要的注意事项。

请务必小心修改任何打印语句或输出到标准IO(stdout)的内容,因为这可能会影响代码的评分方式。你可以为了调试临时添加打印语句,但我建议在完成后将其删除。如果你不希望输出我预设的所有内容,请确保在最终提交前恢复原状,因为评分时会检查特定的输出格式。
总结
本节课中我们一起学习了模块2多CPU编程作业的完整流程。我们回顾了从代码开发、实现特定的数组比较内核、到生成提交文件并最终提交的步骤。关键点在于理解内核函数的比较逻辑(返回1, 0, 或 -1),并注意保持输出格式的规范性以确保正确评分。
非常感谢,希望你能顺利完成这个作业。


067:多CPU与多GPU对比分析 🖥️🆚🎮
在本节课中,我们将对比分析多CPU系统与多GPU系统的架构、通信方式、优缺点及适用场景。理解这些差异有助于为特定计算任务选择合适的硬件平台。
单GPU与多GPU系统架构 🔧
上一节我们介绍了GPU的基本编程模型。本节中,我们来看看多GPU系统的通信基础。


首先回顾单GPU系统的架构图。顶部是CPU与GPU之间的通信机制,在执行内存拷贝时频繁使用。底部是类似NVLink的技术,它作为主干,允许连接在同一台计算机同一总线上的GPU之间进行内存拷贝和通信。这意味着可以执行快速、近乎无缝的数据拷贝,从而构建更高效的系统,因为无需先将数据拷贝到CPU,再传输到下一个GPU,这个过程会显著降低速度。


多CPU系统的通信方式 🔄
了解了GPU间的通信,我们再来看看多CPU系统是如何工作的。
以下是两种简化的多CPU系统通信示意图。左侧展示了多CPU、多核、多处理器系统(例如您可能正在使用的笔记本电脑或手机)的基本工作原理。一个或多个核心上的CPU通过缓存或系统总线进行通信,速度很快,但其性能最终受限于电信号在铜线上的传输距离。
右侧展示的是一个网络化的CPU集群。它们在更高层次上通过以太网交换机等网络设备,经由互联网或内联网进行通信。距离更长,存在连接性问题(这在同一台机器上不存在),并且各节点的配置不一定完全相同,尤其是在异构计算系统中。


因此,左侧方案具有更好的一致性和更多的控制权;右侧方案则拥有更强大的算力,几乎无限数量的CPU、内存和存储空间,但问题在于连接可能不一致,数据一致性可能降低,并且获取完整答案可能需要更长时间。


多CPU与多GPU的优缺点对比 ⚖️
前面我们分别看了两种系统的架构,现在我们来系统性地对比它们的优缺点。
以下是多CPU系统的主要特点:
- 优点:拥有近乎无限的存储或内存。可以执行多种非常复杂的、彼此不同的任务。可以采用“数据向计算迁移”或“计算向数据迁移”的策略。例如,可以选择在数据所在地进行计算以减少传输开销。
- 缺点:计算可能成本高昂。需要管理网络或CPU间的通信。不同节点间的配置差异可能很大。
以下是多GPU系统的主要特点:
- 优点:通信形式有限且通常预先明确定义。所有GPU重复执行相同的任务,这是一大优势,意味着成本通常更低。如果所有GPU在同一台机器内,它们可以近乎无缝地协同工作。
- 缺点:计算受限于分支方案或更复杂的计算模型。因此,它不一定适合处理少量极其庞大复杂的任务,而更适合将问题分解。但这并不一定使编程更容易,这也是我们学习本课程的原因。
适用场景与总结 🎯
分析了各自的优缺点后,我们来看看它们分别适用于哪些场景。
多CPU系统的适用场景:
当您希望在较低功耗的边缘计算层面,或者因为数据遍布各地而希望在数据所在地进行计算时,可以使用多CPU系统。如果连接不同处理器很容易,或者需要处理非常复杂、不易分解的任务,多CPU系统是合适的选择。
多GPU系统的适用场景:
多GPU系统擅长执行大量、大量的简单计算,许多问题可以通过这种方式解决。可以将当今主流的机器学习视为典型例子,TensorFlow等类似库将任务分解成可在这些网络上频繁执行的单元。如果您要执行的计算不常变化,并且成本和计算位置对您很重要(例如,您不想花费大量资金购置机架式服务器,而只想在系统中安装一块显卡),那么多GPU系统会非常强大。


总结:
本节课中,我们一起学习了多CPU与多GPU系统的对比分析。我们回顾了单GPU与多GPU的通信架构,探讨了多CPU系统通过总线或网络通信的不同模式,系统比较了二者在灵活性、成本、控制力和适用性方面的优缺点,并最终明确了它们各自最适合的应用场景。理解这些核心差异是进行高效并行与分布式编程的基础。
068:CUDA多GPU编程模型 🚀
在本节课中,我们将要学习CUDA在多GPU系统下的编程模型。我们将了解如何管理多个设备、如何设置设备间通信以及如何利用统一寻址等高级功能来简化多GPU编程。
概述
上一节我们介绍了CUDA编程的基础。本节中我们来看看当系统拥有多个GPU时,CUDA提供了哪些模型和工具来管理和协调它们的工作。
CUDA拥有一套设备管理系统,无论系统是单设备还是多设备,这套系统都能工作。开发者可以根据命名约定、硬件版本等属性在多个设备间进行选择。系统底层提供了许多方法,允许我们查询诸如全局内存容量、不同类型内存的速度等属性。此外,CUDA还支持设置进程间通信,这意味着不同GPU上的处理器可以相互通信。还有一些方案和方法允许你在所有GPU(甚至包括CPU)上使用统一的寻址方案,从而简化编程。例如,当你指定内存时,无需担心它具体位于哪个设备上,CUDA架构会自行处理。
选择与设置设备
首先需要确定要使用哪些设备。如果只使用单个设备,可以采用默认设置,无需特别指定。但如果系统有多个设备,或者你想确认设备数量,可以使用 cudaGetDeviceCount 函数。
以下是查询设备数量的代码示例:
int deviceCount;
cudaGetDeviceCount(&deviceCount);
获取设备数量后,你可以查询特定设备的属性,并基于这些信息选择设备。cudaGetDeviceProperties 函数可以返回设备的详细属性。
基于这些信息,你可以根据匹配特定属性(如全局内存大小)来选择设备,或者根据一个非常具体的设备ID来设置当前设备。你还可以设置多个设备,并让CUDA来处理哪个内核在哪个设备上执行。这在某些情况下很方便,但如果你期望它们在同一台机器上并共享全局内存(且未使用统一内存),则可能会带来问题。
设备间通信与资源共享
假设你已经设置了多个GPU,接下来可能需要共享内存句柄。这需要你打开和关闭它。共享内存句柄意味着不同GPU上的进程可以使用该句柄进行进程间通信。
此外,你还可以在GPU之间的共享流上创建事件。随着课程的深入,我们将了解更多关于事件的知识,它们功能强大。但有时你需要共享实际数据,这可以通过内存句柄函数来实现。
统一寻址与对等访问
最后一个主要功能块是使用统一寻址或对等访问。在大多数情况下,统一寻址由CUDA框架处理,开发者无需过多担心。
但有一件事你可能需要做:如果你使用不同版本的CUDA或不同的硬件(因此可能需要使用不同版本的CUDA),你可能希望使用 cudaPointerGetAttributes 函数,并专门询问关于统一寻址的支持情况。
假设查询结果显示支持良好,你设置了多个有效设备并启动了内核,那么接下来可以授予设备对等访问权限。这是基于设备ID完成的,通过 cudaDeviceEnablePeerAccess 函数实现,你也可以使用相应的方法禁用它。你可以使用 cudaDeviceCanAccessPeer 函数来查询两个设备之间是否能够建立对等访问。
一旦确认两个设备可以互访,你就可以使用 cudaMemcpyPeerAsync 函数在它们之间进行内存复制。这意味着这两个设备将在特定时间段内共享内存。
下图展示了多GPU编程中设备选择、通信与数据拷贝的流程:

下图进一步说明了在启用对等访问后,GPU间直接进行数据拷贝的路径:

总结


本节课中我们一起学习了CUDA的多GPU编程模型。我们了解了如何查询和选择设备,如何通过内存句柄和事件实现设备间通信与同步,以及如何利用统一寻址和对等访问功能来简化跨设备的数据传输。掌握这些概念是进行高效多GPU并行计算的基础。
069:多GPU实践项目 🖥️
在本节课中,我们将快速浏览多GPU识别实验活动。我们将学习如何编写一个程序来识别系统中的GPU设备,并获取它们的属性信息,为后续选择特定GPU执行计算任务打下基础。
概述
本次实验的核心目标是编写代码来检测系统中可用的GPU数量,并获取每个GPU的详细属性。通过这个过程,我们可以理解如何在多GPU环境中进行设备选择和配置。

实验步骤
以下是完成此实验活动的主要步骤。
- 构建项目:点击构建按钮,执行
make clean build命令。此操作将从multi_gpu.cu源文件生成可执行文件multi_gpu.exe。 - 运行程序:运行生成的可执行文件。程序执行后,会将输出结果写入
output.txt文件。
代码逻辑解析
上一节我们介绍了实验的操作步骤,本节中我们来看看实现这些功能的代码逻辑。
代码的核心流程非常简单。首先,我们需要获取系统中GPU设备的数量。
int device_count;
cudaGetDeviceCount(&device_count);
接着,我设置一个默认的“已选择设备”索引为 -1。然后,创建一个循环来遍历所有检测到的GPU设备。
在循环内部,我们使用 cudaGetDeviceProperties 函数来获取每个GPU的属性,并将其存储在一个 cudaDeviceProp 结构体变量中。
cudaDeviceProp device_prop;
cudaGetDeviceProperties(&device_prop, device_index);
以下是代码输出的一些关键属性,这些通常是选择GPU时最重要的考量因素:
- 设备名称 (
device_prop.name) - 计算能力版本 (
device_prop.major和device_prop.minor) - 全局内存大小 (
device_prop.totalGlobalMem) - 每个块的最大线程数 (
device_prop.maxThreadsPerBlock)
你可以根据需求设定最小或最大值标准(例如,最低计算能力版本,或所需的最小全局内存),然后判断当前设备是否符合条件。
程序会输出所有GPU的统计信息。一旦完成评估,你可以通过某种机制(例如选择第一个符合条件的,或性能最好的)确定要使用的GPU,并记录下其索引。之后,你就可以通过 cudaSetDevice 函数让内核程序运行在所选的特定GPU上。
cudaSetDevice(chosen_device_index);
总结与思考
本节课中我们一起学习了多GPU识别的基本方法。我们了解了如何获取设备数量、查询设备属性,并基于这些信息为计算任务选择合适的GPU。
这个实验的逻辑设计得非常灵活。即使你当前的环境只有一个GPU设备,这套处理多设备选择和单设备适配的代码同样可以正常工作。请记住这一点,因为在某些部署环境中,你可能确实无法访问多个设备。
在后续的作业中,你会遇到一个非常类似的任务。建议你查阅CUDA工具包文档,了解更多可用的设备属性,并思考在实际场景中,你会根据哪些标准在多个GPU之间做出选择。

希望你能享受这个实验过程!
070:多GPU编程作业详解 🖥️
在本节课中,我们将学习如何完成约翰霍普金斯大学《GPU编程》课程中关于多GPU编程的作业。我们将详细解析作业的四个步骤,并理解如何通过代码逻辑选择不同的GPU设备来执行计算任务。
作业概述
本次作业的核心任务是编写或更新多GPU环境下的CUDA代码。你需要创建一个机制,根据GPU设备的属性(如计算能力、内存大小等)智能地选择并设置要使用的设备,并将此选择应用于内核的执行。
作业步骤详解
以下是完成本次作业需要遵循的四个具体步骤。
第一步:编写或更新代码
首先,你需要在项目文件 multi_gpu.cu 中编写或更新你的CUDA代码。代码的关键在于实现一个设备选择逻辑。
上一节我们介绍了作业的整体目标,本节中我们来看看具体的代码要求。代码框架已经提供,但你需要填充核心的设备选择与设置部分。


- 核心任务:你需要实现一个函数或逻辑块,根据系统中的一个或多个GPU设备的属性(例如
cudaDeviceProp结构体中的信息)来决定使用哪个设备。 - 重要提示:代码中不包含内核设置的具体内容,这完全由你自由设计和实现。你的主要精力应放在设备选择机制上。
以下是设备选择与设置的核心代码结构示例:
// 示例:获取设备数量并遍历其属性
int deviceCount;
cudaGetDeviceCount(&deviceCount);
for (int i = 0; i < deviceCount; ++i) {
cudaDeviceProp prop;
cudaGetDeviceProperties(&prop, i);
// 在此处添加你的设备选择逻辑
// 例如:选择计算能力最高的设备
// if (prop.major > bestMajor) { ... }
}
// 根据你的逻辑选择设备后,使用 cudaSetDevice 进行设置
cudaSetDevice(chosenDeviceId);
// 确保在启动内核时,计算将在你设置的设备上进行
myKernel<<<blocks, threads>>>(...);
第二步:构建项目
代码编写完成后,点击开发环境中的“构建”(Build)按钮。此操作会将你的CUDA源代码编译为可执行程序。
第三步:运行程序
构建成功后,点击“运行”(Run)按钮。程序将执行,并根据你的代码逻辑选择GPU设备并运行内核。
程序运行成功后,会在项目根目录下的 output_do.txt 文件中生成输出结果。请检查该文件以确认程序运行符合预期。
第四步:提交作业
最后,点击“提交作业”(Submit Assignment)按钮。系统会自动将你的作业提交以供评分。你可以在提交界面查看作业的提交状态和评分结果。
注意事项与评分标准
在完成作业时,请特别注意以下几点,它们与评分直接相关。
- 输出语句:请不要修改代码中已有的
printf打印语句。这些输出用于自动评分系统验证你的设备选择逻辑是否正确。 - 选择逻辑:你需要在代码中添加逻辑,根据打印出的设备信息来决定执行哪个设备。你可以自由添加更智能的搜索和选择策略。
- 操作顺序:请严格按照“构建 -> 运行 -> 提交”的顺序点击按钮,以确保作业被正确提交。
总结

本节课中我们一起学习了多GPU编程作业的完整流程。我们了解到,作业的核心是实现一个基于设备属性(如 cudaDeviceProp)的智能GPU选择机制,并通过 cudaSetDevice() 函数将选择应用于实际计算。记住,保持输出语句不变、按顺序操作按钮是成功提交作业的关键。现在,你可以开始动手实践,设计你的多GPU选择策略了。
071:CUDA流与事件机制 🚀
在本节课中,我们将要学习CUDA编程中的两个核心概念:流(Streams) 和事件(Events)。我们将探讨它们如何工作,以及如何利用它们来提升程序的并发性和执行效率。
概述
到目前为止,您的CUDA程序可能只使用了默认流。在这种模式下,所有内核(kernel)默认在同一个流上顺序执行,并且通常使用可分页内存(pageable memory)进行数据传输。虽然从某些角度看这更易于使用,但它在效率上并非最优。
上一节我们介绍了默认流的基本执行模型,本节中我们来看看如何通过创建用户定义的流和使用事件来优化程序。
默认流的局限性
在默认流模式下,程序的执行流程如下:
- CPU程序将数据复制到可分页内存。
- 当需要执行内核时,数据从可分页内存复制到固定的锁页内存(pinned memory)。
- 数据再从锁页内存复制到GPU的全局内存(如显存、常量内存、纹理内存等)。
- 内核在默认流上顺序执行。
- 执行完毕后,数据通过锁页内存传回CPU程序。
这种模式的主要特点是:
- 内核间同步:内核在单个流上顺序执行,一个内核必须完全结束后,下一个才能开始。
- CPU异步:CPU程序在发起内核调用后可以继续执行,无需等待内核结束,从这个角度看它是异步的。
- 效率非最优:数据传输路径较长,且内核执行缺乏真正的并行性。
使用用户定义的流
为了提升效率,我们可以创建自己的流,即用户流(user stream)。这需要使用锁页内存(pinned memory),并直接在锁页内存和GPU之间复制数据。

以下是创建和使用流的基本步骤:
- 分配锁页内存:使用
cudaMallocHost或cudaHostAlloc在主机上分配锁页内存。 - 创建流:使用
cudaStreamCreate创建一个新的CUDA流。 - 异步执行:在内核启动和内存复制函数(如
cudaMemcpyAsync)中指定要使用的流。
通过使用多个流,我们可以让不同的内核和内存复制操作并发执行。虽然单个流内的内核仍是顺序的,但不同流之间的操作可以重叠,从而更充分地利用GPU资源。

上图展示了多流编程的模型。您可以将默认流与自己定义的流结合使用,让更多内核有机会同时运行。效率的提升通常不是线性的,例如,当一个内核遇到缓存未命中(cache miss)导致某些线程空闲时,GPU可以将计算资源分配给另一个流上的内核。
需要注意的是,在没有事件机制介入时,这些流彼此完全独立且异步地运行,并且在此刻,它们与CPU程序之间也是异步的。
非阻塞流与更高阶的异步

为了获得更强的异步能力,可以将流定义为非阻塞(non-blocking) 的。这意味着您可以在同一个流上指定多个内核。当某个内核在运行时,如果该流暂时无事可做(例如等待数据),GPU可以切换到执行该流上的另一个已就绪的内核,并确保前一个内核最终完成。

当然,这存在限制,就像GPU上能同时存在的流数量有限一样。但通过这种方式,您可以获得更快的执行速度和更高的效率,代价是对执行顺序的控制力有所减弱。
事件机制的作用
当您需要对流和CPU程序的执行顺序与时机进行精细控制时,就需要用到事件(Events)。

事件的核心用途是标记动作的开始和完成。例如,您可以记录一个内核的启动事件和结束事件。
以下是事件的主要功能:
- CPU同步:CPU程序可以等待某个流或内核上的特定事件完成,然后再继续执行。
- 流间依赖:您可以在不同流之间建立依赖关系。一个流上的内核可以设置为必须等待另一个流上的某个事件完成后才能开始执行。
- 性能分析:记录事件的时间戳,用于测量不同操作的执行时长。

流与事件的典型应用场景
了解了流和事件的基本概念后,我们来看看它们的一些典型应用场景。
以下是流与事件机制的主要应用:
- 等待实时数据并批量处理:CPU持续接收数据,积累到一定量后,通过事件触发,将整批数据发送给GPU内核处理。
- 流式处理:与上述“流编程”不同,这里指持续地将少量或部分数据发送给不同的内核进行处理,形成流水线。
- 构建复杂工作流:控制不同操作的执行顺序。例如,在不同的流上依次执行kernel0、kernel1、kernel2,实现高效但可控的并行。工作流可以“分叉”(fan-out)并行处理,再“汇聚”(fan-in)合并结果。
- 管理多CPU/GPU资源:在拥有多个CPU核心或GPU的环境中,事件可以帮助协调和控制不同设备上的任务执行时机。



总结

本节课中我们一起学习了CUDA中的流与事件机制。我们首先分析了只使用默认流在效率和并发性上的局限性。接着,我们探讨了如何通过创建用户定义的流和使用锁页内存来提升数据传输效率和内核并发度。然后,我们介绍了非阻塞流以实现更高级的异步操作。最后,我们讲解了事件机制,它为我们提供了在CPU程序与多个流之间、以及不同流之间建立依赖和进行同步控制的强大工具。掌握流与事件是进行高效、复杂CUDA并行编程的关键。
072:CUDA流语法解析 🚀
在本节课中,我们将学习CUDA流的语法。我们将了解如何创建流、如何在流中管理内存以及如何管理流本身。
流对象的创建
首先,我们来看如何创建CUDA流对象。创建流有三种主要方式。
以下是创建CUDA流的三种方法:
- 基本创建:仅通过变量名创建流。
- 带标志创建:可以添加标志,例如指定流相对于CPU是阻塞还是非阻塞,从而控制CPU是否等待流完成。
- 带优先级创建:可以为流指定优先级,这样流中执行的操作将具有不同的优先级。你可以创建超过硬件限制数量的流,系统会根据优先级在需要时调度它们。
回调与内存管理
上一节我们介绍了如何创建流,本节中我们来看看如何为流添加回调以及如何管理流中使用的内存。
对于复杂的工作流,可以创建带有回调的流。这意味着CPU可以继续执行其他任务,当该流中的所有操作完成时,系统会自动调用一个预先指定的函数(函数指针)。这样,无论CPU当时在做什么,都会执行这个回调函数。
流中使用的内存必须是固定内存(Pinned Memory),或者使用统一寻址内存(Unified Addressing Memory)。你可以异步地将内存附加到流上,这意味着内存将在附加的那一刻起对指定的流可用,而不是通过复制操作。
附加内存的函数参数包括:流变量、设备内存指针、内存长度和标志。标志可以指定该内存对所有设备、所有流可用,或仅对主机可用(这对于在后台执行某些对CPU全局内存不可见的操作很有用),或者默认地仅对特定设备上的单个流可用。
内存分配与异步拷贝
现在,我们来了解具体的内存分配和异步拷贝操作。
使用固定内存进行分配时,如果使用运行时API,则调用 cudaMallocHost;如果使用驱动API,则调用 cuMemAllocHost。使用统一寻址内存时,则调用 cudaMallocManaged 函数。
如果你需要进行异步内存拷贝(无论是从主机到设备、设备到主机、设备到设备还是主机到主机),可以使用 cudaMemcpyAsync 函数。你需要提供目标指针、源指针、数据大小、拷贝类型(例如 cudaMemcpyHostToDevice)以及执行该拷贝操作的流。
流的管理与同步
假设你已经创建了流,接下来需要学习如何管理它们。
首先需要认识到,所有流管理的API调用都会返回一个状态码。cudaSuccess 表示流操作成功完成,其他错误状态码则与流初始化、上下文是否正确等问题相关。
如果你想查询一个流的状态(例如是否已完成),可以使用 cudaStreamQuery 函数,并传入流变量,它会返回流是否已完成。
如果你想同步主机执行,即让主机代码暂停并等待某个流中的所有已调度操作完成,可以使用 cudaStreamSynchronize 函数。
流的销毁
最后,也是至关重要的一步,是流的销毁。
你应该始终在流使用完毕后销毁它。即使流上的所有操作都已完成,或者你准备退出程序,也应调用 cudaStreamDestroy。这个调用允许已调度的操作继续完成。需要注意的是,主机代码会继续执行,如果你在销毁流后没有进行异步内存拷贝,虽然已调度的操作仍在运行,但你可能无法再访问相关的内存。请记住这一点,但务必像释放内存一样,总是销毁你创建的流。


本节课中我们一起学习了CUDA流的完整语法。我们从创建流的三种方式开始,接着探讨了如何为流设置回调以及流中内存的管理原则,包括固定内存和统一寻址内存的使用。然后,我们介绍了如何进行异步内存拷贝。最后,我们详细讲解了如何查询流状态、同步流以及最终销毁流,这是确保程序资源正确释放的关键步骤。
073:CUDA事件语法详解 🎯
在本节课中,我们将学习如何利用CUDA事件API语法。事件是CUDA编程中用于精确控制执行流程和同步的重要工具。
概述
CUDA事件是一种用于标记和同步GPU操作执行点的机制。它们可以用于流之间的同步、主机与设备之间的同步,从而实现对并行执行顺序的精细控制。
事件的基本概念
CUDA流中的事件状态很简单:要么已发生,要么未发生。当我们说事件“已发生”时,意味着执行流已经完全到达了该事件点。你设置了一个事件,但实际上,只有当流执行到该点时,事件才被视为发生。
事件可以用于流之间的同步,也可以用于主机与流之间的同步。这为你提供了大量控制权,因为你现在可以控制不同流之间的执行顺序。但请注意,在单个流内部,内核的执行顺序是严格按照入队顺序进行的。
事件的创建与使用
以下是创建和使用CUDA事件的基本步骤。
创建事件


事件的创建分为两步:
- 使用
cudaEvent_t语法实例化一个变量。 - 调用创建函数,该函数会在设备上创建事件,并在主机上通过之前实例化的事件变量来引用它。
cudaEvent_t event;
cudaEventCreate(&event);
记录与查询事件
事件通过 cudaEventRecord 函数被记录或标记为“未发生”。这基本上是说:将这个事件放入一个流中,我稍后会引用它。例如,当一个内核完成或一个流执行到某个点时。
之后,你可以查询事件的状态。这允许你判断操作是否仍在运行,从而决定是等待还是执行其他任务,避免了紧密的耦合。
// 在流中记录事件
cudaEventRecord(event, stream);
// ... 执行其他操作 ...
// 查询事件是否完成
cudaEventQuery(event);
等待事件
你可以命令CUDA流等待一个事件。这意味着,一旦设置了这个等待,流中所有已入队的内核和操作都将暂停,直到指定的事件发生。
// 让流等待事件发生
cudaStreamWaitEvent(stream, event, 0);
同步事件
事件同步通过 cudaEventSynchronize 函数完成。这会阻塞主机线程,直到指定的事件发生。这意味着在该事件点之前的所有流操作都必须完成,主机才能继续执行。这与流同步或内核同步功能类似,但粒度更细。
// 主机等待事件完成
cudaEventSynchronize(event);
事件的应用场景
CUDA事件有多种用途,以下是几个核心应用:
- 同步数据拷贝:确保在某些内核执行之前,数据拷贝操作已经完成,数据具有一致性。
- 控制流启动:可以基于一个事件来完全启动另一个流。例如,主机可以在一个流完成后,根据触发的事件来启动另一个流。
- 实现混合执行流程:允许主机和GPU交错执行任务。主机可以在GPU执行某些任务时处理其他工作,或者等待GPU的中间结果进行后续处理,从而实现复杂的、交织的执行流程。
- 资源管理与性能调控:可以用于在特定时间点确保获得足够的GPU计算资源,或者根据处理器的负载情况来调度任务。
总结


本节课我们一起学习了CUDA事件的核心语法与应用。我们了解到,事件是标记GPU操作执行点的工具,通过 cudaEventCreate、cudaEventRecord、cudaEventQuery、cudaStreamWaitEvent 和 cudaEventSynchronize 等函数,可以实现流内、流间以及主机与设备间的精细同步。事件的主要用途包括确保数据一致性、控制任务执行顺序以及构建主机与GPU协同的复杂工作流。掌握事件的使用,是进行高效、可控的CUDA并行编程的关键一步。
074:CUDA流与事件应用场景 🚀
在本节课中,我们将探讨CUDA流(Streams)与事件(Events)的三个主要应用场景。这些示例旨在启发你思考如何在自己的项目中利用这些强大的异步执行与同步工具。虽然应用方式并非无限,但可能性非常丰富,这里只是一个起点。

概述 📋
CUDA流允许在GPU上并发执行多个任务序列,而事件则用于精确同步这些任务或监控其进度。结合使用它们,可以构建高效、响应灵敏的应用程序,特别是在需要处理持续输入、协调多个独立处理流水线或实现复杂工作流依赖的场景中。
应用场景一:交互式图像处理流水线 🖼️
上一节我们介绍了流与事件的基本概念,本节中我们来看看它们如何应用于需要持续用户交互的场景。
一个常见的应用场景是交互式图像处理。例如,程序可能持续要求用户输入要处理的图像。用户输入图像名称后,程序启动一个流来执行灰度转换。由于主机(CPU)在此过程中无需干预,它可以等待一个事件——即灰度转换完成的事件。
当该事件触发后,程序可以再次询问用户:“是否需要添加模糊效果?是否需要镜头光晕?是否需要马赛克?” 用户回答“是”、“否”或提供具体参数值。然后,基于这个用户输入,相应的事件会触发并启动下一个处理步骤(例如高斯模糊)。该步骤完成后,结果可能被保存到文件系统或发布到网络。这个过程可以持续循环进行,形成一个“双循环”结构,实现CPU与GPU不同部分之间的持续同步与异步处理。
以下是此类应用的关键特点:
- 异步处理:GPU处理图像时,CPU可以自由处理用户交互。
- 事件驱动:每个处理步骤的完成通过事件来通知和触发下一步。
- 持续响应:系统能够对用户的连续请求做出即时响应。
应用场景二:音视频文件的并行处理 🎬
接下来,我们探讨流与事件在协调多个独立处理流水线时的作用,例如处理音视频文件。
这个场景与第一个类似,但交互对象从用户变成了文件系统。假设文件系统只能间歇性地提供视频帧(例如,每隔一段时间有一帧新数据可用)。你希望用一个流来压缩视频,用另一个高度优化的流来压缩音频。
理论上,你可以让处理循环持续运行,不断检查新帧并进行压缩。但这样做可能低效,且难以管理文件系统的访问时机(例如,避免在读取文件时写入)。这时,事件就非常有用了。

你可以利用事件来精确计时各项任务的完成时刻,确保文件读写操作不会冲突。这对于处理流程长、步骤多的任务尤其有效。基本模式是:完成一个步骤(如解码)后触发事件,该事件通知并启动下一个依赖步骤(如特效处理或编码),同时确保底层数据(如文件)处于可用状态。

应用场景三:金融市场的预测与交易系统 📈
最后,我们看看流与事件如何用于构建具有多阶段、依赖关系复杂的工作流,例如金融分析系统。
流可以承担不同的角色。例如,第一个流负责预测:基于当前数百上千只股票、商品的状态,持续预测其价格变化。第二个流负责决策:基于某种模型和模拟,分析预测结果,决定当前最佳买入标的、持有时间等。这两个阶段通过事件进行同步。
具体流程是:预测流完成一批计算后,触发一个事件。决策流等待该事件,一旦事件发生,便立即开始基于最新预测结果进行计算并生成交易信号。在真实系统中,执行交易后,其本身又可能对市场产生微小影响,从而形成一个反馈循环。事实上,存在一系列金融应用,专门利用GPU进行高速计算,以预测市场变化、快速执行交易,甚至试图影响市场系统。
总结 🎯
本节课中我们一起学习了CUDA流与事件的三个典型应用场景:
- 交互式图像处理流水线:利用事件实现用户交互与GPU处理步骤之间的无缝衔接。
- 音视频并行处理:使用事件协调多个独立的处理流(如视频流和音频流),并管理资源(如文件)访问冲突。
- 多阶段金融分析系统:通过事件同步预测、决策等连续且依赖的计算阶段,构建复杂的工作流水线。

这些场景展示了如何通过流实现任务并发,并通过事件实现精细化的同步与控制,从而充分发挥GPU的并行计算能力,并提升整个应用程序的效率和响应速度。
075:CUDA流与事件作业指导 🚀
在本节课中,我们将学习如何完成关于CUDA流与事件的编程作业。我们将解析作业代码的结构,理解三个核心执行场景,并学习如何正确地准备内存、流和事件以实现不同的并发模式。
概述
本次编程作业的核心是实践CUDA的流与事件机制。代码结构主要包含三个部分,分别对应三种不同的流执行策略:完全异步流、使用阻塞内核的朴素双流执行,以及经过优化的双流执行。作业要求我们遵循代码注释的指引,完成特定函数的实现,并最终生成用于评分的输出文件。

作业结构与要求
在深入代码之前,我们先了解作业的整体要求。完成代码后,你需要构建并运行程序。程序会生成一个包含主机输入和输出的文本文件,总共有六种组合,对应不同的运行场景。当你对输出结果有信心时,需要点击提交作业按钮,上传生成的输出文件以供评分。
代码结构解析
现在,让我们来看作业代码的主要结构。你的编程作业包含三个主要部分。
以下是三个核心的执行场景:
- 运行完全异步流:在此场景中,运行在流上的任务彼此独立,也独立于CPU程序。
- 运行阻塞内核(双流,朴素版):此场景涉及使用事件在内核之间进行同步。
- 运行阻塞内核(双流,优化版):此场景是第二种的优化版本,关键在于更有效地使用事件进行同步。
需要特别注意,printHostMemory函数及其周围的代码是评分过程的一部分,请不要修改。
内核函数说明
在开始实现各个场景之前,我们先了解将要使用的四个内核函数。
以下是四个核心的内核函数:
- A1:将一个外部值加到浮点数组的所有元素上。公式可表示为:
output[i] = input[i] + value。 - B1:对数组中特定范围内的每个元素进行平方运算。公式可表示为:
output[i] = input[i] * input[i]。 - A2:从数组的每个元素中减去一个值。公式可表示为:
output[i] = input[i] - value。 - B2:将数组的每个元素除以一个值特定次数。这类似于之前见过的操作,即对一块内存进行乘法或除法运算。
内核A1与B1配对,A2与B2配对。根据不同的执行顺序和同步方式,最终的内存结果会有所不同,这是正常的。
场景实现详解
上一节我们介绍了作业的整体结构和内核函数,本节中我们来看看每个具体场景的实现要点。
场景一:完全异步流
在runAsyncStreams函数中,你需要实现完全异步的执行。关键步骤包括准备设备内存、准备CUDA流,然后在两个不同的流上异步执行所有内核。代码注释会详细指导你每一步该做什么,例如如何使用A1和B1内核。记住,对于完全异步的场景,你应该使用异步内存拷贝函数。
场景二:阻塞内核(朴素双流)
在runStreamsBlockingKernelTwoStreamsNaive函数中,目标是使用事件来强制特定的执行顺序。你需要先在不同的流上异步执行两个内核,然后再执行另外两个。这里的“朴素”指的是尚未对事件的使用进行优化,只是简单地用它们来规定顺序。虽然部分同步可以通过流同步实现,但使用事件是更推荐的做法。
场景三:阻塞内核(优化双流)
在runStreamsBlockingKernelTwoStreamsOptimal函数中,你需要实现一个优化的版本。优化的核心在于思考同步发生在哪里:你需要在主机与流之间进行同步,而不是在流与流之间进行不必要的同步,以避免意外的执行顺序问题。仔细阅读代码中大量的注释,理解如何准备流和设备内存是实现优化的关键。
工具函数与准备工作
为了完成上述场景,代码提供了一系列工具函数。你需要自己完成流的创建和准备工作,但之前的实验课已经为此提供了很好的示例。
以下是提供的主要工具函数:
allocateDeviceMemory: 分配设备内存。allocateHostMemory: 分配主机内存。注意,这里要求使用固定内存(Pinned Memory),使用统一寻址内存也可以,但固定内存同样简单有效。copyToDeviceAsync,copyToDeviceSync,copyToHostAsync,copyToHostSync: 用于在主机和设备间进行同步或异步的内存拷贝。请根据场景选择正确的版本(异步场景用异步拷贝)。deallocateMemory: 释放内存。getNumBlocks: 根据输入参数计算需要启动的线程块数量。
总结

本节课中,我们一起学习了CUDA流与事件编程作业的完整指南。我们分析了作业的三个核心场景:完全异步执行、使用事件的朴素同步执行以及优化后的事件同步执行。我们了解了四个关键的内核函数(A1, B1, A2, B2)以及一系列必须使用的工具函数,特别是涉及固定内存分配和异步拷贝的部分。请务必仔细阅读代码中的详细注释,并参考之前的实验经验,逐步完成每个函数的实现,最终生成正确的输出文件以提交作业。
076:基于输入数据的GPU伪代码开发 🚀
在本节课中,我们将学习如何根据输入数据来开发GPU伪代码。伪代码是一种使用高级语言结构来描述算法逻辑的文本大纲,它不依赖于特定编程语言,但包含常见的编程结构,如循环和条件分支。通过伪代码,我们可以在实际编码前进行设计和思考,并将设计过程以注释形式保留在最终代码中,实现自我文档化。
什么是伪代码? 📝
上一节我们介绍了课程目标,本节中我们来看看伪代码的具体定义。根据Martin Bates在《Interfacing PIC Microcontrollers》中的描述,伪代码使用高级语言结构来展示程序的基本流程,包括顺序处理、选择和循环。它是一种通用的算法描述方式,不绑定于特定语言,但会融入常见的语言结构,如for循环和do while循环。
以下是一个简单的伪代码示例,用于在图形程序中绘制一条线:
输入起点 (x_start, y_start) 和终点 (x_end, y_end)
计算 delta_x = x_end - x_start
计算 delta_y = y_end - y_start
设置 difference = 2 * delta_y - delta_x
设置 y = y_start
循环 x 从 x_start 到 x_end:
绘制点 (x, y)
如果 difference >= 0:
y = y + 1
difference = difference - 2 * delta_x
difference = difference + 2 * delta_y
这个示例展示了常见的伪代码结构,如变量、for循环和if条件分支。即使不理解具体逻辑,也能相对容易地将其转化为实际代码。
为什么使用伪代码? 🤔


使用伪代码有多个好处。首先,它允许你在实现前进行设计和思考,并将这些思考以注释形式融入代码,实现自我文档化。其次,伪代码不局限于特定语言,便于跨语言理解和移植。此外,你可以对伪代码进行数学或逻辑证明,这在算法课程中常见,如使用大O符号进行复杂度分析。最后,伪代码可以分解为多个部分,便于创建函数或查找现有库实现。
输入数据如何影响算法设计? 🧩
输入数据的特性直接影响算法设计。你需要从多个角度思考数据,以确定算法如何与之交互。


以下是评估输入数据时需要考虑的几个方面:
- 数据类型:数据是简单的整数、字符串、字符、浮点数,还是更复杂的结构?
- 数据结构:数据是扁平的数组,还是层次化的数据结构或字典?
- 数据规模:数据是否占用大量内存,如图像文件,或是许多小对象,如整数数组?
- 数据维度:数据是否是多维的,例如具有X、Y、Z维度?这有助于思考如何在网格、块和线程中使用它。
- 数据动态性:数据内容是否随时间变化?是否有多维部分或某些值可能为空?你需要考虑如何处理这些情况。
- 现有算法:是否有现成算法可以处理这类数据?如果没有,你是否能将数据转换为适合现有算法的形式?
算法设计技巧 💡
设计算法时,可以遵循以下技巧:
- 尽可能将算法分解为更小的部分。
- 尝试使用现有库,特别是那些知名且有良好文档的库,来解决部分或全部问题。
- 在代码中寻找设计模式,并避免反模式。
- 始终使用具有代表性的示例数据进行设计。不要只选择简单值或极端情况,要考虑边界条件。
将CPU伪代码转换为GPU伪代码的特定技巧 ⚙️
将CPU伪代码适配到GPU环境时,有一些特定的转换技巧:
- 当你看到
for或while循环时,考虑让每个迭代在一个独立的线程或内核中运行。 - 当有线程块需要操作相同数据时,使用共享内存。
- 如果有全局变量,使用常量。
- 尽量避免递归编程,即使它被支持。
- 将处理复杂逻辑或大型数据结构、且不易分解的代码放在主机代码中。
- 尝试以优化GPU速度和减少分支语句数量的方式移动数据内容,因为分支语句在GPU上代价很高。
总结 📚


本节课我们一起学习了基于输入数据开发GPU伪代码的方法。我们首先明确了伪代码的定义和作用,它是不依赖于特定语言、用于描述算法逻辑的高级大纲。接着,我们探讨了输入数据的各种特性如何影响算法设计,例如数据类型、结构和维度。然后,我们介绍了一些通用的算法设计技巧,如分解问题和使用现有库。最后,我们重点学习了将CPU伪代码转换为GPU伪代码的特定策略,包括利用线程并行化循环、使用共享内存以及优化数据移动以减少分支。掌握这些方法将帮助你在GPU编程中更有效地设计和实现算法。
077:GPU排序算法伪代码-冒泡排序 🧮
在本节课中,我们将学习排序算法,并重点分析其在GPU上的伪代码实现以及这类算法所面临的挑战。排序算法通常并非天然适合GPU架构,但我们将通过分析几个经典算法来理解如何将其映射到GPU上。
我们将聚焦于三个广为人知且文档丰富的算法:冒泡排序、基数排序和快速排序。虽然它们并非开箱即用就能高效运行于GPU,但网络上存在大量伪代码和实现,将其移植到GPU需要深入的思考。
冒泡排序算法原理 🫧




上一节我们介绍了本课程将要分析的三种排序算法。本节中,我们来看看第一个算法——冒泡排序的工作原理。冒泡排序是最简单直观的排序算法之一,尽管其效率并不高。
通过一个动画演示可以清晰地看到冒泡排序的工作过程。你会注意到,它的核心是成对的比较,从左到右遍历数据。


这种遍历方式对人类理解而言非常自然。其基本思路是使用一个循环遍历索引,并根据索引位置与它之前或之后的元素进行比较。


然而,需要注意的是,该算法需要进行多轮遍历。第一轮遍历结束后,只能确保最大的元素被“冒泡”到正确位置(即数组末尾)。


为了将每一个元素都归位,算法必须持续运行。对于一个包含 n 个元素的数组,在最坏情况下,它需要进行大约 n-1 轮遍历,每轮遍历的比较次数递减。因此,其时间复杂度接近 O(n²)。
冒泡排序的一个特点是,其效率主要由数据规模决定,而不随数据本身的复杂程度变化。数据量大,效率就显著降低。
从串行代码到GPU伪代码的思考 💡


上一节我们了解了冒泡排序的基本原理和复杂度。本节中,我们来看看如何将其串行代码转化为适合GPU的伪代码。

观察串行代码,它包含两个嵌套的循环,这对GPU编程构成了挑战。我们可以这样思考:外层循环(控制遍历轮数)可以在主机代码中实现,作为一次次的迭代。
而内层循环(负责每轮遍历中的成对比较与交换)则可以映射到GPU设备内核中,由大量线程并行执行。
以下是将冒泡排序映射到GPU时可以考虑的一种伪代码思路:
- 并行比较:每个线程负责一个或一对数据的比较操作。
- 共享内存:利用共享内存来存储待排序的数据块,以实现线程块内的快速数据交换和状态同步。
- 同步与状态判断:线程需要协同工作,并通过检查自身或其他线程索引的状态(例如,是否发生了交换)来决定是否需要进行下一轮比较。这通常需要线程间的同步操作。


从最简单的CPU代码到GPU代码的转换中,我们学到的重要一课是:这种转换并非自动完成,必须仔细设计。随着算法变得越复杂,我们需要思考的就越多。



总结 📝


本节课中,我们一起学习了排序算法在GPU上实现的挑战,并深入分析了冒泡排序。我们回顾了其 O(n²) 的时间复杂度原理,并探讨了如何将其串行的双循环结构转化为GPU伪代码,关键点在于将内层循环的并行比较任务分配给GPU线程,并妥善处理数据交换与线程同步。这为我们后续理解更复杂的基数排序和快速排序的GPU实现打下了基础。
078:GPU排序算法伪代码-基数排序 🧮


在本节课中,我们将学习第二种GPU排序算法:基数排序。我们将探讨其工作原理、对应的伪代码实现,并分析如何将其应用于非整数类型的数据。
上一节我们讨论了排序算法及GPU伪代码的创建与使用,本节中我们来看看基数排序的具体实现。
基数排序工作原理 📊
下图展示了基数排序的过程。它从一系列数字的最左侧(即最低有效位)开始排序。



排序过程首先处理最右边的数字(即个位)。算法将数字0到8进行排序。例如,在第一轮中,数字6和90的个位是0,608的个位是8。算法根据个位值对整个数字进行排序。
然后,算法移动到下一列(十位)。例如,704的十位数字最小。当同一列中的数字具有相同值时,算法会进行次级排序。
最终,当处理到最后一列(最高有效位,在本例中是百位列)时,整个序列就完成了排序。在整个过程中,例如704会排在751之前,因为它在之前的排序轮次中就已经排在前面了。
这张图非常简略地展示了基数排序的工作方式。
基数排序伪代码分析 💻
现在让我们查看基数排序的伪代码。

首先,你会注意到一个for循环:for j = 1 to D。这里的D代表待排序数值的位数或列数。在之前的例子中,D是3。这个循环应该是一个在主机端执行的外部循环,因为它代表了排序的“趟”数。伪代码中使用的“pass”一词,指的就是完整执行一轮排序。
基数排序的一个优点是,每处理一列时,对该列的排序操作可以在一次内核执行中,由大量线程并行完成。
接下来,伪代码中预设了count[10]数组,因为数字位值范围是0到9,共10种可能。数组初始值设为0。实际上,count[0]将记录在当前处理的列(第j列)中,位值为0的元素有多少个。
然后是一个for循环:for i = 0 to n,其中n是待排序值的总数。这个循环的初始作用是遍历所有值,并统计计数。例如,如果有三个元素在当前列(第j列)的位值是0,那么count[0]就会增加3。这是一个非常适合用GPU内核执行的地方,该内核的唯一任务就是遍历所有值并递增计数。但需要注意,稍后必须有一种方法来同步count数组的值。
接着是变量k的处理。伪代码表示,我们不仅要知道位值为0的计数,还要知道小于等于当前位值的累计计数。例如,当处理位值1时,需要将count[1]和count[0]的计数相加。这本质上是计算前缀和。
查看伪代码,接下来的循环完成了排序的主要工作。它根据已知的计数信息构建结果数组。具体来说,它从最高位值到最低位值遍历上一轮排序的结果,然后根据当前列的位值,将元素放置到正确的位置。例如,如果知道有8个元素在当前列的位值是1,就可以在排序时按顺序放置这8个元素。
理解起来可能有些复杂。建议回顾之前展示排序过程的图片:排好一列后,进入下一列。排序下一列时,需要基于前一列的结果进行次级排序。例如,一个元素因为当前列的值更高而需要前移,但同时也要考虑它在前一列中的排序位置。
当然,这里的for i循环,如同所有for循环一样,可以分离出来作为一个独立的内核。和往常一样,需要考虑内存同步问题。
在循环的最后,将值复制到全局数组中。实际上,在执行for i = n-1这个循环时,你已经将数据复制回了设备的全局内存。然后,这些数据可以被复制到主机内存,以供下一趟迭代使用。

处理非整数数据 🔄
现在,让我们暂时离开伪代码,讨论一下当处理的数据不是整数时,如何应用基数排序。

首先要考虑的是浮点数或小数。浮点数的小数点位置不固定,因此不能直接使用之前串行代码中的按列(位)组织方式。
一种方法是乘以10的幂次,将小数点全部移到最右边,并记录乘了多少次。在排序比较时使用这些调整后的整数值,排序完成后再移回小数点。但这可能不是最精确的方法,因为在表示过程中可能会损失一些精度。
另一种方法是将其视为字符串来处理。我们稍后会讨论字符串排序。可以考虑通过填充零,使所有字符串达到相同的长度和精度,然后进行字符串排序。
字符串可以很好地工作,因为一个字符串本质上就是一套编码系统(如ASCII或Unicode)将大小写字母、特殊字符转换成的数字。例如,大写字母‘A’的编码是65,小写字母‘a’的编码是97。你可以根据编码规则进行排序。你可能需要制定一些规则,比如希望小写字母排在大写字母之前,或者将所有字母转换为小写后再视为相同处理。
如果你要排序的数据不能很好地映射为数值(例如是多维数据),那么基数排序可能就不适合作为你的算法选择。
总结 📝

本节课中,我们一起学习了基数排序算法。我们分析了其从最低有效位到最高有效位逐列排序的工作原理,并详细解读了对应的GPU伪代码实现,包括计数、前缀和及元素重排等关键步骤。最后,我们还探讨了基数排序应用于浮点数和字符串等非整数类型数据时的处理思路与潜在挑战。理解基数排序的并行潜力及其数据适应性,对于在GPU上高效实现排序至关重要。
079:GPU排序算法伪代码-快速排序 🚀
在本节课中,我们将学习最后一个排序算法——快速排序,并探讨其在GPU上的伪代码实现。快速排序是一种高效的排序算法,通过递归地分区数组来工作。
概述
快速排序的核心思想是“分而治之”。它选择一个“基准”值,将数组分为两部分:一部分包含小于基准值的元素,另一部分包含大于基准值的元素。然后,对这两部分递归地应用相同的过程。
颜色图例说明
在接下来的动画演示中,我们将使用不同的颜色来代表数组元素的状态。理解这些颜色对于跟随算法流程至关重要。
以下是各种颜色代表的含义:
- 蓝色:表示当前未排序且未被检查的元素。
- 琥珀色/橙色:表示该元素已处于最终的正确排序位置。
- 黄色:表示当前选定的“基准”值,其他元素正与之比较。
- 绿色:表示被确定为小于基准值的元素。
- 紫色:表示被确定为大于基准值的元素,并且已处于相对于基准的正确一侧。
- 红色:表示该元素小于基准值,但当前位于基准点(或中点)的错误一侧(即右侧),需要被交换到左侧。
动画开始时,所有元素都是蓝色的。随着算法运行,你会看到越来越多的元素变成琥珀色,这些是已归位的元素。黄色是当前比较的基准,绿色和紫色是已分好区的元素,而红色则是需要被移动的元素。
算法工作原理

在快速排序中,首先选择一个基准点(通常是起始索引的值)。算法会尝试为这个基准值找到其在数组中的正确位置。在寻找过程中,会进行元素交换,将小于基准值的元素移动到左侧。基准值会逐渐向右移动,其左侧的元素通过交换变得相对有序。这个过程虽然有些复杂,但效率非常高。

快速排序伪代码分析
快速排序的实现主要包含两个函数:quicksort 主函数和 partition 分区函数。


主函数:quicksort
quicksort 函数负责驱动整个排序过程。它有两个主要任务:调用 partition 函数对数据进行分区,然后递归地对分区产生的左右两个子数组执行快速排序。
需要注意的是,这是一个递归算法。这提醒我们,在GPU上直接实现可能需要仔细考虑,因为GPU对递归的支持有限。不过,它确实可以在CUDA中实现。
递归会一直进行,直到达到基本情况,即当 start 索引大于或等于 end 索引时,这意味着当前需要排序的区间已经没有元素或只有一个元素(已经有序),函数就会返回,递归调用栈会逐层退出。
伪代码结构如下:
function quicksort(array, start, end):
if start >= end:
return // 基本情况:区间为空或只有一个元素
pivot_index = partition(array, start, end) // 分区并获取基准索引
quicksort(array, start, pivot_index - 1) // 递归排序左半部分
quicksort(array, pivot_index + 1, end) // 递归排序右半部分
分区函数:partition
partition 函数是快速排序的核心。它确定当前处理数组区间(由 start 和 end 定义)的基准值(通常选择区间最右侧的元素)。然后,它将一个“分区索引”初始化为区间的起始位置。
接着,函数从区间的第一个元素遍历到最后一个元素(不包括基准值本身)。它将当前遍历到的元素与基准值进行比较。
如果当前元素的值小于基准值,说明它应该位于基准的左侧。函数会将该元素与“分区索引”所在位置的元素进行交换,然后将“分区索引”向右移动一位。这样做的效果是,所有小于基准值的元素都被“推”到了数组的左侧部分。
遍历结束后,“分区索引”指向的位置就是基准值最终应该放置的地方。此时,将基准值与此处的元素交换。至此,基准值就处在了其正确的位置上,并且其左侧的所有元素都小于它,右侧的所有元素都大于它。
函数最后返回基准值的最终索引。这个返回值至关重要,它标志着基准值已归位,并且其左侧和右侧的子数组需要在接下来的递归调用中被分别排序。
分区过程的核心逻辑可以概括为:
pivot_index 跟踪的是“小于基准值的子数组”的边界。每当发现一个小于基准值的元素,就将其交换到边界处,并扩展边界。
算法效率
快速排序之所以在许多情况下比冒泡排序等其他算法更快,主要有以下几个原因:
- 比较次数更少:它并不需要像冒泡排序那样进行大量冗余的成对比较。
- 分而治之:算法将大问题分解为小问题,每次递归处理时,并非操作全部数据,减少了冗余操作。
- 高效性依赖于数据规模:其平均时间复杂度为 O(n log n),这比冒泡排序的 O(n²) 要高效得多。
- 受数据分布影响相对较小:虽然最坏情况(如数组已排序)下性能会退化到 O(n²),但平均性能很好,且可以通过随机选择基准值来优化。
总结


本节课我们一起学习了快速排序算法。我们了解了其“分而治之”的核心思想,分析了其在GPU上的伪代码实现,包括递归主函数 quicksort 和核心的分区函数 partition。我们还通过颜色图例直观地理解了算法的动态执行过程。最后,我们探讨了快速排序高效率的关键所在,即通过分区减少不必要的比较和操作,使其平均性能达到 O(n log n)。理解这个算法为我们在并行计算环境中处理排序问题提供了重要的基础。
080:内存管理与GPU伪代码-冒泡排序 🧠
在本节课中,我们将学习如何将GPU伪代码应用于实际的内存管理。我们将探讨在何处使用全局内存,在何处使用常量内存,并通过冒泡排序的例子来具体分析。
概述
上一节我们开始理解GPU的伪代码。本节中,我们将看看这些概念如何实际应用于内存使用。我们将分析冒泡排序算法在GPU上的实现,并思考数据一致性、内存类型选择以及线程间协作等问题。
内存使用策略
首先,我们需要决定在何处使用全局内存,在何处使用常量内存等。当我们看到一个for循环时,应该意识到,一个内核的执行并不意味着它只处理一两份数据。
线程与数据处理
如果有多份数据需要处理,例如有100份数据和64个线程在运行,可能每个线程需要处理两份数据,执行交换操作后就结束了。在这种情况下,以及在任何线程处理两份或更多数据的情况下,需要考虑的是:
- 如何保持数据的一致性?
- 是否存在某些数据(如当前索引、索引大小)可以作为常量?
循环在GPU上的分解
在外层for循环中,这部分工作可以在主机端代码中完成。而在内层for循环中,则必须在线程内部执行。
以下是一个线程可能处理多个数据项的情况:
// 伪代码示例:线程处理数据块
int thread_id = get_thread_id();
int data_per_thread = total_data / total_threads;
int start_index = thread_id * data_per_thread;
for (int i = 0; i < data_per_thread - 1; i++) {
// 比较和交换操作
if (data[start_index + i] > data[start_index + i + 1]) {
swap(data[start_index + i], data[start_index + i + 1]);
}
}
子数组排序与同步
你可能会注意到,如果有数千个线程和数万份数据,最终可能会得到数千个已排序的子数组。但随后,你必须在所有这些子数组之间进行排序。
因此,接下来你可能需要执行一个同步步骤,然后共享数据,例如改变正在查看的索引。一种非常低效的做法是向某个方向逐个移动所有数据。因为跨所有线程的最左值和最右值将被排序,这与冒泡排序的特性相符,即它遍历所有数据并进行多次排序。
内存利用策略
在这种情况下,你可能需要考虑如何利用内存,包括共享内存、全局内存以及任何常量内存。
交换标志的处理
最后是布尔交换标志的问题。你不会共享一个单一的布尔变量来指示是否在所有线程中交换值,但你可以使用类似位标志数组的东西,基本用来表示该索引是否发生了交换。
或者,你可能拥有相同的数组,然后记录“我把它交换到了这个位置”,这向另一个具有更高索引值的线程指示了某些信息,或者指示它被交换成了什么。
请记住这些要点。也许你能由此构思出更好的冒泡排序版本。


总结


本节课中,我们一起学习了如何将GPU编程的伪代码概念应用于实际的内存管理。我们以冒泡排序为例,分析了在GPU上分解循环、管理线程间数据一致性、选择合适的内存类型(全局、共享、常量)以及处理线程间通信(如交换标志)的策略。理解这些基础是设计高效GPU算法的关键。
081:内存管理与基数排序GPU伪代码 🧠
在本节课中,我们将探讨GPU编程中的内存管理策略,并分析基数排序算法的GPU实现伪代码。我们将重点关注如何利用不同的内存层次结构来优化性能。
概述
基数排序是一种非比较型整数排序算法,它通过逐位处理数据来实现排序。在GPU上实现此算法时,高效的内存管理至关重要。我们将分析一段伪代码,并讨论如何利用全局内存和共享内存来提升性能。
内存使用策略分析
上一节我们介绍了基数排序的基本概念,本节中我们来看看其GPU伪代码实现中的内存使用策略。
观察伪代码,外层循环 for j = 1 to D 由主机(CPU)执行。在这个循环内部,有一系列 for 循环,每个循环都对应一个非常简单的内核线程执行。
简单循环的内存策略
以下是两个相对简单的循环,它们的内存访问模式较为友好:
for i = 0 to nfor k = 1 to 10
这两个循环可以直接使用全局内存来执行。或者,你也可以选择使用共享内存进行计算,然后将共享内存中的结果汇总到全局内存中。对于计数操作,有多种可行的策略。
复杂操作的内存策略
然而,在输入数据内部进行交换或移动以实现排序的操作(对应循环 for i = n-1 to 0)则复杂得多。
你可能需要制定一个策略:将数据的“先前顺序”存储在全局内存中。然后,以线程块或甚至整个线程网格为单位工作的线程组可以回溯这些数据,执行从属的交换操作。
当然,它们需要获取计数值。这个值可能是一个常量,或者在本例中,由于它是递减的,你可能希望将其放在全局内存中。但实际的排序结果,你可以利用共享内存来完成。这是一种加速计算的方法。
不同策略的权衡
如果你全部使用全局内存,那么最后一个循环 for i = 0 to n 的操作会自动完成。
但如果你使用共享内存,并在其中进行排序(如前所述),你可能需要在 for j = 1 to D 这个外层循环中进行多次传递:将数据复制回主机内存,在主机端进行合并,然后再运行下一次传递。
因此,这并不简单,也不像冒泡排序那样容易实现,但在GPU上实现基数排序实际上并不算太困难。
核心概念图示
为了更直观地理解上述内存访问模式,请参考以下伪代码结构示意图:

以及GPU内存层次结构与计算流程的对应关系:


总结

本节课中我们一起学习了在GPU上实现基数排序时的内存管理策略。关键点在于根据计算阶段的特点,灵活运用全局内存和共享内存。对于简单的、规整的计数操作,两者皆可;对于复杂的、需要数据交换的排序阶段,结合使用全局内存存储中间状态和共享内存进行高效计算是常见的优化手段。理解这些策略有助于你设计出更高效的GPU并行算法。
082:内存管理与快速排序GPU伪代码复杂度分析
在本节课中,我们将探讨在GPU上实现快速排序算法时,内存管理的复杂性以及相应的伪代码设计思路。我们将重点关注如何将递归算法转化为适合GPU并行执行的模式,并分析其中的关键挑战。
上一节我们介绍了GPU编程的基本概念,本节中我们来看看一个具体算法——快速排序在GPU上的实现挑战。
内存复杂度分析
实现快速排序或编写不基于通用C代码的伪代码时,内存复杂度是需要考虑的核心问题。通常,你需要使用大量的全局内存。
如果你遵循快速排序及其分区函数的标准范式,那么全局内存的使用是不可避免的。
递归算法的顺序化执行
然而,可能存在一种方法,将递归算法转化为顺序执行的行为。实现方式如下:
在每个层级,每一次遍历可以视为一个阶段。例如,第一次遍历将数据对半分割。
这或许只需要两个线程块来处理。然后,可能由一个线程块内的单个线程来执行后续操作。
当该部分完成后,下一次遍历会进一步细分数据。
你可以将数据细分到由单个线程处理一小部分共享数据的程度。然后,让线程在同步机制下工作。
同步与通信机制
以下是实现同步与通信需要考虑的几个方面:
- 同步机制:可能需要使用流(Streams) 和事件(Events),使得线程仅在数据段就绪时才开始工作。例如,一个线程负责排序索引1到20的数据,并在完成后发出信号。
- 内存共享:必须跨线程块共享内存,使得负责更小子集(例如索引3到8)的线程能够进行其部分的排序。
- 同步点:实现中可能需要设置大量的同步点。使用流和事件会提供多种实现方式。
处理可变输入数组
另一个需要考虑的问题是,如何处理大小可变的输入数组?现实中,你可能会通过处理起始和结束索引,并始终使用相同的共享内存或全局内存来解决。
实际上,数组只是一个指针,你操作的只是它的一个子集。在本示例的实现思路中,更倾向于认为每次遍历传递的是数组以及几乎固定的起止索引,因此实现起来并不十分困难。




本节课中我们一起学习了在GPU上为快速排序设计伪代码时的内存管理挑战。关键点在于将递归算法转化为可并行执行的层级化任务,并妥善处理全局内存访问、线程间同步以及可变尺寸数据的索引计算问题。理解这些概念对于在GPU上高效实现复杂算法至关重要。
083:排序算法实验-冒泡排序 🧪
在本节课中,我们将一起学习GPU编程课程中的一个实验活动:冒泡排序算法的实现。我们将分析一个具体的CUDA代码示例,理解其工作原理,并探讨其局限性以及可能的改进方向。

概述
本节实验旨在通过一个具体的代码实现,帮助我们理解如何在GPU上实现经典的冒泡排序算法。我们将从代码结构入手,分析其核心函数,并讨论该实现对于不同数据规模和线程配置的适应性。
代码结构分析
首先,我们来看一下这个冒泡排序实现的主要结构。main函数包含了除冒泡排序和交换操作之外的大部分逻辑。
以下是该实现的核心组成部分:
- 数据规模:当前示例使用的输入数据规模较小。我鼓励你尝试将其增大,例如设置为1000个值,以观察其表现。
- 内存操作:代码正确地确定了线程块(block)和网格(grid)的尺寸,并进行了必要的数据拷贝。
- 核心函数:实现包含了判断是否需要交换的
should_swap函数和执行交换的swap函数。
核心函数详解
上一节我们介绍了代码的整体结构,本节中我们来看看具体的函数实现。
交换判断函数
should_swap函数用于比较两个值,决定它们是否需要交换位置。其逻辑是:如果左侧的值大于右侧的值,则返回真(需要交换)。这可以用一个简单的条件判断来描述:
bool should_swap(int left, int right) {
return left > right;
}
交换函数
swap函数使用一个临时变量来交换两个值,这是交换操作的经典实现:
void swap(int *a, int *b) {
int temp = *a;
*a = *b;
*b = temp;
}
冒泡排序内核(Kernel)
这是算法的核心部分,即GPU上运行的bubble_sort内核。它遍历分配给它的数据(大小为n),每个线程从左到右确定自己的索引,执行交换操作,然后通过__syncthreads()进行线程同步。该内核主要操作全局内存。
其基本逻辑是:在它关心的数据范围内,从左到右进行交换。当n等于整个输入数组的大小时,这种做法是可行的。
实现的局限性与思考
上面我们分析了内核的基本工作流程,现在我们来探讨这个实现的一些局限性。
当前实现的一个关键假设是线程数量与数据规模n相匹配。但当n非常大(例如1000),而可用的线程数量有限(例如只有32个)时,情况会变得复杂。
- 如果每个线程只负责排序数组中与其对应的特定部分,那么最终我们得到的将是多个已排序的“子数组”,而非一个完全有序的整体数组。
- 这就需要在不同线程之间进行额外的“跨线程”交换和合并操作,当前的实现并未处理这部分逻辑。
因此,虽然这个代码是一个不错的起点,但它可能无法高效地利用多个线程对大规模数据进行排序。你可能需要设计多次迭代或更复杂的通信机制来完善它。
总结与挑战
本节课中我们一起学习了一个GPU上的冒泡排序算法实现。我们分析了其代码结构、核心函数以及工作原理。
总结如下:
- 该实现清晰地展示了冒泡排序在GPU上的基本思路。
- 它适用于数据规模与线程数量相匹配的理想情况。
- 其主要局限性在于处理大规模数据且线程数不足时,缺乏跨线程的协同排序机制。

我给你的挑战是:尝试修改和扩展这段代码,使其能够有效地利用固定数量的线程(例如32个或256个)来排序远超线程数量的大规模数组。你可以思考如何将数据分块、如何在多次内核启动中逐步完成排序,或者如何在线程间高效地交换数据。请尽情尝试并享受编程的乐趣!
084:排序算法实验-基数排序 🧮

在本节课中,我们将学习一个用于基准测试的算法框架。该框架主要用于评测 NVIDIA CUB 库(一个类似于 Thrust 的 CUDA 高级库)中两种排序方法的性能。我们将重点关注“键排序”的实现与原理。
上一节我们介绍了排序算法的背景,本节中我们来看看具体的基准测试代码是如何工作的。
算法框架概述
该算法本质上是一个测试工具,用于对 CUB 库进行基准测试。CUB 是 NVIDIA 实验室提供的一个能力库,与 Thrust 库类似。
它的作用是基准测试两种排序方式。这两种方式都使用基数排序,但一种仅对“键”进行排序,另一种则对“键值对”进行排序。
理解“键”与“值”
这里的“键”和“值”概念可能会令人困惑。需要明确的是:
- 键 是被排序的对象。
- 值 是不被排序的关联数据。

这是一种常见做法。例如,当你有一个字典(或映射)时,你可能希望根据键来排序,而不是根据值。当然,也可以反过来根据值排序键,但这通常不太直观,因为你需要从值反向引用到键,而通常的映射关系是从键指向值。

键排序功能分析

在这段代码中,我们将重点关注 sort_keys 函数。
该函数调用了 CUB 库的 DeviceRadixSort::SortKeys 函数。记住,“排序键”意味着我们只对代表键的数组进行排序。

实际上,它只是接收一个数组并对其进行排序。这个数组就是键的数组。“键”和“值”的命名只是 CUB 库采用的语法或命名约定。
以下是创建和排序键数组的核心步骤:
- 创建键数组:代码中创建了一个包含键值的数组。
- 使用双缓冲区:代码创建了一个该数组的“双缓冲区”。这主要是为了处理可能同时发生的访问和更改,开发者无需过度关注其细节,库已妥善处理。双缓冲区的优势在于,你可以从起始或末尾访问数据。
- 调用排序函数:随后调用
SortKeys函数执行排序。单次排序本身并不复杂。
基准测试与性能评估

该代码更重要的作用是进行多次执行和基准测试。
它能做的一件更有趣的事情是:循环执行排序操作一定次数,对每一次操作进行基准测试,并存储这些结果。

代码会进行精确的时钟计时(如挂钟时间)。因此,当你运行此程序时,它不仅会完成排序,还会为你提供该特定函数相当精确的性能结果。
结果打印功能在代码的此处实现。

键值对排序
该框架同样会对“键值对”进行相同的基准测试。你可以将其类比为字典或映射。
基本原理是:对键进行排序,而与之关联的值本身不变。排序后,指针(或索引)指向的是排序后的键,而不再是原始的键。实际上,它并不改变原始数据结构,而是为你提供了一份排序后的键的副本,以及正确指向这些键的指针。
扩展与应用
这个基准测试框架可能很有用。它默认处理大量数据(如数百万个元素),但也许你想进一步增加压力测试,或者自己实现一些它未涵盖的核心功能。
你可以随时查阅 CUB 库的文档和源代码,了解其实现方式。也许你会想自己动手实现部分功能。无论如何,你可以通过此工具探索 CUB 库能为你提供多快的排序速度。
有时,解决排序问题的方案是:我理解其原理,但现在我想使用一个更快的现成工具。
本节课中我们一起学习了:一个用于评测 CUB 库基数排序性能的基准测试框架。我们理解了“键”与“值”在排序上下文中的区别,分析了代码中实现键排序的关键步骤,并了解了该框架如何通过多次迭代和精确计时来进行性能评估。最后,我们探讨了键值对排序的概念以及如何利用此框架进行扩展和深入探索。
085:排序算法实验-快速排序 🚀

在本节课中,我们将要学习基于经典伪代码实现的快速排序算法。我们将分析一个具体的CUDA项目实现,理解其核心逻辑、数据划分方式以及线程间的同步机制。




上一节我们介绍了快速排序的基本概念,本节中我们来看看一个具体的CUDA实现。该实现源自一个GitHub项目,代码结构清晰,并且考虑到了不同的输入数据分布,这是一个很好的实践。


以下是该实现的一些特点:
- 它支持多种数据分布,如均匀分布、高斯分布、已排序数据等,这有助于测试算法在不同场景下的性能。
- 代码结构遵循典型的CUDA编程模式。


现在让我们看看主函数 main 的实现。

主函数执行了典型的CUDA程序流程。它首先定义了线程数(128)和数据大小(512),然后生成指定分布的数据。接着,它根据线程数和每个线程块(plot)的线程数启动内核。最后,它执行同步操作以确保可以查看计算结果。

了解了程序的整体流程后,接下来我们深入分析排序函数本身,看看它与我们之前看到的伪代码有何异同。

该函数使用了一个预定义的常量 MAX_LEVELS(值为300),这代表了递归或迭代的最大深度。接下来,它需要确定基准值(pivot)的索引,并划分出左分区和右分区。这是通过 partition 函数完成的。
每个线程的索引被定义后,程序会为所有 MAX_LEVELS 创建两个数组,用于存储每个层级处理的子数组的起始和结束索引。这种设计的巧妙之处在于,它允许线程在任何时候都能知道自己正在处理全局内存或共享内存中的哪一部分数据。
初始时,当前线程的起始索引是其线程ID,结束索引是 n-1(n为数据大小,例如512)。这意味着每个线程最初都认为自己需要处理整个数组(从自己的位置到末尾)。划分操作会沿着当前线程的索引进行。
然后,代码检查基础情况:如果左索引小于右索引,则继续移动基准值。基准值现在是该索引处的值。在 while 循环中,当左索引小于右索引时:
- 如果
data[R]大于基准值,则将右索引R减一,缩小待处理数组的范围。 - 如果左索引小于右索引(这是关键条件),则将
data[L+1]的值设置为data[R]的值。 - 接着,在另一个循环中,检查
data[L]是否小于基准值,并且L是否小于R。如果是,则简单地将左索引L向右移动。 - 在某些时刻,当
data[L]不小于基准值时,需要进行值交换。
这个实现使用了大量本地和共享内存操作,逻辑较为复杂,可能不是最直观的实现方式。需要注意的是,在每次运行后都必须进行同步(__syncthreads()),因为此时共享内存或全局内存的状态已被许多线程改变,同步能确保所有线程在下一时刻看到一致且连贯的数据。


本节课中我们一起学习了快速排序算法在CUDA上的一个具体实现。我们分析了其主函数流程、排序内核如何利用线程索引进行数据划分、以及关键的同步机制。这个实现展示了在GPU上处理递归类算法时,如何通过迭代和显式的栈(start/end数组)来管理任务,并强调了在修改共享状态后同步的重要性。你可以尝试用更大的数据来测试这个实现,观察其性能表现。
086:归并排序作业项目 🧩
在本节课中,我们将快速浏览归并排序的作业项目。该项目属于课程模块4,旨在通过实践加深对GPU并行排序算法的理解。
概述
作业提供了一个基于GeeksforGeeks多语言实现的归并排序框架。界面包含一系列功能按钮,如清理构建、构建代码、运行测试和提交作业。每个按钮的功能都有明确描述。
以下是界面中可配置的关键参数:
- 线程块维度:可设置每个线程块在各个维度上的线程数量。
- 网格块维度:可设置网格在各个维度上的块数量。
- 元素数量:定义将随机生成的待排序元素数量。

目前实现并未完全优化以处理多维配置,但探索算法及其伪代码以验证其工作方式是很有价值的。
代码结构解析
上一节我们介绍了作业的界面和参数,本节中我们来看看代码的具体实现。
代码主体包含一个main函数,其执行流程如下:
- 解析之前提到的命令行参数。
- 在主机内存中创建指定大小的
long类型数组。 - 打印排序前的数组。
- 执行GPU归并排序。
- 打印排序后的数组。
这里的排序是GPU归并排序。其核心思想是:算法接收一个数据切片,初始时切片很小(对应于递归树的最底层)。随着归并排序的轮次完成并向上“冒泡”,算法处理的切片会越来越大。相应地,在递归树中越往上,使用的线程数会越少。
实际上,代码并非递归实现,而是将其展开为迭代过程。算法主体执行自底向上的归并,具体通过调用bottom_up_merge函数实现,该函数内部包含循环。虽然存在条件分支,并非最优实现,但作为归并排序的初版实现已相当不错。
总结

本节课中我们一起学习了归并排序作业项目的框架、配置参数以及核心代码结构。这是一个基于自底向上归并策略的GPU实现,鼓励大家深入探索代码,理解其工作原理,并尝试提出自己的优化解决方案。
087:NPP图像处理语法解析 📚
在本节课中,我们将学习NVIDIA性能基元库(NPP)在图像处理方面的核心语法与使用方法。我们将重点介绍如何利用NPP库简化图像处理流程,包括数据类型的定义、内存管理以及各种图像处理操作。
概述
NVIDIA性能基元库(NPP)为开发者提供了一套高性能的图像处理函数。本节课程将解析其核心语法,帮助开发者理解如何利用NPP进行高效的图像处理。
主机内存图像对象
上一节我们介绍了NPP库的基本概念,本节中我们来看看如何在主机内存中表示图像数据。
为了简化将图像导入主机内存的过程,我们将使用CUDA示例项目中common/UtilNPP目录下的imageCPU功能。这是一段非常优秀的代码,它抽象了底层数据表示的具体细节。
以下是图像对象或数据类型的命名模式:

- 它以
imageCPU_开头,后跟数值类型。 - 数值类型可以是8位、16位、32位的有符号/无符号整数或浮点数。
- 接下来的部分是通道数。通道数对应色彩光谱中的通道数量:1代表灰度图;2代表带Alpha通道的灰度图;3代表RGB(红、绿、蓝);4代表带Alpha通道的RGB。
因此,一个完整的数据类型可能类似于 imageCPU_8u_C1(8位无符号单通道图像)。但你需要将数据载入这种数据类型。通常的做法是使用像FreeImage这样的C++库,或者OpenCV等其他库。你需要确保有一种简便的方法能将位数据导入NPP。有多种方法可以实现,在本例中我们将使用FreeImage。
设备内存图像对象
了解了主机内存中的图像表示后,我们接下来看看设备(GPU)内存中的对应对象。
对于设备内存,存在类似的对象或数据类型。它们遵循相同的命名模式,并且我们将从同一个地方(即common/UtilNPP目录下的imageNPP)获取对它们的轻度抽象封装。

这里需要考虑的主要一点是,与主机图像对象的构造函数不同,这类数据类型的实例是从主机内存填充的。例如,imageNPP_8u_C1,你给它变量名和主机指针,它就会处理像malloc之类的所有内存操作。实际上,你可以在几页幻灯片后看到具体的内存管理示例。
图像处理功能:滤波
NPP库内部包含多个用于图像处理的子库。我们现在来讨论滤波功能。
以下是函数命名约定的工作方式:
- 函数名以
nppi开头,其中的i代表图像。 - 接着是
Filter,然后是滤波器的名称。 - 之后是数值类型和通道数。
- 最后的
_Ctx用于指示该函数支持CUDA流。你不一定必须包含它。

例如,这里的示例是 nppiFilterBox_8u_C1R_Ctx。这是一个运行在8位无符号单通道图像上的方框滤波器。其中的 R 你无需过多担心,对于单通道图像它通常都会存在。函数名可以是NPP滤波模块中提供的数十种滤波器中的任何一种。
其他图像处理功能
除了滤波功能,NPP还提供了许多其他子库。
以下是NPP库提供的其他主要图像处理功能:
- 几何变换:如果你需要旋转或调整图像大小,可以使用几何或线性滤波功能。
- 色彩操作:可以在灰度图和RGB之间进行转换,或修改伽马值以不同方式查看图像。
- 数学运算:例如,如果你想增强某个通道(比如为图像增加更多红色),你可以为图像中红色通道的所有像素值加上一个常数。
- 形态学变换:这些操作稍微复杂一些,但可以产生一些视觉效果良好的图像。你可以将其类比为社交媒体图像平台上常见的那些扭曲特效。
内存管理

现在,我们来到了这些高性能基元中最基础的部分:内存管理。
使用 nppiMalloc 函数,并遵循你已见过的模式(数值类型、通道数和 _Ctx),你可以为8位到64位、1到4通道的图像定义底层内存。完成后,使用 nppiFree 并传入图像指针即可释放内存。这个指针可以是更高级抽象对象的指针,库会为你处理这一点。
请注意,这并非图像处理所需的一切功能的完整套件。它旨在为图像提供快速、高性能的低层计算,并可被进一步抽象以构建更复杂的功能。
总结

本节课中我们一起学习了NVIDIA性能基元库(NPP)在图像处理方面的核心语法。我们了解了如何在主机和设备内存中表示图像数据,掌握了NPP函数的命名约定,并概览了其提供的滤波、几何变换、色彩操作、数学运算及形态学处理等多种功能。最后,我们学习了使用nppiMalloc和nppiFree进行基础的GPU内存管理。NPP为高效的GPU图像处理提供了强大的底层支持。
088:NPP图像处理代码演示 🖼️


在本节课中,我们将通过一个具体的代码示例,快速了解NVIDIA性能原语(NPP)库中核心的方框滤波器(Box Filter) 的语法和使用方法。我们将学习如何加载图像、在GPU上分配内存、配置滤波器参数、执行处理,并最终保存结果。



概述


我们将逐步解析一个使用NPP库进行图像滤波的完整流程。这个过程涵盖了从主机(CPU)内存准备到设备(GPU)内存操作,再到滤波执行和结果回传的各个环节。核心概念将通过代码片段进行说明。



主机内存准备与图像加载

首先,我们需要在主机端准备一个内存指针作为图像数据的源。由于我们处理的是灰度图像,因此数据格式应为8位无符号整数(8-bit unsigned),并且只有一个通道。

以下是相关的代码概念:
// 伪代码:声明一个指向8位无符号单通道数据的主机指针
unsigned char* host_src_ptr;

我使用了FreeImage库的能力来加载图像。该功能会根据需要处理图像,例如将RGB彩色图像转换为灰度图。


// 伪代码:使用FreeImage加载图像并转换为灰度
LoadImageToHostMemory("input.jpg", &host_src_ptr, FORMAT_GRAYSCALE);


创建设备内存指针
接下来,我们需要在GPU设备上创建对应的内存指针。请注意,这里的API调用指定了NPP,而上一步的主机端操作可能涉及CPU。关键是要确保通道数和位深度与源图像一致。


// 伪代码:在设备上分配内存并复制主机数据
Npp8u* device_src_ptr;
cudaMalloc(&device_src_ptr, image_size);
cudaMemcpy(device_src_ptr, host_src_ptr, image_size, cudaMemcpyHostToDevice);



这个步骤封装了内存管理的复杂性,你也可以选择手动管理,但NPP提供了便捷的接口。


配置方框滤波器


现在需要设置滤波器。方框滤波器在一个核(Kernel) 上操作,在经典的图像处理中,核是一个矩阵。这里我们使用一个5x5的核。

首先,需要定义核的尺寸(即掩码大小):
// 定义滤波器掩码(核)大小为5x5
NppiSize kernelSize = {5, 5};

接着,需要指定源图像的尺寸。你可以根据输入图像动态获取,也可以硬编码。
// 获取或定义源图像尺寸
NppiSize srcSize = {width, height};


然后,定义操作的起始点(oSrcOffset)。如果你不希望从图像的左上角像素开始滤波,可以在这里指定不同的X和Y坐标值。
// 定义操作起始偏移量(例如从(0,0)开始)
NppiPoint oSrcOffset = {0, 0};


定义感兴趣区域(ROI)


除了源图像,还需要定义感兴趣区域(Region of Interest, ROI)。这允许你只处理大图像中的一个子区域。


// 定义ROI的大小,它不能大于源图像
NppiSize oSizeROI = {roi_width, roi_height};


指定输出目标
你必须告诉滤波器将处理结果存放在哪里。因此,需要提供指向另一块设备内存的指针,并且这块内存的大小应与上面定义的oSizeROI一致。

// 为输出结果分配设备内存
Npp8u* device_dst_ptr;
cudaMalloc(&device_dst_ptr, roi_size);




设置锚点


最后,你需要提供一个锚点(Anchor)。锚点是核内所有操作的参考中心点。

例如,如果你希望在整个ROI上执行操作,通常会将锚点设置为核的中心像素(对于5x5核,锚点可能是(2,2)),并将ROI的大小设置为整个图像的尺寸。
// 定义锚点(通常为核的中心)
NppiPoint anchor = {kernelSize.width / 2, kernelSize.height / 2};



执行滤波与边界处理



完成所有配置后,可以进行错误检查,并运行NPP的方框滤波器函数。这里我们处理的是8位无符号、单通道的数据。
// 执行带边界处理的方框滤波
nppiFilterBoxBorder_8u_C1R(device_src_ptr, srcPitch, srcSize, oSrcOffset,
device_dst_ptr, dstPitch, oSizeROI,
kernelSize, anchor, NPP_BORDER_REPLICATE);


方框滤波器会产生平滑效果。但是,当核在图像边缘操作时,可能会访问到不存在的像素。为了避免这种情况,我们使用边界(Border) 处理模式(如NPP_BORDER_REPLICATE)。这样,对于边缘像素,滤波器会复制最近的边界像素值,而不是用零填充,这在灰度图像中能产生更好的视觉效果。



将结果复制回主机并保存

滤波完成后,结果存储在设备内存的device_dst_ptr中。我们需要在主机上创建对应的内存对象来接收它。


// 在主机上分配内存用于接收结果
unsigned char* host_dst_ptr = (unsigned char*)malloc(roi_size);


然后,使用CUDA的内存复制功能将数据从设备传回主机。这里可以指定pitch(步长)来控制复制图像的区域,在我们的例子中,从(0,0)像素开始。

// 将结果从设备内存复制回主机内存
cudaMemcpy2D(host_dst_ptr, dstPitch,
device_dst_ptr, deviceDstPitch,
roi_width, roi_height,
cudaMemcpyDeviceToHost);


最后,再次利用FreeImage等工具库将处理后的图像数据保存为文件,并打印保存成功的消息。

释放资源


作为良好的编程实践,需要释放之前分配的设备内存指针。
// 释放设备内存
cudaFree(device_src_ptr);
cudaFree(device_dst_ptr);


你通常不需要手动释放主机指针(如果它们由FreeImage管理),但需要注意,如果你在其他地方(例如另一个内核或NPP进程)定义了指向设备内存的指针并分配了内存,就必须确保在适当的时候释放它们,否则其他进程将无法访问这些资源。


总结



本节课我们一起学习了使用NPP库执行图像方框滤波的完整流程。我们从加载图像到主机内存开始,然后在GPU上分配内存并传输数据。接着,我们详细配置了滤波器的核大小、ROI、锚点和边界处理模式。之后,我们执行了滤波操作,并探讨了边界处理的重要性。最后,我们将结果复制回主机、保存图像,并释放了设备内存。通过这个示例,你应该对如何利用NPP进行高效的GPU图像处理有了一个直观的认识。
089:NPP信号处理语法详解 🎛️
在本节课中,我们将学习NVIDIA性能原语(NPP)库中用于信号处理的部分。我们将了解其核心数据结构、操作类型以及如何将信号数据从主机内存传输到设备内存进行处理。
概述
上一节我们介绍了NPP库在图像处理方面的应用。本节中,我们来看看NPP库中一个功能相似但相对小众的模块:信号处理。虽然这部分文档和示例较少,但信号处理是一个常见的使用场景,因此值得学习。与图像处理类似,我们将使用CUDA示例中提供的NPP Commons工具来简化操作。
信号处理的数据类型
信号处理的数据类型比图像处理简单。主机端的数据类型以NppSignalCPU_为前缀,后接数值类型。数值类型范围从8位到32位,包括有符号、无符号整数和浮点数。这代表了信号样本的大小。
需要注意的是,您处理的是信号在不同时间间隔的样本。因此,您需要根据选择的数值类型(如无符号整数、有符号整数或浮点数),来确定如何将原始数据导入。
与图像处理不同,支持导入已知信号格式(如WAV文件)的库较少。您需要寻找能够将WAV等文件转换为整数数组,再导入到所选信号数据类型的工具。



设备内存对象
设备端的内存对象模式与主机端非常相似。区别在于,您将使用NppSignal前缀,而不是NppSignalCPU。这些就是构成“NPP”名称基础的性能原语。

CPU端和设备端的数据类型命名存在镜像关系。设备端使用NppSignal,后接数值类型。它们之间应该存在一一映射关系。因此,您可以从一端选择数据类型,然后为设备端的操作选择对应的类型。
请注意,与NPP图像数据类型处理设备内存时类似,您需要传递指向主机内存的指针。查看代码时,将数据获取到内存不同位置的操作与图像处理代码非常相似。
NPP信号处理的功能
NPP库为信号处理提供的功能和种类相对较少。以下是主要操作类别:


- 滤波:基本上只提供32位有符号整数的积分功能。
- 转换:具备在不同数值类型A和B之间进行转换的能力,库会为您处理。
- 数学运算:可以通过加、减、乘等数学运算来增强或减弱信号。
![]()
![]()
- 统计运算:例如计算信号中一系列样本的总和、最小值、最大值。
- 逻辑运算:例如对信号样本内的所有位执行按位与、或、异或等基本布尔逻辑操作。
- 内存操作:与图像处理中的操作非常相似,例如
NppMalloc。
数值类型再次涵盖8位到64位,包括无符号、有符号整数和浮点数。完成所有操作后,您需要运行NppFree并传入指针来释放内存。


总结


本节课中,我们一起学习了NPP库的信号处理模块。我们了解了用于主机和设备的信号数据类型命名规则及其映射关系,回顾了NPP支持的主要信号处理操作,如滤波、转换、数学运算、统计和逻辑运算。最后,我们强调了使用NppMalloc分配和NppFree释放设备内存的重要性。尽管这部分库的资源和文档相对较少,但其提供的功能对于GPU加速的信号处理任务是一个实用的起点。
090:NPP信号处理语法演示 🚀


在本节课中,我们将学习如何使用NVIDIA性能原语(NPP)库中的信号处理模块,具体演示均值滤波操作的语法和流程。我们将通过一个基于真实论坛讨论的示例,了解如何初始化数据、分配设备内存、执行均值计算以及清理资源。



概述

与NPP下的MPPI模块类似,本节将快速介绍MPPS语法,并提供一个实际示例。该示例源自NVIDIA开发者工具包论坛中的一个讨论,涉及均值滤波器及其可能引发的问题。尽管滤波器工作正常,但信号可能存在轻微失真,这恰好是一个很好的教学案例。


详细步骤


上一节我们介绍了NPP库的概况,本节中我们来看看如何具体使用其信号处理功能进行均值滤波。


首先,我们需要一个指向信号的指针。在本例中,信号是32位浮点类型。

float* hostSignal;


我们将随机生成信号数据,在本示例中,信号值全部设为1。然后,我们需要将主机(CPU)上的数据复制到设备(GPU)上。


以下是实现此过程的关键步骤:


- 分配设备内存:使用
cudaMalloc为信号数据分配GPU内存。 - 复制数据:使用
cudaMemcpy将主机上的信号数据复制到刚分配的GPU内存中。


float* deviceSignal;
cudaMalloc((void**)&deviceSignal, signalSize * sizeof(float));
cudaMemcpy(deviceSignal, hostSignal, signalSize * sizeof(float), cudaMemcpyHostToDevice);
完成数据复制后,设备上的 deviceSignal 指针就包含了所需的浮点数据。


接下来,程序会获取CUDA的内存使用情况。然后,需要创建一个缓冲区(Buffer)来辅助计算。


以下是创建缓冲区所需的步骤:

- 调用
nppsMeanGetBufferSize_32f函数。对于32位浮点复数类型,需要指定信号长度。函数将返回所需的缓冲区大小。在本例中,缓冲区大小与信号长度相同,以确保有足够的内存执行均值操作。缓冲区还有助于处理信号边缘的情况。


现在,我们获得了创建缓冲区所需的统计信息。之前操作的是主机指针,现在需要创建一个设备指针。


size_t bufferSize;
nppsMeanGetBufferSize_32f(signalLength, &bufferSize);
void* deviceBuffer;
cudaMalloc(&deviceBuffer, bufferSize);

这就是为什么需要再次使用 cudaMemcpy,将主机上为缓冲区分配的内存指针复制到设备指针。这个过程与创建信号本身是并行的:你同时准备了实际信号数据和计算缓冲区。


接下来,我们将执行均值计算操作。


你需要指定相同的数据类型:32位浮点复数。复数对象可以用一个整数部分和一个表示小数位数的数字来指定,例如 (whole, decimal)。这类似于浮点数,但能表示更复杂的数值,其可表示的有效数字位数更多。这一点很重要,因为均值结果可能包含多位小数。


调用 nppsMean_32f 函数执行计算:
Npp32f meanResult;
nppsMean_32f(deviceSignal, signalLength, &meanResult, (Npp8u*)deviceBuffer);



函数参数包括:设备上的信号指针、信号长度、指向输出结果(meanResult)的指针,以及你创建的缓冲区。


启动计算后,为了确保操作完成(因为数据量可能导致计算耗时),最好添加 cudaDeviceSynchronize() 进行同步。
cudaDeviceSynchronize();



之后,可以查看显卡上的可用内存和总内存信息。你还可以打印一些关于操作是否失败的信息。在本例中结果应为成功,之后你可以将结果输出到某种可视化工具或文件中。

最后,需要进行资源清理。

以下是清理步骤:



- 删除主机上的信号指针。
- 释放设备上为信号和缓冲区分配的内存。
- 可以再次获取内存信息。如果这是GPU上唯一运行的任务,释放后的可用内存值与总内存值应该相同。

delete[] hostSignal;
cudaFree(deviceSignal);
cudaFree(deviceBuffer);

总结



本节课中我们一起学习了使用NPP库进行信号处理的基本流程。我们重点演示了如何为均值滤波操作准备设备内存、创建必要的计算缓冲区、调用NPP函数执行计算,并在最后妥善管理内存资源。通过这个具体的例子,你应该对在GPU上利用NPP进行高效信号处理有了初步的认识。
091:独立项目实验总览 🧪
在本节课中,我们将学习课程独立项目的整体概览。这是一个供你开发代码的沙盒环境,包含示例代码、库以及运行基础图像处理程序的能力。我们将了解如何构建和运行项目,以及如何通过Git管理你的输出成果。
项目环境与目标
上一节我们介绍了课程项目的基本概念,本节中我们来看看项目的具体环境和目标。
这是一个供你开发代码的沙盒环境。项目中包含示例代码、库以及用于运行基础图像处理程序的框架。你可以利用这个环境来学习更多关于GPU编程的知识,也可以在其基础上进行构建,甚至从头开始编写自己的代码。
项目的核心目标是让你能够运行一些基础的图像处理程序,并鼓励你在此基础上进行更深入的探索。
构建与运行示例代码

现在,我们来了解如何实际操作这个项目。以下是运行示例代码的步骤:
- 构建项目:点击界面底部的“Build”按钮来编译代码。
- 运行项目:编译成功后,点击“Run”按钮来执行程序。
示例代码是一个C++程序,它主要使用底层的GPU库。代码会读取一个输入图像文件,对其应用一个盒式滤波器,然后输出处理后的图像。
代码的关键部分涉及传递输入参数和处理图像:
// 示例:传递输入文件参数并处理
./program --input input_image.pgm
程序会打开指定的文件,将其转换为灰度图像(如果需要),进行处理,最后将结果保存为一个新的PGM文件。
处理输出文件与Git集成
程序运行后会产生输出文件,但课程实验环境无法直接下载文件。因此,我们需要使用Git来管理这些输出。
以下是管理输出文件的步骤:
- 初始化Git仓库:在项目目录外,克隆一个你自己的Git仓库(例如来自GitHub)。
- 复制输出文件:将程序生成的输出文件(例如
output_box_filtered.pgm)复制到克隆的Git仓库目录中。 - 提交更改:使用Git命令添加文件、提交更改并添加提交信息。
git add output_box_filtered.pgm git commit -m “添加盒式滤波器处理后的图像” - 推送至远程仓库:执行
git push命令,将你的更改上传到远程Git仓库(如GitHub)。
通过这种方式,你可以在GitHub等平台上查看和分享你的项目输出结果。
获取数据与进一步探索
为了完成更有趣的项目,你可能需要数据。以下是一些可用于图像和信号处理的数据源建议:
- 公开的医学影像数据集(如Kaggle上的相关竞赛数据)。
- 自然图像数据集(如CIFAR-10, ImageNet的子集)。
- 信号处理数据库(如UCI机器学习仓库中的时间序列数据)。
你可以利用这些数据,在提供的沙盒环境中实验更复杂的GPU加速算法。


总结

本节课中我们一起学习了课程独立项目的整体框架。我们了解了它是一个代码开发沙盒,掌握了如何构建和运行示例图像处理程序。更重要的是,我们学会了如何通过Git仓库来管理项目的输出文件,以克服实验环境无法直接下载文件的限制。现在,你可以开始在这个环境中探索和实现你自己的GPU加速创意了。
092:独立项目总体说明 🚀
在本节课中,我们将详细说明本课程的独立项目要求。我们将介绍项目的构成、评分标准、提交方式,并提供如何开始和推进项目的具体建议。
项目概述
本独立项目是课程的重要组成部分,采用同伴互评的方式进行评分。接下来,我将快速回顾项目说明,展示提交要求,并阐述我认为你应该如何推进自己的项目开发。
项目评分标准
以下是构成你项目成绩的三个核心部分。
1. 代码仓库 (占总成绩50%)
代码仓库是你项目的基础。具体要求如下:
- 你需要提供一个公开可访问的URL。
- 我倾向于使用Git(如GitHub、GitLab、Bitbucket)等版本控制仓库,以便其他学生可以克隆你的项目。
- 你也可以使用共享文件夹(如Google Drive、Box),但必须确保该文件夹及其结构是公开可访问的。
- 仓库中需要包含必要的代码和符合一定编码风格的项目文件。

2. 执行证明 (用于展示你的工作成果)
执行证明用于展示你已完成的工作。具体要求如下:
- 证明形式可以是有趣的图像、视频或信号处理前后的对比。
- 我虽然在课程中较多聚焦于图像处理,但如果你愿意处理音频文件(如WAV文件),并将其转换为合适的结构进行处理,这完全没有问题。
- 我倾向于你能将处理后的结果再转换回音频文件,以清晰展示输入与输出的差异。
3. 项目描述 (应同时放在README.md中)
项目描述需要清晰地阐述你的工作。具体要求如下:
- 这段文字描述也应放在你的代码仓库的
README.md文件中(特别是使用GitHub或GitLab时)。 - 描述内容需要说明你的项目目标、完成的工作,以及项目完成后的思考或未能达成全部目标的原因。
- 本项目的目标并非创造世界上最完美的应用,而是展示你的学习成果,并为其他同学提供一个可能的新用例参考。
如何开始你的项目
你可能会问,这个项目应该从哪里开始?我为此创建了一个项目模板。

这个模板是公开的,位于我的课程项目目录中。它本身内容不多,主要提供了一个标准的项目结构。
模板中的README文件描述了文件的一般存放位置。一个典型的项目结构应包含:
README.md:项目描述和使用说明。src/目录:存放源代码。install/目录 (可选):如果项目需要安装,存放相关文件。- 构建文件:如
Makefile或CMakeLists.txt。 - 运行脚本:或是在文档中清晰描述如何运行项目。
即使你选择使用共享文件夹,也应遵循类似的结构。我强烈建议使用GitHub等平台公开分享你的代码,这有助于展示你的工作流程(如提交历史),虽然这不会直接影响你的分数。
项目构思与开发建议
现在你有了起点,接下来需要思考项目内容。以下是一些建议:
- 在
README中清晰定义目标:例如,“本项目旨在探索图像在灰度与彩色之间转换的效果差异”。 - 进行计算和验证:你可以在代码内部或外部使用工具,通过像素级对比来量化展示两张图像的差异。公式可以表示为:
差异度 = Σ |像素_输出(x,y) - 像素_输入(x,y)|(对所有像素求和) - 探索课程内容:你可以尝试对图像应用不同的滤镜,深入探索NPP库。虽然本项目位于NPP模块,旨在让你进行更自由形式的实践,并为后续更复杂的项目做准备。
- 保持适度:本项目不应是耗时数周或需要投入16-24小时的大型工程。建议你花费几个小时,基于已编写的代码或想尝试的想法进行实践。
- 完成最小可行产品:完成足以证明你理解所学知识、并能产生可识别效果的最小工作量。
项目打包与提交
你需要将项目打包以便提交。具体要求如下:
- 提供一个包含代码和(可能的)输入数据的公开URL。
- 将实际的成果物(输入和输出文件)使用ZIP或TAR.GZ等格式打包。
- 撰写一份优秀的项目描述。这份描述应包含一些通常不会写在技术
README中的内容,例如开发过程中遇到的失败经历、特别成功的部分,以及完成项目后的个人体验与感想。
关于同伴互评的提醒
在评价他人项目时,请做到:
- 欣赏他人的努力:认可他人投入的工作。
- 提供建设性反馈:在自由文本反馈区,专业、简洁地指出代码的优点、你发现的问题,或你未曾想到但觉得很好的思路。
- 保持专业:反馈应具有建设性且专业。
希望以上说明能帮助你顺利启动项目。请不要把它看得过于沉重,它旨在作为一个起点,让你适应完全独立的项目开发模式。


本节课中,我们一起学习了独立项目的总体框架。我们明确了项目的三大评分部分:代码仓库、执行证明和项目描述。我们介绍了如何利用提供的模板开始项目,并给出了项目构思、开发以及最终打包提交的实用建议。最后,我们强调了在同伴互评中应保持建设性和专业性。祝你项目顺利!
093:独立项目评分标准详解 📋


在本节课中,我们将详细解读课程独立项目的评分标准。了解评分细则将帮助你明确项目目标,并指导你如何准备和提交一份高质量的项目。
项目提交内容概述


上一节我们介绍了课程项目的背景,本节中我们来看看你需要具体提交哪些内容。你的项目提交将包含几个核心部分,这些部分将作为同行评审的依据。
以下是项目提交的三个主要组成部分:
- 代码仓库:你需要提供一个公开可访问的代码仓库URL。
- 执行证明:你需要提供文件来证明你的代码成功运行并产生了预期结果。
- 项目描述:你需要撰写一份文档,清晰地描述你的项目目标、实现过程以及心得体会。


评分细则详解

接下来,我们将逐一拆解评分标准中的各个部分,并解释每个部分的具体要求。


1. 代码仓库与代码质量 (50分)
这部分评估的重点是你的代码本身。你需要提供一个公开的代码仓库(例如GitHub),评审者可以访问并查看你的代码。
以下是关于代码仓库的具体要求:


- 必须是一个公开可访问的URL。
- 代码仓库是首选,因为它便于展示和组织代码。其他选项(如Google Drive)也可以,但必须确保内容结构清晰。
- 仓库中应包含清晰的说明文件(如README),帮助评审者理解代码结构和运行方式。
- 评审者将根据代码的组织结构、可读性和整体质量进行评分。
核心公式:项目总分 = 代码质量分(50) + 执行证明分(25) + 项目描述分(25)
2. 执行证明 (25分)

这部分要求你提供证据,证明你的代码确实按照设计执行并产生了输出。

以下是关于执行证明的具体要求:
- 证明形式可以是输出图像、处理后的信号数据、日志文件,甚至是展示运行过程的视频。
- 你可以提交单个文件,也可以提交一个压缩包。
- 关键是要能清晰地向评审者展示:你的代码完成了它应该做的事情。
- 评分将根据证明的清晰度和完整性来决定:清晰展示(满分)、基本展示(部分分数)、未展示(0分)。
3. 项目描述 (25分)
这部分要求你撰写一份项目报告,向评审者解释你的工作。
以下是关于项目描述的具体要求:
- 描述不必很长,但必须清晰地说明你的项目目标、代码试图完成的任务。
- 可以包含你遇到的问题、学到的经验教训以及你的总体思考。
- 评分标准:包含深入思考和完整描述(满分)、仅基本描述做了什么(部分分数)、未提供描述(0分)。
4. 同行评审反馈
当你作为评审者评估他人项目时,你提供的反馈也至关重要。

以下是提供反馈时的注意事项:

- 反馈应具有建设性和专业性。
- 目的是帮助同学学习,而不是批评指责。
- 可以指出项目的亮点,也可以提出改进建议。
- 请以你希望收到的反馈方式来对待他人的项目。

项目准备与提交指南

了解了评分标准后,我们来看看如何准备和提交你的项目。你可以将评分标准作为项目完成的清单。

你需要提交一个包含以下信息的完整项目包:
- 项目标题
- 代码仓库URL:一个公开的、可自由访问的链接。
- 执行证明文件:一个文件或压缩包,用于展示你的代码处理了大量数据。这可以是日志、输出结果等,不一定非要是图像。
- 详细的项目描述:这份描述需要足够详细,让他人能够理解你的代码是如何组织的,以及如何解读你的执行证明。
重要提示:在压缩文件时,请使用通用的压缩格式(如ZIP),避免使用像7-Zip这样可能在其他机器上无法打开的专有压缩工具。


项目目标与开放性


最后,我们来明确一下本项目的核心目标。本项目是开放性的。

核心目标:向你的同行证明,你理解了如何利用GPU进行大规模并行处理,能够处理大量数据。
以下是关于项目目标的几点说明:
- “大量数据”可以指许多小文件,也可以指少量大文件(如图像或信号)。
- 你不需要实际在100万张图像上运行代码,但需要证明你的内核设计能够应对这种规模的数据处理。
- 你可以选择课程中学过的MPP(大规模并行处理)相关主题,也可以探索其他相关领域。
- 最终,你只需完成三件事:创建可查看的代码仓库、提供代码执行证明、撰写简短的项目描述。
为了帮助你获取数据,课程也提供了一些公开数据集的链接。详细的评分标准PDF文件也可以在课程页面找到。



本节课中我们一起学习了独立项目的详细评分标准。我们明确了项目需要提交代码仓库、执行证明和项目描述三部分,并了解了每部分的具体要求和评分占比。记住,项目的核心是展示你运用GPU处理大规模数据的能力。请利用这份评分标准作为指南,认真准备你的项目。祝你成功!
094:CUDA高级库课程概述 🚀
在本节课中,我们将要学习约翰霍普金斯大学GPU编程专项课程的第四门,也是最后一门课程——《CUDA高级库》。我们将了解这门课程在整个专项课程中的定位、主要内容以及学习目标。
课程讲师介绍 👨🏫

我是帕斯卡·钱塞勒。我在约翰霍普金斯大学怀廷工程学院担任讲师已有10年。我同时也是一名拥有15年经验的软件开发人员。高性能计算、云计算、Web开发以及计算机视觉的应用都是我的兴趣所在。
GPU编程专项课程全景图 📚
上一节我们介绍了讲师背景,本节中我们来看看整个GPU编程专项课程的构成。该专项课程包含四门循序渐进的课程。
以下是四门课程的列表:
- 第一门课程:《GPU并发编程导论》。这是您当前所在的课程,我将在下一张幻灯片中更详细地描述它。
- 第二门课程:《CUDA并行编程导论》。这门课程专注于NVIDIA GPU和CUDA编程框架提供的基本硬件和软件能力。
- 第三门课程:《面向企业的规模化CUDA》。它深入探讨了CUDA更复杂的功能,以及如何将这些功能应用于超越程序员个人机器、扩展到企业级硬件的大规模场景。
- 第四门课程:《CUDA高级库》。这是GPU专项的最后一门课程,它向学生介绍了CUDA开发工具包中附带的一系列最流行和强大的库。

《CUDA高级库》课程详解 🧰
在了解了整个课程体系后,我们现在聚焦于本门《CUDA高级库》课程。作为GPU专项的收官之作,本课程重点介绍CUDA附带的一些高级库。
以下是本课程将涵盖的核心库及其用途:
- 高级数学库:例如用于快速傅里叶变换的
cuFFT或用于线性代数的cuBLAS,它们用于执行更高级别的数学运算。 - Thrust库:它抽象了一些指针运算和内存管理操作,并提供了核心算法库,允许您在更高层次上操作数据。
- 机器学习库:我们将学习适用于机器学习的库,即
cuDNN和cuTensor。它们可用于目标检测、人类语言翻译以及其他多种机器学习应用。
本课程学习目标 🎯

前面我们介绍了课程内容,本节我们明确一下学习目标。本课程主要有三个核心目标。
以下是三个主要目标:
- 使您能够使用
cuBLAS和cuFFT等库开发更高级的数学计算能力。 - 学习使用Thrust库在更高层次上操作数据。
- 学习使用
cuTensor和cuDNN库实现机器学习技术。

总结 ✨
本节课中,我们一起学习了《CUDA高级库》课程的概述。我们认识了讲师,了解了本课程在GPU编程专项中的位置,预览了将要学习的关键库(如cuBLAS、cuFFT、Thrust、cuDNN和cuTensor),并明确了课程的学习目标。希望本次介绍能让您开始思考自己在本课程乃至整个GPU编程专项中希望实现的目标。
095:课程期望 📋
在本节课中,我们将了解《CUDA高级库》这门课程的整体期望。课程期望主要分为三个部分:课程材料、时间投入和技术要求。掌握这些信息将帮助你更好地规划学习路径,顺利完成课程。
课程材料期望 📚
上一节我们介绍了课程期望的三个主要方面,本节中我们来看看对课程材料的具体要求。本课程的材料构成与其他专项课程中的课程相似。
以下是课程材料的主要组成部分:
- 每个模块包含4到7节课。
- 每节课包含视频、测验、作业、实验和讨论等环节。
- 每个模块预计包含20到30分钟的视频内容。
- 非评分练习预计需要15到30分钟或更长时间。
- 根据模块的不同,评分作业预计需要30到60分钟。
- 在第五个模块,你将完成一个至少需要8小时的顶点项目,作为整个课程的总结。

时间投入期望 ⏰
了解了课程材料后,我们来看看完成这些内容大致需要投入多少时间。合理的时间规划对学习成功至关重要。

以下是各项学习活动的时间预估:
- 观看视频:每节视频课的目标时长是5到7分钟,部分会更短。
- 完成实验:所有模块都包含实验,预计需要10到15分钟,若想深入探索则可能需要更长时间。
- 完成测验:测验通常较短,预计可以很快完成。
- 完成编程作业:你同样拥有一定的探索自由,但作业的设计目标是在30到60分钟内完成。
- 完成顶点项目:从时间角度看,主要的投入将是顶点项目。预计至少需要8小时,因为你需要进行设计、实现,并处理相关成果,例如创建视频演示并将成果提交给同伴评审。
技术要求与能力期望 💻
在规划好时间之后,我们还需要明确课程对技术能力的具体要求。本课程将从简单的编程过渡到复杂的编程任务。

以下是课程对你的技术能力期望:
- 你需要学习包含软硬件细节的课程,并将其应用于你编写的程序中。
- 你必须理解GPU的技术能力和限制。
- 你将像以往一样,使用C++和C语言编写复杂的CUDA代码。
- 课程会提供一些数学背景知识和库语法信息,你需要完成应用这些知识的程序。
- 对于顶点项目,你需要创建一个完整的软件,应用你感兴趣或有经验的领域知识,重点是创建一个完整的、可以记录结果的作品。
两项额外期望 🎯
除了上述核心期望,本课程还有两项额外的重点要求,它们标志着学习深度的提升。


以下是两项额外的课程期望:
- 与核心CUDA和GPU编程课程不同,本课程将学习领域特定库和主题。你可能需要在我提供的内容之外进行探索,但我会提供参考资料,你也可以自行查找关于库特定功能的详细解释。
- 对于顶点项目,除了开发代码并可能输出内容或数据外,你还需要制作一个简短的视频。在视频中,你必须清晰地阐述你的软件功能及其运行结果。
总结

本节课中,我们一起学习了《CUDA高级库》课程的详细期望。我们明确了课程在材料构成、时间投入和技术要求三个方面的具体安排,并了解了两项额外的重点期望:学习领域特定库和完成包含视频演示的顶点项目。这些信息将帮助你为后续的学习做好充分准备。
096:Coursera实验与作业概述 🚀
在本节课中,我们将学习如何完成约翰霍普金斯大学《GPU编程》课程在Coursera平台上的实验与作业。课程将详细介绍从打开环境到最终提交的完整流程。
开发Coursera实验与作业的流程包含六个步骤。
以下是具体步骤:
- 打开Coursera实验环境:在浏览器中使用VS Code。
- 打开模块目录:阅读
modules目录下的README.md文本文件。 - 编辑代码文件:可以编辑
.cu、.cpp源文件或.h、.hpp头文件以及其他相关代码文件。 - 在终端执行命令:使用
make命令来清理、构建、运行你的代码。对于作业,可能需要多次迭代。 - 编辑用户文件(仅作业):编辑
.user文件,并根据README.md中的说明修改相应的代码变量。 - 提交作业:完成上述步骤后提交作业。
这是模块1编程作业的页面。我们将逐步讲解完成作业所需的所有步骤。
页面包含进度通知区域、启动实验活动的按钮、完成作业的说明以及提交结果。
现在,点击“在浏览器中工作”按钮开始我们的作业。


上一节我们启动了作业环境,本节中我们来看看如何在VS Code中打开正确的目录。
接下来,你需要在VS Code中打开与活动或作业关联的目录。
如果页面顶部附近没有显示模块编号和名称,你需要从菜单栏点击“文件”菜单选项,在下拉菜单中选择“打开”。这将在编辑器顶部附近弹出“打开文件或文件夹”对话框。
在对话框中,你可以通过点击“..”(双点号)向上导航目录结构。对于本项目,你需要导航到 /home/coder/project/module1。
在对话框中找到需要打开的文件夹或文件后,点击“确定”按钮。文件或文件夹将在编辑器左侧打开。
现在,你将拥有开始当前模块实验或作业所需的所有文件。
接下来你应该做的是阅读 README.md 文本文件,其中包含背景材料和一系列作业说明。
你可以查看 run.sh 文件,这是一个用于执行作业所有步骤的脚本。
Makefile 用于清理、构建和运行你的工作。你通常不需要修改这两个文件,而应该只修改代码和其他相关文件。
如果你正在处理作业,接下来应确保 .user 文件反映了你的用户名。用户名可以是任何名称或登录名,只要不包含特殊字符。
在开始编写作业其余部分的代码之前,另一个需要添加用户名的地方是你的主代码文件中。在本例中,即 assignment.cpp。你需要修改 USER_NAME 字符串常量,使其与你在 .user 文件中放置的名称一致。
通过编辑这两个文件,你将确保你的提交满足作业的第一个要求:用户名验证。
当你执行代码时,无论是直接执行还是通过 run.sh 或 make 命令,都需要传递相同的用户名。
提交代码的下一步是点击页面底部的“构建提交(步骤1)”按钮。
你将在页面底部看到一个终端窗口,它会清理、构建并执行你当前的代码,并将输出写入一个文件,该文件用于作业评分。请注意终端中的任何输出。
然后,点击同样位于页面底部的“提交作业(步骤2)”按钮,将该文件提交给评分系统。

之后,你可以在作业页面查看提交结果。在评分器完成运行之前,结果将显示为琥珀色。
打开提交结果后,你可以看到分数、是否通过以及任何反馈信息。

如果遇到问题,你真的需要进一步查看日志以获取详细信息。




本节课中我们一起学习了Coursera上《GPU编程》课程实验与作业的完整操作流程,从环境准备、文件编辑、代码执行到最终提交与结果查看。掌握这个流程是顺利完成后续编程任务的基础。
097:毕业项目指南 🎓
在本节课中,我们将介绍GPU编程专项课程的毕业项目要求。我们将详细说明项目的目标、需要提交的成果以及如何规划一个成功的项目。
毕业项目是本专项课程模块五结束时需要完成的核心任务。其目标是让你综合运用在四门课程中学到的知识,并以一种实质性的方式加以实践。
项目目标与范围 🎯
上一节我们介绍了毕业项目的总体要求,本节中我们来看看项目的具体目标和可选范围。
项目的核心是应用你所掌握的GPU知识,并探索你能实现什么。你可以选择深化课程中已涉及的任何主题,也可以探索全新的领域。
以下是你可以选择的项目方向:
- 深化已知领域:对课程中已学内容进行更深入的研究。
- 探索新主题:尝试新的技术、框架,甚至是编程语言,即使这些内容从未在课程中讨论过。
关键在于,你需要开发自己感兴趣、有经验或能在工作中应用的代码,并向你的同伴展示,同时帮助他们学习。
需要提交的成果 📦
了解了项目方向后,接下来我们明确需要提交的具体成果。你需要交付以下四样东西:
以下是项目必须包含的四个交付物:
- 代码仓库URL:一个公开的代码仓库链接,例如Github、Gitlab、Bitbucket,或Google Drive、Dropbox等可供他人匿名访问的位置。
- 执行证明:以各种形式呈现的执行结果,例如文本文件、CSV文件、图像等。
- 项目描述:能够清晰描述你的项目是什么以及它实现了什么功能。
- 视频演示:一个包含演示的视频展示,应展示你尝试实现的功能,并可以包含结果等内容。
项目演示要点 🎬
提交成果是基础,而项目演示则是评审的核心。你的同行评审将聚焦于一个演示。
演示应相对简短,大约5到10分钟,必要时可稍作延长。它可以包含幻灯片、代码展示以及功能演示的任意组合。
在演示中,你需要说明以下几点:
- 项目起点与目标:你的初始目标是什么。
- 技术与背景:涉及的任何算法、技术或语言,可以准备背景介绍的幻灯片或进行概述。
- 最终成果与未来方向:你最终实现了什么,以及你希望未来如何发展。
- 可视化展示:如果能有图形、图表、交互式演示或图像等视觉化内容会非常出色,但这不应是你追求的唯一目标。
项目选择与规划建议 💡
完成演示需要扎实的项目内容,因此项目的选择与规划至关重要。我认为毕业项目最重要的一点是,做你关心的事情。
这无关我或你同伴的兴趣或期望,而关乎你自己。我希望你感到在这四门课程上花费的时间是值得的,并完成了一些有成就感的事情。
以下是一些具体建议:
- 勇于尝试:尝试有风险、可能不会成功的事情完全没问题。尝试并解释为什么事情没有按预期工作,这本身对他人就是宝贵的经验。
- 鼓励探索:如果你想探索新事物,就选择一个新主题。不要担心结果,重点是学习新东西,无论是新算法、新框架还是新语言。
- 尽早规划:我鼓励学生现在就开始思考项目方向。你不需要立即开始编码,但可以浏览课程涉及的主题和即将介绍的库,思考自己的兴趣点。
- 保持灵活:如果你在项目进行到四分之一时发现不喜欢当前的方向,完全可以改变主题。请记录下改变想法的原因,这在演示中说明“我从此处开始,在此处改变了方向,原因是…,我更喜欢新方向因为…”是完全可行且有益的。




本节课中我们一起学习了GPU编程专项课程毕业项目的完整指南。我们明确了项目的目标是综合应用所学知识,需要提交代码、证明、描述和视频演示四项成果,并了解了演示的核心要点。最重要的是,我们强调了选择自己感兴趣的项目主题、勇于探索并尽早开始规划的重要性。祝你项目顺利!
098:CUFFT库性能与特性 🚀
在本节课中,我们将学习快速傅里叶变换以及NVIDIA CUFFT库的核心概念、性能优势与主要特性。我们将探讨为何FFT如此重要,以及为何在GPU上使用CUFFT库进行计算能带来显著的性能提升。
快速傅里叶变换简介
上一节我们介绍了课程主题,本节中我们来看看快速傅里叶变换的基础。快速傅里叶变换由高斯发现,并由Cooley和Tukey在1965年正式发表论文。

观察左侧的图表,一个时域信号通过正向变换转换到频域。随后,它可以被分解为一组频域信号,这些信号再通过逆向变换组合并转换回时域,从而解决信号处理问题。

为何需要FFT?
了解FFT的重要性后,本节我们探讨其必要性。首先,除非数学功底深厚,否则微积分计算相当困难。
从解决问题的时间复杂度来看,传统方法通常是 O(n²) 的复杂度,这并非理想情况。我们使用FFT将问题复杂度降低到 O(n log n),速度大幅提升。
这种提升看似微小,但在处理连续视频、音频等场景时差异显著。当需要在信号输入与输出之间实现极低延迟的实时计算时,FFT至关重要。因此,高质量硬件常采用此技术。
为何选择GPU而非CPU?
理解了FFT的优势后,我们自然会问:为何要在GPU上执行?据NVIDIA称,使用CUFFT库进行计算比CPU实现快10倍。
让我们思考单个信号需要被分解并计算为一系列信号的问题。使用GPU的主要原因是将大问题分解为小问题,这非常适合GPU的并行架构。
GPU的另一个优势在于其设计初衷。GPU为显卡构建,擅长快速渲染场景、视频和音频,因此完美适合处理连续数据流。




CUFFT库特性 🛠️
在了解了GPU的优势后,本节我们详细探讨CUFFT库的具体特性。
以下是CUFFT库的一些核心特性:
- 集成开发包:CUFFT作为NVIDIA CUDA开发工具包的一部分,与其他库一同提供。
- 支持多维变换:它能处理从简单到非常复杂的多维变换。当然,维度越高,计算越复杂,速度也越慢。计算速度与维度数量存在关联。
- 支持多GPU:最新版本开始支持多GPU,这对于拥有计算集群或专为视频处理搭建、需要执行此类变换的系统非常有帮助。
- 支持实数与复数变换:它处理的变换包括实数域和复数域之间的转换,这一点非常重要,因为并非所有计算都在实数域进行。
CUFFT处理模式
介绍了库的特性后,我们来看看它提供的几种主要处理模式。
CUFFT库提供三种主要处理模式:
- 批处理模式:适用于非实时数据处理,但希望高效处理大量数据的场景。
- 流处理模式:当需要接近实时地处理数据流时非常有用,存在许多应用用例。
- 异步处理模式:这意味着,如果你从文件或网络调用接收一批信号,可以将其发送出去进行处理,并在处理完成后获取结果。它介于批处理和流处理之间。

总结

本节课中我们一起学习了快速傅里叶变换的基础及其重要性。我们探讨了FFT如何将计算复杂度从O(n²)降低到O(n log n)。我们分析了在GPU上使用CUFFT库相比CPU实现的巨大性能优势。最后,我们详细介绍了CUFFT库的核心特性,包括其多维变换支持、多GPU功能以及三种关键的处理模式:批处理、流处理和异步处理。
099:CUFFT库语法解析 🧮
在本节课中,我们将学习如何使用CUDA的CUFFT库进行快速傅里叶变换。我们将通过一个简单的示例,解析其核心语法和关键步骤,帮助你理解如何在CUDA应用程序中集成FFT计算。
定义数据维度 📏
首先,我们需要定义三维数据中每个维度的元素数量。为此,我们使用宏定义来指定X、Y和Z的大小。
#define NX 128
#define NY 128
#define NZ 128
创建CUFFT句柄与分配内存 🧠
在CUDA高级库中,通常使用一个“句柄”或“计划”来管理库将要执行的操作。CUFFT中,我们称之为“plan”。
创建计划的第一步是为复数数据分配指针。我们使用cufftComplex数据类型作为指针类型。
与使用任何CUDA库一样,我们需要使用cudaMalloc这样的函数在设备上分配内存。我们需要告诉它在哪里分配内存(例如data1和data2),并计算所需的内存大小。
cufftHandle plan;
cufftComplex *data1, *data2;
size_t data_size = NX * NY * NZ * sizeof(cufftComplex);
cudaMalloc((void**)&data1, data_size);
cudaMalloc((void**)&data2, data_size);
制定与执行FFT计划 ⚙️
下一步是创建FFT计划。对于三维变换,我们使用cufftPlan3d函数。如果是二维变换,则使用cufftPlan2d,依此类推。
你需要传入计划句柄、X、Y和Z方向的大小(如果是2D则只有X和Y),以及你想要创建的计划类型。在本例中,我们进行复数到复数的变换,因此类型是CUFFT_C2C。
cufftPlan3d(&plan, NX, NY, NZ, CUFFT_C2C);
创建计划后,我们需要执行这个复数到复数的变换。这是通过cufftExecC2C函数完成的。请注意,如果是实数到复数变换,函数是cufftExecR2C;复数到实数则是cufftExecC2R。
你需要传入计划、输入和输出数据指针。在本例中,我们将对data1和data2执行正向变换(CUFFT_FORWARD)。在实际应用中,你可能会先计算正向变换,然后再执行逆向变换回到时域,但为了简化示例,我们只展示正向变换。
cufftExecC2C(plan, data1, data1, CUFFT_FORWARD);
cufftExecC2C(plan, data2, data2, CUFFT_FORWARD);
清理资源 🧹
最后,非常重要的一步是销毁你的计划并释放内存。这会释放硬件上的所有资源。你肯定不希望造成内存泄漏或长期占用不必要的资源。

cufftDestroy(plan);
cudaFree(data1);
cudaFree(data2);
理解数据尺寸变化 📊
上一节我们介绍了执行FFT的基本流程,本节中我们来看看一个关键细节:输入和输出数据尺寸的变化。根据CUFFT文档,不同变换类型的数据尺寸规则不同。
以下是不同维度下,复数到复数、复数到实数、实数到复数正向变换的输入和输出尺寸总结表:

需要注意的关键点是:
- 进行复数到复数变换时,输入和输出尺寸通常相同,没有问题。
- 进行复数到实数或实数到复数变换时,输入或输出的尺寸会发生变化。通常,在至少一个维度上,复数数据的尺寸是实数数据尺寸的一半。
因此,在分配内存时,你总是可以分配更大的空间,但里面可能包含空值。请务必仔细查阅文档,了解你的特定变换所需的数据尺寸。
高级功能:批处理与多GPU 🚀
CUFFT库还支持更高级的功能,以处理复杂场景。
多维变换与批处理
你可以执行1D到3D的变换,如上一张幻灯片所示。再次注意,输入和输出尺寸可能根据X、Y、Z维度和变换类型而变化。数据的总大小是各维度大小的乘积。
一个有趣的功能是,你可以指定只处理输入或输出数据的一部分。这允许你进行批处理,对于处理多维数据尤其有用。在并非所有数据都需要同时变换的情况下,这能帮助你解决更大规模的问题。
多GPU支持
那么,当使用多个GPU时,事情是如何工作的呢?你会注意到函数命名存在一个模式。
以下是多GPU函数命名示例:

函数名以cufftXt开头,后跟与单GPU版本类似的函数名。例如,cufftXtMalloc就相当于单GPU的cufftMalloc。
你可以基于ID(通常是从0开始的递增数字,如0,1,2,3)来设置GPU的数量,并通过ID来指定它们。实际上,ID就是一个递增的数字。
你仍然需要制定一个计划,在这种情况下,命令是cufftXtMakePlan。这个函数允许你创建一到多维的计划。

在这种多GPU环境下,务必调用cufftDestroy,因为它不仅会销毁计划,还会释放所有硬件资源,这些资源可能分布在多个GPU上。
总结


本节课中我们一起学习了CUFFT库的核心语法。我们从定义数据维度开始,逐步讲解了如何创建CUFFT句柄、在设备上分配内存、制定并执行FFT计划,以及最后如何清理资源。我们还深入探讨了不同变换类型下数据尺寸的变化规则,并简要介绍了批处理和多GPU支持等高级功能。掌握这些步骤和概念,是使用CUDA高效进行快速傅里叶变换计算的基础。
100:CUFFT数据类型详解 🧮
在本节课中,我们将学习CUFFT库中核心的数据类型及其用法。理解这些数据类型对于正确使用CUFFT进行快速傅里叶变换至关重要,因为它们定义了数据的布局和内存管理方式。
数据类型的重要性
CUFFT的操作并非简单的函数调用,因此拥有清晰的数据抽象非常有帮助。核心的数据类型是cufftHandle,它代表一个FFT计划。
创建FFT计划
如何创建一个计划?首先,你需要确定是进行一维、二维还是三维的快速傅里叶变换。根据这个决定,分别调用cufftPlan1D、cufftPlan2D或cufftPlan3D函数。
批处理与多GPU支持
如果你想将多个变换计划批量处理在一起,可以使用cufftPlanMany函数。它的优点在于能够同时处理实数和复数数据布局。
那么,如果你拥有多个GPU呢?我们之前已经有所涉及。所有支持多GPU的函数都以cufftXt开头。例如,cufftXtMakePlanMany函数,它允许你在多个GPU上进行批处理。这在多GPU环境中几乎是必须的。

输入输出数据布局
接下来,我们再次展示一个更简化的输入输出数据布局视图。
以下是不同变换类型对应的数据布局:
- 复数到复数变换:输入和输出的数据类型都是
cufftComplex,数据大小均为x。 - 复数到实数变换:输入数据类型为
cufftComplex,大小为x;输出数据类型为cufftReal,大小为floor(x/2) + 1。输出大小略大于输入大小的一半。 - 实数到复数变换:输入数据类型为
cufftReal,大小为x;输出数据类型为cufftComplex,大小为floor(x/2) + 1。
初次接触时,这可能看起来需要考虑很多细节。但在处理内存时,确实需要对这些概念有清晰的了解。
总结

本节课我们一起学习了CUFFT库的核心数据类型cufftHandle,以及如何创建不同维度的FFT计划。我们还探讨了使用cufftPlanMany进行批处理以及利用cufftXt系列函数实现多GPU支持的方法。最后,我们详细分析了复数到复数、复数到实数、实数到复数三种变换场景下的输入输出数据布局和内存大小关系。掌握这些是高效、正确使用CUFFT进行GPU加速傅里叶变换的基础。
101:使用CUFFT进行图像处理 🖼️

在本节课中,我们将学习如何使用快速傅里叶变换(FFT)进行图像和视频处理。我们将重点介绍一个具体应用:检测图像是否模糊,并探讨FFT在图像旋转和3D重建等其他领域的应用。
概述:图像模糊检测原理
上一节我们介绍了CUFFT库的基础知识,本节中我们来看看如何将其应用于实际的图像处理任务。检测图像模糊的核心思想是将图像从空间域转换到频域进行分析。模糊的图像在频域中高频分量较少,而清晰的图像则包含丰富的高频信息。
以下是检测图像模糊的主要步骤:
- 获取数据:导入需要处理的图像。
- 正向FFT变换:对图像执行快速傅里叶变换,将其从空间域转换到频域。
- 频谱中心化:将变换后的频谱零频分量移动到图像中心,便于观察。
- 滤除低频:移除可能由噪声或均匀区域产生的低频信号。
- 逆向FFT变换:将处理后的频谱通过逆FFT变换回空间域(左上角为原点)。
- 再次正向FFT:对处理后的图像再次执行FFT变换。
- 计算频谱最大值与均值:计算此次变换后频谱的幅度最大值和均值。
- 判断模糊:如果频谱均值低于某个阈值,则判定图像为模糊。
这个过程看似复杂,但其本质是将图像转换到信号域进行分析。通过将频谱中心化,我们可以聚焦于图像通常最清晰的中心区域。滤除低频是为了消除噪声干扰。最终,我们通过比较整个图像的频谱幅度与其均值来判断清晰度:清晰图像的像素间差异大,频谱幅度高且分布广;模糊图像的色彩和细节变化小,其频谱幅度整体较低。
视觉案例解析
让我们通过两个例子直观理解上述原理。

- 模糊图像(左图小狗):在灰度图像中,存在大量颜色相似的色块,缺乏鲜明的对比和细节。在这种情况下,图像的频谱幅度会非常低。计算出的均值虽然相对较高,但会明显高于整体的频谱幅度水平,符合模糊图像的特征。
- 清晰图像(右图瀑布前的男士):该图像并不模糊,因为其整体频谱幅度较高,这表明像素与像素之间存在显著差异,图像包含丰富的颜色和细节变化。虽然人物本身的颜色对比可能不强,但图像其他部分(如瀑布、背景)的颜色变化剧烈,像素间的差值很大,因此整体频谱幅度高,判定为清晰。
FFT的其他图像处理应用
除了模糊检测,快速傅里叶变换在图像处理中还有多种用途。
从单张图像进行3D重建 🏗️
上一节我们学习了频域分析,本节中我们来看看FFT如何帮助理解图像深度。


FFT可用于将左侧的灰度图像转换为右侧的3D渲染图。这是因为图像中信号的强度与物体的焦距有关。在重建出的第一个信号(砖墙)中,可以看到3D结构。同时,由于焦距不同,信号的幅度会有所衰减,这可以作为确定Z轴(深度)信息的依据。例如,这使得图中的两位女士在3D重建中看起来更近,这与现实情况相符。
高效图像旋转与变换 🔄
在频域中进行某些图像操作比在空间域中直接计算更为高效。


例如,对图像进行旋转等变换,在频域中可能只需要很少的计算量。这类似于我们处理音频信号时的情况,可以将图像“侧放”来观察频谱峰值,并进行平滑等处理。
FFT在视频处理中的应用
了解了静态图像的处理后,我们自然会将思路延伸到动态视频上。
将FFT应用于视频,意味着对视频的每一帧进行上述处理,从而得到一个帧序列的频域表示。其优势在于,当处理与时间相关的变换时(例如让图像在一分钟内旋转360度),在频域中进行计算远比在空间域中逐像素移动和计算要容易得多。
这使得我们可以轻松实现一些效果,例如:
- 独立模糊视频流的不同部分。
- 对每一帧进行3D重建:结合前面提到的单图3D重建技术,对视频的每一帧进行处理,可以构建出更连贯、更精确的3D场景渲染。

总结

本节课中我们一起学习了使用CUFFT和快速傅里叶变换进行图像与视频处理的核心技术。我们详细探讨了利用频域分析检测图像模糊的完整流程,包括数据准备、正/逆FFT变换、频谱处理及阈值判断。此外,我们还了解了FFT在图像3D重建和高效几何变换(如旋转)中的应用,并初步探索了将这些技术扩展到视频处理领域的可能性。掌握这些知识,为利用GPU加速处理复杂的视觉任务奠定了坚实基础。
102:使用CUFFT进行音频信号处理 🎵
在本节课中,我们将学习如何使用CUDA快速傅里叶变换库(CUFFT)来处理音频或其他纯信号。我们将重点探讨一个具体应用案例:语音识别。
概述
快速傅里叶变换(FFT)是一种将信号从时域转换到频域的数学工具。在时域中,我们观察信号随时间变化的幅度。在频域中,我们则能看到构成该时域信号的一个或多个不同频率分量。这就像将信号旋转90度进行观察,你看到的不再是波形的高度,而是其频率成分的分布。
上一节我们介绍了FFT的基本概念,本节中我们来看看它在音频处理中的实际应用。
语音识别流程
以下是语音识别中利用FFT的典型步骤。
- 读取音频信号:首先,将音频文件读入程序。此时,信号必然处于时域。
- 生成频谱图:将通常表示为振幅的音频信号,转换为一种视觉媒介——频谱图。频谱图是多色的显示,能更清晰地展示频率成分随时间的变化。
- 分割音频:在频谱图中,找到信号微弱或中断的区域。这些区域通常是说话者停止发音或词语之间的边界。沿着这些边界将音频切分成更小的片段。
- 执行快速傅里叶变换:将这些较小的音频片段输入FFT,进行前向变换,将其转换到频域。变换后,信号会呈现一种可视化的峰值图案。
- 模式匹配与识别:比较这些频域峰值图案。根据你设计的方案,可以计算置信度或匹配百分比,在已知的语音模式库中寻找匹配项。
- 结果判定:根据匹配的置信度(例如90%或95%),判定被说出的词语。
这是一种常见模式,尤其在语音识别中,但也可以应用于其他音频或通用信号处理。


音频与信号领域的其他应用
除了语音识别,快速傅里叶变换在音频和信号处理领域还有许多重要用途。
- 音频压缩:例如MP3编解码器。它将高质量录音压缩成便于分享的文件格式。其原理之一是去除人耳无法感知或对音质贡献不大的信号分量,从而减小文件体积。这就是为什么黑胶唱片在某些方面听起来可能比从流媒体服务下载的MP3文件更好的原因之一。
- 硬件频谱分析仪:许多硬件设备使用FFT进行实时频谱分析。
- 音频设备测试:可以向音频设备输入已知信号,然后使用FFT比较其输入和输出信号。这能以更定性和定量的方式对比最佳和最差音频性能。
- 信号平滑:对于音乐等信号,平滑处理非常尖锐或锯齿状的信号,可以使其听起来更悦耳。
- 降噪与音源分离:在从时域转换到频域后,信号会分解到不同的频带。你可以提取或抑制特定频带,例如用于去除背景噪音,或者分离出人声部分,将伴奏作为独立音轨处理。这是一种非常常见的应用。

总结

本节课中我们一起学习了快速傅里叶变换(FFT)在音频信号处理中的核心应用。我们详细探讨了其在语音识别中的完整流程,从时域信号读取、频谱图生成、音频分割,到频域变换和模式匹配。此外,我们还了解了FFT在音频压缩、设备测试、信号平滑和音源分离等多个领域的重要作用。掌握这些概念是使用CUDA的CUFFT库进行高效并行信号处理的基础。
103:CUFFT库实验与作业总览 🚀

在本节课中,我们将学习如何使用CUDA的CUFFT库进行快速傅里叶变换(FFT)图像处理实验,并了解相关编程作业的结构与要求。
实验环境与操作 🛠️
上一节我们介绍了CUFFT库的基本概念,本节中我们来看看具体的实验环境与操作步骤。
Visual Studio Code服务器界面底部至少有两个按钮:“Build”和“Run”。工具提示说明,它们分别执行“make clean build”和“make run”命令。
以下是操作流程:
- 点击“Build”按钮,终端窗口将出现并完成编译。
- 编译成功的标志是生成名为
QfftTex.exe的可执行文件。 - 点击“Run”按钮执行程序。程序将创建两个矩阵,并对它们执行CUFFT的正向与反向复数变换操作。
CUFFT示例代码解析 📖

了解了如何运行程序后,我们来看看示例代码的核心逻辑。我尽量保持了代码的简洁性。
代码执行的核心是一个矩阵乘法操作,该操作被置于CUFFT变换的中间过程。主要步骤如下:
- 创建两个信号:输入信号和滤波器核。
- 在设备上分配内存,并将数据从主机复制到设备。
- 创建一个变换计划(plan),其大小为
N x N,对应于两个操作矩阵的大小,并指定为复数到复数的变换类型。 - 对信号执行正向复数变换。
- 对滤波器核执行同样的正向复数变换。
- 在频域对上述两个变换结果执行乘法操作,并将乘积结果存入信号变量。
- 对乘积结果执行反向变换。
- 将结果内存复制到结果指针(复数类型)并打印输出。
- 最后,务必销毁变换计划并释放所有已分配的内存。
编程作业概览 📝

现在让我们进入编程作业部分。你应该对此流程比较熟悉了。
你将获得一个CUDA文件和一些“待办事项”(// TODO)注释来指导你完成任务。我已将代码组织得易于理解。你需要构建并运行它。
需要注意以下几点:
- 你可以进入
run.sh文件,通过修改-n参数来改变矩阵的边长。矩阵始终是方阵。 - 如果未得到预期结果,可以随时执行清理操作。
- 点击“submitit”后,结果将显示在作业提交页面上。
作业代码结构解析 💻
我们已经打开了作业的Visual Studio编辑器。现在来深入了解一些关键内容。
首先,理解 Makefile 文件很重要:
CXX是编译器,在我们的环境中通常是nvcc。CXXFLAGS和LDFLAGS用于编译器和链接动态库。all目标会依次执行clean和build。build目标会查找qfft_example.cu的内容,并使用nvcc进行编译。- 一个重要的细节是链接了CUFFT库以及CUDA本身,以确保包含的头文件能被正确链接。
- 随后,
run目标会使用参数执行qfft.exe并将输出重定向到.txt文件。
接下来,qfft_example.cu 文件包含多个预定义的函数和“待办事项”结构:
complexScaleMult:接收两个复数值,将它们分别与一个标量相乘后再彼此相乘。complexProd:获取用于A和B的值,并将其与标量一同传递给complexScaleMult函数。makeCpx:创建一个指向大小为size的复数数据类型集合的指针。printCpx:打印该复数指针的内容。generateDeviceCpx:基于主机指针生成设备上的复数指针。这是因为CUFFT的复数指针可以基于主机或设备内存创建。transformT2S:执行正向变换(从时域到频域)。transformS2T:执行反向变换(从频域到时域)。main函数:整合所有步骤,为主机和设备内存创建输入指针A和B,以及输出指针C,并指导你需要完成的操作。
最后,请始终注意:代码中任何包含 print 语句或 cout 输出的部分都应予以保留,切勿修改。这能确保你的作业得到正确的评分。
总结 🎯

本节课中,我们一起学习了CUFFT库的实验操作流程,分析了示例代码中FFT变换与矩阵乘法的结合应用,并详细剖析了编程作业的代码结构与完成要点。核心在于理解CUFFT计划的生命周期管理、主机与设备间的数据传递,以及在频域执行点乘操作后通过逆变换得到时域结果的完整流程。
104:线性代数入门 🧮
在本节课中,我们将快速了解线性代数的基础知识。这并非一个完整的课程,而是一个简短的介绍,旨在帮助你理解核心概念,以便后续探索如cuBLAS、NVBLAS等CUDA线性代数库。建议你参考其他资源,例如Coursera课程、YouTube视频或Gilbert Strang的线性代数教材,以进行更深入的学习。
基本概念
上一节我们提到了学习线性代数的目的,本节中我们来看看其基本概念。考虑一个描述向量或直线的公式,其形式如下:
a₁x₁ + a₂x₂ + ... + aₙxₙ = b
其中,x₁ 到 xₙ 是变量,a₁ 到 aₙ 是标量值。
通常,你会遇到一系列这样的方程,并需要求解每个 x 变量。
矩阵构造
以下是构建矩阵以求解方程组的方法。

一个常见的方法是构造一个矩阵。将所有变量及其标量乘数放在左侧,并按 x、y、z 的顺序排列。在右侧,放置方程等于的常数。
矩阵的行数等于方程的数量,列数等于变量的数量加上一个常数项。如前所述,每一行代表一个方程,即描述一条直线或一个向量。这些值的集合就构成了一个多维矩阵。

矩阵运算
上一节我们介绍了矩阵的构造,本节中我们来看看两种常见的矩阵运算。
两种常见的矩阵运算是:矩阵与标量(常数)相乘,以及矩阵的点积(乘法)。
- 标量乘法:将一个矩阵乘以一个常数。例如,将矩阵乘以2,矩阵中的所有值都相应地被乘以2,结果放入一个相同大小的矩阵中。
- 点积乘法:取两个矩阵进行相乘。
我们通过行和列来描述矩阵的大小。在下面的例子中,一个 2x3 的矩阵与一个 3x2 的矩阵进行点积运算,结果得到一个 2x2 的矩阵。

矩阵点积乘法的运算规则是:取左侧矩阵的行,与右侧矩阵的列相乘。例如,在结果矩阵的第二个位置(第一行,第二列),其值是左侧矩阵第一行与右侧矩阵第二列的点积:1*8 + 2*10 + 3*12 = 64。依此类推,最终得到一个方阵。拥有方阵通常是有益的,因为某些运算只能在方阵上执行。

行列式
在单个矩阵上最常执行的操作之一是计算其行列式。此操作只能对方阵进行。这里展示的是一个 2x2 矩阵的例子,但实际上它可以是任何方阵,如 3x3、4x4。计算会变得更加复杂,但有方法可以求解。这也是人们喜欢用计算机计算这些值的原因之一,因为它可能非常复杂。
那么行列式有什么用呢?你可以用它来判断三条线是否共线。这意味着即使标量不同,它们实际上可能重叠。请记住几何学的基本知识:任何直线都可以用不同的方程表示,但可能具有相同的轨迹,起点和终点相同。
此外,行列式还可用于确定三角形的大小。如果行列式等于0,意味着至少两条线是共线的。如果行列式大于或小于0,意味着所有三条线不共线。它们起始和结束于不同的点,但会相交。因此在多维空间中,你得到了一个三角形,并且可以通过行列式知道它的大小。

总结

本节课中我们一起学习了线性代数的入门知识。我们介绍了描述直线的基本公式、如何构建矩阵来求解方程组,以及两种基本的矩阵运算:标量乘法和点积乘法。最后,我们探讨了方阵行列式的概念及其在判断线共线和计算三角形大小方面的应用。这些基础概念是理解更高级GPU线性代数库功能的基石。
105:CUBLAS库语法详解 🧮
在本节课中,我们将深入学习CUDA高级库中一个最常用、最广泛的库——CUBLAS(CUDA Basic Linear Algebra Subprograms)的语法。我们将从初始化与销毁讲起,逐步深入到不同级别的函数操作。
初始化与销毁 🔧
在开始使用CUBLAS进行任何编程之前,必须首先初始化库。这是通过 cublasCreate 函数完成的。
cublasCreate:此函数接收一个指向cublasHandle_t类型句柄的指针,对其进行初始化,并返回该句柄。这意味着底层的CUBLAS库也同时被初始化。在执行任何后续函数之前,此步骤是必需的。
上一节我们介绍了如何初始化CUBLAS,相应地,在程序结束时,必须进行清理工作。
cublasDestroy:这是你需要调用的最后一个函数。它会释放任何可能被锁定的硬件或软件资源。
流管理 🌊
默认情况下,CUBLAS操作在默认流上执行。如果你想使用非默认流,需要进行设置。
以下是相关的流管理函数:
cublasSetStream:此函数用于指定一个句柄将在哪个非默认流上执行。cublasGetStream:此函数用于获取之前为该句柄设置的流,以便将后续操作放入该流中。
Level 1 函数:向量运算 📏
Level 1 函数是作用于一个或多个向量上的操作,处理的是单方程,而非矩阵。

你需要熟悉函数名中的前缀 S、D、C、Z,这在其他操作中也会出现。它们的含义如下:
S:单精度浮点数。D:双精度浮点数。C:单精度复数。Z:双精度复数。

以下是Level 1函数的一些例子:
- 最大值/最小值查找:函数如
cublasIsamax、cublasIdamax、cublasIcamax、cublasIzamax。它们用于查找向量中最大值的索引。 - 点积计算:函数如
cublasSdot、cublasDdot。注意在dot之后可能有U(无符号)和C(复数)等变体。这些函数计算两个向量的点积。
此外,还有其他数值函数可以执行,例如旋转向量、缩放向量以及对一个或多个向量进行其他简单计算。
Level 2 函数:矩阵-向量运算 ➗
Level 2 函数对矩阵和向量进行操作,执行乘法运算。
一个例子是 cublas[S|D|C|Z]gbmv,这是一个通用的带状矩阵与向量乘法函数。你需要传递以下参数:
- 句柄 (
handle):CUBLAS上下文。 - 操作类型 (
trans):指示矩阵是否转置、共轭转置等。 - 矩阵维度 (
m,n):矩阵A的行数和列数。 - 带状矩阵参数 (
kl,ku):矩阵的上下对角线数量,kl是较小的那个。 - 标量 (
alpha):用于与A * x的乘积相乘的标量。 - 矩阵 (
A):要进行乘法的矩阵。 - 主维度 (
lda):矩阵A的主维度(leading dimension)。 - 向量 (
x):输入向量。 - 向量步长 (
incx):向量x中元素的存储步长。如果向量是稠密的(每个位置都有值),则通常为1;如果是稀疏的,则可能大于1。 - 另一组参数 (
beta,y,incy):参数beta、y和incy的作用与alpha、x和incx类似,用于处理输出向量y。
这看起来相当复杂。如果你有相关经验,可以思考一下如何使用它。
Level 3 函数:矩阵-矩阵运算 ✖️
Level 3 函数在CUBLAS中执行矩阵与矩阵的乘法。
一个更常见的例子是通用矩阵乘法函数 GEMM。它有五种变体:
- 前四种基于两个矩阵具有相同的精度(单精度、双精度、单精度复数或双精度复数)。
- 第五种是
H表示埃尔米特(共轭转置)。 - 此外,还有
3M变体,用于降低高斯复杂度。
除了使用 GEMM 的函数外,还有其他函数用于执行特定类型的三角矩阵和埃尔米特矩阵乘法。因此,务必查阅NVIDIA的CUDA CUBLAS文档。
这是一个功能强大的库,能为你提供所需的大部分线性代数功能。


本节课中,我们一起学习了CUBLAS库的核心语法。我们从初始化 (cublasCreate) 和销毁 (cublasDestroy) 开始,这是使用库的基础。接着,我们了解了如何通过 cublasSetStream 和 cublasGetStream 来管理计算流。然后,我们深入探讨了三个级别的函数:处理向量运算的 Level 1 函数(如点积 cublasSdot)、处理矩阵与向量乘法的 Level 2 函数(如 cublasSgbmv),以及处理矩阵与矩阵乘法的 Level 3 函数(尤其是通用的 GEMM 函数)。掌握这些语法是高效利用GPU进行线性代数计算的关键。
106:cuSOLVER库语法详解 🧮
在本节课中,我们将要学习CUDA高级库中的cuSOLVER库。cuSOLVER库主要用于解决线性代数问题,特别是针对矩阵和向量的方程求解,而不是进行矩阵乘法运算。
上一节我们介绍了CUDA高级库的概况,本节中我们来看看专门用于求解线性方程组的cuSOLVER库。
cuSOLVER库概述
cuSOLVER库的设计理念源于广泛使用的线性代数包LAPACK。它具备两种核心能力:一是能够将矩阵分解为更小的、可独立求解的矩阵;二是能够直接求解矩阵。该库支持单GPU和多GPU模块。
cuSOLVER库主要包含三个功能模块:
- cuSOLVER DN:用于求解稠密矩阵。
- cuSOLVER SP:用于求解稀疏矩阵。稀疏矩阵是指其中超过一半的元素值为零的矩阵。
- cuSOLVER RF:用于执行矩阵的重新分解或分解操作。
通用辅助函数
与其他CUDA高级库类似,cuSOLVER也提供了一系列辅助函数。

以下是使用cuSOLVER时常见的辅助函数:
- 总会有一个用于创建句柄的函数,其形式为
cusolver[空白]Create,其中[空白]可以是上一张幻灯片中描述的DN、SP或RF。 - 与之匹配的销毁功能,称为
cusolver[空白]Destroy([空白]同样为DN、SP或RF)。该函数用于释放创建和使用句柄过程中所申请的任何硬件资源。 - 与其他库一样,cuSOLVER也提供了通过设置和获取机制来使用流的方法。这样,如果你使用非默认的CUDA流,就可以在指定的流上按定义的顺序执行你的函数。

使用cuSOLVER的标准步骤
使用cuSOLVER求解线性系统或矩阵时,最常遵循的步骤如下。
以下是使用cuSOLVER的标准工作流程:
- 首先,需要获取一个句柄并按照前一步骤的描述对其进行初始化。
- 初始化主机和设备内存。
- 将输入矩阵从主机内存复制到设备内存。
- 根据需求(稀疏、稠密或重新分解),调用相应的cuSOLVER函数在设备内存上执行计算。
- 在该操作上进行同步,以确保如果你的应用程序需要这些数据,不会在数据就绪前继续执行。
- 将计算结果从设备复制回主机。
- 查看结果。可以通过调试、打印输出或写入文件的方式。
- 最后,必须始终释放主机和设备内存,并销毁库句柄。

本节课中我们一起学习了cuSOLVER库的核心概念、功能模块、通用辅助函数以及使用该库求解线性代数问题的标准步骤。掌握这些是高效利用GPU进行科学计算和工程问题求解的基础。
GPU编程:13:CUSPARSE库语法详解 🧮
在本节课中,我们将学习如何使用CUSPARSE库处理稀疏矩阵的线性代数运算。我们将重点介绍其核心语法、支持的运算级别以及关键函数的使用模式。
上一节我们介绍了GPU编程的通用概念,本节中我们来看看专门用于稀疏矩阵计算的CUSPARSE库。从高层次看,CUSPARSE是一个用于处理稀疏矩阵的库,它支持Level 1到Level 3的运算以及稀疏矩阵的转换函数。
关于Level 1运算,你应当停止使用或完全不要考虑使用它,因为它即将被弃用,并很快会从CUDA中移除。
Level 2和Level 3函数通常以一个稀疏矩阵作为主要操作对象。与之进行运算的始终是稠密对象。

- 在Level 2中,是稀疏矩阵与稠密向量之间的运算。
- 在Level 3中,是稀疏矩阵与稠密矩阵之间的运算。

接下来,我们将介绍一些主要的Level 2运算。请注意,所有这些运算都需要BSR存储格式,这是一种紧凑且能快速表示稀疏矩阵的方式。
以下是主要的Level 2运算函数:
cusparseTbsrmv:此函数用于执行稀疏矩阵与向量的乘法。其中T代表数据类型。cusparseTbsrmv_bufferSize:此函数用于计算表示该矩阵所需的缓冲区大小。cusparseTbsrmv_analysis和cusparseTbsrmv_solve:你可以使用这些函数分别确定分析阶段和求解这个矩阵向量应用的过程。
请注意,所有函数名都以cusparse开头,而T是我们之前见过的类型占位符,可替换为:S代表单精度,D代表双精度,C代表单精度复数,Z代表双精度复数。
正如我们之前描述的,Level 3运算是稀疏矩阵与稠密矩阵之间的乘法或其他运算。
其语法遵循相同的模式。以下是主要的Level 3运算函数:
cusparseTbsrmm:此函数用于执行矩阵乘法或矩阵-矩阵乘法。函数名中的BSR代表其使用的紧凑快速的BSR存储格式。cusparseTbsrmm_bufferSize:此函数用于定义稀疏矩阵BSR格式所需的缓冲区大小。cusparseTbsrmm_analysis和cusparseTbsrmm_solve:你可以使用这些函数确定当前所处的分析阶段以及求解过程的阶段。




本节课中,我们一起学习了CUSPARSE库的核心语法。我们了解到该库通过Level 2和Level 3函数高效处理稀疏矩阵与稠密向量/矩阵的运算,所有相关函数都遵循cusparseT[运算名]的命名约定,并使用BSR格式进行高性能存储和计算。
108:NVBLAS语法解析 🧠
在本节课中,我们将要学习NVBLAS的语法。NVBLAS是一个基于cuBLAS构建的库,它本身又建立在cuBLAS XT之上,后者是cuBLAS的多GPU版本。这意味着NVBLAS天然支持多GPU计算,并提供了与cuBLAS相同的1级到3级函数支持。

NVBLAS的架构基础

上一节我们介绍了NVBLAS的基本概念,本节中我们来看看它的具体架构。NVBLAS建立在cuBLAS XT之上,而cuBLAS XT是cuBLAS的多GPU版本。因此,NVBLAS内置了多GPU支持。没有cuBLAS,就没有NVBLAS。
支持的函数类型
NVBLAS支持一系列线性代数运算。以下是可用的例程类型概述:
- 秩更新(Rank Updates):这类函数用于对矩阵进行低秩更新。其中,“S”代表对称矩阵,“H”代表厄米特矩阵。“RK”代表秩K更新,“R2K”代表秩2K更新。具体使用哪种取决于输入和输出矩阵的维度。
- 矩阵乘法(Matrix Multiplication):NVBLAS提供了多种矩阵乘法函数,主要分为四类:
- 三角矩阵乘法:仅使用矩阵的上三角或下三角部分进行计算。
- 对称矩阵乘法:用于两个相同大小的对称矩阵相乘。
- 厄米特矩阵乘法:专门处理复数或双精度复数类型的厄米特矩阵。
- 通用矩阵乘法:用于两个可能不同大小的通用矩阵相乘。
- 三角矩阵求解(Triangular Solve):对应函数
TRSM,用于求解三角矩阵方程组。
需要注意的是,对于厄米特矩阵类型的更新或乘法运算,NVBLAS仅支持复数(cuComplex)和双精度复数(cuDoubleComplex),不支持单精度或双精度实数类型。
函数调用与参数
NVBLAS提供了丰富的函数,你需要根据计算任务选择合适的一个。每个函数都有一系列属性和参数需要传递,例如用于管理上下文的句柄(handles)等。
总结


本节课中我们一起学习了NVBLAS的语法。我们了解到NVBLAS是一个基于cuBLAS XT的多GPU加速库,支持从1级到3级的BLAS函数。我们详细介绍了它支持的函数类型,包括各种秩更新、矩阵乘法和三角求解,并特别指出了其对复数数据类型的限制。最后,我们提到调用这些函数时需要传递正确的参数和句柄。
109:CUDA线性代数实验与作业综述 🧮

在本节课中,我们将快速浏览CUDA线性代数库的实验与作业。我们将了解实验内容的结构,并详细说明作业的要求与实现步骤。
实验部分概览
上一节我们介绍了CUDA编程的基础,本节中我们来看看如何使用CUDA提供的线性代数库进行实际计算。
实验部分包含了几个示例文件,用于演示不同的库功能。
以下是实验包含的核心文件列表:
Parallel.cKla.cKusalver.cKusSprse.c
这些文件展示了相应库函数的运行示例。你可以快速浏览它们,了解每个操作的具体步骤。
关于Makefile,你需要知道:
- 执行
make clean命令会清理之前的构建文件。 - 执行
make build命令会编译所有示例程序。 - 执行
make run命令会依次运行每个编译好的示例。

点击构建按钮后,程序会编译三次。编译过程中可能会出现一些警告,但不应有错误。之后可以点击运行按钮。
运行每个示例时,你可能需要按回车键来继续。程序会输出一些信息,例如卡格尔(可能指矩阵条件数)、迭代次数(最后一个示例进行了10,000次迭代计算)以及各种计算结果。你可以根据需要查看输出,也可以选择不运行全部迭代。
作业部分说明
现在让我们转到线性代数库的作业部分。在查看代码之前,请先阅读作业说明。
你需要修改的文件是 cublas_assignment.cu。文件中预留了多个需要你填充功能的函数,并有两条注释大致说明你需要完成的任务。
完成代码后,你应该点击构建按钮。如果构建顺利,接着点击运行按钮来执行 make run 命令。
如果遇到任何问题,可以点击清理按钮。设置两个独立按钮(清理和构建)的原因是,本作业涉及多个可执行文件,流程相对复杂。
建议你按顺序逐一完成第1至第4部分。完成后,点击提交作业按钮。

请注意,不要修改任何 printf 或 cout 输出语句。但你可以自由尝试不同的数据类型,并在此基础上扩展作业,特别是如果你对此领域感兴趣,并考虑在最终项目中使用cuBLAS等库。
代码结构解析
接下来,我们具体查看 cublas_assignment.cu 文件。文件中有些函数已被隐藏,因为它们不重要且无需修改。
这些无需修改的函数主要负责:
- 打印一个或多个矩阵。
- 释放矩阵内存。
你的责任是完成其他核心函数。主要函数包括:
initialize_host_memory:根据指定矩阵大小初始化主机内存。initialize_device_memory:在设备上做同样的内存初始化。retrieve_from_device:将数据从设备内存检索到主机内存。main:主函数,它需要完成多项任务,包括初始化矩阵A和B、执行不同的运算、填充数值、创建设备内存、执行操作以及最后的清理工作。
这个作业并不非常复杂。这样设计是为了让你有更多时间进行探索,或者考虑是否在最终项目中使用cuBLAS库。请充分利用这一点。
请注意界面上的按钮功能:
- 清理:用于清除旧的构建文件和产物。
- 构建:用于编译你的代码并运行测试。
- 提交作业:当你确认完成后点击此按钮。
点击提交后,一个终端窗口会弹出并显示提交是否成功。我的提交是成功的,因为之前已经运行过并且有输出存在。你不需要手动在终端里输入输出,但必须确保有可用的输出写入 output.txt 文件,并且系统返回提交成功的提示。
课程总结

本节课我们一起学习了CUDA线性代数库实验与作业的完整流程。我们回顾了实验示例的结构和运行方式,详细解析了作业的代码框架、需要完成的任务以及提交流程。掌握这些内容,将帮助你更高效地利用GPU进行线性代数计算。
110:Thrust向量库语法 🚀
在本节课中,我们将学习Thrust库及其核心数据结构——向量的基本语法。Thrust是一个强大的C++库,旨在提供类似于C++标准模板库(STL)和Boost库的功能,同时简化GPU编程。通过抽象指针操作,它让开发者能更专注于算法逻辑。
什么是Thrust?
上一节我们介绍了本课程的目标,本节我们来具体看看Thrust是什么。Thrust库的设计模式旨在满足特定需求,即提供类似于C++ STL和Boost库的能力。它的核心优势在于移除了开发者直接操作指针的需要,将这些底层细节抽象化。
核心数据类型:向量
Thrust库中主要(但并非唯一)的数据类型是向量。向量分为两种:
- 主机向量:用于主机(CPU)内存。
- 设备向量:用于设备(GPU)内存。
向量使用迭代器的概念。迭代器允许应用程序函数遍历向量中的所有值。你可以将向量的部分,甚至通过指定起始和结束迭代器,作为参数传递给函数,特别是那些使用Thrust编写的函数。Thrust库本身内置了大量现成的算法。
如何创建向量?
了解了向量的概念后,我们来看看如何创建它。在所有Thrust函数中,你都会看到 thrust:: 前缀。如果你使用C++的 using 语法和 namespace thrust,则可以省略此前缀,但保留命名空间前缀通常是个好习惯。
以下是创建向量的方法:
thrust::host_vector<int> H(4); // 创建一个初始大小为4的主机int向量
H[0] = 14; // 使用数组表示法设置值
H[1] = 20;
H[2] = 38;
H[1] = 99; // 修改值
向量在创建时是静态大小的,但可以使用 resize 函数调整大小。
- 如果新尺寸大于当前尺寸,会在末尾添加空值。
- 如果新尺寸小于当前尺寸,则会截断超出部分。
例如,H.resize(2) 会将向量大小调整为2,超出部分的值将被丢弃。
向量操作函数
现在,我们重点介绍三个主要的向量操作函数。
1. 向量填充
thrust::fill 函数用指定的值填充向量中从起始到结束迭代器范围内的所有元素。你可以使用 vector.begin() 和 vector.end() 来填充整个向量。
thrust::fill(H.begin(), H.end(), 7); // 将向量H的所有元素填充为7
2. 生成序列
thrust::sequence 函数在向量的指定范围内生成一个数字序列。序列基于起始值和步长生成。请注意,范围是左闭右开的。例如,指定范围0到10,实际会生成0到9。
thrust::sequence(H.begin(), H.end(), 0, 2); // 生成序列: 0, 2, 4, 6, ...
如果目标向量小于指定的序列范围,函数只会填充向量能容纳的部分。
3. 向量复制
thrust::copy 函数将一个向量的内容复制到另一个向量。它从源向量的起始迭代器复制到结束迭代器,并将值填充到目标向量的起始迭代器位置。函数会尽可能多地复制值,直到填满目标范围或源数据用完。
thrust::device_vector<int> D(5);
thrust::copy(H.begin(), H.end(), D.begin()); // 将主机向量H复制到设备向量D
总结


本节课我们一起学习了Thrust向量库的基本语法。我们了解了Thrust库的设计目标,认识了其核心数据类型——主机向量和设备向量,并掌握了如何创建向量以及使用 fill、sequence 和 copy 这三个关键函数进行操作。这些基础是使用Thrust进行高效GPU编程的第一步。
111:Thrust向量迭代器解析 🧠
在本节课中,我们将聚焦于Thrust库中的向量数据类型,并深入探讨其迭代器的语法与用法。
概述
迭代器是Thrust库中的一个核心概念,它抽象了指针和索引操作中的许多复杂细节。迭代器能够高效地处理循环等操作,尤其是在调用或使用它的函数中。我们最近在copy序列和fill函数中已经见过一些使用迭代器的例子。大多数Thrust向量操作都接受一个起始和一个结束迭代器作为参数。
迭代器基础
上一节我们概述了迭代器的概念,本节中我们来看看它的具体应用场景。
如果你出于某种原因,希望使用类似C/C++的指针索引方式,而不是迭代器,你可以使用raw_pointer_cast将向量转换为原始指针。
如果你习惯使用标准库容器,或者你的代码中使用了标准库,你可以在其中使用迭代器来创建Thrust向量,并且可以在标准库容器和Thrust向量之间进行数据拷贝。
迭代器类型
Thrust库提供了多种类型的迭代器,以满足不同的编程需求。以下是几种主要的迭代器类型:
常量迭代器:在构造时指定大小,它会创建一个无论你访问哪个索引(从first[0]到first[10],甚至first[1000000])都返回相同值的迭代器。它的一个典型用途是,当你需要将一个向量乘以一个常数标量时。因为大多数运算需要传入另一个向量作为参数,这时你可以使用常量迭代器来充当这个“向量”。例如,以下代码会将一个向量中的所有值乘以10:
thrust::constant_iterator<int> const_iter(10);
// 假设 vec 是另一个 thrust::device_vector<int>
thrust::transform(vec.begin(), vec.end(), const_iter, vec.begin(), thrust::multiplies<int>());
计数迭代器:类似于一个序列生成器。你给定一个初始值,然后根据你访问该迭代器的索引,它会将该索引值加到初始值上。如下图所示,对于counting_iterator,第一个值(索引0)是10,第200个值则是210(10+200)。
thrust::counting_iterator<int> count_iter(10);
// count_iter[0] == 10, count_iter[200] == 210
变换迭代器:你提供一个迭代器和一个变换函数(例如乘以2),它会应用该函数来生成新的值序列,从而创建出第二个迭代器。
// 假设 input_iter 是某个输入迭代器
struct multiply_by_two {
__host__ __device__ int operator()(int x) const { return x * 2; }
};
auto trans_iter = thrust::make_transform_iterator(input_iter, multiply_by_two());
排列迭代器:你向它提供两个迭代器。第一个迭代器是索引的集合,这些索引指向第二个迭代器中的位置。排列迭代器会根据你选择的索引,对第二个迭代器中相应位置的值进行求和(或其它操作)。
// 假设 map_iter 是索引迭代器,value_iter 是值迭代器
auto perm_iter = thrust::make_permutation_iterator(value_iter, map_iter);
// perm_iter[i] 将返回 value_iter[map_iter[i]]


总结


本节课中,我们一起学习了Thrust库中向量迭代器的核心概念。我们了解了迭代器如何简化指针操作,探讨了在Thrust与标准库之间进行数据交互的方法,并详细介绍了常量迭代器、计数迭代器、变换迭代器和排列迭代器这几种主要类型及其用途。总的来说,Thrust提供了丰富多样的迭代器类型,为GPU上的数据变换和处理提供了强大的灵活性。
112:函数式编程范式 🧮
在本节课中,我们将学习函数式编程范式,这是一种在包括CUDA、C++在内的多种语言中,用于高效进行数据映射、归约和转换的方法。
概述
函数式编程的核心在于以声明式而非命令式的方式处理数据。它强调数据的不可变性,并通过一系列高阶函数(如 map、reduce)来操作数据,而非使用传统的循环结构。这种方法可以使代码更简洁、更易于推理,并且在某些情况下,通过库(如Thrust)的优化实现,还能获得更好的性能。
核心概念
不可变性
在函数式编程中,数据对象通常是不可变的。这意味着一旦一个对象被实例化并赋予值,你就不能直接修改这个值。任何操作都会基于原始数据生成新的数据,并将结果赋值给新的变量或指针,而不是改变底层数据本身。
递归迭代
函数式编程通常使用递归而非循环来进行迭代。一个函数会接收一个数据集合(如向量),并处理其“第一个”元素和“剩余”部分。函数会处理第一个元素,然后递归地调用自身来处理剩余部分,直到剩余部分为空。这种方式与我们在GPU编程中通常避免递归的思路有所不同,需要适应。
常见高阶函数
以下是函数式编程中一些非常常见的操作,它们在Thrust等库中也有实现。
each:对输入中的每个值执行一个操作。输出的大小不一定与输入相同,操作可能影响一个完全不同的外部状态。map:对输入中的每个元素执行相同的操作,并生成一个大小相同的输出。公式可表示为:output[i] = function(input[i])。reduce:接收一个输入,通过一个操作(如加法)将其归约为一个单一的值。它通常需要一个初始累加器值acc。例如,求和:result = acc + input[0] + input[1] + ... + input[n-1]。filter:根据一个判断函数(返回布尔值)来过滤输入。只有使判断函数返回true的元素才会被包含在输出中。intersection、union、unique:这些是用于集合操作的函数,基于布尔逻辑处理两个向量,分别用于求交集、并集和去重后的唯一值集合。
匿名函数:Lambda与箭头函数
传递给上述高阶函数的操作(op)不一定是一个独立定义的函数。它通常可以是匿名函数。
- Lambda表达式:一种可以捕获上下文中变量的匿名函数。它可以有多行逻辑,相对更强大。例如,在C++中:
auto total = [](int acc, int x) { return acc + x; }; - 箭头函数:一种语法更简洁的匿名函数,通常只适合单行简单操作。在支持的语言中(如JavaScript),它可能看起来像这样:
(acc, x) => acc + x
使用建议:对于中等复杂度的逻辑,使用Lambda表达式;对于非常复杂的逻辑,使用外部定义的普通函数;对于求和、乘法等简单操作,可以使用箭头函数以保持代码简洁。
总结


本节课我们一起学习了函数式编程范式。我们了解了其核心原则——数据不可变性和递归迭代,并认识了 map、reduce、filter 等关键的高阶函数。此外,我们还探讨了Lambda表达式和箭头函数这两种定义匿名操作的方式。掌握这些概念有助于你编写出更清晰、更模块化的代码,并能更好地利用像Thrust这样的高级库进行GPU编程。
113:Thrust数据转换技术 🚀
在本节课中,我们将学习Thrust库的数据转换功能。Thrust提供了一系列强大的转换操作,允许我们对向量中的区间元素执行相同的运算。这些操作通常遵循相似的调用模式,理解其结构是掌握其用法的关键。
概述
Thrust的数据转换操作非常强大,它们允许你对向量内的一个区间执行相同的操作。这些操作的调用模式通常很相似:前两个参数总是区间的起始和结束迭代器,最后一个参数总是要执行的操作。在某些情况下,操作参数可能不是第三个参数,但位于最后一个参数之前的所有参数,都是用来支持你如何执行该操作的。
核心转换操作示例
以下是Thrust中几种常见的数据转换操作,它们展示了如何对向量元素进行批量处理。
取反操作
上一节我们介绍了Thrust转换操作的基本模式,本节中我们来看看一个简单的例子:取反操作。该操作接收一个输入数组或向量,并返回其元素的相反数。例如,[-1, -2, -3] 会变成 [1, 2, 3]。
以下是使用thrust::negate进行取反操作的步骤:
- 输入:源向量
x。 - 输出:目标向量
y。 - 操作:
thrust::negate<int>()。 - 代码示例:
thrust::transform(x.begin(), x.end(), y.begin(), thrust::negate<int>()); - 解释:你提供输入向量
x的起始和结束迭代器,以及输出向量y的起始迭代器。转换操作会将x中每个元素的相反数依次放入y中。
模运算操作
了解了基本的单向量操作后,我们来看看如何处理两个向量。模运算操作就是一个典型的例子,它会对两个向量中相同索引的元素进行模运算,并将结果放入第三个向量。
以下是使用thrust::modulus进行模运算的步骤:
- 输入:两个源向量
x和z。 - 输出:目标向量
y。 - 操作:
thrust::modulus<int>()。 - 代码示例:
thrust::transform(x.begin(), x.end(), z.begin(), y.begin(), thrust::modulus<int>()); - 解释:模运算计算的是整数除法后的余数。例如,
4 % 2 = 0,3 % 2 = 1。此调用从x和z的起始位置开始,对每一对元素执行模运算,并将结果依次放入y中。
SAXPY 变换
最后,我们探讨一个在科学计算中非常常见的操作:SAXPY变换。SAXPY是“标量a乘向量x加向量y”的缩写,其公式为:y = a * x + y。这是一个原地操作,结果会存回向量 y。
以下是实现SAXPY变换的步骤:
- 创建临时向量:首先,创建一个临时向量
temp,并用标量值a填充所有位置。代码为:thrust::fill(temp.begin(), temp.end(), a); - 执行乘法:然后,对临时向量
temp和输入向量x执行逐元素乘法,结果存回temp。代码为:thrust::transform(temp.begin(), temp.end(), x.begin(), temp.begin(), thrust::multiplies<float>());此时,temp中保存的是a * x的结果。 - 执行加法:最后,将
temp(即a*x)与向量y相加,结果存回y。代码为:thrust::transform(temp.begin(), temp.end(), y.begin(), y.begin(), thrust::plus<float>());
这个例子可能看起来步骤稍多,但用三行代码完成在常规编程中至少需要两个循环的操作,已经非常简洁高效了。
总结


本节课中我们一起学习了Thrust库的核心数据转换技术。我们掌握了三种基本转换操作:使用 thrust::negate 进行取反,使用 thrust::modulus 对两个向量进行模运算,以及通过组合 thrust::multiplies 和 thrust::plus 来实现经典的SAXPY变换。理解这些操作的调用模式——即提供输入迭代器、输出迭代器和具体的转换函子——是灵活运用Thrust进行高效GPU编程的关键。
114:Thrust数据归约操作
在本节课中,我们将要学习Thrust库中用于执行数据归约操作的功能。归约操作是指对一个输入序列执行某种运算,最终得到一个单一值的过程。

上一节我们介绍了Thrust库的基本概念,本节中我们来看看如何使用它进行数据归约。
归约操作概述
数据归约操作作用于设备向量。对主机向量执行归约没有意义。归约操作在序列的值之间执行指定的运算。例如,如果你有一个向量 V,并对它执行加法归约,其过程类似于计算 V[0] + V[1] + V[2] + ...。如果向量包含值 1 和 2,那么结果将是 3。
归约语法
以下是归约函数的基本语法和参数说明。
归约函数的主要参数如下:
- 参数0和1:起始和结束索引(或迭代器)。通常传递向量的
begin()和end()迭代器以处理整个序列,但你也可以指定一个子集。 - 参数2:初始值。计算从这个值开始,然后与输入序列中的值进行累加、相乘等操作。
- 参数3:要执行的运算操作符。
对于求和操作,有一个简化的用法。你可以使用完全指定的形式,也可以只提供起始和结束迭代器。默认情况下,这将执行求和操作,且初始值默认为零。这是专门为求和提供的简写形式。
特殊归约操作
Thrust库还提供了一些特殊的归约函数,你无需传递操作符。
以下是这些特殊归约函数的列表:
countminmaxmin_elementmax_elementis_sortedinner_product
对于这些函数,你只需传递起始和结束迭代器(以及可选的初始值),库会自动为你执行相应的操作。

本节课中我们一起学习了Thrust库的数据归约功能。我们了解了归约的基本概念,即从一组数据中计算出一个单一值;学习了通用归约函数的语法,包括如何指定范围、初始值和操作;最后,我们还认识了一些无需指定操作符的特殊归约函数,它们为常见操作提供了便捷的接口。掌握这些归约操作对于在GPU上高效处理和分析数据至关重要。
115:Thrust数据重构与排序 🧮
在本节课中,我们将学习如何使用Thrust库对数据进行重新排序和排序。这些操作对于高效地组织GPU上的数据至关重要,尤其是在为后续的并行计算(如内核执行)准备数据时。
数据分区
上一节我们介绍了Thrust库的基本概念,本节中我们来看看如何使用partition函数对数据进行分区。数据分区是指根据特定条件,将数据集合重新排列成两个部分。
partition函数需要以下参数:
- 第一个和最后一个索引迭代器:定义了要操作的向量数据范围。
- 模板(Stencil):一个与数据向量大小相同的数组或向量,用于指定操作的执行顺序。
- 谓词(Predicate):决定如何分区数据的实际条件。它可以是二元的(是/否),也可以是分类的(例如,0, 1, 2, 3, 4代表四个类别)。
使用模板S和谓词“是否为偶数”对向量a进行分区的结果是:所有非偶数值排在前面,所有偶数值排在后面。
你可以尝试调整参数以观察不同的分区效果。这个功能非常有用,可以快速将数据排列成特定顺序。结合内核使用,你可以让数据呈现出特定的模式,从而实现经线(warp)划分或块(block)划分。
以下是谓词的常见类型:
- 二元测试:条件为1或0,然后执行相应操作,以减少等待经线另一侧执行完毕而产生的停顿或中止。
数据排序
了解了数据分区后,我们接下来探讨Thrust中的排序功能。排序主要通过三种方式实现。
默认排序:你只需要提供两个参数,即第一个和最后一个索引的迭代器。请注意,在示例中,我传递的是指向向量a起始位置的指针。


按键值对排序:你可以提供两个向量。指定键(keys)向量的起始和结束位置,然后提供值(values)向量。函数会根据键的顺序(大于或序列)对值进行排序,同时键的顺序也会相应改变。这在支持映射(maps)或字典(dictionaries)的语言中非常常见,即存在键值对结构。
稳定排序:此功能允许你创建自定义的排序机制。同样,你需要提供向量的起始和结束迭代器,并传入比较运算符。在这个例子中,我们使用了Thrust内置的greater运算符来处理整数。

你也可以提供自己的运算符。例如,对于按字典序排序单词,你可能希望将数字(在字符值中较早出现)排在字母之后,或者区分大写和小写字母。

本节课中我们一起学习了Thrust库中数据重构与排序的核心操作。我们介绍了如何使用partition函数根据条件对数据进行分区,以及三种主要的排序方法:默认排序、按键值对排序和允许自定义比较规则的稳定排序。掌握这些工具能帮助你高效地组织GPU数据,为并行计算优化数据布局。
116:Thrust库实验与作业总览 🚀

在本节课中,我们将学习如何使用Thrust库进行GPU编程,具体包括一个实验活动和一个编程作业。我们将了解如何编译和运行Thrust代码,并完成一个基于基数排序的键值对排序任务。
实验活动概览
上一节我们介绍了Thrust库的基本概念,本节中我们来看看配套的实验活动。这个实验旨在帮助你熟悉Thrust的编译流程和一个具体的排序示例。
实验的核心是一个名为 radix_sort_thrust.cu 的CUDA文件。它实现的功能是:对一组键(keys)和一组值(values)进行排序,排序的依据是键。代码已经为你写好,你的主要任务是理解它并成功编译运行。
编译与运行步骤
以下是编译和运行该实验代码的关键步骤:
- 使用Makefile编译:实验提供了一个Makefile。最重要的构建目标是
radix_sort_thrust,它会编译radix_sort_thrust.cu文件。make radix_sort_thrust - 处理权限问题:编译成功后,Makefile可能会尝试创建一个目录并复制可执行文件。如果你遇到“权限被拒绝”的错误,可以安全地忽略或删除Makefile中创建目录和复制文件的步骤,直接运行生成的可执行文件即可。
- 运行程序:Thrust库的编译有时较慢。编译完成后,直接运行生成的可执行文件。程序会对超过一百万个元素进行排序,并输出排序的吞吐量和耗时,以此展示Thrust排序实现的效率。
请花时间探索这段代码,理解其如何组织和使用Thrust的排序函数,然后再进入作业部分。

编程作业详解
在熟悉了实验代码后,本节我们将深入分析本次的编程作业。作业要求你填充一个框架代码,实现特定的功能。
作业流程是标准的:你会拿到一个几乎为空的 .cu 文件,需要根据其中的 // TODO 注释填充代码,然后编译、运行并提交。
作业要求与注意事项
以下是完成作业时需要牢记的几点:
- 不要修改输出语句:框架中的
printf或cout语句用于自动评分,请勿修改或删除。你可以添加自己的调试输出,但提交前建议清理。 - 鼓励探索:作业本身对Thrust的探索深度有限。在完成基本要求后,鼓励你尝试不同的数据类型和Thrust函数,这对你的期末项目可能大有裨益。
- 定期提交:你可以随时提交作业,系统会记录你的进度。
代码框架分析

让我们具体看一下作业的代码框架 thrust_sort.cu:
- 核心任务:在
generate_device_memory函数中,你需要完成一系列步骤,最终在设备上创建出键(d_keys)和值(d_values)的向量,并在主机上准备一个用于接收排序后键的向量(h_sorted_keys)。 - 排序迭代:
run_sort_iteration函数是作业的核心。你会获得元素数量(num_elements)和键的位数(key_bits),你需要根据这些参数,使用Thrust库实现基数排序(radix sort)来对键值对进行排序。 - 测试验证:代码中包含测试部分,用于比较排序后的键值顺序是否正确,并生成类似实验部分的性能输出。
- 主函数:
main函数参数基本固定,会处理一个较大的数据量(例如1024 * 1024 * 32个元素),进行多次排序迭代并测试性能。
总的来说,这是一个结构清晰、目标明确的作业,重点在于实践Thrust中内存管理和排序算法的调用。
课程总结
本节课中,我们一起学习了Thrust库的实践应用。我们首先通过一个实验活动,熟悉了Thrust代码的编译流程和一个高效的键值对排序示例。接着,我们详细分析了编程作业的要求,你需要在一个给定的框架中,填充代码以实现基于Thrust的基数排序功能。

关键要点包括:理解Thrust的设备与主机向量操作,掌握使用Thrust算法(如排序)的流程,以及遵循作业的编译、测试和提交规范。希望你能通过动手实践,巩固对GPU并行编程中高级库使用的理解。
117:神经网络入门教程 🧠
在本节课中,我们将学习如何使用GPU进行机器学习,特别是聚焦于神经网络。我们将探讨神经网络的基本概念、结构,以及为何GPU是运行神经网络的理想硬件。
神经网络建立在模仿人脑工作方式的理念之上。它们使计算机能够通过识别模式来响应特定问题。神经网络在人工智能、机器学习和深度学习领域应用非常广泛。它们已存在一段时间,存在一些变体或类似技术,这些技术使用略有不同的数学运算或表示方法。
神经网络已被用于处理人类语言、分析图像和视频以识别物体、碰撞检测以及自动驾驶汽车等领域。神经网络并不适合在CPU上高效运行,因为CPU擅长处理少量核心上的大型问题,而神经网络需要持续计算大量小型运算,GPU是更合适的工具。
神经网络构成要素
上一节我们介绍了神经网络的应用背景,本节中我们来看看神经网络的具体构成。
神经网络必须包含一个或多个输入和输出节点。可以将神经网络视为一个从左到右布局的图,左侧是输入层,可以有一个或多个输入。例如,在处理图像时,每个像素都可以是神经网络的一个特定输入。输出通常是类似“某个特定像素或整张图像属于某个类别(例如,在自动驾驶场景中,识别出行人)”的置信度。
输入层和输出层之间还存在中间层。层是一组大致同时被“激活”的节点的集合,它们按照从左到右的顺序依次处理。有时,层或单个节点是“隐藏”的,这意味着我们并不明确指定它将执行的计算,其计算方式可以在深度学习过程中随时间改变。
以下是关于节点和连接的详细信息:
- 节点值类型:节点的输入和输出可以是二进制的(真/假,1/0),可以是连续的(例如0到100之间的数字),也可以是分类的(例如,识别图像中的物体类型,输出是一个带有相关值的类别)。
- 节点计算:理论上可以使用任何计算,但简单的计算运行效果更好(即更快)。有时为了获得更精确的输出,可能需要复杂的计算。
- 节点连接:节点之间通过输入和输出相互连接。

神经网络结构可视化
现在让我们从视觉上理解这个概念。如前所述,神经网络包含输入层和输出层。右上角展示的是一个复杂的深度神经网络,它包含多个用绿色表示的隐藏层,并且可以看到几乎每个节点都连接到其他节点(尽管这不是必须的)。
左下角展示的是最简单的神经网络示例——感知机。它接收多个输入,产生一个输出。它可以被视为更复杂神经网络的一部分,也可以独立工作。

通常,从定性角度看,节点越多,尤其是层数越多,性能可能越好。但层数越多,模型也越大,运行速度可能越慢。因此需要权衡,同时也要避免层数过多导致“过拟合”,即神经网络只在训练数据上表现良好,而缺乏泛化能力。
GPU如何加速神经网络
上一节我们了解了神经网络的结构,本节中我们来看看GPU如何与之结合。
可以将GPU中的每个独立处理器视为神经网络中的一个节点。

这些处理器可以根据给定的输入输出一个0/1值或连续值。它们需要在连续的层中运行。这意味着,可能需要通过流或其它同步方式,使得特定的层只有在前一层计算完成后才会开始执行。
计算越简单越好,并且它们应该彼此独立。每个处理器接收输入并生成输出。这基本上就是我们一直在学习的GPU和CUDA编程的核心思想。

最棒的是,为了帮你完成所有这些连接和大量工作,已经有了现成的工具——CUDA深度神经网络库。

总结

本节课中我们一起学习了神经网络的基础知识。我们了解到神经网络是一种受大脑启发、用于模式识别的计算模型,广泛应用于AI领域。其核心结构包括输入层、输出层以及可能存在的隐藏层,节点之间相互连接。由于神经网络涉及大量并行的小型计算,GPU相比CPU更具优势。最后,我们介绍了如何将GPU处理器映射为网络节点,并提到了CUDA DNN库可以简化在GPU上实现神经网络的过程。
118:cuDNN语法详解 🧠
在本节课中,我们将详细学习NVIDIA cuDNN库的基本语法。cuDNN是一个用于深度神经网络的GPU加速库,许多主流框架(如TensorFlow和PyTorch)都在底层使用它。理解其核心步骤对于掌握GPU加速的深度学习至关重要。
创建句柄与描述符
首先,使用cuDNN需要创建一个句柄(handle)。这类似于在其他库中初始化一个工作环境。
cudnnHandle_t handle;
cudnnCreate(&handle);
接着,需要为张量(Tensor)创建一个描述符。描述符定义了数据在内存中的布局和类型。
描述张量
以下是定义张量描述符的关键步骤。首先需要确定整个神经网络中将使用的数据类型,例如浮点数(float)。然后需要选择数据格式。

cuDNN支持多种数据格式。在本例中,我们选择CUDNN_TENSOR_NCHW格式。这个格式名称中的字母代表以下维度:
- N: 输入图像的数量(批次大小)。
- C: 特征图的数量或图像中待识别的特征通道数。
- H: 特征图的高度或神经网络层中的节点数。
- W: 特征图的宽度或神经网络中的层数。
张量的总元素数是N、C、H、W这四个维度的乘积。创建描述符后,需要设置其属性。
cudnnTensorDescriptor_t tensorDesc;
cudnnCreateTensorDescriptor(&tensorDesc);
cudnnSetTensor4dDescriptor(tensorDesc, CUDNN_TENSOR_NCHW, CUDNN_DATA_FLOAT, N, C, H, W);
至此,我们便完成了一个四维张量描述符的创建。
定义激活函数与运行网络
上一节我们定义了数据的容器(张量),现在需要定义如何激活并运行这个神经网络。
我们需要创建激活描述符。激活函数定义了神经元何时被激活。cuDNN提供了多种开箱即用的函数,如Sigmoid和ReLU。
本例中,我们选择Sigmoid函数。它是一个连续激活函数(而非从0直接跳变到1的阶跃函数),适用于输出概率或置信度等需要在0到1范围内连续取值的情况。
我们设置激活模式为Sigmoid,并设置NaN(非数字)传播模式为不传播,这意味着如果计算中出现NaN,它不会向任何方向传递。
cudnnActivationDescriptor_t activationDesc;
cudnnCreateActivationDescriptor(&activationDesc);
cudnnSetActivationDescriptor(activationDesc, CUDNN_ACTIVATION_SIGMOID, CUDNN_NOT_PROPAGATE_NAN, 0.0);
创建好激活描述符并定义了使用Sigmoid进行前向传播后,我们现在可以运行神经网络了。运行方向(前向或反向)取决于你的目标:前向传播用于计算输出,反向传播则常用于根据输出误差来更新前面层的参数。
cudnnActivationForward(handle, activationDesc, &alpha, tensorDesc, inputData, &beta, tensorDesc, outputData);
最后,程序将输出计算结果。
总结


本节课我们一起学习了cuDNN的核心语法。我们了解了使用cuDNN的基本流程:首先创建句柄,然后定义描述数据布局的张量描述符,接着指定激活函数并创建相应的激活描述符,最后选择传播方向来执行神经网络操作。掌握这些基础步骤是使用GPU进行高效深度学习编程的关键。
119:CUTENSOR语法详解(上) 🧮
在本节课中,我们将学习CUTENSOR库的基础语法和核心概念。CUTENSOR是用于在GPU上进行张量运算的高性能库。我们将从术语定义开始,逐步讲解使用CUTENSOR进行张量收缩运算的基本步骤。
术语定义
首先,我们来明确几个核心概念。
- 张量:一个N维数组或矩阵。
- 模式:张量中的一个维度,或者张量的一个子矩阵。
- 范围:某个模式的大小,例如
X和Y方向上的尺寸。 - 步长:内存中从一个数据指针到下一个数据指针的距离。通常,这是所管理数据类型的大小,但数据不一定需要在内存中是连续的。
计划缓存
计划缓存用于封装算法执行过程中的状态。它处理诸如文件读写、线程安全以及最小化算法重新运行成本等事务。
使用CUTENSOR的基本步骤
以下是使用CUTENSOR开发程序时应遵循的步骤流程。
- 创建句柄:类似于其他库,首先需要创建一个句柄。
- 创建计划缓存。
- 读取缓存文件:如果之前没有从头开始,可以读取已有的缓存文件。
- 创建张量描述符:基于缓存和收缩运算,创建一个或多个CUTENSOR描述符。
- 确定算法:根据CUTENSOR执行的多次迭代,确定要使用的算法。
- 查询工作空间。
- 创建收缩计划。
- 获取数据。
- 执行收缩运算。
- 缓存结果或设置检查点:这是一个进行结果缓存或设置检查点的好时机。
- 输出结果。
注意:由于拥有缓存文件,你可以从第3步开始重新启动程序。
核心操作详解
上一节我们介绍了使用CUTENSOR的整体流程,本节中我们来看看其中几个关键步骤的具体实现。
创建句柄
创建句柄相当简单。基本操作是调用 cuTensorInit 函数,并传入一个你预先初始化好的句柄引用。
创建与关联计划缓存
首先,你需要确定缓存的大小,例如设定为1024行。确定大小后,你需要完成所有设置,并将计划与这些缓存行关联起来,这样你就准备好缓存任何结果了。



读取缓存文件
在CUTENSOR中读取缓存文件非常简单。你传入一个包含文件相对或绝对路径的字符数组。将这个路径传入CUTENSOR句柄,调用 readCacheFromFile 函数,并传入句柄引用、文件名以及缓存行数即可。

创建张量描述符
在CUTENSOR中创建张量描述符比其他描述符稍复杂一些。你需要初始化张量描述符,传入句柄引用、将要创建的描述符、数据的模式和类型、数据的范围。你还可以定义一个步长。然后,指定你想要处理的操作标识类型。完成这些后,你可以为来自输入A的要求创建对齐方式。
创建收缩描述符
我们现在要做的重要事情是创建收缩描述符,这是整个过程中最关键的部分。收缩运算实际上是完成计算工作的地方。
这个过程类似于CUDA中的初始化激活。收缩运算接受三个输入:A、B和C,并将结果输出到C中。


本节课中我们一起学习了CUTENSOR的基础语法。我们从核心术语定义开始,了解了计划缓存的作用,并详细梳理了使用CUTENSOR库进行张量收缩运算的标准步骤流程,包括创建句柄、管理缓存、创建描述符等关键操作。理解这些基础是后续进行高效GPU张量计算的前提。
120:CUTENSOR语法详解(下) 🧮
在本节课中,我们将继续学习CUTENSOR库的典型应用流程。我们将从确定算法开始,逐步讲解到输出结果,涵盖工作空间管理、计划创建以及张量收缩的实际执行等核心步骤。
上一节我们介绍了CUTENSOR应用流程的前半部分,本节中我们来看看构成典型CUTENSOR应用的后半部分步骤。
步骤五:确定算法与属性设置
此步骤的核心是定义张量收缩运算并配置其执行属性。张量收缩是CUTENSOR执行的主要计算任务,其输出结果通常被称为激活。
以下是执行此步骤的关键操作:
- 获取句柄并定义收缩运算:首先获取操作句柄,然后定义具体的张量收缩运算。
- 指定算法类型:告知库你所寻找的默认算法类型。
- 设置缓存模式:在此特定示例中,我们使用
CUTENSOR_CACHE_MODE_PEDANTIC模式。该模式要求所有属性必须与缓存中的记录完全匹配。 - 应用属性设置:将缓存模式属性设置到操作句柄。库将根据现有缓存或创建一个新的缓存来寻找可用的算法。
这些步骤实现两个功能:首先,张量库在每次收缩运算后将进行自动调优;其次,计划(Plan)的每次迭代结果都能被保存和后续检索。其中涉及的 incremental count 属性,用于指定同时执行的内核数量,以确定最佳结果,这是自动调优步骤的一部分。
步骤六:管理工作空间

此步骤非常简单,主要目的是确定并获取所需工作空间的最大尺寸。
在示例中,我们并未设置一个最大尺寸限制。如果你需要的工作空间尺寸大于当前数据结构所能表示的,可以在此进行调整,但本例中我们暂时未做处理。

步骤七:执行张量收缩
现在,我们实际执行张量收缩运算,可以将其视为张量计算的一次迭代。
以下是执行流程:
- 配置缓存模式:为此次运算设置缓存模式。
- 创建执行计划:基于确定的工作空间大小创建执行计划。
- 执行收缩运算:调用执行函数,传入计划句柄、所有设备内存指针以及其他必要参数(例如执行流)。在示例中,最后一个参数设置为0,代表使用默认流(default stream)。

本节课中我们一起学习了CUTENSOR应用流程的后半部分,包括如何确定算法并设置属性、如何管理工作空间内存,以及最终如何创建计划并执行张量收缩运算。理解这些步骤对于高效利用CUTENSOR进行GPU张量计算至关重要。
121:实验活动总览 🧪

在本节中,我们将快速浏览一个关于cuTensor的实验活动。cuTensor是CUDA生态系统中的一个高级库,专门用于高效执行张量收缩运算。我们将通过分析一段示例代码,了解使用cuTensor进行GPU编程的基本流程和关键步骤。
上一节我们介绍了cuTensor库的基本概念,本节中我们来看看一个具体的实验活动代码。
代码位于 cuTensor_example.c 文件中。它基于cuTensor官方文档提供的一个示例代码构建。
以下是代码中包含的主要部分:
- 错误处理宏:代码定义了一些宏,用于处理常规错误以及特定于CUDA和cuTensor的错误。请注意,cuTensor并非CUDA部署或安装的一部分,需要单独安装,因此错误处理需要涵盖两者。
- 计时结构体:定义了一个用于GPU计时的结构体。
- 主函数:一个较大的主函数,它遵循了我们之前讨论过的所有步骤。
主函数的执行流程如下:
- 定义模式和尺寸:定义张量运算所涉及的模式(如索引字符)以及各张量的大小。
- 分配与初始化数据:在主机(CPU)和设备(GPU)上为输入输出张量分配内存,并用随机值初始化输入数据。
- 设置cuTensor环境:
- 创建一个cuTensor句柄(
handle)。 - 设置计划缓存(
plan cache),这需要几个步骤来完成。 - 创建各个张量的描述符(
tensor descriptors)。 - 为每个张量(输入A、B和输出C)对齐内存。
- 创建一个cuTensor句柄(
- 规划收缩运算:
- 考虑具体的收缩(
contraction)操作。 - 选择算法(
algorithms)。 - 获取查询空间(
query workspace)。示例中将其设置为零,但你可以尝试设置一个较大的值来探索是否为工作区分配更多内存能提升性能。注意,有时cuTensor或cuDNN等库使用更大的工作区可能性能更好,但也可能更慢。
- 考虑具体的收缩(
- 执行运算:
- 创建收缩计划(
contraction plan)。 - 实际运行代码。
- 创建收缩计划(
- 迭代与优化:代码会运行多次迭代。示例中包含四个增量步骤,这确保了每个子迭代都能找到最佳结果,完成其他调优,然后将结果保存到缓存中。
- 缓存与检查点:如果你熟悉TensorFlow或其他机器学习框架,可以将此计划缓存视为存储检查点(
checkpoint)的一种方式。你可以在任何时候重新启动程序并利用缓存。 - 资源释放:最后,务必释放所有分配的GPU内存,并销毁创建的计划和描述符,这是良好的编程实践。

本节课中我们一起学习了cuTensor实验活动的代码结构。我们回顾了从定义问题、初始化环境、规划并执行张量收缩,到利用缓存进行优化和最终清理资源的完整流程。理解这个流程对于高效使用cuTensor库进行GPU加速的张量计算至关重要。
122:毕业项目介绍 🎓



在本节课中,我们将要学习关于GPU编程专项课程的毕业项目要求。这个项目是课程的重要组成部分,旨在让你综合运用所学知识,完成一个实际的应用。

项目概述

首先,我们来谈谈你的GPU编程专项课程的毕业项目。这将是你的第二个由同伴评审(peer reviewed)或同伴评分(peer graded)的作业。


关于你可以做什么,有一份通用的指导说明。

项目要求与灵活性


上一节我们介绍了项目的基本性质,本节中我们来看看项目的具体要求。
我对使用其他库或语言持非常开放的态度,但前提是,你必须明确指出它们底层必须使用GPU硬件。


一个典型的例子是,像Torch和TensorFlow这样的框架可以在CPU上运行,但它们的性能会差很多。

老实说,这门课程的目的不仅仅是作为一门机器学习课程。因此,你应该找到自己感兴趣的方向。

你需要使用GPU硬件,并对此进行文档记录。

总结


本节课中我们一起学习了毕业项目的基本框架。我们了解到,项目是一个同伴评审的作业,核心要求是必须利用GPU硬件进行计算,并且对使用的技术栈有很高的灵活性。关键在于选择一个你感兴趣的主题,并确保你的实现能够有效利用GPU的并行计算能力。

浙公网安备 33010602011771号