CUDA 编程指南 13.4.2 · 非官方中文译本
连续阅读

CUDA 编程指南

CUDA 与 CUDA 编程指南

CUDA 是 NVIDIA 开发的并行计算平台和编程模型,可利用 GPU 的强大能力大幅提升计算性能。它使开发者能够加速计算密集型应用,并广泛应用于深度学习、科学计算和高性能计算(HPC)等领域。

本 CUDA 编程指南是介绍 CUDA 编程模型以及如何使用 CUDA 平台编写在 GPU 上执行的代码的官方综合性资料。本指南涵盖从 CUDA 编程模型、CUDA 平台到语言扩展细节的各方面内容,并介绍如何利用特定的硬件和软件特性。本指南为初学开发者提供了学习 CUDA 的途径,同时也为开发者使用 CUDA 构建应用提供了必不可少的参考资料。

本指南的组织结构

即使对于主要使用库、框架或领域专用语言(DSL)的开发者,了解 CUDA 编程模型以及 GPU 如何执行代码,也有助于理解各层抽象背后发生的事情。本指南首先用一章介绍独立于任何特定编程语言的CUDA 编程模型,适合任何有兴趣了解 CUDA 工作原理的人,甚至包括非开发者。

本指南分为五个主要部分:

  • 第 1 部分:简介与编程模型概述

    • 独立于具体语言的 CUDA 编程模型概述,以及对 CUDA 平台的简要介绍。

    • 本部分面向任何希望了解 GPU 以及在 GPU 上执行代码的概念的人,即使他们不是开发者。

  • 第 2 部分:使用 CUDA 进行 GPU 编程

    • 使用 C++ 和 Python 进行 GPU 编程的基础知识。

    • 本部分面向任何希望入门 GPU 编程的人。

    • 本部分旨在提供教学指导,而非全面介绍,讲解 CUDA 编程中最重要且最常用的内容,包括一些常见的性能考量。

  • 第 3 部分:高级 CUDA

    • 介绍 CUDA 的一些更高级的特性,这些特性既支持细粒度控制,也提供了更多最大化性能的机会,包括在单个应用中使用多个 GPU。

    • 本部分最后概览第 4 部分涵盖的特性,简要介绍每项特性的用途和功能,并按开发者可能在何时以及为何会发现各项特性有用来排序。

  • 第 4 部分:CUDA 特性

    • 本部分全面介绍特定的 CUDA 特性,例如 CUDA 图、动态并行、与图形 API 的互操作性,以及统一内存。

    • 当需要全面了解某项特定的 CUDA 特性时,应查阅本部分。在可能的情况下,前面的部分已注意介绍本部分涵盖的特性,并说明其用途。

  • 第 5 部分:技术附录

    • 技术附录提供了一些参考文档,介绍 CUDA 对 C++ 高级语言的支持、特定硬件的规格以及其他技术规范。

    • 本部分旨在作为技术参考,具体描述 CUDA 各要素的语法、语义和技术行为。

第 1 至第 3 部分为 CUDA 新手开发者提供循序引导的学习体验,同时也为具有任何经验水平的 CUDA 开发者提供有用的见解和最新信息。

第 4 部分和第 5 部分提供了有关特定特性和详细主题的大量信息,旨在为编写 CUDA 应用时需要了解更多细节的开发者提供经过精选、条理清晰的参考资料。

1.1. 简介

1.1.1. 图形处理器

图形处理器(GPU)最初作为 3D 图形的专用处理器而诞生,起初是用于加速实时 3D 渲染中并行操作的固定功能硬件。经过连续多代的发展,GPU 的可编程性不断增强。到 2003 年,图形流水线中的一些阶段已变得完全可编程,能够针对 3D 场景或图像的每个组成部分并行运行自定义代码。

2006 年,NVIDIA 推出了统一计算设备架构(CUDA),使任何计算工作负载都能独立于图形 API 利用 GPU 的吞吐能力。

此后,CUDA 和 GPU 计算被用于加速几乎所有类型的计算工作负载,从流体动力学或能量传输等科学仿真,到数据库和分析等商业应用。此外,GPU 的能力和可编程性一直是推动新算法和新技术发展的基础,这些算法和技术涵盖从图像分类到扩散模型或大语言模型等生成式人工智能的广泛领域。

1.1.2. 使用 GPU 的优势

在相近的价格和功耗范围内,GPU 可提供远高于 CPU 的指令吞吐量和内存带宽。许多应用利用这些能力,在 GPU 上的运行速度显著快于在 CPU 上的运行速度(参见GPU 应用)。其他计算设备,例如 FPGA,也具有很高的能效,但其编程灵活性远不及 GPU。

GPU 与 CPU 的设计目标不同。CPU 的设计侧重于尽可能快地执行串行操作序列(称为线程),并且能够并行执行几十个这样的线程;GPU 的设计则侧重于并行执行数千个线程,以较低的单线程性能为代价,换取大得多的总吞吐量。

GPU 专为高度并行的计算而设计,将更多晶体管用于数据处理单元,而 CPU 则将更多晶体管用于数据缓存和流程控制。图 1展示了 CPU 与 GPU 芯片资源分配的对比示例。

GPU 将更多晶体管用于数据处理

图 1 GPU 将更多晶体管用于数据处理

图内文字中文对照
Core
核心
L1 Cache
L1 缓存
L2 Cache
L2 缓存
L3 Cache
L3 缓存
Control
控制

1.1.3. 快速入门

利用 GPU 所提供计算能力的方式有很多。本指南介绍如何使用 C++ 等高级语言面向 CUDA GPU 平台进行编程。不过,在应用中利用 GPU 还有许多不需要直接编写 GPU 代码的方式。

通过专用库,可以使用来自各种领域且不断增加的算法和例程。如果某个库已经实现,尤其是 NVIDIA 提供的库,使用它通常比从头重新实现算法具有更高的开发效率和更好的性能。cuBLAS、cuFFT、cuDNN 和 CUTLASS 等只是其中的几个例子,这些库可帮助开发者避免重新实现成熟算法。这些库还有一个额外的优势,即针对每种 GPU 架构进行了优化,实现了开发效率、性能和可移植性的理想结合。

还有一些框架,尤其是用于人工智能的框架,提供了 GPU 加速的基础构件。其中许多框架通过利用上述 GPU 加速库来实现加速。

此外,NVIDIA 的 Warp 或 OpenAI 的 Triton 等领域专用语言(DSL)经过编译后可直接在 CUDA 平台上运行。与本指南涵盖的高级语言相比,这提供了一种抽象层次更高的 GPU 编程方式。

NVIDIA 加速计算中心包含用于教授 GPU 和 CUDA 计算的资源、示例和教程。

1.2. 编程模型

本章从总体上介绍 CUDA 编程模型,且不依赖任何语言。此处介绍的术语和概念适用于以任何受支持的编程语言使用 CUDA 的情况。后续章节将使用 C++ 说明这些概念。

1.2.1. 异构系统

CUDA 编程模型假定使用异构计算系统,即同时包含 GPU 和 CPU 的系统。CPU 及与其直接连接的内存分别称为主机和主机内存。GPU 及与其直接连接的内存分别称为设备和设备内存。在某些片上系统(SoC)中,它们可能是同一封装的一部分。在更大的系统中,可能有多个 CPU 或 GPU。

CUDA 应用在 GPU 上执行部分代码,但应用始终从 CPU 上开始执行。主机代码,即在 CPU 上运行的代码,可以使用 CUDA API 在主机内存与设备内存之间复制数据、启动在 GPU 上执行的代码,以及等待数据复制或 GPU 代码执行完成。CPU 和 GPU 可以同时执行代码,通常通过最大化 CPU 和 GPU 两者的利用率来获得最佳性能。

应用在 GPU 上执行的代码称为设备代码,而被调用以在 GPU 上执行的函数,由于历史原因,称为核函数。开始运行核函数的操作称为启动核函数。一次核函数启动可以理解为启动多个线程,在 GPU 上并行执行核函数代码。GPU 线程的工作方式与 CPU 上的线程类似,不过也存在一些对正确性和性能都很重要的差异,后续章节将介绍这些差异(参见第 3.2.2.1.1 节)。

1.2.2. GPU 硬件模型

与任何编程模型一样,CUDA 依赖于底层硬件的概念模型。对于 CUDA 编程而言,可以将 GPU 视为一组流式多处理器(SM),这些 SM 被组织成若干组,称为图形处理簇(GPC)。每个 SM 包含一个局部寄存器文件、一个统一数据缓存以及若干执行计算的功能单元。统一数据缓存为共享内存和 L1 缓存提供物理资源。统一数据缓存在 L1 与共享内存之间的分配可以在运行时配置。不同 GPU 架构中,SM 内各类内存的大小和功能单元的数量可能有所不同。

注意

GPU 的实际硬件布局或其在物理上执行编程模型的方式可能有所不同。这些差异不会影响使用 CUDA 编程模型编写的软件的正确性。

CUDA 编程模型视角下的 CPU 和 GPU 组件及其连接

图 2 GPU 具有许多流式多处理器(SM),每个 SM 都包含许多功能单元。图形处理簇(GPC)是 SM 的集合。GPU 是一组连接到 GPU 内存的 GPC。CPU 通常具有多个核心和一个连接到系统内存的内存控制器。CPU 与 GPU 通过 PCIe 或 NVLINK 等互连连接。

图内文字中文对照
SYSTEM DRAM
系统 DRAM
CPU Core
CPU 核心
Memory Controller
内存控制器
L3 Cache
L3 缓存
PCIe or NVLINK
PCIe 或 NVLINK
L2 Cache
L2 缓存
Unified Data Cache (L1 Cache + Shared Memory)
统一数据缓存(L1 缓存 + 共享内存)
Register File
寄存器文件
SM
流式多处理器

1.2.2.1. 线程块与网格

应用启动核函数时,会使用大量线程,通常多达数百万个。这些线程被组织成块。顾名思义,由线程组成的块称为线程块。线程块被组织成网格。一个网格中的所有线程块具有相同的大小和维度。图 3展示了由线程块组成的网格的示意图。

由线程块组成的网格

图 3 由线程块组成的网格。每个箭头代表一个线程(箭头数量不代表实际线程数量)。

图内文字中文对照
Grid
网格
Thread Block
线程块

线程块和网格可以是一维、二维或三维的。这些维度可以简化将各个线程映射到工作单元或数据项的过程。

启动核函数时,会使用特定的执行配置,以指定网格和线程块的维度。执行配置还可以包含可选参数,例如簇大小、流和 SM 配置设置,这些将在后续章节中介绍。

通过内置变量,每个执行核函数的线程都可以确定自己在所属线程块中的位置,以及其线程块在所属网格中的位置。线程还可以利用这些内置变量,确定核函数启动所使用的线程块和网格的维度。这使每个线程在运行该核函数的所有线程中都具有唯一的身份标识。此标识经常用于确定线程负责哪些数据或操作。

一个线程块的所有线程都在单个 SM 中执行。这使得线程块内的线程能够高效地相互通信和同步。线程块内的所有线程都可以访问片上共享内存,该内存可用于在线程块的线程之间交换信息。

一个网格可能由数百万个线程块组成,而执行该网格的 GPU 可能只有几十个或几百个 SM。一个线程块的所有线程都由单个 SM 执行,并且在大多数情况下[1],会在该 SM 上运行直至完成。线程块之间的调度没有任何保证,因此一个线程块不能依赖其他线程块的结果,因为其他线程块可能必须等到该线程块完成后才能被调度。图 4展示了将网格中的线程块分配给 SM 的示例。

调度到 SM 上的线程块

图 4 每个 SM 都有一个或多个活动线程块。在此示例中,每个 SM 同时调度了三个线程块。对于网格中的线程块被分配给 SM 的顺序,没有任何保证。

图内文字中文对照
Grid of Thread Blocks
线程块网格
Active Thread Blocks
活动线程块
SM
流式多处理器

CUDA 编程模型使任意大的网格都能在任何规模的 GPU 上运行,无论 GPU 仅有一个 SM,还是有数千个 SM。为实现这一点,除少数例外情况外,CUDA 编程模型要求不同线程块中的线程之间不存在数据依赖。也就是说,一个线程不应依赖同一网格中其他线程块内线程的结果,也不应与其同步。一个线程块内的所有线程同时在同一个 SM 上运行。网格中的不同线程块被调度到可用的 SM 上,并且可以按任意顺序执行。简而言之,CUDA 编程模型要求线程块能够以任意顺序、并行或串行执行。

1.2.2.1.1. 线程块簇

除了线程块之外,计算能力为 9.0 及更高版本的 GPU 还具有一个可选的分组层级,称为簇。簇是一组线程块,与线程块和网格一样,可以按一维、二维或三维进行布局。图 5展示了一个同时组织成簇的线程块网格。指定簇不会改变网格的维度,也不会改变线程块在网格中的索引。

调度到 SM 上的线程块

图 5 指定簇时,线程块在网格中的位置保持不变,但也具有其在所属簇中的位置。

图内文字中文对照
Grid with Clusters
包含簇的网格
Thread Block Cluster
线程块簇
Thread Block
线程块

指定簇会将相邻线程块分组成簇,并在簇级别提供一些额外的同步和通信机会。具体而言,一个簇中的所有线程块都在同一个 GPC 中执行。图 6展示了指定簇时,如何将线程块调度到一个 GPC 中的 SM 上。由于这些线程块被同时调度且位于同一个 GPC 内,因此,位于不同线程块但处于同一个簇中的线程可以使用协作组提供的软件接口相互通信和同步。簇中的线程可以访问该簇中所有线程块的共享内存,这称为分布式共享内存。簇的最大大小取决于硬件,并因设备而异。

图 6展示了如何将一个簇中的线程块同时调度到一个 GPC 内的 SM 上。一个簇中的线程块在网格中始终彼此相邻。

以簇为单位调度到 GPC 上的线程块

图 6 指定簇时,簇中的线程块在网格中按照其簇的形状排列。一个簇的线程块被同时调度到同一个 GPC 的 SM 上。

图内文字中文对照
Grid of Thread Blocks With 1x2 Clusters
包含 1x2 簇的线程块网格
Active Thread Blocks
活动线程块
SM
流式多处理器

1.2.2.2. 线程束与 SIMT

在线程块内,线程按每组 32 个线程组织,称为线程束。线程束以单指令多线程(SIMT)范式执行核函数代码。在 SIMT 中,线程束中的所有线程都执行相同的核函数代码,但每个线程可能沿着代码中的不同分支执行。也就是说,尽管程序中的所有线程都执行相同的代码,线程却不必遵循相同的执行路径。

线程在线程束中执行时,会被分配一个线程束通道。线程束通道的编号为 0 到 31,线程块中的线程以一种可预测的方式分配到线程束中,详见硬件多线程。

线程束中的所有线程同时执行相同的指令。如果线程束中的某些线程在执行时进入某个控制流分支,而其他线程不进入,则在执行进入该分支的线程时,不进入该分支的线程将被屏蔽。例如,如果某个条件仅对线程束中一半的线程为真,那么在活跃线程执行这些指令时,线程束中的另一半线程将被屏蔽。图 7展示了这种情况。线程束中的不同线程遵循不同代码路径的情况有时称为线程束分歧。因此,当线程束中的线程遵循相同的控制流路径时,GPU 的利用率可达到最大。

线程束通道在不活跃时被屏蔽

图 7 在此示例中,只有线程索引为偶数的线程执行 if 语句体,其他线程在执行语句体期间被屏蔽。

图内文字中文对照
Warp Lanes
线程束通道

在 SIMT 模型中,线程束中的所有线程以锁步方式执行核函数。硬件执行情况可能有所不同。有关这种区别在哪些情况下很重要的更多信息,请参阅独立线程执行相关章节。不建议利用线程束执行实际上如何映射到真实硬件的知识。CUDA 编程模型和 SIMT 规定,线程束中的所有线程一起执行代码。只要遵循该编程模型,硬件就可以以对程序透明的方式优化被屏蔽的通道。如果程序违反该模型,可能导致未定义行为,并且这种行为在不同的 GPU 硬件上可能有所不同。

虽然编写 CUDA 代码时不必考虑线程束,但了解线程束执行模型有助于理解全局内存访问合并和共享内存存储体访问模式等概念。一些高级编程技术通过在线程块内对线程束进行专门化来限制线程分歧并最大限度地提高利用率。这种优化以及其他优化都利用了线程在执行时会被分组成线程束这一知识。

线程束执行意味着,最好将线程块的线程总数指定为 32 的倍数。使用任意数量的线程都是合法的,但当总数不是 32 的倍数时,线程块的最后一个线程束会有一些通道在整个执行过程中都不使用。这很可能导致该线程束的功能单元利用率和内存访问效果不佳。

SIMT 经常被拿来与单指令多数据(SIMD)并行性进行比较,但两者存在一些重要区别。在 SIMD 中,执行遵循单一控制流路径,而在 SIMT 中,允许每个线程遵循自己的控制流路径。因此,SIMT 不像 SIMD 那样具有固定的数据宽度。有关 SIMT 的更详细讨论,请参阅SIMT 执行模型。

1.2.2.3. CUDA 中的分块编程

除了前面各节介绍的 SIMT 模型之外,CUDA 还支持分块编程模型。在分块编程中,程序员在整个线程块的层级上编写代码,描述对称为分块(tile)的多维数据集合执行的操作。编译器将这些操作映射到线程块中的各个线程。

分块核函数在线程块网格上启动,如线程块与网格一节所述。每个线程块执行分块核函数,并可以查询自身在网格中的位置,以确定自己负责哪一部分数据。程序员只需指定网格维度;每个线程块的线程数由编译器根据核函数中的分块操作确定(图 8)。

SIMT 和分块编程模型中的程序员视角

图 8 SIMT 和分块编程模型中的程序员视角。在 SIMT 中,程序员编写每个线程的代码,并控制每个线程如何访问数据。在分块编程中,程序员编写每个线程块的代码来操作分块;编译器将操作映射到线程块中的线程。

图内文字中文对照
SIMT Model
SIMT 模型
Tile Model
分块模型
Kernel Code
核函数代码
Per-thread logic
每个线程的逻辑
Per-block logic
每个线程块的逻辑
Programmer controls thread behavior
程序员控制线程行为
Thread Block
线程块
Block
线程块
operates on
作用于
Tile
分块
Compiler manages threads inside block
编译器管理线程块内的线程
Data elements
数据元素
SIMT: programmer thinks in threads. Tile: programmer thinks in blocks and tiles.
SIMT:程序员以线程为单位思考。分块:程序员以线程块和分块为单位思考。

在分块核函数内,线程块执行单一控制流。程序员指定对分块执行的操作,编译器则将工作分配给线程块中的各个线程。分块编程支持条件语句和循环等标准控制流结构,但由于线程块遵循单一控制流,因此不存在线程束分歧的概念。标量操作(例如计算索引或循环边界)由线程块中的一个线程执行。分块操作(例如将两个分块逐元素相加)由线程块中的所有线程共同并行执行。

重要的是,不要将作为执行单位的线程块与作为数据单位的分块混淆。单个线程块可以创建并操作许多具有不同形状和数据类型的分块。

1.2.2.3.1. 数组与分块

分块核函数处理两类数据:数组和分块。数组(或全局数组)是存储在设备内存中的元素的多维容器。数组是可变的:可以通过核函数内的存储操作修改其内容。数组具有形状和数据类型。

分块是一个多维值集合,仅存在于分块代码中,并且局限于单个线程块。分块是不可变的:对分块执行的每个操作都会产生一个新分块,而不是修改现有分块。与数组不同,分块不一定在内存中具有表示形式;编译器决定如何存储分块数据,可能会使用寄存器、共享内存或 SM 的其他资源。分块的每个维度都必须是 2 的幂,并且必须在编译时已知(也就是说,其值必须能够在核函数执行前确定,而不是在执行期间计算)。分块不能作为核函数参数传递;它们完全在分块代码内创建和使用。

1.2.2.3.2. 分块空间与数据移动

数据通过加载和存储操作在数组与分块之间移动。这些操作使用一个称为分块空间的概念,即在概念上将数组划分为大小相等、互不重叠的分块所得到的结果。例如,考虑一个形状为 (M, N) 的二维数组。如果加载操作指定分块形状为 (tm, tn),则该数组在概念上被划分为⌈M/tm⌉\lceil M/t_m \rceil行、⌈N/tn⌉\lceil N/t_n \rceil列的分块。该分块空间中的索引(例如 (i, j))标识要加载哪个分块。加载操作返回一个形状为 (tm, tn) 的分块,其中包含数组中对应的元素。当分块超出数组边界时,例如数组维度不是分块维度的整数倍时的边缘位置,加载操作会指定如何处理越界元素,例如用零填充这些元素(图 9)。

分块空间与数据移动

图 9 分块空间与数据移动。形状为 (M, N) 的二维数组在概念上被划分为一个由形状为 (tm, tn) 的分块组成的网格。在分块空间索引 (i, j) 处执行加载操作会返回对应的分块。在数组边界处,落在数组之外的元素可以用零填充。存储操作将分块写回数组中给定分块空间索引处的位置。

图内文字中文对照
Array — shape (M, N)
数组,形状 (M, N)
load
加载
store
存储
Tile Space: ⌈M/tₘ⌉ × ⌈N/tₙ⌉
分块空间:⌈M/tₘ⌉ × ⌈N/tₙ⌉
Tile — shape (tₘ, tₙ)
分块,形状 (tₘ, tₙ)
zeros (fill)
零(填充)

存储操作执行相反的过程:给定一个分块和一个分块空间索引,它会将该分块的元素写入数组的对应区域。任何超出数组边界的写入都会被静默丢弃。分块程序还支持聚集和散布操作,用于从数组中的任意位置加载数据或向其中存储数据。

1.2.2.3.3. 分块操作

分块程序提供了一组作用于分块的内置操作,包括逐元素算术运算、矩阵乘法、沿一个或多个轴的归约(例如求和和求最大值)、形状操作(例如重塑形状和转置)以及类型转换。当两个不同形状的分块在一个操作中组合使用时,较小的分块会先自动扩展以匹配较大的分块,然后再执行该操作。

1.2.2.3.4. 与 SIMT 编程的关系

分块编程和 SIMT 编程在 CUDA 中共存。应用程序可以同时包含 SIMT 核函数和分块核函数,而且这两类核函数都可以操作设备内存中的相同数据。编程模型的选择是针对每个核函数分别作出的决定。分块编程并不取代 SIMT 编程。SIMT 提供了对各个线程的细粒度控制,这对于某些算法和优化技术仍然是必需的。分块编程提供了更高层级的抽象,可以简化核函数开发。由于线程级别的决策交给了编译器,同一个分块核函数无需更改源代码即可在不同的 GPU 架构上运行。这两种模型都建立在前面各节所介绍的相同底层硬件之上,即 SM、线程块和网格。这两种模型也使用相同的设备内存空间,这些内存空间将在下一节介绍。

1.2.3. GPU 内存

在现代计算系统中,高效利用内存与最大限度地利用执行计算的功能单元同样重要。异构系统具有多个内存空间,GPU 除了缓存之外,还包含各种类型的可编程片上内存。以下各节将更详细地介绍这些内存空间。

1.2.3.1. 异构系统中的 DRAM 内存

GPU 和 CPU 都具有直接连接的 DRAM 芯片。在具有多个 GPU 的系统中,每个 GPU 都有自己的内存。从设备代码的角度看,连接到 GPU 的 DRAM 称为全局内存,因为 GPU 中的所有 SM 都可以访问它。这一术语并不意味着系统中的任何位置都一定能访问它。连接到一个或多个 CPU 的 DRAM 称为系统内存或主机内存。

与 CPU 一样,GPU 使用虚拟内存寻址。在目前支持的所有系统上,CPU 和 GPU 使用单一的统一虚拟内存空间。这意味着,系统中每个 GPU 的虚拟内存地址范围都是唯一的,并且与 CPU 及系统中其他每个 GPU 的地址范围不同。对于给定的虚拟内存地址,可以确定该地址位于 GPU 内存还是系统内存中;在具有多个 GPU 的系统上,还可以确定该地址位于哪个 GPU 的内存中。

CUDA 提供了用于分配 GPU 内存、CPU 内存以及在 CPU 和 GPU 的内存分配之间、在一个 GPU 内部或在多 GPU 系统的 GPU 之间进行复制的 API。需要时,可以显式控制数据的局部性。下文讨论的统一内存允许 CUDA 运行时或系统硬件自动处理内存的放置。

1.2.3.2. 局部性域:

局部性域是一个 GPU 的内存和流式多处理器(SM)的子集。所有 GPU 都至少具有一个局部性域。较大的 GPU 可能具有多个局部性域。在具有多个局部性域的 GPU 上,使用局部化的内存和计算可以提高某些核函数的性能。局部化内存是完全包含在单个局部性域内的内存分配。使用此内存的核函数可以被调度到同一局部性域内的 SM 上。当内存和计算在各局部性域之间划分,并且可以并行地向每个局部性域发出工作时,应用程序就能从局部化中受益。

分配时未指定局部性域的 GPU 内存称为未局部化的内存。除分配量非常小的情况外,未局部化的内存分配会跨越多个局部性域。

在仅有一个局部性域的 GPU 上,GPU 的所有 SM 和内存都属于这一个局部性域。

第 4.7 节介绍了将 GPU 内存和计算局部化到特定局部性域内的机制。局部性域是一项性能特性:未对内存或计算进行局部化的 CUDA 代码仍然能够正确执行。

1.2.3.3. GPU 中的片上内存

除全局内存外,每个 GPU 还具有一些片上内存。每个 SM 都有自己的寄存器文件和共享内存。这些内存是 SM 的一部分,在该 SM 内执行的线程可以极快地访问它们。

寄存器文件存储线程局部变量,这些变量通常由编译器分配。线程块或簇内的所有线程都可以访问共享内存。共享内存可用于在线程块或簇的线程之间交换数据。

SM 中的寄存器文件和统一数据缓存的大小都是有限的。有关 SM 的寄存器文件和统一数据缓存的大小,以及如何配置统一数据缓存以平衡 L1 缓存与共享内存,请参见各计算能力的内存信息。寄存器文件、共享内存空间和 L1 缓存由线程块中的所有线程共享。

要将一个线程块调度到某个 SM,每个线程所需的寄存器总数乘以线程块中的线程数,必须小于或等于该 SM 中可用的寄存器数量。如果一个线程块所需的寄存器数量超过了寄存器文件的大小,则无法启动该核函数,必须减少线程块中的线程数,使该线程块能够启动。

共享内存在线程块级别进行分配。也就是说,与按线程进行的寄存器分配不同,分配的共享内存由整个线程块共用。

1.2.3.3.1. 缓存

除了可编程内存,GPU 还具有 L1 和 L2 缓存。每个 SM 都有一个 L1 缓存,它是统一数据缓存的一部分。一个更大的 L2 缓存由 GPU 内的所有 SM 共享。这可以在图 2的 GPU 框图中看到。每个 SM 还具有一个独立的常量缓存,用于缓存全局内存中已声明在核函数整个生命周期内保持不变的值。编译器也可能将核函数参数放入常量内存。这使核函数参数能够在 SM 中独立于 L1 数据缓存进行缓存,从而可以提高核函数性能。

1.2.3.4. 统一内存

当应用程序显式地在 GPU 或 CPU 上分配内存时,该内存只能由在该设备上运行的代码访问。也就是说,CPU 内存只能由 CPU 代码访问,而 GPU 内存只能由在 GPU 上运行的核函数访问[2]。用于在 CPU 和 GPU 之间复制内存的 CUDA API 可用来在适当的时间将数据显式复制到正确的内存中。

一项称为统一内存的 CUDA 功能允许应用程序分配可由 CPU 或 GPU 访问的内存。CUDA 运行时或底层硬件会在需要时启用访问,或将数据重新放置到正确的位置。即便使用统一内存,也要尽量减少内存迁移,并尽可能从与数据所在内存直接相连的处理器访问数据,才能获得最佳性能。

系统的硬件特性决定了如何实现内存空间之间的数据访问和交换。统一内存一节介绍了统一内存系统的不同类别。统一内存一节包含了在各种情况下使用统一内存及其行为的更多详细信息。

1.3. CUDA 平台

NVIDIA CUDA 平台由许多软件和硬件,以及为实现异构系统计算而开发的众多重要技术组成。本章旨在介绍 CUDA 平台的一些基本概念和组件,理解这些内容对于应用程序开发者十分重要。与编程模型一样,本章并不针对任何特定编程语言,而是适用于所有使用 CUDA 平台的场景。

1.3.1. 计算能力与流式多处理器(SM)版本

每个 NVIDIA GPU 都有一个计算能力(CC)编号,它表明该 GPU 支持哪些功能,并规定了该 GPU 的一些硬件参数。这些规格记录在附录的第 5.1 节中。CUDA GPU 计算能力页面维护了一份包含所有 NVIDIA GPU 及其计算能力的列表。

计算能力以主版本号和次版本号表示,格式为 X.Y,其中 X 是主版本号,Y 是次版本号。例如,CC 12.0 的主版本号为 12,次版本号为 0。计算能力与 SM 的版本号直接对应。例如,CC 12.0 的 GPU 内部的 SM 具有 SM 版本sm_120。此版本用于标记二进制文件。

第 5.1.1 节说明了如何查询和确定系统中一个或多个 GPU 的计算能力。

1.3.2. CUDA Toolkit 与 NVIDIA 驱动程序

可以将NVIDIA 驱动程序视为 GPU 的操作系统。NVIDIA 驱动程序是一个必须安装在主机系统操作系统上的软件组件,GPU 的所有用途都需要它,包括显示和图形功能。NVIDIA 驱动程序是 CUDA 平台的基础。除了 CUDA,NVIDIA 驱动程序还提供使用 GPU 的所有其他方法,例如 Vulkan 和 Direct3D。NVIDIA 驱动程序具有诸如 r580 这样的版本号。

CUDA Toolkit是一组库、头文件和工具,用于编写、构建和分析利用 GPU 计算的软件。CUDA Toolkit 是独立于 NVIDIA 驱动程序的软件产品

CUDA 运行时是 CUDA Toolkit 所提供的库中的一个特例。CUDA 运行时提供 API 和一些语言扩展,用于处理常见任务,例如分配内存、在 GPU 与其他 GPU 或 CPU 之间复制数据,以及启动核函数。CUDA 运行时的 API 组件被称为 CUDA 运行时 API。

CUDA 兼容性文档提供了有关不同 GPU、NVIDIA 驱动程序和 CUDA Toolkit 版本之间兼容性的完整详细信息。

1.3.2.1. CUDA 运行时 API 与 CUDA 驱动程序 API

CUDA 运行时 API 基于一个称为CUDA 驱动程序 API的更底层 API 实现,后者是 NVIDIA 驱动程序公开的 API。本指南重点介绍 CUDA 运行时 API 所公开的 API。如果需要,仅使用驱动程序 API 也可以实现所有相同的功能。有些功能只能通过驱动程序 API 使用。应用程序可以使用其中一种 API,也可以让两种 API 互操作。CUDA 驱动程序 API一节介绍了运行时 API 与驱动程序 API 之间的互操作。

有关 CUDA 运行时 API 函数的完整 API 参考,请参见CUDA 运行时 API 文档。

有关 CUDA 驱动程序 API 的完整 API 参考,请参见CUDA 驱动程序 API 文档。

1.3.3. 并行线程执行(PTX)

CUDA 平台中一个基础但有时不可见的层是并行线程执行(PTX)虚拟指令集架构(ISA)。PTX 是面向 NVIDIA GPU 的高级汇编语言。PTX 在真实 GPU 硬件的物理 ISA 之上提供了一个抽象层。与其他平台一样,可以直接使用这种汇编语言编写应用程序,但这样做可能会为软件开发增加不必要的复杂性和难度。

领域专用语言及高级语言的编译器可以生成 PTX 代码作为中间表示(IR),然后使用 NVIDIA 的离线或即时(JIT)编译工具生成可执行的 GPU 二进制代码。这使得 CUDA 平台能够使用多种语言进行编程,而不仅仅局限于NVCC:NVIDIA CUDA 编译器等 NVIDIA 提供的工具所支持的语言。

由于 GPU 的能力会随时间变化和增强,PTX 虚拟 ISA 规范采用版本管理。PTX 版本与 SM 版本一样,对应于某个计算能力。例如,支持计算能力 8.0 的所有功能的 PTX 称为compute_80。

有关 PTX 的完整文档,请参见PTX ISA。

1.3.4. Cubins 与 Fatbins

CUDA 应用程序和库通常使用 C++ 等较高级的语言编写。这种较高级的语言被编译为 PTX,然后 PTX 再被编译为面向物理 GPU 的实际二进制文件,称为CUDA 二进制文件,简称cubin。cubin 针对特定的 SM 版本(如sm_120)具有特定的二进制格式。

使用 GPU 计算的可执行文件和库二进制文件同时包含 CPU 和 GPU 代码。GPU 代码存储在一个称为fatbin的容器中。Fatbins 可以包含面向多个不同目标的 cubins 和 PTX。例如,可以将一个应用程序构建为包含面向多种不同 GPU 架构(即不同 SM 版本)的二进制文件。当应用程序运行时,其 GPU 代码被加载到特定 GPU 上,并使用 fatbin 中最适合该 GPU 的二进制文件。

可执行文件或库中的 Fatbin 容器可以包含多个 GPU 代码版本

图 10 可执行文件或库的二进制文件同时包含 CPU 二进制代码和用于存储 GPU 代码的 fatbin 容器。一个 fatbin 可以同时包含 cubin GPU 二进制代码和 PTX 虚拟 ISA 代码。PTX 代码可以针对未来的目标进行 JIT 编译。

图内文字中文对照
Executable or Library
可执行文件或库
CPU Binary Code
CPU 二进制代码

Fatbins 还可以包含一个或多个 PTX 版本的 GPU 代码,其用途在PTX 兼容性中进行了说明。图 10展示了一个应用程序或库的二进制文件示例,其中包含多个 cubin 版本的 GPU 代码以及一个版本的 PTX 代码。

1.3.4.1. 二进制兼容性

NVIDIA GPU 在某些情况下保证二进制兼容性。具体而言,在同一计算能力主版本内,计算能力次版本大于或等于 cubin 目标版本的 GPU 可以加载并执行该 cubin。例如,如果应用程序包含一个 cubin,其中的代码针对计算能力 8.6 编译,那么该 cubin 可以在计算能力为 8.6 或 8.9 的 GPU 上加载和执行。但是,它不能在计算能力为 8.0 的 GPU 上加载,因为该 GPU 的 CC 次版本号 0 低于代码的次版本号 6。

NVIDIA GPU 在不同的计算能力主版本之间不具备二进制兼容性。也就是说,针对计算能力 8.6 编译的 cubin 代码无法在计算能力为 9.0 的 GPU 上加载。

讨论二进制代码时,通常会说二进制代码具有某个版本,例如上述示例中的sm_86。这与说该二进制文件是针对计算能力 8.6 构建的含义相同。这种简写经常使用,因为开发者就是以这种方式向 NVIDIA CUDA 编译器nvcc指定此二进制构建目标的。

注意

只有由nvcc等 NVIDIA 工具创建的二进制文件才享有二进制兼容性保证。不支持手动编辑或生成用于 NVIDIA GPU 的二进制代码。如果以任何方式修改二进制文件,兼容性保证即告失效。

1.3.4.2. PTX 兼容性

GPU 代码可以以二进制或 PTX 形式存储在可执行文件中,这在Cubins 与 Fatbins中有所介绍。当应用程序存储了 PTX 版本的 GPU 代码时,该 PTX 可以在应用程序运行时针对任何等于或高于该 PTX 代码计算能力的计算能力进行 JIT 编译。例如,如果应用程序包含面向compute_80的 PTX,则可以在应用程序运行时将该 PTX 代码 JIT 编译为面向较新 SM 版本(如sm_120)的代码。这实现了与未来 GPU 的前向兼容,而无需重新构建应用程序或库。

1.3.4.3. 即时编译

应用程序在运行时加载的 PTX 代码由设备驱动程序编译为二进制代码。这称为即时(JIT)编译。即时编译会增加应用程序的加载时间,但使应用程序能够受益于每个新设备驱动程序随附的任何新编译器改进。它还使应用程序能够在其编译时尚不存在的设备上运行。

当设备驱动程序为应用程序即时编译 PTX 代码时,它会自动缓存所生成二进制代码的一个副本,以避免在后续调用该应用程序时重复编译。这个称为计算缓存的缓存会在设备驱动程序升级时自动失效,使应用程序能够受益于设备驱动程序中内置的新即时编译器的改进。

相较于 CUDA 的最早版本,对运行时如何以及何时对 PTX 进行 JIT 编译的限制已经放宽,在何时以及是否对部分或全部核函数进行 JIT 编译方面提供了更大的灵活性。延迟加载一节介绍了可用的选项以及如何控制 JIT 行为。此外,还有几个控制即时编译行为的环境变量,详见CUDA 环境变量。

除了使用nvcc编译 CUDA C++ 设备代码外,还可以使用 NVRTC 在运行时将 CUDA C++ 设备代码编译为 PTX。NVRTC 是用于 CUDA C++ 的运行时编译库;更多信息可参见 NVRTC 用户指南。

1.3.4.4. 二进制最终化

从计算能力 10.0 开始,同一系列内的二进制兼容性由 NVIDIA 驱动程序以软件方式管理。用于计算能力 10.0 及更高版本架构的 cubin 采用一种扩展了二进制表示的格式,以提供同一系列内的二进制兼容性。为某个特定目标架构生成(最终化)的二进制文件可以直接在该目标架构上执行,也可以在运行时针对任何保证具有二进制兼容性的 GPU重新进行最终化。用于计算能力 9.x 及更早版本的 cubin 不具备运行时重新最终化的资格。

当为某个目标架构生成的、用于计算能力 10.0 或更高版本的 cubin 被加载到同一 GPU 系列中另一个不同但兼容的架构上时,驱动程序会执行即时最终化,将该 cubin 绑定到目标 GPU 的架构。此过程称为非目标架构最终化。

非目标架构最终化使非目标架构执行成为可能,即为一个架构最终化的 cubin 在同一 GPU 系列中的另一个兼容架构上运行。当 cubin 被加载到生成它时所针对的架构上时,这种执行被视为目标架构执行。目标架构执行不需要进一步的最终化。

不应将用于非目标架构执行的即时最终化与 PTX 即时编译混淆。PTX JIT 编译将 PTX 代码编译成已经针对目标架构完成最终化的 GPU 二进制代码。对 cubin 进行非目标架构最终化,是针对另一个兼容架构对现有 cubin 重新进行最终化。非目标架构最终化的开销显著低于 PTX JIT 编译,但相对于目标架构执行,它仍可能增加模块加载时间。

非目标架构最终化的开销取决于要进行最终化的代码量、模块加载期间可用的主机 CPU 资源,以及源架构与目标架构之间的关系。当对 cubin 进行非目标架构最终化时,加载大型模块或大量模块的应用程序可能会观察到启动时间增加。

与 PTX JIT 编译一样,NVIDIA 驱动程序可以缓存 cubin 的非目标架构最终化结果,以避免在随后调用该应用程序时重复最终化工作。此缓存由第 5.2.2 节中介绍的同一组 CUDA 缓存环境变量控制。

2.1. CUDA C++ 入门

本章通过说明 CUDA 编程模型的一些基本概念如何在 C++ 中呈现,来介绍这些概念。

本编程指南重点介绍 CUDA 运行时 API。CUDA 运行时 API 是在 C++ 中使用 CUDA 最常用的方式,它建立在更底层的 CUDA 驱动程序 API 之上。

CUDA 运行时 API 与 CUDA 驱动程序 API讨论了这两种 API 之间的区别,CUDA 驱动程序 API讨论了如何编写混合使用这两种 API 的代码。

本指南假定已安装 CUDA Toolkit 和 NVIDIA 驱动程序,并且系统中配备了受支持的 NVIDIA GPU。有关安装所需 CUDA 组件的说明,请参阅《CUDA 快速入门指南》。

2.1.1. 使用 NVCC 编译

用 C++ 编写的 GPU 代码使用 NVIDIA Cuda Compiler,即nvcc进行编译。nvcc是一个编译器驱动程序,它简化了编译 C++ 或 PTX 代码的过程:它提供简单且熟悉的命令行选项,并通过调用实现各个编译阶段的一组工具来执行这些选项。

本指南将展示nvcc命令行,这些命令行可用于安装了 CUDA Toolkit 的任何 Linux 系统、Windows 命令行或 power shell,也可用于装有 CUDA Toolkit 的 Windows Subsystem for Linux。本指南的nvcc 章节涵盖了nvcc的常见用例,完整文档由nvcc 用户手册提供。

2.1.2. 核函数

如CUDA 编程模型的介绍中所述,在 GPU 上执行且可从主机调用的函数称为核函数。核函数是为由许多并行线程同时运行而编写的。

2.1.2.1. 指定核函数

核函数的代码使用__global__声明说明符来指定。这向编译器表明,此函数将被编译为在 GPU 上运行,并且能够通过核函数启动来调用。核函数启动是一种开始运行核函数的操作,通常从 CPU 发起。核函数是返回类型为void的函数。

// Kernel definition
__global__ void vecAdd(float* A, float* B, float* C)
{

}

2.1.2.2. 启动核函数

将并行执行核函数的线程数量作为核函数启动的一部分来指定。这称为执行配置。同一核函数的不同调用可以使用不同的执行配置,例如不同数量的线程或线程块。

从 CPU 代码启动核函数有两种方式:三重尖括号记法和cudaLaunchKernelEx。这里介绍三重尖括号记法,它是启动核函数最常用的方式。使用cudaLaunchKernelEx启动核函数的示例在在第 3.1.1 节一节中给出并进行了详细讨论。

2.1.2.2.1. 三重尖括号记法

三重尖括号记法是一种用于启动核函数的CUDA C++ 语言扩展。之所以称为三重尖括号,是因为它使用三个尖括号字符将核函数启动的执行配置括起来,即<<< >>>。执行配置参数在尖括号内以逗号分隔的列表形式指定,类似于函数调用的参数。启动vecAdd核函数的语法如下所示。

 __global__ void vecAdd(float* A, float* B, float* C)
 {

 }

int main()
{
    ...
    // Kernel invocation
    vecAdd<<<1, 256>>>(A, B, C);
    ...
}

三重尖括号记法的前两个参数分别是网格的维度大小和线程块的维度大小。使用一维线程块或网格时,可以用整数指定维度大小。

上述代码启动一个包含 256 个线程的线程块。每个线程都将执行完全相同的核函数代码。在线程和网格索引内建函数中,我们将展示每个线程如何使用其在线程块和网格中的索引来改变它所操作的数据。

每个块的线程数量存在上限,因为一个块的所有线程都驻留在同一个流式多处理器(SM)上,并且必须共享该 SM 的资源。在当前的 GPU 上,一个线程块最多可以包含 1024 个线程。如果资源允许,可以在一个 SM 上同时调度多个线程块。

核函数启动相对于主机线程是异步的。也就是说,核函数将被设置为在 GPU 上执行,但主机代码在继续执行之前,不会等待核函数在 GPU 上执行完成(甚至不会等待其开始执行)。必须使用某种形式的 GPU 与 CPU 之间的同步,才能确定核函数已经完成。最基本的形式是对整个 GPU 进行完全同步,同步 CPU 与 GPU中展示了这种形式。更复杂的同步方法在异步执行中介绍。

使用二维或三维网格或线程块时,使用 CUDA 类型dim3作为网格和线程块的维度大小参数。下面的代码片段展示了MatAdd核函数的启动,使用由 16 × 16 个线程块构成的网格,每个线程块的大小为 8 × 8。

int main()
{
    ...
    dim3 grid(16,16);
    dim3 block(8,8);
    MatAdd<<<grid, block>>>(A, B, C);
    ...
}

2.1.2.3. 线程和网格索引内建函数

在核函数代码内,CUDA 提供内建函数来访问执行配置的参数以及线程或块的索引。

  • threadIdx给出线程在其线程块内的索引。线程块中的每个线程都有不同的索引。

  • blockDim给出线程块的维度大小,该大小是在核函数启动的执行配置中指定的。

  • blockIdx给出线程块在网格内的索引。每个线程块都有不同的索引。

  • gridDim给出网格的维度大小,该大小是在启动核函数时的执行配置中指定的。

这些内建函数中的每一个都是一个三分量向量,包含.x、.y和.z成员。启动配置中未指定的维度大小默认为 1。threadIdx和blockIdx的索引从零开始。也就是说,threadIdx.x的取值从 0 到blockDim.x-1,包含该上限。.y和.z在各自的维度上以相同方式工作。

类似地,blockIdx.x的取值从 0 到gridDim.x-1,包含该上限;.y和.z维度各自也遵循相同规则。

这些内建函数使单个线程能够确定自己应执行什么工作。回到vecAdd核函数,该核函数接受三个参数,每个参数都是一个浮点数向量。核函数对A和B逐元素相加,并将结果存储到C中。该核函数经过并行化,使每个线程执行一次加法。它计算哪个元素由其线程和网格索引决定。

__global__ void vecAdd(float* A, float* B, float* C)
{
   // calculate which element this thread is responsible for computing
   int workIndex = threadIdx.x + blockDim.x * blockIdx.x;

   // Perform computation
   C[workIndex] = A[workIndex] + B[workIndex];
}

int main()
{
    ...
    // A, B, and C are vectors of 1024 elements
    vecAdd<<<4, 256>>>(A, B, C);
    ...
}

在此示例中,使用 4 个线程块,每个线程块有 256 个线程,来对含有 1024 个元素的向量进行相加。在第一个线程块中,blockIdx.x为零,因此每个线程的 workIndex 就是它的threadIdx.x。在第二个线程块中,blockIdx.x为 1,因此blockDim.x * blockIdx.x与blockDim.x相同,在此情况下为 256。第二个线程块中每个线程的workIndex将是它的threadIdx.x + 256。在第三个线程块中,workIndex将为threadIdx.x + 512。

这种workIndex的计算在一维并行化中十分常见。扩展到二维或三维时,通常会在每个维度上遵循相同的模式。

2.1.2.3.1. 边界检查

上面给出的示例假定向量长度是线程块大小的倍数,此处的线程块大小为 256 个线程。为了使核函数能够处理任意向量长度,我们可以如下所示添加检查,确保内存访问不超出数组边界,然后启动一个包含一些非活动线程的线程块。

__global__ void vecAdd(float* A, float* B, float* C, int vectorLength)
{
     // calculate which element this thread is responsible for computing
     int workIndex = threadIdx.x + blockDim.x * blockIdx.x;

     if(workIndex < vectorLength)
     {
         // Perform computation
         C[workIndex] = A[workIndex] + B[workIndex];
     }
}

使用上述核函数代码,可以启动多于所需数量的线程,而不会造成对数组的越界访问。当workIndex超过vectorLength时,线程会退出而不执行任何工作。在线程块中启动不执行工作的额外线程不会产生很大的开销,但应避免启动其中所有线程都不执行工作的线程块。现在,这个核函数可以处理长度不是块大小倍数的向量。

所需线程块的数量可以通过将所需线程数(此处为向量长度)除以每个块的线程数,然后向上取整来计算。也就是说,将所需线程数除以每个块的线程数进行整数除法,并向上取整。下面给出了一种将其表示为单次整数除法的常见方式。在整数除法之前加上threads - 1,就能使其行为类似于向上取整函数,仅当向量长度不能被每个块的线程数整除时,才增加一个线程块。

// vectorLength is an integer storing number of elements in the vector
int threads = 256;
int blocks = (vectorLength + threads-1)/threads;
vecAdd<<<blocks, threads>>>(devA, devB, devC, vectorLength);

CUDA Core Compute Library (CCCL)提供了一个方便的实用工具cuda::ceil_div,用于执行这种向上取整除法,以计算核函数启动所需的块数。包含头文件<cuda/cmath>即可使用此实用工具。

// vectorLength is an integer storing number of elements in the vector
int threads = 256;
int blocks = cuda::ceil_div(vectorLength, threads);
vecAdd<<<blocks, threads>>>(devA, devB, devC, vectorLength);

这里为每个块选择 256 个线程是任意的,不过这个数值通常很适合作为初始选择。

2.1.3. GPU 计算中的内存

要使用上面展示的vecAdd核函数,数组A、B和C必须位于 GPU 可访问的内存中。有几种不同的方法可以做到这一点,这里将介绍其中两种。其他方法将在后面有关统一内存的章节中介绍。在 GPU 上运行的代码可使用的内存空间已在GPU 内存中介绍,并将在GPU 设备内存空间中更详细地讨论。

2.1.3.1. 统一内存

统一内存是 CUDA 运行时的一项功能,它让 NVIDIA 驱动程序管理主机与一个或多个设备之间的数据移动。内存通过cudaMallocManagedAPI 分配,或通过使用__managed__说明符声明变量来分配。无论 GPU 还是 CPU 尝试访问该内存,NVIDIA 驱动程序都会确保它能够访问该内存。

下面的代码展示了一个用于启动vecAdd核函数的完整函数,它为将在 GPU 上使用的输入和输出向量使用统一内存。cudaMallocManaged分配可从 CPU 或 GPU 访问的缓冲区。这些缓冲区使用cudaFree释放。

void unifiedMemExample(int vectorLength)
{
    // Pointers to memory vectors
    float* A = nullptr;
    float* B = nullptr;
    float* C = nullptr;
    float* comparisonResult = (float*)malloc(vectorLength*sizeof(float));

    // Use unified memory to allocate buffers
    cudaMallocManaged(&A, vectorLength*sizeof(float));
    cudaMallocManaged(&B, vectorLength*sizeof(float));
    cudaMallocManaged(&C, vectorLength*sizeof(float));

    // Initialize vectors on the host
    initArray(A, vectorLength);
    initArray(B, vectorLength);

    // Launch the kernel. Unified memory will make sure A, B, and C are
    // accessible to the GPU
    int threads = 256;
    int blocks = cuda::ceil_div(vectorLength, threads);
    vecAdd<<<blocks, threads>>>(A, B, C, vectorLength);
    // Wait for the kernel to complete execution
    cudaDeviceSynchronize();

    // Perform computation serially on CPU for comparison
    serialVecAdd(A, B, comparisonResult, vectorLength);

    // Confirm that CPU and GPU got the same answer
    if(vectorApproximatelyEqual(C, comparisonResult, vectorLength))
    {
        printf("Unified Memory: CPU and GPU answers match\n");
    }
    else
    {
        printf("Unified Memory: Error - CPU and GPU answers do not match\n");
    }

    // Clean Up
    cudaFree(A);
    cudaFree(B);
    cudaFree(C);
    free(comparisonResult);

}

CUDA 支持的所有操作系统和 GPU 都支持统一内存,不过其底层机制和性能可能因系统架构而异。统一内存提供了更多详细信息。在某些 Linux 系统上(例如具有地址转换服务或异构内存管理的系统),所有系统内存都会自动成为统一内存,无需使用cudaMallocManaged或__managed__说明符。

2.1.3.2. 显式内存管理

显式管理内存分配和内存空间之间的数据迁移有助于提高应用程序性能,不过这确实会使代码更加冗长。下面的代码使用cudaMalloc在 GPU 上显式分配内存。GPU 上的内存使用cudaFreeAPI 释放,这与前一个示例中用于统一内存的 API 相同。

void explicitMemExample(int vectorLength)
{
    // Pointers for host memory
    float* A = nullptr;
    float* B = nullptr;
    float* C = nullptr;
    float* comparisonResult = (float*)malloc(vectorLength*sizeof(float));
    
    // Pointers for device memory
    float* devA = nullptr;
    float* devB = nullptr;
    float* devC = nullptr;

    //Allocate Host Memory using cudaMallocHost API. This is best practice
    // when buffers will be used for copies between CPU and GPU memory
    cudaMallocHost(&A, vectorLength*sizeof(float));
    cudaMallocHost(&B, vectorLength*sizeof(float));
    cudaMallocHost(&C, vectorLength*sizeof(float));

    // Initialize vectors on the host
    initArray(A, vectorLength);
    initArray(B, vectorLength);

    // start-allocate-and-copy
    // Allocate memory on the GPU
    cudaMalloc(&devA, vectorLength*sizeof(float));
    cudaMalloc(&devB, vectorLength*sizeof(float));
    cudaMalloc(&devC, vectorLength*sizeof(float));

    // Copy data to the GPU
    cudaMemcpy(devA, A, vectorLength*sizeof(float), cudaMemcpyDefault);
    cudaMemcpy(devB, B, vectorLength*sizeof(float), cudaMemcpyDefault);
    cudaMemset(devC, 0, vectorLength*sizeof(float));
    // end-allocate-and-copy

    // Launch the kernel
    int threads = 256;
    int blocks = cuda::ceil_div(vectorLength, threads);
    vecAdd<<<blocks, threads>>>(devA, devB, devC, vectorLength);
    // wait for kernel execution to complete
    cudaDeviceSynchronize();

    // Copy results back to host
    cudaMemcpy(C, devC, vectorLength*sizeof(float), cudaMemcpyDefault);

    // Perform computation serially on CPU for comparison
    serialVecAdd(A, B, comparisonResult, vectorLength);

    // Confirm that CPU and GPU got the same answer
    if(vectorApproximatelyEqual(C, comparisonResult, vectorLength))
    {
        printf("Explicit Memory: CPU and GPU answers match\n");
    }
    else
    {
        printf("Explicit Memory: Error - CPU and GPU answers to not match\n");
    }

    // clean up
    cudaFree(devA);
    cudaFree(devB);
    cudaFree(devC);
    cudaFreeHost(A);
    cudaFreeHost(B);
    cudaFreeHost(C);
    free(comparisonResult);
}

CUDA APIcudaMemcpy用于将数据从位于 CPU 上的缓冲区复制到位于 GPU 上的缓冲区。除了目标指针、源指针和以字节为单位的大小之外,cudaMemcpy的最后一个参数是cudaMemcpyKind_t。它可以具有如下取值:

  • cudaMemcpyHostToDevice用于从 CPU 到 GPU 的复制

  • cudaMemcpyDeviceToHost用于从 GPU 到 CPU 的复制

  • cudaMemcpyDeviceToDevice用于 GPU 内部或 GPU 之间的复制

在此示例中,cudaMemcpyDefault作为最后一个参数传递给cudaMemcpy。这使 CUDA 使用源指针和目标指针的值来确定要执行的复制类型。

cudaMemcpyAPI 是同步的。也就是说,它直到复制完成后才会返回。异步复制在在 CUDA 流中启动内存传输中介绍。

该代码使用cudaMallocHost在 CPU 上分配内存。这会在主机上分配页锁定内存,这种内存可以提高复制性能,并且是异步内存传输所必需的。通常,对于将用于与 GPU 进行双向数据传输的 CPU 缓冲区,使用页锁定内存是一种良好做法。如果过多的主机内存被页锁定,某些系统上的性能可能会下降。最佳做法是仅对将用于向 GPU 发送数据或从 GPU 接收数据的缓冲区进行页锁定。

2.1.3.3. 内存管理与应用程序性能

从上面的示例可以看出,显式内存管理更加繁琐,要求程序员指定主机与设备之间的复制操作。这既是显式内存管理的优势,也是其劣势:它能够更精确地控制何时在主机与设备之间复制数据、内存驻留在何处,以及具体在哪个位置分配哪些内存。显式内存管理可以通过控制内存传输以及使其与其他计算重叠,提供提升性能的机会。

使用统一内存时,可以使用 CUDA API(将在内存建议与预取中介绍)向负责管理内存的 NVIDIA 驱动程序提供提示,从而在使用统一内存时获得显式内存管理所带来的部分性能优势。

2.1.4. 同步 CPU 与 GPU

如启动核函数中所述,核函数启动相对于发起调用的 CPU 线程是异步的。这意味着,在核函数完成之前,甚至可能在其启动之前,CPU 线程的控制流就会继续执行。要确保在主机代码继续执行之前核函数已经执行完毕,就需要某种同步机制。

同步 GPU 与主机线程的最简单方法是使用cudaDeviceSynchronize,它会阻塞主机线程,直到此前向 GPU 提交的所有工作都已完成。对于本章中的示例,这样做已经足够,因为 GPU 上仅执行单个操作。在较大的应用程序中,可能有多个流在 GPU 上执行工作,而cudaDeviceSynchronize会等待所有流中的工作完成。在这些应用程序中,建议使用流同步 API 仅与特定流同步,或者使用CUDA 事件。这些内容将在异步执行一章中详细介绍。

2.1.5. 整合所有内容

下面的代码清单给出了本章介绍的简单向量加法核函数的完整代码,以及所有主机代码和用于检查所获结果是否正确的实用函数。这些示例默认使用 1024 的向量长度,但也接受通过可执行文件的命令行参数指定不同的向量长度。

#include <cuda_runtime_api.h>
#include <memory.h>
#include <cstdlib>
#include <ctime>
#include <stdio.h>
#include <cuda/cmath>

__global__ void vecAdd(float* A, float* B, float* C, int vectorLength)
{
    int workIndex = threadIdx.x + blockIdx.x*blockDim.x;
    if(workIndex < vectorLength)
    {
        C[workIndex] = A[workIndex] + B[workIndex];
    }
}

void initArray(float* A, int length)
{
     std::srand(std::time({}));
    for(int i=0; i<length; i++)
    {
        A[i] = rand() / (float)RAND_MAX;
    }
}

void serialVecAdd(float* A, float* B, float* C,  int length)
{
    for(int i=0; i<length; i++)
    {
        C[i] = A[i] + B[i];
    }
}

bool vectorApproximatelyEqual(float* A, float* B, int length, float epsilon=0.00001)
{
    for(int i=0; i<length; i++)
    {
        if(fabs(A[i] -B[i]) > epsilon)
        {
            printf("Index %d mismatch: %f != %f", i, A[i], B[i]);
            return false;
        }
    }
    return true;
}

//unified-memory-begin
void unifiedMemExample(int vectorLength)
{
    // Pointers to memory vectors
    float* A = nullptr;
    float* B = nullptr;
    float* C = nullptr;
    float* comparisonResult = (float*)malloc(vectorLength*sizeof(float));

    // Use unified memory to allocate buffers
    cudaMallocManaged(&A, vectorLength*sizeof(float));
    cudaMallocManaged(&B, vectorLength*sizeof(float));
    cudaMallocManaged(&C, vectorLength*sizeof(float));

    // Initialize vectors on the host
    initArray(A, vectorLength);
    initArray(B, vectorLength);

    // Launch the kernel. Unified memory will make sure A, B, and C are
    // accessible to the GPU
    int threads = 256;
    int blocks = cuda::ceil_div(vectorLength, threads);
    vecAdd<<<blocks, threads>>>(A, B, C, vectorLength);
    // Wait for the kernel to complete execution
    cudaDeviceSynchronize();

    // Perform computation serially on CPU for comparison
    serialVecAdd(A, B, comparisonResult, vectorLength);

    // Confirm that CPU and GPU got the same answer
    if(vectorApproximatelyEqual(C, comparisonResult, vectorLength))
    {
        printf("Unified Memory: CPU and GPU answers match\n");
    }
    else
    {
        printf("Unified Memory: Error - CPU and GPU answers do not match\n");
    }

    // Clean Up
    cudaFree(A);
    cudaFree(B);
    cudaFree(C);
    free(comparisonResult);

}
//unified-memory-end


int main(int argc, char** argv)
{
    int vectorLength = 1024;
    if(argc >=2)
    {
        vectorLength = std::atoi(argv[1]);
    }
    unifiedMemExample(vectorLength);		
    return 0;
}
#include <cuda_runtime_api.h>
#include <memory.h>
#include <cstdlib>
#include <ctime>
#include <stdio.h>
#include <cuda/cmath>

__global__ void vecAdd(float* A, float* B, float* C, int vectorLength)
{
    int workIndex = threadIdx.x + blockIdx.x*blockDim.x;
    if(workIndex < vectorLength)
    {
        C[workIndex] = A[workIndex] + B[workIndex];
    }
}

void initArray(float* A, int length)
{
     std::srand(std::time({}));
    for(int i=0; i<length; i++)
    {
        A[i] = rand() / (float)RAND_MAX;
    }
}

void serialVecAdd(float* A, float* B, float* C,  int length)
{
    for(int i=0; i<length; i++)
    {
        C[i] = A[i] + B[i];
    }
}

bool vectorApproximatelyEqual(float* A, float* B, int length, float epsilon=0.00001)
{
    for(int i=0; i<length; i++)
    {
        if(fabs(A[i] -B[i]) > epsilon)
        {
            printf("Index %d mismatch: %f != %f", i, A[i], B[i]);
            return false;
        }
    }
    return true;
}

//explicit-memory-begin
void explicitMemExample(int vectorLength)
{
    // Pointers for host memory
    float* A = nullptr;
    float* B = nullptr;
    float* C = nullptr;
    float* comparisonResult = (float*)malloc(vectorLength*sizeof(float));
    
    // Pointers for device memory
    float* devA = nullptr;
    float* devB = nullptr;
    float* devC = nullptr;

    //Allocate Host Memory using cudaMallocHost API. This is best practice
    // when buffers will be used for copies between CPU and GPU memory
    cudaMallocHost(&A, vectorLength*sizeof(float));
    cudaMallocHost(&B, vectorLength*sizeof(float));
    cudaMallocHost(&C, vectorLength*sizeof(float));

    // Initialize vectors on the host
    initArray(A, vectorLength);
    initArray(B, vectorLength);

    // start-allocate-and-copy
    // Allocate memory on the GPU
    cudaMalloc(&devA, vectorLength*sizeof(float));
    cudaMalloc(&devB, vectorLength*sizeof(float));
    cudaMalloc(&devC, vectorLength*sizeof(float));

    // Copy data to the GPU
    cudaMemcpy(devA, A, vectorLength*sizeof(float), cudaMemcpyDefault);
    cudaMemcpy(devB, B, vectorLength*sizeof(float), cudaMemcpyDefault);
    cudaMemset(devC, 0, vectorLength*sizeof(float));
    // end-allocate-and-copy

    // Launch the kernel
    int threads = 256;
    int blocks = cuda::ceil_div(vectorLength, threads);
    vecAdd<<<blocks, threads>>>(devA, devB, devC, vectorLength);
    // wait for kernel execution to complete
    cudaDeviceSynchronize();

    // Copy results back to host
    cudaMemcpy(C, devC, vectorLength*sizeof(float), cudaMemcpyDefault);

    // Perform computation serially on CPU for comparison
    serialVecAdd(A, B, comparisonResult, vectorLength);

    // Confirm that CPU and GPU got the same answer
    if(vectorApproximatelyEqual(C, comparisonResult, vectorLength))
    {
        printf("Explicit Memory: CPU and GPU answers match\n");
    }
    else
    {
        printf("Explicit Memory: Error - CPU and GPU answers to not match\n");
    }

    // clean up
    cudaFree(devA);
    cudaFree(devB);
    cudaFree(devC);
    cudaFreeHost(A);
    cudaFreeHost(B);
    cudaFreeHost(C);
    free(comparisonResult);
}
//explicit-memory-end


int main(int argc, char** argv)
{
    int vectorLength = 1024;
    if(argc >=2)
    {
        vectorLength = std::atoi(argv[1]);
    }
    explicitMemExample(vectorLength);		
    return 0;
}

可以使用nvcc构建并运行这些示例,如下所示:

$ nvcc vecAdd_unifiedMemory.cu -o vecAdd_unifiedMemory
$ ./vecAdd_unifiedMemory
Unified Memory: CPU and GPU answers match
$ ./vecAdd_unifiedMemory 4096
Unified Memory: CPU and GPU answers match
$ nvcc vecAdd_explicitMemory.cu -o vecAdd_explicitMemory
$ ./vecAdd_explicitMemory
Explicit Memory: CPU and GPU answers match
$ ./vecAdd_explicitMemory 4096
Explicit Memory: CPU and GPU answers match

在这些示例中,所有线程都在执行独立的工作,不需要相互协调或同步。线程经常需要与其他线程协作和通信来完成工作。一个线程块内的线程可以通过共享内存共享数据,并通过同步来协调内存访问。

线程块级别最基本的同步机制是__syncthreads()内建函数,它充当一个屏障,线程块中的所有线程都必须在此等待,之后才允许任何线程继续执行。共享内存给出了使用共享内存的示例。

为了高效协作,共享内存应当是靠近各处理器核心的低延迟内存(很像 L1 缓存),而__syncthreads()应当是轻量级的。__syncthreads()仅同步单个线程块内的线程。

仅在特定情况下才支持线程块之间的同步。例如,线程块簇允许簇内的线程块进行同步,而协作组 API提供了创建跨线程块同步域的机制。

通常,将同步限制在线程块内部能够获得最佳性能。线程块仍然可以使用原子内存函数共同处理结果,这些函数将在后续各节中介绍。

在第 3.2.4 节这一节中,介绍了 CUDA 同步原语,它们提供非常细粒度的控制,以最大限度地提高性能和资源利用率。

2.1.6. 运行时初始化

CUDA 运行时为系统中的每个设备创建一个CUDA 上下文。该上下文是此设备的主上下文,在首次调用需要此设备上的活动上下文的运行时函数时进行初始化。该上下文由应用程序的所有主机线程共享。作为上下文创建过程的一部分,设备代码会在必要时进行即时编译,并加载到设备内存中。所有这一切都是透明进行的。可以通过驱动程序 API 访问 CUDA 运行时创建的主上下文,以实现互操作,如运行时 API 与驱动程序 API 之间的互操作性中所述。

从 CUDA 12.0 开始,cudaInitDevice和cudaSetDevice调用会初始化运行时以及与指定设备关联的主上下文。如果运行时 API 请求发生在这些调用之前,运行时将隐式使用设备 0,并根据需要自行初始化以处理这些请求。在对运行时函数调用计时以及解读首次调用运行时所返回的错误码时,这一点很重要。在 CUDA 12.0 之前,cudaSetDevice不会初始化运行时。

cudaDeviceReset会销毁当前设备的主上下文。如果在主上下文被销毁后调用 CUDA 运行时 API,则会为该设备创建一个新的主上下文。

注意

CUDA 接口使用全局状态,该状态在主机程序启动期间初始化,并在主机程序终止期间销毁。在程序启动期间或 main 之后的程序终止期间使用任何这些接口(无论隐式还是显式),都会导致未定义行为。

从 CUDA 12.0 开始,cudaSetDevice在更改主机线程的当前设备后,如果运行时尚未初始化,就会显式初始化运行时。早期版本的 CUDA 会延迟新设备上的运行时初始化,直到cudaSetDevice之后首次调用运行时函数时才进行。因此,检查cudaSetDevice的返回值以发现初始化错误非常重要。

参考手册中错误处理和版本管理部分的运行时函数不会初始化运行时。

2.1.7. CUDA 中的错误检查

每个 CUDA API 都会返回一个枚举类型cudaError_t的值。在示例代码中,通常不会检查这些错误。在生产应用程序中,最佳实践是始终检查和处理每次 CUDA API 调用的返回值。没有错误时,返回值为cudaSuccess。许多应用程序选择实现一个实用宏,如下所示

#define CUDA_CHECK(expr_to_check) do {            \
    cudaError_t result  = expr_to_check;          \
    if(result != cudaSuccess)                     \
    {                                             \
        fprintf(stderr,                           \
                "CUDA Runtime Error: %s:%i:%d = %s\n", \
                __FILE__,                         \
                __LINE__,                         \
                result,\
                cudaGetErrorString(result));      \
    }                                             \
} while(0)

此宏使用cudaGetErrorString API,该 API 会返回一个便于人阅读的字符串,用于描述特定cudaError_t值的含义。使用上述宏时,应用程序会将 CUDA 运行时 API 调用放在CUDA_CHECK(expression)宏中,如下所示:

    CUDA_CHECK(cudaMalloc(&devA, vectorLength*sizeof(float)));
    CUDA_CHECK(cudaMalloc(&devB, vectorLength*sizeof(float)));
    CUDA_CHECK(cudaMalloc(&devC, vectorLength*sizeof(float)));

如果这些调用中的任何一个检测到错误,就会使用此宏将错误打印到stderr。此宏在较小的项目中很常见,但也可以对其进行调整,以适配较大应用程序中的日志系统或其他错误处理机制。

注意

需要特别注意,任何 CUDA API 调用返回的错误状态,也可能表明先前提交的异步操作发生了错误。异步错误处理一节对此有更详细的介绍。

2.1.7.1. 错误状态

CUDA 运行时为每个主机线程维护一个cudaError_t状态。该值默认为cudaSuccess,每当发生错误时都会被覆盖。cudaGetLastError返回当前错误状态,然后将其重置为cudaSuccess。另一种方式是使用cudaPeekAtLastError,它返回错误状态但不将其重置。

使用三尖括号语法进行核函数启动时,不会返回cudaError_t。良好的实践是在核函数启动后立即检查错误状态,以检测核函数启动中的即时错误,或核函数启动之前的异步错误。在核函数启动后立即检查错误状态时,值为cudaSuccess并不意味着核函数已成功执行,甚至不意味着它已经开始执行。这只能确认传递给运行时的核函数启动参数和执行配置没有触发任何错误,并且错误状态不是核函数开始之前的先前错误或异步错误。

2.1.7.2. 异步错误

CUDA 核函数启动和许多运行时 API 都是异步的。异步 CUDA 运行时 API 将在异步执行中详细讨论。每当发生错误时,CUDA 错误状态都会被设置并覆盖。这意味着,异步操作执行期间发生的错误,只有在下一次检查错误状态时才会报告。如前所述,这可能是对cudaGetLastError、cudaPeekAtLastError的调用,也可能是任何返回cudaError_t的 CUDA API 调用。

CUDA 运行时 API 函数返回错误时,不会清除错误状态。这意味着,对于异步错误(例如核函数的无效内存访问),每个 CUDA 运行时 API 都会返回其错误码,直到通过调用cudaGetLastError清除错误状态为止。

    vecAdd<<<blocks, threads>>>(devA, devB, devC);
    // check error state after kernel launch
    CUDA_CHECK(cudaGetLastError());
    // wait for kernel execution to complete
    // The CUDA_CHECK will report errors that occurred during execution of the kernel
    CUDA_CHECK(cudaDeviceSynchronize());
    

注意

cudaStreamQuery和cudaEventQuery可能返回的cudaError_t值cudaErrorNotReady不被视为错误,cudaPeekAtLastError或cudaGetLastError也不会报告该值。

2.1.7.3. CUDA_LOG_FILE

另一种识别 CUDA 错误的好方法是使用CUDA_LOG_FILE环境变量。设置此环境变量后,CUDA 驱动程序会将遇到的错误消息写入文件,文件路径由该环境变量指定。例如,考虑下面这段不正确的 CUDA 代码,它试图启动一个线程块,其大小超过了所有架构所支持的最大值。

__global__ void k()
{ }

int main()
{
        k<<<8192, 4096>>>(); // Invalid block size
        CUDA_CHECK(cudaGetLastError());
        return 0;
}

构建并运行此代码后,核函数启动后的检查会使用第 2.1.7 节中展示的宏检测并报告错误。

$ nvcc errorLogIllustration.cu -o errlog
$ ./errlog
CUDA Runtime Error: /home/cuda/intro-cpp/errorLogIllustration.cu:24:1 = invalid argument

不过,在将CUDA_LOG_FILE设置为一个文本文件后运行应用程序,该文件会包含关于错误的更多一些信息。

$ env CUDA_LOG_FILE=cudaLog.txt ./errlog
CUDA Runtime Error: /home/cuda/intro-cpp/errorLogIllustration.cu:24:1 = invalid argument
$ cat cudaLog.txt
[12:46:23.854][137216133754880][CUDA][E] One or more of block dimensions of (4096,1,1) exceeds corresponding maximum value of (1024,1024,64)
[12:46:23.854][137216133754880][CUDA][E] Returning 1 (CUDA_ERROR_INVALID_VALUE) from cuLaunchKernel

将CUDA_LOG_FILE设置为stdout或stderr,会分别打印到标准输出和标准错误。使用CUDA_LOG_FILE环境变量,即使应用程序没有针对 CUDA 返回值实现适当的错误检查,也能够捕获并识别 CUDA 错误。这种方法对于调试可能极为有效,但仅靠该环境变量并不能让应用程序在运行时处理 CUDA 错误并从中恢复。CUDA 的错误日志管理功能还允许向驱动程序注册一个回调函数,每当检测到错误时就会调用该函数。这可用于在运行时捕获和处理错误,还可将 CUDA 错误日志记录无缝集成到应用程序现有的日志系统中。

第 4.9 节给出了 CUDA 错误日志管理功能的更多示例。错误日志管理和CUDA_LOG_FILE可用于 NVIDIA 驱动程序 r570 及更高版本。

2.1.8. 设备函数与主机函数

__global__说明符用于指示核函数的入口点,即将被调用以在 GPU 上并行执行的函数。核函数通常从主机启动,不过,也可以使用动态并行在另一个核函数内部启动核函数。

说明符__device__表示一个函数应当编译为在 GPU 上运行,并且可由其他__device__或__global__函数调用。函数,包括类成员函数、函数对象和 lambda 表达式,可以同时指定为__device__和__host__,如下例所示。

2.1.9. 变量说明符

可以在静态变量声明中使用CUDA 说明符来控制存储位置。

在__device__或__global__函数内部声明变量且不带说明符时,会尽可能将其分配到寄存器中,并在必要时分配到局部内存中。在__device__或__global__函数外部声明的任何不带说明符的变量,都将分配在系统内存中。

2.1.9.1. 检测设备编译

为函数指定__host__ __device__时,就是指示编译器为此函数同时生成 GPU 代码和 CPU 代码。在这样的函数中,可能希望使用预处理器,为函数的 GPU 版本或 CPU 版本单独指定代码。检查是否定义了__CUDA_ARCH_是最常见的方法,如下面的示例所示。

2.1.10. 线程块簇

从计算能力 9.0 开始,CUDA 编程模型包含一个可选的层级,称为线程块簇,由线程块组成。正如线程块内的线程保证会被共同调度到一个流式多处理器(SM)上一样,簇内的线程块也保证会被共同调度到 GPU 中的一个 GPU 处理簇(GPC)上。

与线程块类似,簇也被组织为一维、二维或三维的线程块簇网格,如图 5所示。

簇中的线程块数量可以由用户定义,CUDA 支持的可移植簇大小为每个簇最多 8 个线程块。请注意,对于规模过小、无法支持 8 个多处理器的 GPU 硬件或 MIG 配置,最大簇大小会相应减小。如何识别这些较小的配置,以及支持线程块簇大小超过 8 的较大配置,取决于具体架构,可以使用cudaOccupancyMaxPotentialClusterSize API 进行查询。

簇中的所有线程块保证会被共同调度到单个 GPU 处理簇(GPC)上同时执行,并允许簇内的线程块使用协作组 APIcluster.sync()执行硬件支持的同步。簇组还提供成员函数,可分别使用num_threads()和num_blocks() API,以线程数或线程块数查询簇组大小。可以分别使用dim_threads()和dim_blocks() API 查询线程或线程块在簇组中的秩。

属于同一个簇的线程块可以访问分布式共享内存,它由簇内所有线程块的共享内存组合而成。簇中的线程块能够对分布式共享内存中的任意地址执行读、写和原子操作。分布式共享内存给出了在分布式共享内存中计算直方图的示例。

注意

在使用簇支持启动的核函数中,出于兼容性考虑,gridDim 变量仍然以线程块数量表示大小。可以使用协作组 API 获取一个块在簇中的秩。

2.1.10.1. 使用三重尖括号表示法进行带簇的启动

可以通过使用__cluster_dims__(X,Y,Z)的编译时核函数属性,或通过 CUDA 核函数启动 APIcudaLaunchKernelEx,在核函数中启用线程块簇。下面的示例展示了如何使用编译时核函数属性启动簇。使用核函数属性设置的簇大小在编译时就已固定,之后可以使用传统的<<< , >>>启动核函数。如果核函数使用编译时簇大小,则在启动该核函数时无法修改簇大小。

// Kernel definition
// Compile time cluster size 2 in X-dimension and 1 in Y and Z dimension
__global__ void __cluster_dims__(2, 1, 1) cluster_kernel(float *input, float* output)
{

}

int main()
{
    float *input, *output;
    // Kernel invocation with compile time cluster size
    dim3 threadsPerBlock(16, 16);
    dim3 numBlocks(N / threadsPerBlock.x, N / threadsPerBlock.y);

    // The grid dimension is not affected by cluster launch, and is still enumerated
    // using number of blocks.
    // The grid dimension must be a multiple of cluster size.
    cluster_kernel<<<numBlocks, threadsPerBlock>>>(input, output);
}

2.2. CUDA Python 简介

本章介绍使用 Python 进行 CUDA 核函数编程。CUDA Python 生态系统涵盖了一个庞大且正在积极演进的工具与库生态系统。本章将首先介绍其中的一些组件,然后使用其中的几个组件来说明在 Python 中编写和执行 GPU 代码的方法。

在 Python 中利用 GPU 计算的方式有很多,其中许多方式不需要显式编写 GPU 核函数。CUDA Python 生态系统的一些组件提供了在 GPU 上执行操作的函数,开发者无需进行任何特定的 GPU 控制或编码。NVIDIA 加速计算中心提供了一份加速 Python 用户指南,其中介绍并讨论了许多不同的库和工具,它们能够在 Python 中实现 GPU 加速计算。对于希望尽可能快速、轻松地在 Python 中使用 GPU,而不一定要直接编写 GPU 代码的用户来说,这份资源是一个很好的起点。

另一方面,本章侧重于直接控制 GPU,以及在 Python 中编写在 GPU 上执行的核函数。本章重点介绍在 Python 中进行CUDA 单指令多线程(SIMT)编程。

2.2.1. CUDA Python 生态系统

CUDA Python 是一个由工具和库构成的生态系统,可在 Python 中实现 GPU 计算。以下列表介绍 CUDA Python 的主要组成部分,这里所涵盖的内容并不需要用到所有这些组成部分。此列表改编自CUDA Python github 仓库中的完整列表。

主要组件:用于 GPU 控制和运行库提供的 GPU 代码

  • cuda.core:用于内存管理和设备管理等 CUDA 控制操作的 Python 风格接口。它为 Python 提供的功能,相当于 CUDA Runtime 为 CUDA C++ 提供的功能。

  • cuda.compute:一个 Python 模块,提供由CUDA Core Compute Library (CCCL)提供的 GPU 加速函数。

  • CuPy:一个 Python 库,提供 Numpy 例程的 GPU 加速版本,以及ndarray数据容器的 GPU 加速版本。

核函数编写组件

  • cuda.lang:一种 Python 领域专用语言(DSL),用于使用 Python 语言的一个子集,在 SIMT 编程模型中编写 CUDA 核函数和设备函数。

  • cuda.coop:一个 Python 模块,提供可从设备调用(使用cuda.lang)的CUDA Core Compute Library (CCCL)原语。

  • cuda.tile:一种 Python 领域专用语言(DSL),用于在分块编程模型中编写 CUDA 核函数和设备函数。

其他组件

  • cuda.pathfinder:一个用于定位 Python 环境中已安装的 CUDA 组件的实用工具

  • cuda.bindings:CUDA 库和实用工具的底层 Python 绑定,包括 CUDA Driver API、CUDA Runtime API、NVRTC、NVVM 等。cuda.bindings通过 CUDA Driver 和 CUDA Runtime 组件提供与cuda.core中可用功能相同的功能。不过,cuda.bindings是通过对 C 语言 API 的 Python 封装来提供这些功能的,而不是通过原生的 Python 风格接口。

2.2.1.1. 在 Python 中使用 CUDA 库

CUDA C++ 拥有丰富的库生态系统,无需直接编写核函数或 GPU 代码即可实现 GPU 加速。CUDA C++ 于 2006 年推出时,库的数量很少,开发者基本上必须自行编写 GPU 核函数代码。此后,人们开发了大量库,使开发者无需编写太多 GPU 代码,甚至完全不必编写 GPU 代码,就能在 C++ 中利用 GPU 计算。

CUDA Python 生态系统则从另一个方向演进而来:在能够直接使用 Python 语法和语义编写自定义核函数之前,CuPy 等 Python 库就已经为 Python 开发者提供了计算和算法的 GPU 加速实现。这些库中的许多库为使用 CUDA C++ 实现的 GPU 代码提供了 Python 绑定。

在当今的 CUDA 时代,如果 GPU 加速库能够提供满足您需求所需的表达能力,那么几乎总是建议使用这些库。这些库中的许多实现都经过 GPU 计算专家的调优。当没有可用的库或库无法满足需求时,在 Python 中也可以像在 C++ 中一样,直接编写 GPU 核函数和设备函数。

2.2.1.2. 本章范围

虽然开发者应在可能的情况下优先使用库,但本章其余部分介绍的是当开发者需要用 Python 编写自定义 GPU 代码时该如何进行。本章采用与第 2.1 节介绍 C++ 时相同的方式,讲解如何在 GPU 上编写和运行自定义 Python 代码,首先介绍如何指定 GPU 核函数,然后介绍如何使用 cuPy 提供的 GPU 加速的ndarray在 GPU 上分配内存,并实现 CPU 与 GPU 之间的通信。

2.2.1.3. 环境设置

一般来说,CUDA Python 生态系统中的大多数组件都可在 PyPi 上获取,并可使用pip命令或任何流行的 Python 包管理器进行安装。所有包都要求系统上安装最新的 NVIDIA 驱动程序。编写或运行 CUDA Python 应用程序通常不需要 CUDA Toolkit。

有关在不同平台上安装和配置 CUDA Python 的信息,请参阅NVIDIA 开发者专区中的 CUDA Python。

2.2.1.4. 运行 CUDA Python 应用程序

CUDA Python 应用程序无论是使用 CUDA 加速库,还是包含用户编写的 GPU 代码,其运行方式都与常规 Python 应用程序相同。在本节中,示例始终通过命令行运行,具体做法是如下所示调用python3来执行名为cuda-python-app.py的程序。

$ python3 cuda-python-app.py

2.2.2. Python 中的 SIMT 核函数

如CUDA 编程模型的介绍中所述,在 GPU 上执行且可以从主机调用的函数称为核函数。CUDA 提供两种不同的模型:单指令多线程(SIMT)和CUDA tile。SIMT 核函数是为由许多并行线程同时运行而编写的。这一概念在 CUDA Python 和 CUDA C++ 中是相同的。本章通过 SIMT 核函数介绍 CUDA Python。

2.2.2.1. 指定核函数

在 CUDA Python 中指定核函数之前,必须导入numba.cuda包。通常采用如下所示的方式完成此操作。

from numba import cuda

这会导入numba.cuda包,并允许我们使用该包提供的cuda命名空间中的组件。

要在 CUDA Python 中将一个函数指定为核函数,请如下所示将装饰器@cuda.jit放在函数定义的上一行。

from numba import cuda

@cuda.jit
def function(input_array, output_array):
    ...

这将使核函数在第一次启动时,针对当前活动的 GPU 进行 JIT 编译。如果未指定其他 GPU,则使用默认的 CUDA 设备,本节展示的示例就是如此。

2.2.2.2. 启动核函数

执行核函数的线程数量在核函数启动时指定,是启动的一部分。这称为执行配置。每次核函数调用都可以具有独有的执行配置,例如不同的块大小或线程块数量。

2.2.2.2.1. 核函数启动

要启动核函数,需要将执行配置放在核函数名称之后、函数实参之前的方括号[ ]中。实参的顺序与第 2.1.2.2.1 节介绍的 C++ 三重尖括号表示法中的顺序相同,具体如下:

kernel_name[number_of_thread_blocks, threads_per_block](arguments, ...)

下面的代码片段展示了如何在 Python 源文件中定义核函数,然后调用它。

from numba import cuda

@cuda.jit
def my_kernel(input, output):
    ...

## launch the kernel
my_kernel[num_thread_blocks, threads_per_block](in_array, out_array)

每个块的线程数量存在限制,因为一个块中的所有线程都驻留在同一个流式多处理器(SM)上,且必须共享该 SM 的资源。在当前的 GPU 上,一个线程块最多可以包含 1024 个线程。如果资源允许,可以在一个 SM 上同时调度多个线程块。

2.2.2.2.2. 多维网格和线程块

在 CUDA 中,线程块以及由线程块组成的网格可以是一维、二维或三维的。当网格或线程块是一维时,可以在指定核函数启动的执行配置时使用整数。当线程块或网格是二维或三维时,则使用二维或三维元组,如下方的二维网格和线程块启动示例所示,其中gridX和gridY是网格的 x 和 y 维度大小,blockX和blockY是网格中每个线程块的 x 和 y 维度大小。

from numba import cuda

@cuda.jit
def function(input, output):
    ...

## launch the kernel
function[(gridX, gridY), (blockX, blockY)](in_array, out_array)

2.2.2.3. 线程和网格索引内建函数

第 1.2.2.1 节介绍了线程和网格,第 2.2.2.2 节展示了如何为核函数启动指定网格和线程块的大小。在核函数内部,每个线程都可以访问执行配置的参数,以及线程的索引和线程块在网格中的索引。

可以从核函数内部访问以下变量,以确定线程的身份:

  • cuda.threadIdx.[xyz]给出线程在其线程块内的索引。一个线程块中的每个线程都有不同的索引。

  • cuda.blockDim.[xyz]给出线程块的各维度大小,这些大小是在核函数启动的执行配置中指定的。

  • cuda.blockIdx.[xyz]给出线程块在网格中的索引。每个线程块都有不同的索引。

  • cuda.gridDim.[xyz]给出网格的各维度大小,这些大小是在核函数启动时的执行配置中指定的。

这些变量中的每一个都是一个三分量向量,具有.x、.y和.z成员。如果在核函数启动的执行配置中未指定某个维度,则对于维度大小,其默认值为 1;对于索引,其默认值为 0。

cuda.threadIdx和cuda.blockIdx的索引从零开始。也就是说,cuda.threadIdx.x的取值从 0 到cuda.blockDim.x - 1,包含上限值。.y和.z在各自的维度中采用相同的方式。

下面展示了一个简单的向量加法核函数的代码,它将两个向量逐元素相加。该函数接受三个数组A、B和C,以实现逐元素向量加法C = A + B

# C = A + B vector addition
@cuda.jit
def vecadd(A, B, C):
    idx = cuda.threadIdx.x + cuda.blockIdx.x * cuda.blockDim.x
    C[idx] = A[idx] + B[idx]

该核函数首先计算线程在网格中的唯一索引。此核函数假定它是在一维网格中以一维线程块启动的。idx变量是一个从 0 到N-1的唯一索引,其中 N 是网格中的线程总数,即N = cuda.gridDim.x * cuda.blockDim.x。

上面代码块所示的线程索引计算模式非常常见,因此 Numba 为此操作提供了一种简写语法:cuda.grid(n),其中n是维数。在上面的示例中,下列这一行

idx = cuda.threadIdx.x + cuda.blockIdx.x * cuda.blockDim.x

可以替换为简单得多的

idx = cuda.grid(1)

此核函数一个值得注意的方面是,它不检查对A、B或C的越界访问。在本章中,我们假定它们是由 cuPy 创建的ndarrays,稍后将在第 2.2.3.3 节介绍。使用 cuPyndarrays时,边界检查由数组类型隐式实现。

2.2.3. GPU 计算中的内存

注意

cuPy 等 Python 包通过直接使用 CUDA C++ API 来执行 GPU 内存管理,例如第 2.1.3.2 节所介绍的那些 API。多个 Python 包提供了用于控制 GPU 内存分配的封装和实用工具。本指南仅介绍 cuPy。大多数包的概念都相似,并且除非另有说明,否则大多数包的行为方式都与其对应的 C++ 实现相似。

如第 1.2.3 节所介绍,GPU 连接有 DRAM。将在核函数中使用的数据数组,通常需要在核函数访问它们之前就位于 GPU 的 DRAM 中。在 Python 中,控制数据在内存中的位置,即在 CPU 与 GPU 之间移动数据,是程序员的责任。这与第 2.1.3.2 节所介绍的 C++ 中的显式内存管理情况相同。

2.2.3.1. 在 GPU 上实例化数组

CuPy 提供了在 GPU 上创建指定类型和维度的ndarray对象的函数,也提供了在 CPU 与 GPU 之间复制数据的函数。cuPy 中许多函数的函数签名与 Numpy 中用于创建ndarrays的函数相似。下面展示了几个示例,说明如何使用 CuPy 在 GPU 内存中创建和填充数组。

import cupy as cp
import numpy as np

## create a matrix of zeros on the GPU
## when a datatype is not specified, float32 is used by default
A_device = cp.zeros((1024, 1024))

## create an array of 2^20 random doubles on the GPU
B_device = cp.rand.random((2**20), dtype=np.double)

## create an array of zeroes with the same shape and datatype as an existing array
C_device = cp.zeros_like(A)

2.2.3.2. 在主机内存与 GPU 内存之间复制数组

CuPy 还可用于将数据从位于 CPU 内存中的 Numpyndarrays复制到位于 GPU 内存中的 CuPy 数组。

import cupy as cp
import numpy as np

## Create an array in host memory
A_host = np.zeros((1024, 1024))
## Copy the array to the GPU
A_device = cp.array(A_host)

## Create an array in GPU memory
B_device = cp.rand.random((1024, 1024))
## copy the array to host memory
B_host = cp.asnumpy(B_device)

2.2.3.3. ndarray 对象类型

上一节展示的ndarray对象要么存在于主机内存中,要么存在于 GPU 内存中,但不会同时存在于两者中。将驻留在主机上的数组作为实参传递给核函数会导致错误。将驻留在 GPU 内存中的数组传递给普通 Python 函数,即非核函数,也会导致错误。CuPy 不会隐式执行 CPU 与 GPU 之间的复制,因为这些操作可能开销很大,而且过多的数据复制会损害性能。因此,CuPy 要求程序员有意识地决定何时在 CPU 与 GPU 之间复制数据。

在 GPU 核函数中使用ndarray类型的一个优点是,数组自身携带其各维度的范围。如第 2.2.2.3 节所示,边界检查会自动完成,当所需的线程总数略小于执行块或网格的总大小时,核函数代码无需检查越界访问。

2.2.4. 同步 CPU 与 GPU

与 C++ 一样,CUDA Python 中的核函数启动相对于主机线程是异步的。也就是说,在核函数启动之后,主机代码会继续在 CPU 上执行,而不能保证核函数已经完成执行,甚至不能保证它已经开始执行。为了保证 GPU 核函数已经执行完毕,主机线程必须与 GPU 进行某种形式的同步。

最简单的同步形式是同步整个 GPU。这种设备范围的同步是 CUDA 驱动程序提供的一种操作,cuPy 和 numba.cuda 都通过synchronize()方法将其暴露给 Python。

import cupy as cp
from numba import cuda

..

## Wait on host thread for all pending GPU work to complete
## this uses the interface provided by cupy
cp.synchronize()

## Wait on host thread for all pending GPU work to complete
## this uses the interface provided by numba.cuda
cuda.synchronize()

设备范围的同步会让主机线程等待,直到此前在 GPU 上发出的所有工作都已完成。使用 CUDA 流可以实现更细粒度的同步,如第 2.5 节所述。在 Python 中使用流时,建议使用cuda.core创建 CUDA 流,并根据需要仅与特定的流进行同步。

2.2.5. 综合运用

下面的源代码清单给出了通常用作第一个 GPU 核函数的并行向量加法核函数的 Python 形式。

import numpy as np
from numba import cuda
import cupy as cp


## Defines a CUDA kernel to perform C = A + B vector addition
@cuda.jit
def vecadd(A, B, C):
    work_index = cuda.grid(1)
    C[work_index] = A[work_index] + B[work_index]


# note that vector size is not a power of 2 nor a multiple of the block_size defined below
vector_size = 2**24 + 11

device = cp.cuda.Device()
## Create device arrays of uniform random float32 values as input, and an array of zeros 
## as the result vector
a = cp.random.uniform(-1, 1, vector_size)
b = cp.random.uniform(-1, 1, vector_size)
c = cp.zeros_like(a)

block_size = 256
grid_size = int(np.ceil(vector_size/block_size))
vecadd[grid_size, block_size](a, b, c)

## synchronize the CPU thread and the GPU to ensure that the kernel has completed
## this is included to illustrate good practices, even though the copy below would implicitly wait for
## the kernel to complete
device.synchronize()

## Copy all 3 arrays to the CPU as ndarrays
a_np = cp.asnumpy(a)
b_np = cp.asnumpy(b)
c_np = cp.asnumpy(c)

## Perform the copy on the CPU to verify the answer
expected = a_np + b_np

## Test that the answer is correct, within floating point epsilon
np.testing.assert_array_almost_equal(c_np, expected)

## The assert will print diagnostics and abort
## so this only prints if the assertion passes
print("Test succeeded")

在此示例中,输入数组A和B由 CuPy 在 GPU 上创建并初始化为随机值。在代码末尾将它们复制到 CPU,仅仅是为了让 CPU 也能执行向量加法,并验证 CPU 和 GPU 的结果是否一致。

2.2.6. CUDA Python 中的错误检查

任何影响 GPU 的操作,从内存分配和复制到核函数启动,都有可能引发错误状况。如第 2.1.7 节针对 C++ 所示,确保在与 GPU 交互的过程中没有发生错误是最佳实践。

在 Python 中,CUDA 错误会抛出异常,如果未捕获这些异常,程序就会终止。可以使用常规的 Python 语法捕获这些异常。下面的示例与上面的向量加法相同,但有意加入了一个错误:每个线程块的线程数为 2048,超过了当前任何 GPU 所能运行的数量。这会导致核函数启动失败并抛出异常,而这段代码将捕获该异常。

import numpy as np
from numba import cuda
import cupy as cp


## Defines a CUDA kernel to perform C = A + B vector addition
@cuda.jit
def vecadd(A, B, C):
    work_index = cuda.grid(1)
    C[work_index] = A[work_index] + B[work_index]


try:
    vector_size = 2**24 + 11

    device = cp.cuda.Device()
    a = cp.random.uniform(-1, 1, vector_size)
    b = cp.random.uniform(-1, 1, vector_size)
    c = cp.zeros_like(a)

    ## this block size is too large for any current GPUs
    block_size = 2048
    grid_size = int(np.ceil(vector_size/block_size))
    # Error: launching kernel with invalid block size
    vecadd[grid_size, block_size](a, b, c)

    device.synchronize()
    print("Test did not encounter any errors")

except Exception as e:
    print(f"Exception occurred: {e}")

运行这段代码会捕获并显示该错误,如下所示:

$ python3 vecadd_error.py
Exception occurred: CUDA_ERROR_INVALID_VALUE: This indicates that one or more of the parameters passed to the API call is not within an acceptable range of values.

程序捕获异常后正常退出。如果运行这段代码时没有try:和except:,它将异常退出,并将回溯信息输出到控制台,其中应显示相同的错误。

2.3. 编写 SIMT 核函数

针对给定问题,CUDA 核函数的编写方式在很大程度上可以与传统 CPU 代码的编写方式相同。不过,GPU 具有一些可用于提高性能的独特特性。此外,了解 GPU 上的线程如何调度、如何访问内存以及如何执行,可以帮助开发者编写出最大限度利用可用计算资源的核函数。

本章进一步介绍使用SIMT 编程模型在 C++ 和 Python 中编写核函数的一些细节。

2.3.1. SIMT 基础

从开发者的角度来看,CUDA 线程是并行性的基本单位。第 1.2.2.2 节介绍了 GPU 执行的基本 SIMT 模型,SIMT 执行模型则提供了 SIMT 模型的更多细节。SIMT 模型允许每个线程维护自身的状态和控制流。从功能角度来看,每个线程都可以执行独立的代码路径。然而,注意让核函数代码尽量减少同一线程束中的线程采取分歧代码路径的情况,可以显著提高性能。

2.3.2. 线程层次结构

线程组织成线程块,线程块再组织成网格。网格可以是一维、二维或三维的,在核函数内部可以使用内建变量gridDim查询网格的大小。线程块也可以是一维、二维或三维的。在核函数内部可以使用内建变量blockDim查询线程块的大小。可以使用内建变量blockIdx查询线程块的索引。在线程块内部,可以使用内建变量threadIdx获取线程的索引。这些内建变量用于为每个线程计算唯一的全局线程索引,从而使每个线程都能够从全局内存加载特定数据或将特定数据存储到全局内存,并根据需要执行独有的代码路径。

  • gridDim.[x|y|z]:网格在x、y和z维度上的大小,依次对应。这些值对于所有线程都相同,是核函数启动时设置的执行配置的一部分。

  • blockDim.[x|y|z]:线程块在x、y和z维度上的大小,依次对应。这些值对于所有线程都相同,是核函数启动时设置的执行配置的一部分。

  • blockIdx.[x|y|z]:线程块在x、y和z维度上的索引,依次对应。这些值在线程之间会有所不同,以指示正在执行的是哪个线程块。

  • threadIdx.[x|y|z]:线程在x、y和z维度上的索引,依次对应。这些值在线程之间会有所不同,以指示正在执行的是哪个线程。

  • cuda.gridDim.[xyz]:网格在x、y和z维度上的大小,依次对应。这些值对于所有线程都相同,是核函数启动时设置的执行配置的一部分。

  • cuda.blockDim.[xyz]:线程块在x、y和z维度上的大小,依次对应。这些值对于所有线程都相同,是核函数启动时设置的执行配置的一部分。

  • cuda.blockIdx.[xyz]:线程块在x、y和z维度上的索引,依次对应。这些值在线程之间会有所不同,以指示正在执行的是哪个线程块。

  • cuda.threadIdx.[xyz]:线程在x、y和z维度上的索引,依次对应。这些值在线程之间会有所不同,以指示正在执行的是哪个线程。

使用多维线程块和网格只是为了方便,并不会影响性能。线程块中的线程以可预测的方式线性化:第一个索引x变化最快,其次是y,然后是z。这意味着,在线程索引的线性化过程中,threadIdx.x的连续值表示连续的线程,threadIdx.y的步长为blockDim.x,而threadIdx.z的步长为blockDim.x * blockDim.y。这会影响线程分配到线程束的方式,详见硬件多线程。

图 11展示了一个由一维线程块构成的二维网格的简单示例。

线程块网格

图 11 线程块网格

图内文字中文对照
Grid
网格
Thread Block
线程块

2.3.2.1. 线程块同步

在前面给出的示例中,不需要对线程块内的线程进行同步。当线程块内的线程相互协作或访问相同的内存地址时,尤其是访问下文介绍的共享内存时,就需要同步以避免竞态条件和内存冒险。

线程块内最基本的同步形式称为syncthreads

2.3.3. GPU 设备内存空间

CUDA 设备具有多种内存空间,核函数内的 CUDA 线程可以访问这些空间。表 1汇总了常见的内存类型、它们的线程作用域及生命周期。以下各节将更详细地解释这些内存类型。

表 1 内存类型、作用域和生命周期

内存类型

作用域

生命周期

位置

全局

网格

应用程序

设备

常量

网格

应用程序

设备

共享

线程块

核函数

SM

局部

线程

核函数

设备

寄存器

线程

核函数

SM

2.3.3.1. 全局内存

全局内存(也称为设备内存)是用于存储核函数中所有线程均可访问的数据的主要内存空间。它类似于 CPU 系统中的 RAM。在 GPU 上运行的核函数可以直接访问全局内存,就像在 CPU 上运行的代码可以访问系统内存一样。

全局内存具有持久性。也就是说,在全局内存中分配的内存及其中存储的数据会一直保留,直到该分配被释放或应用程序终止。cudaDeviceReset也会释放所有已分配的内存。

全局内存通过cudaMalloc和cudaMallocManaged等 CUDA API 调用进行分配。可以使用cudaMemcpy等 CUDA 运行时 API 调用将数据从 CPU 内存复制到全局内存中。使用 CUDA API 分配的全局内存通过cudaFree释放。

在核函数启动之前,通过 CUDA API 调用分配并初始化全局内存。在核函数执行期间,CUDA 线程可以读取全局内存中的数据,并将 CUDA 线程所执行操作的结果写回全局内存。核函数执行完成后,可以将其写入全局内存的结果复制回主机,或供 GPU 上的其他核函数使用。

由于网格中的所有线程都可以访问全局内存,因此必须注意避免线程之间的数据竞争。由于从主机启动的 CUDA 核函数的返回类型为void,因此将核函数计算出的数值结果返回给主机的唯一方法是将这些结果写入全局内存。

下面的核函数是说明全局内存用法的一个简单示例,其中三个数组A、B和C均位于全局内存中,由这个向量加法核函数访问。

__global__ void vecAdd(float* A, float* B, float* C, int vectorLength)
{
    int workIndex = threadIdx.x + blockIdx.x*blockDim.x;
    if(workIndex < vectorLength)
    {
        C[workIndex] = A[workIndex] + B[workIndex];
    }
}
@cuda.jit
def vecadd(A, B, C):
    work_index = cuda.grid(1)
    C[work_index] = A[work_index] + B[work_index]


2.3.3.2. 共享内存

共享内存是线程块内所有线程都可以访问的内存空间。它在物理上位于每个 SM 上,并与 L1 缓存使用相同的物理资源,即统一数据缓存。共享内存中的数据在整个核函数执行期间持续存在。共享内存可以视为由用户管理、供核函数执行期间使用的暂存区。虽然与全局内存相比容量较小,但由于共享内存位于每个 SM 上,因此访问共享内存的带宽高于访问全局内存的带宽,延迟也更低。

由于线程块内的所有线程都可以访问共享内存,因此必须注意避免同一线程块内线程之间的数据竞争。在 C++ 中,可以使用__syncthreads()函数实现同一线程块内线程之间的同步。在 Python 中,使用cuda.syncthreads()实现同样的线程块同步。该函数会阻塞线程块中的所有线程,直到所有线程都到达对__syncthreads()或cuda.syncthreads()的调用处。

// assuming blockDim.x is 128
__global__ void example_syncthreads(int* input_data, int* output_data) 
{
    __shared__ int shared_data[128];
    shared_data[threadIdx.x] = input_data[blockDim.x*blockIdx.x + threadIdx.x];

    // All threads synchronize, guaranteeing all writes to 'shared_data' are ordered 
    // before any thread is unblocked from '__syncthreads()':
    __syncthreads();

    // A single thread safely reads 'shared_data':
    if (threadIdx.x == 0) {
        float sum = 0;
        for (int i = 0; i < blockDim.x; ++i) {
            sum += shared_data[i];
        }
        output_data[blockIdx.x] = sum;
    }
}
import numpy as np
from numba import cuda
import cupy as cp
@cuda.jit
def example_syncthreads(input_data, output_data):
    shared_data = cuda.shared.array(shape=128, dtype=np.int32)
    
    shared_data[cuda.threadIdx.x] = input_data[cuda.blockIdx.x*cuda.blockDim.x + cuda.threadIdx.x]
    cuda.syncthreads()

    if cuda.threadIdx.x == 0:
        sum = 0.0
        for x in shared_data: 
            sum = sum + x
        output_data[cuda.blockIdx.x] = sum

共享内存的大小因所使用的 GPU 架构而异。由于共享内存和 L1 缓存共享同一物理空间,使用共享内存会减小核函数可用的 L1 缓存大小。此外,如果核函数不使用共享内存,则整个物理空间都将用于 L1 缓存。CUDA 运行时 API 提供了查询每个 SM 和每个线程块的共享内存大小的功能,可以使用cudaGetDeviceProperties函数并查看cudaDeviceProp.sharedMemPerMultiprocessor和cudaDeviceProp.sharedMemPerBlock设备属性来进行查询。

CUDA 运行时 API 提供了cudaFuncSetCacheConfig函数,用于告知运行时应为共享内存分配更多空间,还是为 L1 缓存分配更多空间。该函数向运行时指定一种偏好,但不保证这一偏好会得到遵循。运行时可以根据可用资源和核函数的需求自行作出决定。

共享内存既可以静态分配,也可以动态分配。

2.3.3.2.1. 共享内存的静态分配

要静态分配共享内存,程序员必须在核函数内部声明变量,在 C++ 中使用__shared__说明符,在 Python 中使用cuda.shared.array()。该数组将分配在共享内存中,并在核函数执行期间持续存在。以这种方式声明的共享内存的大小必须在编译时指定。例如,下面位于核函数体中的代码片段声明了一个类型为float、包含 1024 个元素的共享内存数组。

__shared__ float sharedArray[1024];
from numba import cuda
import numpy as np

shared_array = cuda.shared.array(shape=1024, dtype=np.float32)

完成此声明后,线程块内的所有线程都可以访问这个共享内存数组。

2.3.3.2.2. 共享内存的动态分配

要在 C++ 中动态分配共享内存,程序员可以在核函数启动的三重尖括号记法中,将每个线程块所需的共享内存字节数指定为第三个(可选的)参数,如functionName<<<grid, block, sharedMemoryBytes>>>()所示。未指定此参数时,其默认值为 0。

在 Python 中,必须使用cuda.core.launch()来启动核函数。LaunchConfig参数接受一个cuda.core.LaunchConfig对象,该对象具有一个名为shmem_size的字段,其作用与 C++ 中三重尖括号记法的第三个参数相同。

在核函数内部,程序员可以在 C++ 中使用extern __shared__说明符和空的[]来声明一个将在核函数启动时动态分配的变量。在 Python 中,则使用与静态分配共享内存时相同的cuda.shared.array方法,并将shape参数设为 0。

extern __shared__ float sharedArray[];
from numba import cuda
import numpy as np

## setting shape=0 indicates that this array will be dynamically sized by the kernel launch's execution configuration
shared_array = cuda.shared.array(shape=0, type=np.float32)

需要注意的一点是,一个核函数只能有一个动态分配的共享数组。如果需要多个动态分配的共享内存数组,则必须分配一个足够大、能够容纳所有所需数组的动态共享内存数组,并手动对其进行分区。例如,在 C++ 中,如果希望获得与以下代码等效的形式,

short array0[128];
float array1[64];
int   array2[256];

并将其置于动态分配的共享内存中,则可以按以下方式声明并初始化这些数组:

extern __shared__ float array[];

short* array0 = (short*)array;
float* array1 = (float*)&array0[128];
int*   array2 =   (int*)&array1[64];

注意,指针需要按照其所指向的类型进行对齐,因此,例如下面的代码无法正常工作,因为array1没有按 4 字节对齐。

extern __shared__ float array[];
short* array0 = (short*)array;
float* array1 = (float*)&array0[127];

在 Python 中,由于没有指针,因此无法使用这种形式的类型双关。

2.3.3.3. 寄存器

寄存器位于 SM 上,具有线程局部作用域。寄存器的使用由编译器管理,在核函数执行期间用于线程局部存储。可以使用 GPU 的regsPerMultiprocessor和regsPerBlock设备属性查询每个 SM 的寄存器数量以及每个线程块的寄存器数量。

编译 C++ 代码时,NVCC 允许开发者通过-maxrregcount选项为核函数指定寄存器数量上限。使用此选项减少核函数可以使用的寄存器数量,可能会使更多线程块被调度到 SM 上并发执行,但也可能导致更多寄存器溢出。寄存器溢出是指,为了给其他值腾出空间,必须将当前存储在片上寄存器中的值写出到全局内存,然后再在稍后读回。

2.3.3.4. 局部内存

局部内存与寄存器类似,是由 NVCC 管理的线程局部存储,但局部内存的物理位置位于全局内存空间中。“局部”这一名称指的是它的逻辑作用域,而不是物理位置。局部内存在核函数执行期间用于线程局部存储。编译器可能放入局部内存的自动变量包括:

  • 编译器无法确认其索引使用常量的数组,

  • 会占用过多寄存器空间的大型结构体或数组,

  • 当核函数使用的寄存器数量超过可用数量时的任何变量,即发生寄存器溢出。

由于局部内存空间位于设备内存中,局部内存访问具有与全局内存访问相同的延迟和带宽,并且受到合并的全局内存访问中所述的相同内存访问合并要求的约束。不过,局部内存的组织方式使得连续的线程 ID 访问连续的 32 位字。因此,只要线程束中的所有线程访问相同的相对地址,例如数组变量中的同一索引或结构体变量中的同一成员,访问就会完全合并。

2.3.3.5. 常量内存

常量内存具有网格作用域,并且在应用程序的整个生命周期内均可访问。常量内存位于设备上,对核函数而言是只读的。

在 C++ 中,变量或数组使用__constant__说明符在任何核函数或函数之外声明。

在 Python 中,在核函数代码内部指定const_array = numba.cuda.const.array_like(ary)方法,以创建一个常量内存数组,其中包含存储在主机数组ary中的数据

常量内存意味着变量:

  • 位于常量内存空间中,

  • 在每个设备上都有一个独立的对象,

  • 可由网格中的所有线程访问,并且在 C++ 中可由主机通过运行时库(cudaGetSymbolAddress() / cudaGetSymbolSize() / cudaMemcpyToSymbol() / cudaMemcpyFromSymbol())访问。

在 C++ 中,常量内存的生命周期与创建它的上下文相同。在 Python 中,常量内存的生命周期与声明它的核函数相同。

可以通过totalConstMem设备属性成员查询常量内存的总量。

常量内存适用于每个线程都会以只读方式使用的少量数据。与其他内存相比,常量内存较小,通常每个设备为 64KB。

下面是一个声明和使用常量内存的示例代码片段。

// In your .cu file
__constant__ float coeffs[4];

__global__ void compute(float *out) {
    int idx = threadIdx.x;
    out[idx] = coeffs[0] * idx + coeffs[1];
}

// In your host code
float h_coeffs[4] = {1.0f, 2.0f, 3.0f, 4.0f};
cudaMemcpyToSymbol(coeffs, h_coeffs, sizeof(h_coeffs));
compute<<<1, 10>>>(device_out);
from numba import cuda
import numpy as np

host_array = np.zeros(128, dtype=np.float32)
## fill host_array with other data

@cuda.jit
def kernel(args):
    ...

    const_array = cuda.const.array_like(a)

    # this access now goes through constant memory
    a = const_array[cuda.threadIdx.x]

2.3.3.6. 缓存

GPU 设备具有多级缓存结构,其中包括 L2 和 L1 缓存。

L2 缓存位于设备上,由所有 SM 共享。可以通过cudaGetDeviceProperties函数返回的l2CacheSize设备属性成员查询 L2 缓存的大小。

如上文共享内存中所述,L1 缓存在物理上位于每个 SM 上,与共享内存使用的是同一物理空间。如果核函数未使用任何共享内存,则整个物理空间都将由 L1 缓存使用。

可以通过一些函数控制 L2 和 L1 缓存,这些函数允许开发者指定各种缓存行为。有关这些函数的详细信息,请参阅配置 L1/共享内存平衡、L2 缓存控制和低级加载和存储函数。

如果不使用这些提示,编译器和运行时将尽力高效地利用缓存。

2.3.3.7. 纹理和表面内存

注意

一些较旧的 CUDA 代码可能会使用纹理内存,因为在较旧的 NVIDIA GPU 上,这样做在某些场景下可以带来性能收益。在当前所有受支持的 GPU 上,这些场景可以使用直接加载和存储指令来处理,使用纹理和表面内存指令对于非纹理加载不再提供任何性能收益。

GPU 可能具有专用指令,用于从图像中加载数据,作为 3D 渲染中的纹理使用。CUDA 通过纹理对象 API和表面对象 API公开这些指令以及使用这些指令的机制。

在当前任何受支持的 NVIDIA GPU 上,纹理和表面内存都不会为 CUDA 中的非图形应用程序提供任何性能优势。在为渲染读取纹理或表面数据时,这些 API 仍然有用,例如为NVIDIA OptiX编写命中着色器,NVIDIA OptiX 使用 CUDA 作为其着色器语言。

对于正在维护仍使用这些 API 进行非纹理加载的现有代码库的开发者,仍可在旧版CUDA C++ 编程指南中找到这些 API 的说明。

2.3.3.8. 分布式共享内存

注意

分布式共享内存是使用线程块簇时可用的一项功能。线程块簇使用cooperative_groups API,这些 API 目前仅在 C++ 中可用。

线程块簇在计算能力 9.0 中引入,并由协作组提供支持,使线程块簇中的线程能够访问该簇中所有参与线程块的共享内存。这种分区式共享内存称为分布式共享内存,相应的地址空间称为分布式共享内存地址空间。属于线程块簇的线程可以在分布式地址空间中读取、写入或执行原子操作,无论该地址属于本地线程块还是远程线程块。无论核函数是否使用分布式共享内存,共享内存大小的指定方式,无论是静态还是动态,仍然以每个线程块为单位。分布式共享内存的大小就是每个簇中的线程块数量乘以每个线程块的共享内存大小。

访问分布式共享内存中的数据要求所有线程块都存在。用户可以使用cluster_group 类中的cluster.sync()来保证所有线程块均已开始执行。用户还需要确保所有分布式共享内存操作都发生在线程块退出之前,例如,如果某个远程线程块正尝试读取给定线程块的共享内存,程序需要确保远程线程块完成对该共享内存的读取后,该给定线程块才能退出。

下面来看一个简单的直方图计算,以及如何在 GPU 上使用线程块簇来优化它。计算直方图的一种标准方法是在每个线程块的共享内存中执行计算,然后执行全局内存原子操作。这种方法的一个限制是共享内存容量。一旦共享内存无法再容纳直方图桶,用户就需要直接在全局内存中计算直方图,因而也需要在全局内存中执行原子操作。借助分布式共享内存,CUDA 提供了一个中间步骤,根据直方图桶的大小,可以在共享内存、分布式共享内存或直接在全局内存中计算直方图。

下面的 CUDA 核函数示例展示了如何根据直方图桶的数量,在共享内存或分布式共享内存中计算直方图。

#include <cooperative_groups.h>

// Distributed Shared memory histogram kernel
__global__ void clusterHist_kernel(int *bins, const int nbins, const int bins_per_block, const int *__restrict__ input,
                                    size_t array_size)
{
    extern __shared__ int smem[];
    namespace cg = cooperative_groups;
    int tid = cg::this_grid().thread_rank();

    // Cluster initialization, size and calculating local bin offsets.
    cg::cluster_group cluster = cg::this_cluster();
    unsigned int clusterBlockRank = cluster.block_rank();
    int cluster_size = cluster.dim_blocks().x;

    for (int i = threadIdx.x; i < bins_per_block; i += blockDim.x)
    {
    smem[i] = 0; //Initialize shared memory histogram to zeros
    }

    // cluster synchronization ensures that shared memory is initialized to zero in
    // all thread blocks in the cluster. It also ensures that all thread blocks
    // have started executing and they exist concurrently.
    cluster.sync();

    for (int i = tid; i < array_size; i += blockDim.x * gridDim.x)
    {
    int ldata = input[i];

    //Find the right histogram bin.
    int binid = ldata;
    if (ldata < 0)
        binid = 0;
    else if (ldata >= nbins)
        binid = nbins - 1;

    //Find destination block rank and offset for computing
    //distributed shared memory histogram
    int dst_block_rank = (int)(binid / bins_per_block);
    int dst_offset = binid % bins_per_block;

    //Pointer to target block shared memory
    int *dst_smem = cluster.map_shared_rank(smem, dst_block_rank);

    //Perform atomic update of the histogram bin
    atomicAdd(dst_smem + dst_offset, 1);
    }

    // cluster synchronization is required to ensure all distributed shared
    // memory operations are completed and no thread block exits while
    // other thread blocks are still accessing distributed shared memory
    cluster.sync();

    // Perform global memory histogram, using the local distributed memory histogram
    int *lbins = bins + cluster.block_rank() * bins_per_block;
    for (int i = threadIdx.x; i < bins_per_block; i += blockDim.x)
    {
    atomicAdd(&lbins[i], smem[i]);
    }
}

上述核函数可以在运行时启动,簇大小取决于所需的分布式共享内存量。如果直方图足够小,仅一个线程块的共享内存就能容纳,用户可以使用簇大小 1 启动核函数。下面的代码片段展示了如何根据共享内存需求动态启动簇核函数。

// Launch via extensible launch
{
    cudaLaunchConfig_t config = {0};
    config.gridDim = array_size / threads_per_block;
    config.blockDim = threads_per_block;

    // cluster_size depends on the histogram size.
    // ( cluster_size == 1 ) implies no distributed shared memory, just thread block local shared memory
    int cluster_size = 2; // size 2 is an example here
    int nbins_per_block = nbins / cluster_size;

    //dynamic shared memory size is per block.
    //Distributed shared memory size =  cluster_size * nbins_per_block * sizeof(int)
    config.dynamicSmemBytes = nbins_per_block * sizeof(int);

    CUDA_CHECK(::cudaFuncSetAttribute((void *)clusterHist_kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, config.dynamicSmemBytes));

    cudaLaunchAttribute attribute[1];
    attribute[0].id = cudaLaunchAttributeClusterDimension;
    attribute[0].val.clusterDim.x = cluster_size;
    attribute[0].val.clusterDim.y = 1;
    attribute[0].val.clusterDim.z = 1;

    config.numAttrs = 1;
    config.attrs = attribute;

    cudaLaunchKernelEx(&config, clusterHist_kernel, bins, nbins, nbins_per_block, input, array_size);
}

2.3.4. 内存性能

确保正确使用内存是 CUDA 核函数实现高性能的关键。本节讨论在 CUDA 核函数中从全局内存和共享内存获得高内存吞吐量的一些一般原则和示例。对于大多数核函数,全局内存性能是首要的性能考虑因素。当线程操作并非由其显式加载或创建的数据时,通常会使用共享内存,因此理解共享内存与性能相关的特征也很重要。

以下各小节将通过逐步改进一个矩阵转置示例核函数对全局内存和共享内存的访问,说明内存访问的重要方面。

2.3.4.1. 合并的全局内存访问

全局内存通过 32 字节的内存事务进行访问。当 CUDA 线程从全局内存请求一个字的数据时,相关线程束会根据每个线程访问的字的大小以及各线程所访问内存地址的分布,将该线程束中所有线程的内存请求合并成满足该请求所需数量的内存事务。例如,如果一个线程请求一个 4 字节的字,该线程束实际向全局内存发起的内存事务总计将为 32 字节。为了最高效地使用内存系统,线程束应使用单次内存事务所取回的全部内存。也就是说,如果一个线程正在从全局内存请求一个 4 字节的字,而事务大小为 32 字节,那么,如果该线程束中的其他线程能够使用该 32 字节请求中的其他 4 字节数据字,就能最高效地利用内存系统。

举一个简单的例子,如果线程束中连续的线程请求内存中连续的 4 字节字,那么该线程束总共将请求 128 字节的内存,所需的这 128 字节将通过四个 32 字节的内存事务取回。这使得线程束中的线程对内存事务的利用率达到 100%。图 12展示了这个完全合并的内存访问示例。

合并内存访问

图 12 合并的内存访问

图内文字中文对照
Addresses requested by the threads in the warp
线程束中的线程请求的地址

相反,极端的最坏情况是连续的线程访问在内存中彼此相隔 32 字节或更多的数据元素。在这种情况下,线程束将被迫为每个线程发起一个 32 字节的内存事务,内存流量的总字节数将为 32 字节乘以 32 线程/线程束 = 1024 字节。然而,实际使用的内存量仅为 128 字节(线程束中每个线程使用 4 字节),因此内存利用率仅为 128 / 1024 = 12.5%。这是一种效率非常低的内存系统使用方式。图 13展示了这个未合并的内存访问示例。

非合并内存访问

图 13 未合并的内存访问

图内文字中文对照
Addresses requested by the threads in the warp
线程束中的线程请求的地址

实现合并的内存访问最直接的方法,是让连续的线程访问内存中连续的元素。例如,对于以一维线程块启动的核函数,前面展示的向量加法核函数将实现合并的内存访问。请注意线程访问这三个数组的方式:连续的线程(由workIndex的连续值表示)访问数组中的连续元素。

__global__ void vecAdd(float* A, float* B, float* C, int vectorLength)
{
    int workIndex = threadIdx.x + blockIdx.x*blockDim.x;
    if(workIndex < vectorLength)
    {
        C[workIndex] = A[workIndex] + B[workIndex];
    }
}
## Defines a CUDA kernel to perform C = A + B vector addition
@cuda.jit
def vecadd(A, B, C):
    work_index = cuda.grid(1)
    C[work_index] = A[work_index] + B[work_index]


实现合并的内存访问并不要求连续的线程访问连续的内存元素,这仅仅是实现合并的典型方式。只要线程束中的不同线程以某种线性或置换的方式访问同一批 32 字节内存段中的元素,就会发生合并的内存访问。换句话说,实现合并的内存访问的最佳方法,是最大化已使用字节数与已传输字节数的比值。

理解全局内存访问合并的一种等价方式,是考虑需要多少次全局内存事务才能满足单个线程束的一条加载指令所请求的 32 个地址。在可能的最佳情况下,只需一次全局内存事务即可满足所有加载。对于对 4 字节数据元素的完全合并访问,需要 4 次全局内存事务。在最坏情况下,可能需要 32 次全局内存事务才能满足单个线程束的一条加载指令所请求的地址。一般而言,满足一次加载所需的全局内存事务数量尽可能少时,性能最佳。

注意

确保全局内存访问正确合并,是编写高性能 CUDA 核函数时最重要的性能考虑因素之一。应用程序必须尽可能高效地使用内存系统。

2.3.4.1.1. 使用全局内存的矩阵转置示例

举一个简单的例子,考虑一个非原地矩阵转置核函数,它将一个大小为 N x N 的 32 位浮点方阵从矩阵a转置到矩阵c。此示例使用二维网格,并假设启动大小为 32 x 32 个线程的二维线程块,即blockDim.x = 32和blockDim.y = 32,因此每个二维线程块将操作矩阵的一个 32 x 32 分块(tile)。每个线程操作矩阵中的一个唯一元素,因此不需要显式地同步线程。图 14展示了这一矩阵转置操作。核函数源代码位于图后。

使用全局内存进行矩阵转置

图 14 使用全局内存的矩阵转置

每个矩阵顶部和左侧的标签是二维线程块索引,也可以视为分块索引,其中每个小方格表示矩阵的一个分块,将由一个二维线程块对其进行操作。在此示例中,分块大小为 32 x 32 个元素,因此每个小方格都代表矩阵的一个 32 x 32 分块。绿色阴影方格显示了一个示例分块在转置操作前后的位置。

/* macro to index a 1D memory array with 2D indices in row-major order */
/* ld is the leading dimension, i.e. the number of columns in the matrix     */

#define INDX( row, col, ld ) ( ( (row) * (ld) ) + (col) )

/* CUDA kernel for matrix transpose */

__global__ void cuda_transpose(int m, float *a, float *c )
{
    int myCol = blockDim.x * blockIdx.x + threadIdx.x;
    int myRow = blockDim.y * blockIdx.y + threadIdx.y;

    if( myRow < m && myCol < m )
    {
        c[INDX( myCol, myRow, m )] = a[INDX( myRow, myCol, m )];
    } /* end if */
    return;
} /* end cuda_transpose */
import numpy as np
from numba import cuda
import cupy as cp

## Matrix transpose kernel, one thread per matrix element launched with 
## 2D thread block on 2D grid to match matrix size
@cuda.jit
def transpose(a, c):
    col = cuda.blockDim.x * cuda.blockIdx.x + cuda.threadIdx.x
    row = cuda.blockDim.y * cuda.blockIdx.y + cuda.threadIdx.y
    c[(col,row)] = a[(row,col)]


要确定此核函数是否实现了合并的内存访问,需要确定连续的线程是否正在访问连续的内存元素。在二维线程块中,x索引变化最快,因此threadIdx.x的连续值应当访问连续的内存元素。threadIdx.x出现在myCol中,可以观察到,当myCol是INDX宏的第二个参数时,连续的线程正在读取a的连续值,因此对a的读取是完全合并的。

然而,对c的写入并未合并,因为threadIdx.x的连续值(再次查看myCol)向c写入的元素彼此相隔ld(主维度)个元素。出现这一情况,是因为现在myCol是INDX宏的第一个参数,而当INDX的第一个参数递增 1 时,内存位置就会改变ld。当ld大于 32 时(只要矩阵大小大于 32 就会出现这种情况),这就等同于图 13中所示的极端情况。

为了缓解这些未合并的写入,可以使用共享内存,下一节将对此进行介绍。

2.3.4.2. 共享内存访问模式

共享内存具有 32 个存储体,其组织方式使得连续的 32 位字映射到连续的存储体。每个存储体的带宽为每时钟周期 32 位。

当同一线程束中的多个线程尝试访问同一存储体中的不同元素时,就会发生存储体冲突。在这种情况下,对该存储体中数据的访问将串行化,直到请求该存储体中数据的所有线程都已获得数据。这种访问串行化会导致性能损失。

当同一线程束中的多个线程访问(读取或写入)同一个共享内存位置时,这种情况会出现两种例外。对于读访问,该字会广播给请求它的线程。对于写访问,每个共享内存地址只由其中一个线程写入(由哪个线程执行写入是未定义的)。

图 15展示了一些跨步访问的示例。存储体内的红色方框表示共享内存中的一个唯一位置。

32 位存储体大小模式下的跨步共享内存访问。

图 15 32 位存储体大小模式下的跨步共享内存访问。

左图

步长为一个 32 位字的线性寻址(无存储体冲突)。

中图

步长为两个 32 位字的线性寻址(两路存储体冲突)。

右图

步长为三个 32 位字的线性寻址(无存储体冲突)。

图内文字中文对照
Threads:
线程:
Banks:
存储体:

图 16展示了一些涉及广播机制的内存读访问示例。存储体内的红色方框表示共享内存中的一个唯一位置。如果多个箭头指向同一个位置,数据就会广播给所有请求它的线程。

不规则共享内存访问。

图 16 不规则共享内存访问。

左图

通过随机置换实现的无冲突访问。

中图

由于线程 3、4、6、7 和 9 访问存储体 5 中的同一个字,因此访问无冲突。

右图

无冲突的广播访问(线程访问某个存储体内的同一个字)。

图内文字中文对照
Threads:
线程:
Banks:
存储体:

注意

在编写使用共享内存的高性能 CUDA 核函数时,避免存储体冲突是一项重要的性能考量。

2.3.4.2.1. 使用共享内存的矩阵转置示例

前面的使用全局内存的矩阵转置示例展示了一种矩阵转置实现,该实现在功能上正确,但没有针对全局内存的高效使用进行优化,因为对c矩阵的写入没有得到恰当的合并。在本示例中,共享内存将被用作用户管理的缓存,暂存全局内存的加载和存储操作所涉及的数据,从而使全局内存的读访问和写访问都成为合并访问。

#define THREADS_PER_BLOCK_X 32
#define THREADS_PER_BLOCK_Y 32

/* macro to index a 1D memory array with 2D indices in column-major order */
/* ld is the leading dimension, i.e. the number of rows in the matrix     */

#define INDX( row, col, ld ) ( ( (col) * (ld) ) + (row) )

/* CUDA kernel for shared memory matrix transpose */
__global__ void smem_transpose(int m,
                                    float *a,
                                    float *c )
{

    /* declare a statically allocated shared memory array */

    __shared__ float smemArray[THREADS_PER_BLOCK_X][THREADS_PER_BLOCK_Y];

    /* determine my row and column indices for the error checking code */

    const int myRow = blockDim.x * blockIdx.x + threadIdx.x;
    const int myCol = blockDim.y * blockIdx.y + threadIdx.y;

    /* determine my row tile and column tile index */

    const int tileX = blockDim.x * blockIdx.x;
    const int tileY = blockDim.y * blockIdx.y;

    if( myRow < m && myCol < m )
    {
        /* read from global memory into shared memory array */
        smemArray[threadIdx.x][threadIdx.y] = a[INDX( tileX + threadIdx.x, tileY + threadIdx.y, m )];
    } /* end if */

    /* synchronize the threads in the thread block */
    __syncthreads();

    if( myRow < m && myCol < m )
    {
        /* write the result from shared memory to global memory */
        c[INDX( tileY + threadIdx.x, tileX + threadIdx.y, m )] = smemArray[threadIdx.y][threadIdx.x];
    } /* end if */
    return;

} 
import numpy as np
from numba import cuda
import cupy as cp

## Matrix transpose kernel, one thread per matrix element launched with 
## 2D thread block on 2D grid to match matrix size
## Stages input to shared memory
@cuda.jit
def smem_transpose(a, c):
    smemArray = cuda.shared.array(shape=(32, 32), dtype=np.float32)

    tile_col = cuda.blockDim.x * cuda.blockIdx.x
    tile_row = cuda.blockDim.y * cuda.blockIdx.y

    smemArray[(cuda.threadIdx.x, cuda.threadIdx.y)] = a[(tile_row + cuda.threadIdx.y, tile_col + cuda.threadIdx.x)]

    cuda.syncthreads()
    
    c[(tile_col + cuda.threadIdx.y, tile_row + cuda.threadIdx.x)] = smemArray[(cuda.threadIdx.y, cuda.threadIdx.x)]



本示例展示的基本性能优化是确保在访问全局内存时,内存访问得到恰当的合并。在执行复制之前,每个线程都会计算自己的tileRow和tileCol索引。这些是将要操作的特定分块(tile)的索引,而这些分块索引取决于正在执行的是哪个线程块。同一个线程块中的每个线程都具有相同的tileRow和tileCol值,因此可以将它们视为这个特定线程块将要操作的分块的起始位置。

随后,核函数让每个线程块使用以下语句,将矩阵的一个 32 x 32 分块从全局内存复制到共享内存。由于一个线程束包含 32 个线程,此复制操作将由 32 个线程束执行,线程束之间的执行顺序不作保证。

smemArray[threadIdx.x][threadIdx.y] = a[INDX( tileRow + threadIdx.y, tileCol + threadIdx.x, m )];
smemArray[(cuda.threadIdx.x, cuda.threadIdx.y)] = a[(tile_row + cuda.threadIdx.y, tile_col + cuda.threadIdx.x)]

注意,由于threadIdx.x出现在INDX的第二个参数中,连续的线程会访问内存中的连续元素,因此对a的读取是完全合并的。在 Python 中,使用cuda.threadIdx.x 作为元组的最后一个索引也有相同效果:对 a的访问是完全合并的。

核函数的下一步是调用__syncthreads()/cuda.syncthreads()。这可确保线程块中的所有线程在继续执行之前,都已完成前面代码的执行,因此可以确保在下一步之前,已完成将a写入共享内存的操作。这一点至关重要,因为下一步将涉及线程从共享内存中读取数据。如果没有__syncthreads()/cuda.syncthreads()调用,就无法保证在某些线程束继续从共享内存中的数组读取数据之前,线程块中的所有线程束都已完成将a读取到共享内存的操作。当线程处理或存储不是由它加载的数据时,就必须进行同步,以确保在访问该元素之前已完成其加载操作。

核函数执行到此处时,对于每个线程块,共享内存数组中都包含矩阵的一个 32 x 32 分块,其排列顺序与原矩阵相同。为了确保分块内的元素正确转置,在从smemArray读取时会交换threadIdx.x和threadIdx.y。为了确保整个分块被放置在c中的正确位置,在写入c时也会交换tileRow和tileCol索引。为了确保恰当的合并访问,threadIdx.x用于INDX的第二个参数,如下方语句所示。在 Python 中,这同样通过在矩阵索引元组的最后一个索引中使用cuda.threadIdx.x来实现。

c[INDX( tileCol + threadIdx.y, tileRow + threadIdx.x, m )] = smemArray[threadIdx.y][threadIdx.x];
c[(tile_col + cuda.threadIdx.y, tile_row + cuda.threadIdx.x)] = smemArray[(cuda.threadIdx.y, cuda.threadIdx.x)]

此核函数展示了共享内存的两种常见用途。

  • 共享内存用于暂存来自全局内存的数据,以确保全局内存的读取和写入都得到恰当的合并。

  • 共享内存用于让同一线程块中的线程彼此共享数据。

2.3.4.2.2. 共享内存存储体冲突

第 2.3.4.2 节介绍了共享内存的存储体结构。在前面的矩阵转置示例中,对全局内存的读写已实现恰当的合并内存访问,但没有考虑是否存在共享内存存储体冲突。考虑以下二维共享内存声明,

__shared__ float smemArray[32][32];
from numba import cuda
import numpy as np

smemArray = cuda.shared.array(shape=(32,32), dtype=np.float32)

假设预期以包含 32 x 32 个线程的二维线程块来启动核函数。由于一个线程束包含 32 个线程,特定线程束中的每个线程都将具有固定的threadIdx.y值,并且具有0 <= threadIdx.x < 32。

图 17的左图展示了线程束中的线程访问smemArray某一列数据时的情况。线程束 0 正在访问从smemArray[0][0]到smemArray[31][0]的内存位置(在 Python 中为从smemArray[(0,0)]到smemArray[(31,0)])。在 C++ 和 Python 的多维数组排列顺序中,最后一个索引变化最快,因此线程束 0 中连续的线程访问的内存位置相隔 32 个元素。如图所示,颜色表示存储体,而线程束 0 沿整列进行的这种访问会导致 32 路存储体冲突。

图 17的右图展示了线程束中的线程沿smemArray某一行访问数据时的情况。线程束 0 正在访问从smemArray[0][0]到smemArray[0][31]的内存位置(在 Python 中为从smemArray[(0,0)]到smemArray[(0,31)])。在这种情况下,线程束 0 中连续的线程访问的是相邻的内存位置。如图所示,颜色表示存储体,而线程束 0 沿整行进行的这种访问不会导致存储体冲突。理想情况是,线程束中的每个线程都访问一种不同颜色所对应的共享内存位置。

共享内存中的存储体结构

图 17 32 x 32 共享内存数组中的存储体结构。

方框中的数字表示线程束索引。颜色表示该共享内存位置对应哪个存储体。

图内文字中文对照
Warp index
线程束索引

回到第 2.3.4.2.1 节中的示例,可以检查共享内存的使用方式,以确定是否存在存储体冲突。第一次使用共享内存是在将来自全局内存的数据存储到共享内存时:

smemArray[threadIdx.x][threadIdx.y] = a[INDX( tileRow + threadIdx.y, tileCol + threadIdx.x, m )];
smemArray[(cuda.threadIdx.x, cuda.threadIdx.y)] = a[(tile_row + cuda.threadIdx.y, tile_col + cuda.threadIdx.x)]

由于数组按行优先顺序存储,同一线程束中由threadIdx.x的连续值所表示的连续线程,将以 32 个元素的步长访问smemArray,因为threadIdx.x是数组的第一个索引。这会导致 32 路存储体冲突,如图 17的左图所示。

第二次使用共享内存是在将来自共享内存的数据写回全局内存时:

c[INDX( tileCol + threadIdx.y, tileRow + threadIdx.x, m )] = smemArray[threadIdx.y][threadIdx.x];
c[(tile_col + cuda.threadIdx.y, tile_row + cuda.threadIdx.x)] = smemArray[(cuda.threadIdx.y, cuda.threadIdx.x)]

在这种情况下,由于threadIdx.x是smemArray数组的第二个索引,同一线程束中的连续线程将以 1 个元素的步长访问smemArray。这不会导致存储体冲突,如图 17的右图所示。

第 2.3.4.2.1 节所示的矩阵转置核函数中,有一次共享内存访问不存在存储体冲突,另一次访问则存在 32 路存储体冲突。避免存储体冲突的一种常见修正方法是对共享内存进行填充,将数组的列维度增加 1,如下所示:

__shared__ float smemArray[THREADS_PER_BLOCK_X][THREADS_PER_BLOCK_Y+1];
smemArray = cuda.shared.array(shape=(32, 32 + 1), dtype=np.float32)

对smemArray声明的这一小幅调整将消除存储体冲突。为了说明这一点,请看图 18,其中共享内存数组被声明为 32 x 33 的大小。可以看到,无论同一线程束中的线程是沿整列还是沿整行访问共享内存数组,存储体冲突都已消除,即同一线程束中的线程访问的是不同颜色所对应的位置。

共享内存中的存储体结构

图 18 32 x 33 共享内存数组中的存储体结构。

方框中的数字表示线程束索引。颜色表示该共享内存位置对应哪个存储体。

图内文字中文对照
Warp index
线程束索引

2.3.5. 原子操作

高性能 CUDA 核函数依赖于尽可能充分地表达算法并行性。GPU 核函数执行的异步性质要求线程尽可能独立地运行。线程并不总能完全独立,正如我们在共享内存中所见,存在一种机制,可让同一线程块中的线程交换数据并进行同步。

在整个网格层面上,没有这种机制来同步网格中的所有线程。不过,有一种机制可以通过使用原子函数来提供对全局内存位置的同步访问。原子函数允许线程获取某个全局内存位置上的锁,并对该位置执行读-修改-写操作。在持有锁期间,其他任何线程都无法访问同一位置。

2.3.5.1. 类似于 C++ std::atomic 的原子操作

在 C++ 中,CUDA 提供了与名称相似的 C++ 标准库原子类型cuda::std::atomic和cuda::std::atomic_ref类似的语法与行为。CUDA 还提供了扩展的 C++ 原子类型cuda::atomic和cuda::atomic_ref,允许用户指定原子操作的线程作用域。原子函数的详细信息在原子函数中介绍。

下面是使用cuda::atomic_ref执行设备范围原子加法的示例,其中array是一个浮点数数组,result是一个指向全局内存中某个位置的 float 指针,数组的总和将存储在该位置。

__global__ void sumReduction(int n, float *array, float *result) {
...
tid = threadIdx.x + blockIdx.x * blockDim.x;

cuda::atomic_ref<float, cuda::thread_scope_device> result_ref(result);
result_ref.fetch_add(array[tid]);
...
}

应谨慎、少量地使用原子函数,因为它们会强制线程同步,这可能影响性能。

2.3.5.2. Python 中的内存原子操作

在 Python 中,原子内存操作由numba.cuda.atomic命名空间中可供 GPU 代码使用的函数提供。可用的典型操作包括add、sub、max、min和compare_and_swap。受支持原子操作的完整列表见Numba CUDA 文档。

以下代码展示了一个使用原子内存访问来计算数组中所有值之和的核函数示例。每个线程块都将数组的一个切片加载到共享内存中。每个线程块中的一个线程计算局部和,并对结果数组s执行原子加法。由于数据位于共享内存中,而共享内存靠近 SM 的计算资源,因此让单个线程执行求和通常仍能获得相当不错的性能。

import numpy as np
from numba import cuda
import cupy as cp


@cuda.jit
def sum_reduce(a, s):
    ## create a shared array to support a block size up to 512 threads
    ## even though we'll use fewer in this example
    shared_staging = cuda.shared.array(shape=512, dtype=np.float32)

    ## Load values into shared memory and then synchronize to make sure all loads completed
    shared_staging[cuda.threadIdx.x] = a[cuda.blockIdx.x*cuda.blockDim.x + cuda.threadIdx.x]
    cuda.syncthreads()

    ## only thread 0 of each block does the local additions, followed by a single
    ## atomic operation per thread block
    local_sum = float(0.0)
    if cuda.threadIdx.x == 0:
        for i in range(cuda.blockDim.x):
            local_sum = local_sum + shared_staging[i]
        cuda.atomic.add(s, 0, local_sum)
    

array_length = 2**18

a = cp.ones(array_length)
s = cp.zeros(1, dtype=np.float32)

block_size = 256
grid_size = int(array_length/block_size)
sum_reduce[grid_size, block_size](a, s)

s_host = cp.asnumpy(s)
print(f"Sum is {int(s_host[0])}, expected {array_length}")

在这个简单示例中,输入数组的元素全为 1,因此正确的总和与array_length相同。

如果将这一行

cuda.atomic.add(s, 0, local_sum)

替换为非原子加法

s[0] = s[0] + local_sum

则对s[0]的访问将不是原子的,s[0]的最终值将小于array_length。此外,该值可能会随着每次运行以及在具有不同 SM 数量的 GPU 上运行而变化。这说明了在此代码中,必须使用原子内存访问才能保证正确性。

注意

此示例虽然能够正常工作,但并非旨在演示如何在 GPU 上编写具有峰值性能的归约操作。NVIDIA 的CUDA Core Compute Libraries (CCCL)为包括归约在内的许多操作提供了高性能原语。出于开发效率和性能两方面的考虑,开发者应尽可能优先使用这些经过高度调优的实现,而不是重新实现相同的算法。通过cuda.coop 包可以在 Python 中使用这些原语。

C++ 中也提供了类似的原子操作,第 5.4.5.1 节对此进行了讨论,但建议使用类似于std::atomic的原子操作,这被视为 CUDA C++ 中的最佳实践。

2.3.6. 协作组

协作组是 CUDA C++ 中提供的一种软件工具,它允许应用程序定义能够相互同步的线程组,即使该线程组跨越多个线程块、单个 GPU 上的多个网格,甚至跨越多个 GPU。CUDA 编程模型通常允许线程块或线程块簇中的线程高效地同步,但不提供指定小于一个线程块或簇的线程组的机制。同样,CUDA 编程模型也不提供支持跨线程块同步的机制或保证。

协作组通过软件提供上述两种能力。协作组允许应用程序创建跨越线程块和簇边界的线程组,不过这样做会带来一些语义限制和性能影响,介绍协作组的特性一节对此作了详细说明。

2.3.7. 核函数启动与占用率

当 CUDA 核函数启动时,CUDA 线程会根据核函数启动时指定的执行配置,组织为线程块和一个网格。核函数启动后,调度器会将线程块分配给 SM。应用程序无法控制或查询哪些线程块被调度到哪些 SM 上执行的细节,而且调度器不提供任何顺序保证,因此程序不能依赖特定的调度顺序或方案来保证正确执行。

一个 SM 上能够调度的线程块数量取决于给定线程块所需的硬件资源,以及该 SM 上可用的硬件资源。核函数刚启动时,调度器开始将线程块分配给 SM。只要 SM 有足够的、未被其他线程块占用的硬件资源,调度器就会继续向 SM 分配线程块。如果在某个时刻没有任何 SM 有能力接纳另一个线程块,调度器就会等待,直到 SM 完成先前分配的线程块。一旦完成,SM 就可以接纳更多工作,调度器便会向它们分配线程块。这个过程会持续进行,直到所有线程块都已被调度并执行完毕。

cudaGetDeviceProperties函数允许应用程序通过设备属性查询每个 SM 的限制。请注意,每个 SM 和每个线程块都有相应的限制。

  • maxBlocksPerMultiProcessor:每个 SM 的最大驻留块数。

  • sharedMemPerMultiprocessor:每个 SM 的可用共享内存量,以字节为单位。

  • regsPerMultiprocessor:每个 SM 的可用 32 位寄存器数。

  • maxThreadsPerMultiProcessor:每个 SM 的最大驻留线程数。

  • sharedMemPerBlock:一个线程块可分配的最大共享内存量,以字节为单位。

  • regsPerBlock:一个线程块可分配的最大 32 位寄存器数。

  • maxThreadsPerBlock:每个线程块的最大线程数。

CUDA 核函数的占用率是活跃线程束数与 SM 所支持的最大活跃线程束数之比。一般来说,尽可能提高占用率是一种良好做法,这可以隐藏延迟并提高性能。

要计算占用率,需要了解刚才介绍的 SM 资源限制,还需要了解所考察的 CUDA 核函数需要哪些资源。要确定每个核函数的资源使用情况,可以在程序编译期间使用nvcc的--resource-usage选项,它会显示核函数所需的寄存器数和共享内存量。

为便于说明,以计算能力为 10.0 的设备为例,其设备属性列于表 2。

表 2 SM 资源示例

资源

值

maxBlocksPerMultiProcessor

32

sharedMemPerMultiprocessor

233472

regsPerMultiprocessor

65536

maxThreadsPerMultiProcessor

2048

sharedMemPerBlock

49152

regsPerBlock

65536

maxThreadsPerBlock

1024

如果以testKernel<<<512, 768>>>()的方式启动核函数,即每个块有 768 个线程,则每个 SM 一次只能执行 2 个线程块。由于maxThreadsPerMultiProcessor为 2048,调度器无法为每个 SM 分配超过 2 个线程块。因此,占用率为 (768 * 2) / 2048,即 75%。

如果以testKernel<<<512, 32>>>()的方式启动核函数,即每个块有 32 个线程,则每个 SM 不会达到maxThreadsPerMultiProcessor的限制,但由于maxBlocksPerMultiProcessor为 32,调度器只能为每个 SM 分配 32 个线程块。由于块中的线程数为 32,SM 上的驻留线程总数为 32 个块 * 每个块 32 个线程,即总共 1024 个线程。由于计算能力为 10.0 的 SM 上,每个 SM 的最大驻留线程数为 2048,因此这种情况下的占用率为 1024 / 2048,即 50%。

对共享内存也可以进行同样的分析。例如,如果一个核函数使用 100KB 的共享内存,调度器就只能为每个 SM 分配 2 个线程块,因为该 SM 上的第三个线程块会再需要 100KB 的共享内存,总计 300KB,这超过了每个 SM 可用的 233472 字节。

每个块的线程数和每个块的共享内存使用量均由程序员显式控制,可以通过调整来达到期望的占用率。程序员对寄存器使用情况的控制有限,因为编译器和运行时会尝试优化寄存器使用。不过,程序员可以通过nvcc的--maxrregcount选项指定每个线程块的最大寄存器数。如果核函数所需的寄存器数超过此指定数量,核函数很可能会溢出到局部内存,从而改变核函数的性能特性。在某些情况下,即使发生溢出,限制寄存器数也能让更多线程块得到调度,进而提高占用率,并可能使性能获得净提升。

2.4. 编写分块核函数

CUDA Tile 提供了一种编写 GPU 核函数代码的方法,不同于前几章介绍的单指令多线程(SIMT)模型。分块(tile)编程允许程序员以不同的方式表达并行性,并将最低层次的并行性留给编译器和内置操作处理。通过这种方式,分块为利用 NVIDIA GPU 新近的性能特性提供了一种更简单的方法,例如张量内存加速器(TMA)单元和张量核心。

  • 在 Python 中,可以通过 cuTile Python 包cuda.tile进行 CUDA Tile 编程。

  • 从 13.3 版本开始,CUDA Toolkit 提供 CUDA Tile C++。

分块核函数外围用于分配设备内存、在主机与设备之间传输数据以及安排核函数启动顺序等任务的应用程序代码,与前几章针对 SIMT 核函数介绍的代码完全相同。分块核函数对使用标准 CUDA API 分配的全局内存进行操作,其结果也以相同方式复制回主机。唯一发生变化的是程序员在核函数本身内部编写的代码。

在 SIMT 核函数中,程序员以单个线程为单位进行思考:计算全局线程索引、加载该线程的元素、对这些元素执行操作,然后存储结果。在分块核函数中,程序员在整个块的层次上进行思考:加载包含许多元素的分块、对整个分块执行操作,然后存储结果。编译器负责将分块操作映射到每个块的硬件线程,而在 SIMT 中,这一问题由程序员显式处理。

本章仅关注这一差异:如何编写核函数入口点及其内部的分块操作。每种模式都使用 CuTile Python(cuda.tile)和 CUDA Tile C++(cuda::tiles)两种语言演示,它们共用一个编译器后端(CUDA Tile IR),因此具有相同的执行语义。

按照惯例,在这两种语言中都将分块 API 的别名设为ct。

  • Python 中的import cuda.tile as ct

  • C++ 中的namespace ct = cuda::tiles

在 Python 中,分块 API 位于cuda.tiles模块中,该模块按上面所示的方式导入。

在 C++ 中,分块 API 位于cuda::tiles命名空间中,由cuda_tile.h头文件公开。

#include "cuda_tile.h"
namespace ct = cuda::tiles;

在下面的代码片段中,无论你阅读的是哪种语言,ct. / ct::前缀都指该语言中的分块 API。

2.4.1. 核函数和函数声明

分块核函数是 GPU 的入口点,会在启动网格中的每个块上执行一次。分块函数可从分块核函数或另一个分块函数中调用,但它本身不是入口点。与 SIMT 核函数一样,分块核函数不能从主机代码中直接调用;必须启动它们。

在 CUDA Tile C++ 中:

  • __tile_global__是分块中与__global__对应的形式,用于标记分块核函数入口点

  • __tile__是分块中与__device__对应的形式,表示一个应针对 GPU 编译且可从其他__tile__或__tile_global__函数中调用的函数。

数组参数和标量参数的传递方式与 SIMT 核函数相同。分块代码和 SIMT 代码可以共存:单个.cu文件可以同时定义__tile_global__和__global__核函数,单个主机程序可以启动这两种核函数。

注意

目前,不能从__global__或__device__函数中调用__tile__函数。同样,不能从__tile_global__或__tile__函数中调用__device__函数。未来版本的 CUDA 可能会解除这一限制。

在 cuTile Python 中:

  • @ct.kernel装饰器将一个函数标记为分块核函数入口点

  • @ct.function装饰器将一个函数标记为可从分块核函数或另一个分块函数中调用的函数。

实际上,从核函数中调用的任何函数都会自动编译为分块代码,因此@ct.function装饰器是可选的。数组参数接受任何驻留在设备上且公开 DLPack 或 CUDA Array Interface 的数组。例如,PyTorch 张量和 CuPy 数组。标量参数直接传递。

#include "cuda_tile.h"

// Tile kernel entry point. Cannot be called directly; must be launched.
__tile_global__ void my_kernel(float* a, float* b, float* c) {
    ...
}

// Tile function. Callable from tile kernels and tile functions.
__tile__ float helper(float x, float y) {
    return x + y;
}
import cuda.tile as ct

# Tile kernel entry point. Cannot be called directly; must be launched.
@ct.kernel
def my_kernel(a, b, c):
    ...

# Tile function. Callable from tile kernels and tile functions.
# @ct.function is optional, any function called from tile code
# is automatically compiled as tile code.
@ct.function
def helper(x, y):
    return x + y

2.4.2. 启动核函数

分块核函数在由分块线程块组成的网格上启动,正如 SIMT 核函数在由线程块组成的网格上启动一样。程序员指定网格形状,最多为三个维度。从程序员的角度看,每个分块线程块都由单个逻辑线程执行。块内的并行性由编译器管理。

在 C++ 中,分块核函数复用 SIMT 中熟悉的三重尖括号启动语法。尖括号中的第一个参数是网格形状(分块线程块的数量)。第二个参数是 SIMT 中每个块的线程数;对于分块核函数,编译器在内部确定线程数,第二个参数必须为1。分块核函数也是普通的 CUDA 核函数,因此可以通过运行时现有的 APIcudaLaunchKernel和cudaLaunchKernelEx使用相同的grid, 1配置来启动它。当要将分块核函数集成到已经通过这些 API 执行启动的代码库中时,这一点很有用。

在 Python 中,ct.launch接受四个位置参数:一个 CUDA 流、一个指定每个维度上分块线程块数量的网格元组、核函数对象,以及一个核函数参数元组。

my_kernel<<<dim3(num_blocks_x, num_blocks_y), 1>>>(a, b, c);  // second arg must be 1
import torch

stream = torch.cuda.current_stream()     # CUDA stream object
grid = (num_blocks_x, num_blocks_y, 1)   # tile-block grid (x, y, z)
ct.launch(stream, grid, my_kernel, (a, b, c))

2.4.2.1. 网格大小设置模式

一种常见模式是启动足够多的块以覆盖整个数组,其中最后一个块可能在一个或多个维度上超出数组的大小。

int num_blocks = (N + tile_size - 1) / tile_size;   // ceil division -> covers partial tail
kernel<<<num_blocks, 1>>>(in, out, N);
import math

grid = (math.ceil(N / TILE),)   # ceil division -> covers partial tail
ct.launch(stream, grid, my_kernel, (arr_in, arr_out, TILE))

第 2.4.6 节的各小节讨论了如何处理数组大小不能被分块大小整除的情况。

2.4.3. 查询块的位置

每个块都需要知道自己位于网格中的什么位置,以便确定要处理哪一部分数据。在 SIMT 中,程序员结合blockIdx和threadIdx计算全局线程索引。在分块代码中,只需要块索引。编译器会处理块内所有线程级别的索引计算。

在 C++ 中,ct::bid()返回一个uint3,其中包含所有三个维度上的块索引。ct::num_blocks()返回一个dim3,其中包含每个维度上的块总数(由核函数启动参数确定)。通过.x、.y、.z访问各个分量。

在 Python 中,ct.bid(axis)以int32标量的形式返回当前块在给定轴(0、1 或 2)上的索引。ct.num_blocks(axis)返回该轴上的块总数,这对于边界检查和循环计数很有用。

#include "cuda_tile.h"

__tile_global__ void my_kernel(float* a, float* b, float* c) {
    namespace ct = cuda::tiles;
    int bid_x = ct::bid().x;          // block index along .x
    int bid_y = ct::bid().y;          // block index along .y
    int num_x = ct::num_blocks().x;   // total blocks along .x
}
@ct.kernel
def my_kernel(a, b, c):
    bid_x = ct.bid(0)          # block index along axis 0
    bid_y = ct.bid(1)          # block index along axis 1
    num_x = ct.num_blocks(0)   # total blocks along axis 0

2.4.4. 创建分块

确定块的标识之后,下一个问题是分块核函数实际操作的对象是什么。答案就是分块:它是大小固定的多维标量元素数组,其形状和元素类型在编译时已知。分块每个维度的大小都必须是 2 的幂。分块具有值语义。这意味着复制分块会复制其元素,且两个副本完全独立。尽管如此,复制的开销很小,因为编译器控制着分块在硬件内部的表示方式。程序员无需为分块分配或释放内存。

实际上,分块的创建方式有两种:从数组中加载数据(分块空间中的加载和存储),或使用工厂函数生成按指定模式填充的分块。

在 C++ 中,分块类型是显式的:ct::tile<T, ct::shape<dims...>>,其中T是元素类型,ct::shape<dims...>将各维度编码为模板参数(这些整数值是各轴上在编译时确定的大小)。例如,ct::tile<float, ct::shape<8>>是一个包含 8 个浮点数的一维分块,而ct::tile<float, ct::shape<4, 4>>是一个 4×4 的浮点数分块。由于形状是类型的一部分,因此它始终在编译时已知。

工厂函数接受完整的分块类型(下文中的Tile)作为模板参数:

  • ct::zeros<Tile>()和ct::ones<Tile>():用零或一填充的分块。

  • ct::full<Tile>(val):每个元素的值均为val的分块。

  • ct::iota<Tile>():包含(0, 1, ..., N-1)的分块,其中N是分块的大小。

本章所有 C++ 示例都使用using别名(例如using f32x4x4 = ct::tile<float, ct::shape<4, 4>>),以保持调用处的分块类型易于阅读。

在 Python 中,传给分块工厂的shape元组和dtype参数都是编译时值。Python 字面量(如(64, 64)和ct.float32)天然满足这一要求。也可以使用带有Constant注解的核函数参数来提供这些值,如下文Python Constant[T]所示。得到的分块会公开.shape、.dtype和.ndim属性,这些属性反映其编译时特性。

工厂函数包括:

  • ct.zeros(shape, dtype)和ct.ones(shape, dtype):用零或一填充的分块。

  • ct.full(shape, fill_value, dtype):用任意常量值填充的分块。

  • ct.arange(size, dtype=...):包含[0, 1, ..., size-1]的一维分块。

#include "cuda_tile.h"

__tile__ void factories() {
    namespace ct = cuda::tiles;

    using i32x8   = ct::tile<int,   ct::shape<8>>;      // 1-D: 8 ints
    using f32x4x4 = ct::tile<float, ct::shape<4, 4>>;   // 2-D: 4x4 floats

    auto z      = ct::zeros<f32x4x4>();       // all zeros
    auto o      = ct::ones<f32x4x4>();        // all ones
    auto filled = ct::full<f32x4x4>(3.14f);   // all 3.14
    auto seq    = ct::iota<i32x8>();          // {0, 1, 2, 3, 4, 5, 6, 7}
}
import cuda.tile as ct

@ct.function
def factories():
    zeros  = ct.zeros((64, 64), dtype=ct.float32)            # 64x64 tile of 0.0
    ones   = ct.ones((128,), dtype=ct.float16)               # 128-element tile of 1.0
    filled = ct.full((32, 32), 3.14, dtype=ct.float32)       # 32x32 tile of 3.14
    seq    = ct.arange(8, dtype=ct.int32)                    # [0, 1, 2, 3, 4, 5, 6, 7]

2.4.5. 编译时常量

分块编译器为分块形状、数据类型和其他结构参数的每种组合生成专门的机器代码。因此,影响所生成代码的值必须在编译时已知。也就是说,分块的形状和数据类型必须在编译时已知。创建分块使用字面量来指定分块形状和数据类型:ct.zeros((64, 64), dtype=ct.float32)和ct::tile<int, ct::shape<8>>。

也可以通过核函数接口将形状作为编译时已知的值传递,如以下各节所示。

2.4.5.1. Python Constant[T]

核函数参数上的ct.Constant[T]类型提示将其标记为常量嵌入参数。这意味着,在核函数内部每次使用该参数时,其行为都如同在相应位置直接写入了字面量值。类型实参是可选的,不带类型实参的ct.Constant会嵌入任意类型的常量。ct.Constant最常用于整数,即ct.Constant[int],用于决定分块形状和循环边界的参数。

import cuda.tile as ct

@ct.kernel
def my_kernel(TILE: ct.Constant[int]):
    # TILE is constant-embedded: wherever TILE appears, the compiler sees its
    # literal value (e.g., 128) and generates specialized code. Here TILE drives
    # the shape of a factory-built tile.
    zeros = ct.zeros((TILE,), dtype=ct.float32)

2.4.5.2. C++ integral_constant 和 _ic 字面量

在 CUDA Tile C++ 中,编译时值通过ct::integral_constant表达,这是一种将数值编码在类型本身中的类型。ct::literals命名空间中的_ic字面量提供了一种简洁的简写形式:0_ic生成一个ct::integral_constant<0>值。

接受编译时值的 API 同时接受非类型模板参数(NTTP)形式和_ic字面量形式。例如,ct::cat沿给定维度拼接两个分块,而该维度必须在编译时已知。下面两行使用相同的编译时轴调用ct::cat;它们唯一的区别在于编译时值写在什么位置:

#include "cuda_tile.h"

__tile__ void concat_demo() {
    namespace ct = cuda::tiles;
    using namespace ct::literals;

    using T = ct::tile<int, ct::shape<4, 8>>;
    T lhs = ct::full<T>(0);
    T rhs = ct::full<T>(1);

    auto a = ct::cat<0>(lhs, rhs);     // NTTP form
    auto b = ct::cat(lhs, rhs, 0_ic);  // _ic form
}

_ic字面量还经常出现在另一个地方。ct::extents和ct::shape各自都有 NTTP 形式(例如ct::extents<std::uint32_t, 4, 8>)和花括号形式。与 NTTP 形式不同,花括号形式接受运行时值,因此,当一个或多个维度仅在启动时才已知时,就应使用这种形式:编译时维度使用_ic字面量,运行时维度使用普通变量。ct::tensor_span和ct::partition_view等分块空间 API(在分块空间中的加载和存储中介绍)使用这种形式来封装此类数组:

auto shape2d = ct::extents{8_ic, length};  // 8 is compile-time; length is runtime

凡是值形式的 API 实参需要编译时值时,_ic字面量都是编译时值的统一简写形式,例如ct::cat维度或extents或shape分量。

2.4.6. 加载和存储分块

正如第 1.2.2.3.1 节最初介绍的那样,CUDA 分块编程模型中有两种关键的内存对象:分块和数组。数组是全局内存中的多维元素容器,对分块核函数的所有块可见。分块也是多维元素容器,但仅局限于 CUDA 分块代码的单个块。分块通常是数组元素的一个子集。本节讨论如何将数组中的数据加载到分块中,以便在分块核函数中使用,以及如何将分块存储回数组。

后续各节将介绍两种加载和存储分块的方法

  • 分块空间加载与存储介绍使用分块空间索引的加载和存储,这类操作使用视图对象,以可预测的模式规定数组元素如何映射到分块

  • 聚集与散布介绍使用索引分块或指针分块的加载和存储,分别用于指示加载时分块元素在数组中的来源元素,或存储时在数组中的目标元素

性能注意事项:在支持的硬件上,编译器可以将分块空间加载映射到张量内存加速器(TMA),这比逐元素聚集快得多。(有关 C++ 方面的内容,另请参阅C++ 性能提示。)

程序员必须决定加载时越界元素应取何值。在 Python 中,默认会静默丢弃越界写入;在 C++ 中,使用带掩码的变体时也会如此。

2.4.6.1. 分块空间加载与存储

使用分块空间加载时,会创建一个视图对象,指定如何将数组划分为由分块大小的区域构成的网格。这种映射称为分块空间,分块核函数可以使用分块空间索引,每次加载或存储一个区域。

分块空间加载这一概念的核心是数组的分块视图,它指定数组元素如何映射到指定大小的分块。图 19中所示的分块视图是一种分区视图,即由指定大小的互不重叠分块构成的分块空间,分块之间没有间隙。

将 10 × 16 数组划分为 2 × 4 分块时的分块空间索引

图 19 分区视图的分块空间索引。将形状为(10, 16)的二维数组划分为形状为(2, 4)的分块,生成形状为(5, 4)的分块网格。每个单元格显示其分块空间索引(i, j)。分块空间索引(1, 2)处突出显示的区域涵盖从(2, 8)到(3, 11)的元素索引。

图内文字中文对照
Array Shape: (10,16)
数组形状:(10,16)
Tile Shape: (2,4)
分块形状:(2,4)
Tile Grid: (5,4)
分块网格:(5,4)
Tile Index: (1,2)
分块索引:(1,2)
Element Indices:
元素索引:

当数组的各维度不能被完整地划分为分块时,在一个或多个维度上跨越数组边界的分块将仅被部分填充。程序员可以指定加载这些分块时的行为,第 2.4.6.1.3 节将对此进行介绍。

分区视图是可以在数组上定义的多种分块空间之一。两种语言还都提供了另外两种分块空间:

  • 以步幅间隔排列的固定形状数据块,因此这些数据块之间可能留有间隙,也可能相互重叠。C++ 中将其写作ct::strided_view;Python 则向Array.tiled_view传递traversal_steps参数。

  • 通过一个元素索引分块沿某一维度选择的数据块,用于聚集与散布中介绍的不规则访问模式。C++ 中将其写作ct::gather_scatter_view;Python 使用ct.load_advanced_indexing和ct.store_advanced_indexing。

分区视图是本章演示的唯一一种分块空间,也是Array.tiled_view默认生成的分块空间。有关其他分块空间,请参阅各语言的 API 参考(CUDA Tile C++ API 参考、cuTile Python 分块视图和高级索引)。

2.4.6.1.1. 分区视图加载与存储

结构化的分块空间加载是在全局内存与分块之间移动数据的首选方式。核函数必须首先构建一个定义分块空间的视图对象,然后根据分块空间索引,每次加载或存储一个分块。

在 C++ 中,分区视图分两个步骤构建:

  • ct::tensor_span:将原始指针与ct::extents配对,为该指针赋予多维结构。

  • ct::partition_view:将 span 划分为由固定大小的分块构成的网格,并提供在分块空间坐标中操作的.load(idx...) / .store(tile, idx...)方法。

在 Python 中,Array.tiled_view(tile_shape)返回一个TiledView,将数组划分为给定形状的分块。该视图提供接受分块空间索引的.load(index) / .store(index, tile)方法,与 C++ 的partition_view直接对应。

注意

本章中的 C++ 示例代码使用__restrict__标注指针参数,并在核函数体靠近开头的位置调用ct::assume_aligned(ptr, 16_ic)。这些都是重要的性能标注,第 2.4.12 节将进一步介绍。数值字面量(例如128_ic、8_ic)上的_ic后缀将其标记为编译期常量,如编译期常量中所介绍的那样。

__tile_global__ void vec_add(float* __restrict__ a, float* __restrict__ b, float* __restrict__ out) {
    namespace ct = cuda::tiles;
    using namespace ct::literals;

    a   = ct::assume_aligned(a,   16_ic);
    b   = ct::assume_aligned(b,   16_ic);
    out = ct::assume_aligned(out, 16_ic);

    // Step 1: attach a shape to each raw pointer. 128_ic marks 128 as a compile-time constant.
    auto aSpan = ct::tensor_span{a,   ct::extents{128_ic}};
    auto bSpan = ct::tensor_span{b,   ct::extents{128_ic}};
    auto oSpan = ct::tensor_span{out, ct::extents{128_ic}};

    // Step 2: partition each span into a tile space of fixed 8-element tiles.
    auto aView = ct::partition_view{aSpan, ct::shape{8_ic}};
    auto bView = ct::partition_view{bSpan, ct::shape{8_ic}};
    auto oView = ct::partition_view{oSpan, ct::shape{8_ic}};

    int  bx    = ct::bid().x;             // this block's tile-space index along .x
    auto aTile = aView.load(bx);          // pick the bx-th tile of a
    auto bTile = bView.load(bx);
    oView.store(aTile + bTile, bx);       // write the tile back at the bx-th position of out
}
@ct.kernel
def vec_add(a, b, c, TILE: ct.Constant[int]):
    a_view = a.tiled_view((TILE,))
    b_view = b.tiled_view((TILE,))
    c_view = c.tiled_view((TILE,))

    bid = ct.bid(0)
    a_tile = a_view.load((bid,))
    b_tile = b_view.load((bid,))
    c_view.store((bid,), a_tile + b_tile)


2.4.6.1.2. Python 单次调用加载与存储

Python 还提供了一种单次调用形式,在每次加载和存储时直接传入分块形状,而无需显式的视图对象。ct.load(array, index, shape)在给定的分块空间索引处读取给定形状的分块。ct.store(array, index, tile)是相应的写入操作。

ct.load/ct.store和Array.tiled_view表达的是相同的分块空间访问模式。区别在于分块形状在何处指定。使用Array.tiled_view时,分块形状一次性绑定到视图对象。使用ct.load/ct.store时,则在每次调用中直接提供分块形状。当多次加载和存储复用同一种划分时,首选使用tiled_view。当单次独立加载这样写更简洁时,使用ct.load/ct.store。

@ct.kernel
def vec_add(a, b, c, TILE: ct.Constant[int]):
    bid = ct.bid(0)                                    # this block's tile-space index along axis 0
    a_tile = ct.load(a, index=(bid,), shape=(TILE,))   # (index, shape) = pick the bid-th TILE-sized region of a
    b_tile = ct.load(b, index=(bid,), shape=(TILE,))
    ct.store(c, index=(bid,), tile=a_tile + b_tile)    # write the tile back to the bid-th region of c


2.4.6.1.3. 分块空间边界处理

在 C++ 中,partition_view提供不带掩码和带掩码的变体:

  • .load(idx...) / .store(tile, idx...)假定分块完全位于边界内。部分越界访问属于未定义行为。

  • .load_masked(idx...) / .store_masked(tile, idx...)能够安全地处理边缘处的不完整分块。
    • .load_masked()默认用零填充越界位置;也可以选择其他填充模式(例如为浮点分块填充 NaN)。

    • .store_masked()会静默丢弃越界写入。

当数组能够按分块大小完整划分时,首选使用不带掩码的加载和存储变体。当必须处理边界条件时,即使对于完全填满的分块,也可以使用带掩码的变体。

这也是本指南中第一个将数组维度设为运行时值的 C++ 示例。ct::extents{N}接受运行时维度,ct::extents支持编译期值(_ic)和运行时值的任意混合,因此 span 和分区视图可以封装直到核函数启动时才知道大小的数组。

在 Python 中,ct.load接受一个padding_mode参数,用于控制越界元素获得的值。两种常用模式是:

  • PaddingMode.UNDETERMINED(默认值):越界元素的值由实现决定。仍然会对分块进行边界检查,因此加载操作绝不会读取数组之外的内存;只有越界通道获得的值是未指定的。这适用于永远不会读取越界通道的情况。

  • PaddingMode.ZERO:越界元素用零填充。

对于存储操作,ct.store不需要padding_mode参数:默认情况下,它根据数组对分块进行边界检查,并静默丢弃对越界位置的写入。相同的规则也适用于tiled_view,它在创建视图时便固定了其padding_mode。

ct.load和ct.store还接受check_bounds,其默认值为True。padding_mode决定越界通道包含什么,而check_bounds则决定是否进行边界检查:使用check_bounds=False时,超出数组范围的访问属于未定义行为,会读取或覆盖相邻内存。它是上面不带掩码的 C++ .load() / .store()在 Python 中的对应形式,适用相同的使用指导。TiledView.load和TiledView.store接受同一个关键字。

__tile_global__ void edge_safe(float* __restrict__ in, float* __restrict__ out, int N) {
    namespace ct = cuda::tiles;
    using namespace ct::literals;

    in  = ct::assume_aligned(in,  16_ic);
    out = ct::assume_aligned(out, 16_ic);

    // ct::extents{N} uses a runtime dimension; 128_ic stays compile-time.
    auto inView  = ct::partition_view{ct::tensor_span{in,  ct::extents{N}}, ct::shape{128_ic}};
    auto outView = ct::partition_view{ct::tensor_span{out, ct::extents{N}}, ct::shape{128_ic}};

    int  bx   = ct::bid().x;
    auto tile = inView.load_masked(bx);    // masked load: OOB lanes default to 0
    outView.store_masked(tile, bx);        // masked store: OOB writes silently discarded
}
@ct.kernel
def edge_safe(arr_in, arr_out, TILE: ct.Constant[int]):
    bid = ct.bid(0)
    tile = ct.load(arr_in, index=(bid,), shape=(TILE,),
                   padding_mode=ct.PaddingMode.ZERO)   # OOB lanes of a partial edge tile become 0
    ct.store(arr_out, index=(bid,), tile=tile)         # OOB writes are silently discarded


在 C++ 核函数内部,.load_masked()和.store_masked()负责处理边缘处的不完整分块。在 Python 核函数内部,加载操作上的PaddingMode.ZERO确保边缘处的不完整分块用零填充,而ct.store使用其默认的边界检查,静默丢弃超出数组边界的写入。有关完整的填充模式、掩码选项和填充值,请参阅各语言的 API 参考(CUDA Tile C++ 视图填充、cuTile Python 填充模式)。

从完全位于数组之外的分块加载,或向这样的分块存储,其行为是未定义的。这里讨论的边界处理仅适用于在一个或多个维度上部分越界的分块。

2.4.6.2. 聚集与散布

分块空间加载与存储中的分块空间加载使用了分区视图,它定义了对数组的一种规则的、按块对齐的划分。当访问模式不规则或依赖于数据时,例如查找表或置换,聚集和散布操作允许从数组的非均匀且非连续元素加载分块,以及将分块存储到这些元素。.. 通过任意索引或地址。

聚集和散布操作在 C++ 和 Python 中的形式略有不同:

  • Python 使用传递给ct.gather() / ct.scatter()的整数索引分块,并内置边界检查。

  • C++ 使用传递给ct::load() / ct::store()的指针分块,其带掩码的变体ct::load_masked()和ct::store_masked()接受一个布尔掩码分块,用于处理数组边界处的分块。

在 C++ 中,聚集和散布的工作方式是构造一个指针分块,每个元素对应一个指针,然后将该指针分块传递给ct::load()或ct::store()。标量指针与整数分块之间的算术运算按元素执行,生成一个指针分块。这是在 C++ 中构造聚集/散布索引分块的标准惯用法。

在 Python 中,ct.gather加载索引分块中每个索引处的元素。边界检查默认启用:越界索引返回填充值(默认为零,可通过padding_value=配置),可以使用check_bounds=False禁用边界检查。ct.scatter在每个索引处存储一个值;越界写入会被静默丢弃,check_bounds=False在这里同样适用。

__tile_global__ void vec_add_gather(int* __restrict__ a, int* __restrict__ b, int* __restrict__ out) {
    namespace ct = cuda::tiles;
    using namespace ct::literals;
    using i32x8 = ct::tile<int, ct::shape<8>>;

    a   = ct::assume_aligned(a,   16_ic);
    b   = ct::assume_aligned(b,   16_ic);
    out = ct::assume_aligned(out, 16_ic);

    int bx       = ct::bid().x;
    auto offsets = 8 * bx + ct::iota<i32x8>();   // element-level offsets, one per lane

    // scalar pointer + int tile = tile of pointers (one pointer per offset).
    auto aPtrs = a + offsets;
    auto bPtrs = b + offsets;

    auto aTile = ct::load(aPtrs);                // gather: one load per pointer
    auto bTile = ct::load(bPtrs);
    ct::store(out + offsets, aTile + bTile);     // scatter: one store per pointer
}
@ct.kernel
def vec_add_gather(a, b, c, TILE: ct.Constant[int]):
    bid = ct.bid(0)
    indices = bid * TILE + ct.arange(TILE, dtype=ct.int32)   # one element index per lane

    a_tile = ct.gather(a, indices)                           # load a[indices[i]] per lane
    b_tile = ct.gather(b, indices)
    ct.scatter(c, indices, a_tile + b_tile)                  # store one value per index into c


2.4.6.2.1. 聚集与散布的边界处理

聚集与散布中介绍的聚集/散布操作的边界处理遵循不同的规则。

在 Python 中,ct.gather和ct.scatter默认保证边界安全。越界读取返回填充值(默认为零),越界写入则被静默丢弃。当你能够证明每个索引都在范围内时,可以禁用边界检查;这样做会使越界访问成为未定义行为。有关可选的掩码和填充值设置,请参阅 API 参考(CUDA Tile C++ 加载操作、cuTile Python 加载/存储操作)。

在 C++ 中,边界检查不是自动进行的。程序员构造一个布尔掩码(例如,将偏移量与数组长度进行比较),然后将其传递给ct::load_masked或ct::store_masked:

__tile_global__ void gather_safe(int* __restrict__ arr, int* __restrict__ out, int N) {
    namespace ct = cuda::tiles;
    using namespace ct::literals;
    using i32x8 = ct::tile<int, ct::shape<8>>;

    arr = ct::assume_aligned(arr, 16_ic);
    out = ct::assume_aligned(out, 16_ic);

    int bx       = ct::bid().x;
    auto offsets = 8 * bx + ct::iota<i32x8>();   // element-level offsets, one per lane
    auto mask    = offsets < N;                  // boolean tile: true where the offset is in-bounds

    auto ptrs = arr + offsets;                   // tile of pointers, one per offset
    auto tile = ct::load_masked(ptrs, mask, 0);  // masked lanes get the pad value 0
    ct::store_masked(out + offsets, tile, mask); // masked lanes are skipped on the store
}

2.4.7. 控制流

从程序员的角度来看,分块核函数的每个块都遵循单一的控制流路径。条件和循环边界中的标量值驱动控制流,而主体中的分块操作则由编译器分配给各个硬件线程。

并非所有控制流结构都受支持。例如,分块代码中不允许从循环内部返回。有关完整的限制列表,请参阅各语言的 API 参考(CUDA Tile C++ 一般原则、cuTile Python 控制流)。

2.4.7.1. 循环

一种常见模式是遍历数组中的分块,依次处理每个分块。

在 C++ 中,ct::irange是一个前向范围,表示从下界开始、到上界但不包含上界的递增整数序列,序列中的整数按可选的步长间隔排列。使用ct::irange可以向编译器提供关于迭代边界的结构化信息,这些信息可用于更好地优化生成的代码。要使该优化生效,循环变量必须通过遍历ct::irange的基于范围的 for 表达式进行绑定。

在 Python 中,分块代码支持内置的range()、for、while以及嵌套循环。

步长参数必须严格大于零;不支持负步长范围。

以下单块核函数对一维数组的所有分块求和:

__tile_global__ void tile_sum(float* __restrict__ arr, float* __restrict__ out, int num_tiles) {
    namespace ct = cuda::tiles;
    using namespace ct::literals;
    using f32x8 = ct::tile<float, ct::shape<8>>;

    arr = ct::assume_aligned(arr, 16_ic);
    out = ct::assume_aligned(out, 16_ic);

    auto inView  = ct::partition_view{ct::tensor_span{arr, ct::extents{8 * num_tiles}},
                                      ct::shape{8_ic}};
    auto outView = ct::partition_view{ct::tensor_span{out, ct::extents{8_ic}},
                                      ct::shape{8_ic}};

    auto acc = ct::full<f32x8>(0.0f);
    // range-for over ct::irange gives the compiler structured iteration bounds.
    for (auto k : ct::irange(0, num_tiles)) {
        auto tile = inView.load(k);
        acc = acc + tile;                               // accumulate the k-th tile into acc
    }
    outView.store(acc, 0);                              // write the final result as the 0-th tile of out
}
@ct.kernel
def tile_sum(arr, out, TILE: ct.Constant[int], N_TILES: ct.Constant[int]):
    # Intended grid: (1,) -- a single block sums all tiles of arr.
    acc = ct.zeros((TILE,), dtype=ct.float32)
    for k in range(N_TILES):                            # range() works natively in tile code
        tile = ct.load(arr, index=(k,), shape=(TILE,))
        acc = acc + tile                                # accumulate the k-th tile into acc
    ct.store(out, index=(0,), tile=acc)                 # write the final result as the 0-th tile of out


2.4.7.2. 条件语句

标准的if/else条件语句可以正常工作。由于每个块都遵循单一的控制流路径,关于线程束内分支分歧的注意事项不适用于分块核函数。

__tile_global__ void conditional_load(float* __restrict__ arr, float* __restrict__ out, int N) {
    namespace ct = cuda::tiles;
    using namespace ct::literals;
    using f32x8 = ct::tile<float, ct::shape<8>>;

    arr = ct::assume_aligned(arr, 16_ic);
    out = ct::assume_aligned(out, 16_ic);

    auto inView  = ct::partition_view{ct::tensor_span{arr, ct::extents{N}}, ct::shape{8_ic}};
    auto outView = ct::partition_view{ct::tensor_span{out, ct::extents{N}}, ct::shape{8_ic}};

    int bx   = ct::bid().x;
    int nb_x = ct::num_blocks().x;

    auto tile = ct::full<f32x8>(0.0f);    // default for the last-block branch
    // Scalar condition -> one control-flow path per block; no divergence to reason about.
    if (bx < nb_x - 1) {
        tile = inView.load(bx);           // all blocks except the last
    }
    outView.store_masked(tile, bx);       // masked to handle a potentially partial final tile
}
@ct.kernel
def conditional_load(arr, out, TILE: ct.Constant[int]):
    bid = ct.bid(0)
    # Scalar condition -> one control-flow path per block; no divergence to reason about.
    if bid < ct.num_blocks(0) - 1:
        tile = ct.load(arr, index=(bid,), shape=(TILE,))    # all blocks except the last
    else:
        tile = ct.zeros((TILE,), dtype=ct.float32)          # last block: emit zeros
    ct.store(out, index=(bid,), tile=tile)


2.4.8. 逐元素算术运算与广播

分块支持标准的逐元素算术运算。当两个操作数的形状兼容但不同时,在执行运算之前,会将较小的操作数广播到匹配的形状。

2.4.8.1. 广播

广播遵循 NumPy 语义:标量会复制到整个分块,单例维度(长度为 1)会扩展以匹配另一个操作数的对应维度,而秩较低的操作数会将缺失的前导维度视为单例维度,从而与秩较高的操作数的尾部维度对齐。如果两个对应维度都不是单例维度且大小不相等,则该操作不符合规则。

下面的示例在一次加法中同时运用了单例维度扩展和秩提升:将形状为 8x2 的秩为 2 的分块提升为 1x8x2,然后与形状为 4x1x2 的秩为 3 的分块一起广播到共同的形状 4x8x2。

auto x = ct::iota<ct::tile<int, ct::shape<8, 2>>>();      // 8x2   (rank 2)
auto y = ct::iota<ct::tile<int, ct::shape<4, 1, 2>>>();   // 4x1x2 (rank 3)
auto z = x + y;                                           // x promoted to 1x8x2, then broadcasts to 4x8x2
x = ct.full((8, 2),    3, dtype=ct.int32)   # 8x2   (rank 2)
y = ct.full((4, 1, 2), 5, dtype=ct.int32)   # 4x1x2 (rank 3)
z = x + y                                    # x promoted to 1x8x2, then broadcasts to 4x8x2

2.4.8.2. 算术运算符

所有受支持的算术运算符都按元素作用于分块,并生成具有广播后形状的新分块。标量与分块一起运算时,标量会广播到每个元素。当操作数类型不同时,优先选择能够保留更多信息的类型:

  • 分块与分块一起运算:结果是一个分块,其类型具有更高的精度或更大的范围。例如:

    • int + float得到float

    • int16 + int32得到int32

  • 标量与分块一起运算:当标量的类型能够由分块的元素类型精确表示时(例如,整数字面量2与int分块一起运算,或2.0f与float分块一起运算),运算以分块的元素类型进行。当标量必须经过窄化才能适应分块的元素类型时(例如,字面量2.5与int分块一起运算),两种语言的行为有所不同:

    • Python 将结果提升为能够容纳两者的类型

    • C++ 将该表达式视为不符合规则并予以拒绝

下面的代码片段说明了两种语言在标量与分块一起运算时存在差异的情况:

using i32x8 = ct::tile<int, ct::shape<8>>;
i32x8 x = ct::full<i32x8>(3);

x + 2;       // OK - int literal matches int tile element type
x + 2.5;     // ill-formed - 2.5 would narrow to int
x = ct.full((8,), 3, dtype=ct.int32)

x + 2          # int32 - int literal matches int32 tile dtype
x + 2.5        # float32 - result promoted to hold both

在实践中,应尽可能以分块的元素类型编写标量字面量,并在需要不同精度时进行显式转换。当核函数内部的操作数是加载的分块时,同样的规则也适用:

__tile_global__ void elementwise(float* __restrict__ a, float* __restrict__ b, float* __restrict__ out, int N) {
    namespace ct = cuda::tiles;
    using namespace ct::literals;

    a   = ct::assume_aligned(a,   16_ic);
    b   = ct::assume_aligned(b,   16_ic);
    out = ct::assume_aligned(out, 16_ic);

    auto aView = ct::partition_view{ct::tensor_span{a,   ct::extents{N}}, ct::shape{8_ic}};
    auto bView = ct::partition_view{ct::tensor_span{b,   ct::extents{N}}, ct::shape{8_ic}};
    auto cView = ct::partition_view{ct::tensor_span{out, ct::extents{N}}, ct::shape{8_ic}};

    int  bx = ct::bid().x;
    auto x  = aView.load(bx);
    auto y  = bView.load(bx);
    // 2.0f matches the float tiles' element type, so no narrowing conversion is required.
    // The scalar is broadcast across every element; + then runs elementwise.
    auto z  = 2.0f * x + y;
    cView.store(z, bx);
}
@ct.kernel
def elementwise(a, b, c, TILE: ct.Constant[int]):
    bid = ct.bid(0)
    x = ct.load(a, index=(bid,), shape=(TILE,))
    y = ct.load(b, index=(bid,), shape=(TILE,))
    # 2.0 is a loosely typed float constant; with float tiles, the result stays float.
    # Scalars are broadcast across every element of the tile, then + runs elementwise.
    z = 2.0 * x + y
    ct.store(c, index=(bid,), tile=z)


当你需要显式控制舍入模式或次正规数处理时,CUDA Tile API 提供了显式开放这些选项的数学函数,既用于逐元素算术运算(例如ct.add、ct::add),也用于超越函数(例如exp和tanh)。

2.4.9. 分块原语

工厂函数(创建分块)、加载和存储(分块空间加载和存储)以及逐元素算术运算(逐元素算术运算与广播)都是分块原语,即语言组成部分中的操作。程序员以分块为粒度编写这些操作,编译器则将其映射到硬件,包括可用的张量核心。本节介绍 CUDA tile 中可用的其他原语。

2.4.9.1. 矩阵乘法

两个分块的矩阵乘法是实现两个数组之间矩阵乘法的基本操作。CUDA Tile 提供两种形式的分块间矩阵乘法:纯矩阵乘法(matmul)a @ b,以及矩阵乘累加(mma)a @ b + acc。在 mma 中,累加器将部分乘积从一个 K 分块传递到下一个 K 分块。这有助于分块矩阵乘法的内层循环。matmul和mma都支持二维矩阵乘法和三维批量乘法,也支持混合使用不同数据类型(精度)的操作数和累加器。这些操作的 API 参考文档(CUDA Tile C++ 矩阵乘法、cuTile Python matmul)中记录了秩和元素类型的约束。

下面的核函数采用了一种常见模式:无论输入精度如何,都以 FP32 进行累加,并在存储时转换为输出元素类型。在 Python 中,这使用ct.mma(a, b, acc),并配合 FP32 类型的acc。在 C++ 中,这使用ct::mma(a, b, acc),并显式指定 FP32 累加器类型。K 循环迭代ceil(K / tk)次,以覆盖 A 的右边缘和 B 的下边缘;不完整的 K 分块在加载时用零填充(Python 中为PaddingMode.ZERO,C++ 中为.load_masked()),C 一侧不完整的 M/N 边缘分块则通过存储侧丢弃越界(OOB)元素来处理(Python 中为ct.store,C++ 中为.store_masked())。

__tile_global__ void gemm(const __half* __restrict__ A, const __half* __restrict__ B, float* __restrict__ C,
                          std::size_t M, std::size_t K, std::size_t N) {
    namespace ct = cuda::tiles;
    using namespace ct::literals;
    using f32_acc = ct::tile<float, ct::shape<32, 32>>;

    A = ct::assume_aligned(A, 16_ic);
    B = ct::assume_aligned(B, 16_ic);
    C = ct::assume_aligned(C, 16_ic);

    constexpr auto tm = 32_ic;
    constexpr auto tn = 32_ic;
    constexpr auto tk = 16_ic;

    auto aView = ct::partition_view{ct::tensor_span{A, ct::extents{M, K}}, ct::shape{tm, tk}};
    auto bView = ct::partition_view{ct::tensor_span{B, ct::extents{K, N}}, ct::shape{tk, tn}};
    auto cView = ct::partition_view{ct::tensor_span{C, ct::extents{M, N}}, ct::shape{tm, tn}};

    auto [bx, by, bz] = ct::bid();
    auto acc = ct::full<f32_acc>(0.0f);                 // FP32 accumulator

    std::size_t num_k = (K + tk - 1) / tk;
    for (auto k : ct::irange(std::size_t{0}, num_k)) {
        acc = ct::mma(aView.load_masked(bx, k),         // zero-pad partial K-tile
                      bView.load_masked(k, by),
                      acc);                             // acc += a @ b
    }
    cView.store_masked(acc, bx, by);                    // drop OOB edge lanes
}
@ct.kernel
def gemm(A, B, C,
         tm: ct.Constant[int], tn: ct.Constant[int], tk: ct.Constant[int]):
    bx, by = ct.bid(0), ct.bid(1)
    num_k  = ct.num_tiles(A, axis=1, shape=(tm, tk))    # number of K-tiles

    acc = ct.full((tm, tn), 0, dtype=ct.float32)        # FP32 accumulator
    for k in range(num_k):
        a = ct.load(A, index=(bx, k), shape=(tm, tk),
                    padding_mode=ct.PaddingMode.ZERO)   # zero-pad partial K-tile
        b = ct.load(B, index=(k, by), shape=(tk, tn),
                    padding_mode=ct.PaddingMode.ZERO)
        acc = ct.mma(a, b, acc)                         # acc += a @ b

    ct.store(C, index=(bx, by), tile=acc.astype(C.dtype))  # cast + store


2.4.9.2. 归约与扫描

归约是将分块折叠成标量或一行标量的工具。计算 softmax 的分母、层归一化的均值和方差,或注意力评分中的最大值,都涉及归约操作。

首先需要牢记的一点是结果的形状。Python 默认移除被归约的轴(传入keepdims=True可将其保留为长度 1);C++ 则始终保留该轴,从而保持分块的秩。下面的两个代码片段都沿轴 1 对一个 2×4 分块进行归约;输出形状是二者的明显区别。

using namespace ct::literals;
using i32x2x4 = ct::tile<int, ct::shape<2, 4>>;

auto x = ct::iota<i32x2x4>();                         // [[0,1,2,3],[4,5,6,7]]
auto row_sums = ct::sum(x, 1_ic);                     // shape (2, 1) - axis kept
// row_sums == [[6], [22]]
x   = ct.arange(8, dtype=ct.int32).reshape((2, 4))    # [[0,1,2,3],[4,5,6,7]]
s   = ct.sum(x, axis=1)                               # shape (2,)    - axis dropped
s_k = ct.sum(x, axis=1, keepdims=True)                # shape (2, 1)  - axis kept
# s == [6, 22];  s_k == [[6], [22]]

扫描是与归约对应的逐步累积操作,它沿某一轴生成累积结果。例如,前缀和(cumsum)生成的输出与输入维度相同,其中给定索引处的值是沿指定轴从起点到该索引(包含该索引)处所有元素之和。有关每种语言中可用操作的完整集合,请参阅 API 参考文档(CUDA Tile C++ 归约与扫描、cuTile Python 归约和扫描)。

2.4.9.3. 转置与置换

两个相关原语可以在不触及分块数据的情况下重新排列其轴:transpose交换前两个轴,permute则进行任意重排。凡是需要在操作之间改变分块逻辑布局的地方,都会用到它们,例如将 matmul 操作数的转置具体化、在注意力块中交换行和列,或在广播前对齐各轴。

在 Python 中,对秩为 2 的分块使用ct.transpose(x)会交换其两个轴;对更高秩的分块使用时,则需要显式的axis0/axis1参数。ct.permute(x, axes)接受一个轴索引元组。在 C++ 中,ct::transpose(x)交换前两个维度(保留后续维度),ct::permute(x, map)则接受一个描述新顺序的ct::dimension_map。

using namespace ct::literals;
using t2d = ct::tile<int, ct::shape<2, 4>>;
using t3d = ct::tile<int, ct::shape<2, 2, 2>>;

auto tx = ct::iota<t2d>();
auto ty = ct::transpose(tx);                                     // shape (4, 2)

auto tz = ct::iota<t3d>();
auto tw = ct::permute(tz, ct::dimension_map{2_ic, 0_ic, 1_ic});  // axes (0,1,2) -> (2,0,1)
tx = ct.arange(8, dtype=ct.int32).reshape((2, 4))
ty = ct.transpose(tx)                                            # shape (4, 2)

tz = ct.arange(8, dtype=ct.int32).reshape((2, 2, 2))
tw = ct.permute(tz, (2, 0, 1))                                   # axes (0,1,2) -> (2,0,1)

2.4.9.4. 逐元素选择

逐元素选择是条件判断的分块形式:给定一个布尔分块和两个操作数分块,根据相应的布尔值,从其中一个操作数分块选取每个输出元素。条件会广播到操作数的形状;操作数类型必须兼容(有关各语言中的确切规则,请参阅 API 参考文档:CUDA Tile C++ select、cuTile Python 选择)。在 Python 中写作ct.where(cond, x, y);在 C++ 中写作ct::select(cond, lhs, rhs)。

using namespace ct::literals;
auto cond = ct::iota<ct::tile<int, ct::shape<4>>>() < 2;   // {T, T, F, F}
auto t    = ct::full<ct::tile<float, ct::shape<4>>>( 1.0f);
auto f    = ct::full<ct::tile<float, ct::shape<4>>>(-1.0f);
auto r    = ct::select(cond, t, f);                        // {1, 1, -1, -1}
cond    = ct.arange(4, dtype=ct.int32) < 2                 # [T, T, F, F]
x_true  = ct.full((4,),  1.0, dtype=ct.float32)
x_false = ct.full((4,), -1.0, dtype=ct.float32)
result  = ct.where(cond, x_true, x_false)                  # [1, 1, -1, -1]

2.4.9.5. 数学函数

在分块代码中,常见的逐元素数学运算以ct命名空间中的函数形式提供:

  • add, sub, mul

  • truediv, floordiv, cdiv

  • mod

  • pow

  • exp, exp2, log, log2

  • sqrt, rsqrt

  • sin, cos, tan

  • sinh, cosh, tanh

  • minimum, maximum

  • negative

  • floor, ceil

每个函数都对输入分块逐元素应用其运算,并返回形状相同的分块。这些运算也适用于分块代码中的标量。

有关确切细节以及所支持的逐元素运算的完整列表,请参阅 API 参考文档:

2.4.10. 原子内存操作

在分块代码中,有两种情况需要使用内存原子操作:

  • 在块间争用中,每个块生成一个部分结果,并使用原子操作,在某个全局内存位置将其与其他块的部分结果合并。

  • 在块内争用中,分块的多个元素被写入内存中的同一位置。

对分块执行原子操作会对分块的每个元素执行一次原子更新。逐元素操作是原子的,但整个调用并不是。逐元素原子操作的执行顺序未作规定。

在 Python 中,原子操作通过数组索引定位目标,所用约定与ct.gather和ct.scatter相同。可选参数控制边界检查、内存顺序和线程作用域。默认设置(启用边界检查、ACQ_REL、设备作用域)使普通调用只需传入数组、索引和更新值。TiledView还以实例方法的形式提供归约式原子操作,这些方法以atomic_store_为名称前缀(例如TiledView.atomic_store_add(index, update));它们通过分块空间索引定位目标,不返回值,并转换为 PTX 中的原子归约。当不需要原先的值时,应优先使用TiledView形式,以获得更好的性能。

在 C++ 中,原子操作接受指针及其对应的值:对于单个位置,接受原始指针和标量;或者接受指针分块和值分块。内存顺序是在调用点指定的编译期类型标签,例如ct::memory_order_relaxed_t{}。线程作用域是相同形式的类型标签,省略时默认为系统范围可见。

ct::partition_view和ct::strided_view还以成员函数的形式提供归约原子操作(atomic_add、atomic_sub、atomic_and、atomic_or、atomic_xor、atomic_max和atomic_min)。它们通过分块空间索引而非指针定位目标,并且与.store()一样,先接受值分块,最后接受索引。与返回内存中原先的值的自由函数不同,它们不返回任何值。与 Python 中一样,当不需要原先的值时,应优先使用它们:成员形式会转换为硬件归约,而指针分块形式则会转换为每个元素一次原子操作。ct::gather_scatter_view不提供这些操作。

2.4.10.1. 块间争用

在下面的代码示例中,由于不同的块写入同一内存位置out,因此会发生块间争用。如果没有原子操作,并行运行的块会导致错误的结果。本例使用设备线程作用域(C++ 中为ct::thread_scope_device_t{},Python 中线程作用域默认为设备范围),因为内存操作的结果必须对设备上运行的所有块可见。Python 核函数使用TiledView.atomic_store_add,因为每个块的部分和都累加到out[0]中,随后立即丢弃。

__tile_global__ void block_sum(int* __restrict__ arr, int* __restrict__ out, std::size_t N) {
    namespace ct = cuda::tiles;
    using namespace ct::literals;
    constexpr auto TILE = 16_ic;

    arr = ct::assume_aligned(arr, 16_ic);
    out = ct::assume_aligned(out, 16_ic);

    auto aView = ct::partition_view{ct::tensor_span{arr, ct::extents{N}},
                                    ct::shape{TILE}};
    int bid = ct::bid().x;
    auto tile    = aView.load_masked(bid);        // partial final tile -> OOB lanes default to 0
    auto partial = ct::sum(tile, 0_ic);           // reduce to a 1-element tile

    ct::atomic_add(out, (int)partial,             // accumulate the scalar into out[0]
                   ct::memory_order_relaxed_t{},  // single-location accumulator -> relaxed suffices
                   ct::thread_scope_device_t{});  // visible across the device
}
@ct.kernel
def block_sum(arr, out, TILE: ct.Constant[int]):
    bid = ct.bid(0)
    # partial final tile -> OOB lanes default to 0
    tile    = ct.load(arr, index=(bid,), shape=(TILE,),
                      padding_mode=ct.PaddingMode.ZERO)
    partial = ct.sum(tile)                               # reduce to a scalar
    out.tiled_view((1,)).atomic_store_add((0,), partial)  # atomically accumulate into out[0]


2.4.10.2. 块内争用

在下面的代码片段中,由于分块的所有值都以原子方式加到内存中的单个位置,因此会发生块内争用。

在本例中,ptrs分块中的每个元素都指向内存中的同一位置slot。由ct::iota<i32x16>()创建的分块中的每个元素,都会以原子方式加到该内存位置中存储的值上。从一个分块对同一内存地址执行多个原子操作时,其执行顺序未作规定。使用块线程作用域ct::thread_scope_block_t{}来指定原子操作的结果只需在此线程块内可见。

using i32x16 = ct::tile<int, ct::shape<16>>;

int* slot = /* pointer to the contended location */;

// 16 lanes all aim at the same address. Add is commutative, so the
// unspecified ordering doesn't affect this sum; block scope suffices
// since contention stays within one block.
auto ptrs = ct::full<ct::tile<int*, ct::shape<16>>>(slot);
ct::atomic_add(ptrs, ct::iota<i32x16>(),
            ct::memory_order_relaxed_t{},
            ct::thread_scope_block_t{});

注意

这里仅作说明之用。若要在线程块内将一个分块求和为标量,应优先采用第 2.4.9.2 节所示的分块归约操作。

2.4.10.3. 支持的原子操作

分块代码支持多种原子内存操作,这些操作的区别在于如何将要写入的值与内存中已有的值相结合:

  • atomic_and:对传入的值与内存中的值逐元素执行原子按位与运算

  • atomic_or:对传入的值与内存中的值逐元素执行原子按位或运算

  • atomic_xor:对传入的值与内存中的值逐元素执行原子按位异或运算

  • atomic_max:逐元素比较传入的值与内存中的值,并将较大的值存入内存

  • atomic_min:逐元素比较传入的值与内存中的值,并将较小的值存入内存

  • atomic_add:将传入的值加到内存中的值上,并将结果存入内存

  • atomic_sub(仅限 C++):从内存中的值减去传入的值,并将结果存入内存

  • atomic_xchg:将传入的值写入内存,并返回写入前内存中的值

  • atomic_cas(C++ 中为atomic_compare_exchange):逐元素比较内存中的值与作为参数传入的预期值。如果两者匹配,则用所需的值替换内存中的值

有关所有受支持原子内存操作的完整文档,请参阅CUDA Tile C++ API 参考或cuTile Python API 参考中的内存操作章节。

2.4.11. 优化提示

优化提示是附加到源代码构造(分块核函数、加载/存储调用点等)的元数据,用于指导编译器的代码生成。提示不会改变程序的语义:无论是否有提示,核函数的编译和运行行为都相同,因此可以自由添加、移除或调整提示,而不影响正确性。编译器也可以忽略任何提示。

提示具有两个共同的一般属性:

  • 提示针对各个构造单独生效。提示适用于其附加到的特定核函数或特定调用表达式,而不适用于周围的代码。

  • 提示可以按架构指定。每个提示都可以针对不同的 GPU 架构设置不同的值,也可以设置为适用于所有目标的单一值。

两种语言提供提示的方式不同:

  • C++ 使用 C++ 属性,将其放在相关声明或语句上。

  • Python 在核函数装饰器以及各个内存操作调用点上使用关键字参数。

提示种类的集合,也就是每个提示实际控制的内容,在两种语言之间是共用的,具体记录在提示种类中。

2.4.11.1. C++:cutile::hint属性

在 C++ 中,提示通过 C++ 属性cutile::hint来表达:

[[ cutile::hint(arch, kind1=value1, kind2=value2, ...) ]]

第一个参数是目标架构,它采用与__CUDA_ARCH__宏相同的约定编码为整数(例如,sm_90对应900,sm_100对应1000)。特殊值0表示适用于所有目标架构的与架构无关的提示。其余每个参数都是一个kind=value对,用于指定提示种类及其值。

cutile::hint属性适用于其后紧接的构造:

  • 对于分块核函数,将属性放在函数声明上。

  • 对于ct::load、ct::store和ct::partition_view加载/存储等内存操作,将其放在包含调用的表达式语句上。

其他放置位置存在限制;有关完整规则,请参阅CUDA Tile C++ 提示规范。

下面的核函数展示了这两种放置方式:核函数级提示为sm_90和sm_100设置不同的num_cta_in_cga,表达式语句提示则将某次特定加载标记为带宽需求高的操作。

[[ cutile::hint(900,  num_cta_in_cga=4),    // sm_90:  prefer 4 CTAs per cluster
   cutile::hint(1000, num_cta_in_cga=8) ]]  // sm_100: prefer 8 CTAs per cluster
__tile_global__ void optimization_hints(float* __restrict__ in,
                                        float* __restrict__ out) {
    namespace ct = cuda::tiles;
    using namespace ct::literals;

    in  = ct::assume_aligned(in,  16_ic);
    out = ct::assume_aligned(out, 16_ic);

    auto inSpan  = ct::tensor_span{in,  ct::extents{128_ic}};
    auto outSpan = ct::tensor_span{out, ct::extents{128_ic}};
    auto inView  = ct::partition_view{inSpan,  ct::shape{8_ic}};
    auto outView = ct::partition_view{outSpan, ct::shape{8_ic}};

    int bx = ct::bid().x;

    // Expression-statement hint: tag this particular load as bandwidth-heavy.
    ct::tile<float, ct::shape<8>> tile;
    [[ cutile::hint(0, latency=8) ]]
    tile = inView.load(bx);

    outView.store(tile, bx);
}

当多个同种类的提示适用于同一构造时,特定架构的提示会覆盖与架构无关的提示。

2.4.11.2. Python:装饰器参数与调用点关键字

Python 通过两种方式提供提示:

  • 核函数级提示是@ct.kernel(...)装饰器的关键字参数。已编译的核函数对象还有一个.replace_hints(**hints)方法,该方法返回一个提示已被覆盖的新核函数;新核函数拥有自己的 JIT 缓存,因此replace_hints自然成为构建自动调优循环的基本组件。

  • 逐调用提示是内存操作调用点上的关键字参数:ct.load/ct.store、TiledView.load/TiledView.store以及ct.gather/ct.scatter。

对于按架构指定的值,将值包装在cuda.tile.ByTarget(*, default=..., sm_XXX=..., sm_YYY=...)中。架构键必须是"sm_<major><minor>"形式的字符串(例如"sm_100"或"sm_120")。普通值(非ByTarget值)适用于所有目标,它在 Python 中等同于 C++ 中使用arch=0的与架构无关的提示。

下面的核函数是上面的 C++ 示例的直接 Python 对应版本:ByTarget携带核函数级提示,latency=8关键字携带逐调用提示,而replace_hints可在不编辑源代码的情况下生成重新调优的核函数。

@ct.kernel(num_ctas=ByTarget(sm_90=4, sm_100=8))
def optimization_hints(in_, out, TILE: ct.Constant[int]):
    bid = ct.bid(0)

    # Per-call hint: this particular load is bandwidth-heavy.
    tile = ct.load(in_, index=(bid,), shape=(TILE,), latency=8)

    ct.store(out, index=(bid,), tile=tile)


# Autotuning: produce a new kernel with overridden hints without editing the
# source. The new kernel has its own JIT cache.
tuned_kernel = optimization_hints.replace_hints(num_ctas=8)


2.4.11.3. 提示种类

以下提示在两种语言之间是共用的。对于每个提示,C++ 名称和Python 名称条目是同一个底层提示的不同写法;其他方面均相同:提示的适用位置、取值及含义。

2.4.11.3.1. 每个簇的 CTA 数量

  • C++ 名称:num_cta_in_cga(核函数属性)。

  • Python 名称:num_ctas(@ct.kernel装饰器参数)。

  • 允许的值:1、2、4、8、16。在sm_80上,仅1适用。

  • 含义:启动核函数时,编译器应优先为每个协作组数组(CGA)使用的协作线程数组(CTA)数量。

2.4.11.3.2. 占用率

  • C++ 名称:occupancy(核函数属性)。

  • Python 名称:occupancy(@ct.kernel装饰器参数)。

  • 允许的值:包含端点的[1, 32]范围内的任意整数。

  • 含义:每个流式多处理器(SM)上活跃 CTA 数量的目标值。编译器将该值视为建议,并会在代码生成期间尝试遵循它。

2.4.11.3.3. 内存访问延迟

  • C++ 名称:latency(包含调用的表达式语句上的属性)。

  • Python 名称:latency(调用点上的关键字参数)。

  • 适用于:分块空间加载和存储(C++ 中为ct::partition_view;Python 中为Array.tiled_view和ct.load/ct.store),以及聚集/散布(C++ 中为使用指针分块的ct::load/ct::store;Python 中为ct.gather/ct.scatter)。

  • 允许的值:包含端点的[1, 10]范围内的任意整数,其中1表示较低的 DRAM 流量,10表示较高的流量。较大的值通常会使编译器安排更大的预取深度。

2.4.11.3.4. 允许 TMA

  • C++ 名称:allow_tma(包含调用的表达式语句上的属性)。

  • Python 名称:allow_tma(调用点上的关键字参数)。

  • 适用于:仅适用于分块空间的加载和存储(C++ 中的ct::partition_view;Python 中的Array.tiled_view以及ct.load/ct.store)。收集和散布操作不接受此提示。

  • 允许的值:true/false(C++)或True/False(Python)。默认允许使用 TMA;将提示设置为false/False会指示编译器,即使在支持 TMA 的硬件上,也不要将这一特定的加载或存储转换为 TMA 操作。

2.4.12. C++ 性能提示

本指南中的 C++ 核函数均使用相同的少量注解和惯用写法。本节说明它们的作用及其重要性。

2.4.12.1. 对内存中的数组使用__restrict__指针

__restrict__关键字告知编译器,在指针的生存期内,通过该指针访问的内存区域将只通过该指针访问。请参阅第 5.4.1.4 节。

在分块 C++ 中,使用满足这些条件的内存数组,并用__restrict__关键字标记指向这些数组的指针,对于获得良好的内存操作性能至关重要。

要了解其中的原因,请考虑一个逐元素复制操作,该操作使用的数组指针未被标记为__restrict__:

__tile_global__ void tile_elementwise_copy(float* out, float const* in) {
    namespace ct = cuda::tiles;

    using f32x64 = ct::tile<float, ct::shape<64>>;
    using i32x64 = ct::tile<int, ct::shape<64>>;

    auto inPtrs  = in  + 64 * ct::bid().x + ct::iota<i32x64>();
    auto outPtrs = out + 64 * ct::bid().x + ct::iota<i32x64>();

    auto data = ct::load(inPtrs);   // (1)
    ct::store(outPtrs, data);       // (2)
}

在 CUDA Tile 程序中,通常可以忽略编译器如何并行化分块操作。不过,为了理解为什么使用不重叠的数组能让编译器生成性能更好的代码,我们将在此考察这一点。

考虑编译器将如何并行化load和store分块操作。如果输入数组与输出数组不重叠,load就可以并行化为一组独立的内存读取操作。同样,store可以并行化为多个内存写入操作,其中每个写入操作仅依赖于对它所写入的一个或多个数据元素的加载操作。

然而,如果输入数组和输出数组可能重叠,那么编译器就必须确保整个分块的所有内存加载操作均已完成,才能发出任何内存存储操作,以保证正确的程序语义。否则,存储操作可能会在某个元素被加载操作读取之前执行并覆盖该元素,导致程序执行不正确。这限制了编译器交错安排读取和写入的能力,因为在发出任何写入之前,所有读取都必须完成。

简而言之,当编译器无法保证数组互不重叠时,它必须生成更保守的代码。这就是为什么使用不重叠的数组,并在其指针上使用__restrict__关键字将这一点告知编译器,有助于实现最佳性能。

如果某个内存区域可通过另一个指针访问,却用__restrict__标记指向该区域的指针,将导致未定义行为。

2.4.12.2. 将数组指针标记为按 16 字节对齐

使用ct::assume_aligned将指向数组的指针标记为按 16 字节对齐:

__tile_global__ void foo(float* __restrict__ in) {
    namespace ct = cuda::tiles;
    using namespace ct::literals;

    in = ct::assume_aligned(in, 16_ic);

    ct::tensor_span t{in, ct::extents{256_ic, 256_ic}};
    ct::partition_view{t, ct::shape{4_ic, 4_ic}};

    // ...
}

这一对齐保证是ct::partition_view使用张量内存加速器(TMA)的必要条件。使用此方法时,必须在运行时提供按 16 字节对齐的指针,否则行为未定义。

由cudaMalloc等 CUDA 内存分配器返回的指针保证至少按 16 字节对齐。

2.4.12.3. 内存访问优先使用ct::partition_view

对于结构化内存访问,应优先使用ct::partition_view,而不是收集和散布形式ct::load和ct::store。基于视图的形式可以在支持的硬件上转换为张量内存加速器(TMA)操作,其速度明显快于逐元素收集。有关收集/散布的背景,请参阅收集和散布。

2.4.12.4. 对有界循环使用ct::irange

在固定范围内迭代时,使用ct::irange,而不是普通的for循环。这种结构化形式让编译器能够应用流水线化和向量化等优化,而当循环边界和步长是不透明的整数表达式时,这些优化无法应用(请参阅控制流):

for (auto idx : ct::irange(lowerBound, upperBound, step)) {
    // ...
}

2.5. 异步执行

2.5.1. 什么是异步并发执行?

CUDA 允许多个任务并发执行,也就是重叠执行,具体包括:

  • 主机上的计算

  • 设备上的计算

  • 从主机到设备的内存传输

  • 从设备到主机的内存传输

  • 给定设备内存内部的内存传输

  • 设备之间的内存传输

这种并发性通过异步接口表达,其中用于分派操作的函数调用或核函数启动会立即返回。异步调用通常在所分派的操作完成之前返回,并且可能在异步操作开始之前就返回。随后,应用程序便可在最初分派的操作执行的同时执行其他任务。当需要最初分派的操作的最终结果时,应用程序必须进行某种形式的同步,以确保该操作已经完成。并发执行模式的一个典型示例是让主机与设备之间的内存传输与计算重叠,从而减少或消除这些传输的开销。

使用 CUDA 流进行异步并发执行

图 20 使用 CUDA 流进行异步并发执行

图内文字中文对照
Stream Execution
流执行
API calls
API 调用
Memcpy H2D
主机到设备的内存复制
Kernel
核函数
Event
事件
Kernel 2
核函数 2
Memcpy D2H
设备到主机的内存复制
CPU Work
CPU 工作
Synchronization point
同步点
Time
时间

一般而言,异步接口通常提供三种主要方式来与所分派的操作同步

  • 阻塞方式,应用程序调用一个会阻塞的函数,即一直等待到操作完成

  • 非阻塞方式,也称为轮询方式,应用程序调用一个立即返回并提供操作状态信息的函数

  • 回调方式,在操作完成后执行一个预先注册的函数。

虽然编程接口是异步的,但实际能否并发执行各种操作取决于 CUDA 的版本和所用硬件的计算能力。这些细节将在本指南后面的章节中介绍(请参阅计算能力)。

在同步 CPU 和 GPU中,介绍了 CUDA 运行时函数cudaDeviceSynchronize(),这是一个阻塞调用,会等待此前发出的所有工作完成。之所以需要调用cudaDeviceSynchronize(),是因为核函数启动是异步的,会立即返回。CUDA 为阻塞和非阻塞这两种同步方式都提供了 API,甚至支持使用主机端回调函数。

CUDA 中用于异步执行的核心 API 组件是CUDA 流和CUDA 事件。在本节余下部分中,我们将解释如何使用这些元素来表达 CUDA 中的异步执行。

一个相关主题是CUDA 图,它允许预先定义由异步操作构成的图,然后以极小的开销重复执行该图。我们在第 2.4.9.2 节 通过流捕获入门 CUDA 图中对 CUDA 图进行非常基础的介绍,并在第 4.1 节 CUDA 图中提供更全面的讨论。

2.5.2. CUDA 流

从最基本的层面来看,CUDA 流是一种抽象,允许程序员表达一系列操作。流的工作方式类似于工作队列,程序可以向其中添加操作,例如内存复制或核函数启动,以便按顺序执行。给定流的队首操作执行后会出队,使下一个已排队的操作移至队首,并被考虑执行。流中操作的执行顺序是顺序性的,操作按其进入流中队列的顺序执行。

应用程序可以同时使用多个流。在这种情况下,运行时将根据 GPU 资源的状态,从有待执行工作的流中选择一个任务来执行。可以为流分配优先级,该优先级作为提示提供给运行时以影响调度,但不保证特定的执行顺序。

在流中执行的 API 函数调用和核函数启动相对于主机线程是异步的。应用程序可以通过等待流中不再有任务来与流同步,也可以在设备级别进行同步。

CUDA 有一个默认流,未指定具体流的操作和核函数启动会排入此默认流。未指定流的代码示例隐式地使用此默认流。默认流具有一些特定语义,将在阻塞流、非阻塞流和默认流小节中讨论。

2.5.2.1. 创建和销毁 CUDA 流

可以使用cudaStreamCreate()函数创建 CUDA 流。该函数调用会初始化流句柄,此句柄可在后续函数调用中用于标识该流。

cudaStream_t stream;        // Stream handle
cudaStreamCreate(&stream);  // Create a new stream

// stream based operations ...

cudaStreamDestroy(stream);  // Destroy the stream

如果应用程序调用cudaStreamDestroy()时,设备仍在流stream中执行工作,则该流会在完成流中的所有工作后才被销毁。

2.5.2.2. 在 CUDA 流中启动核函数

用于启动核函数的常规三尖括号语法也可用于在特定流中启动核函数。流作为核函数启动的一个额外参数来指定。在以下示例中,名为kernel的核函数被启动到句柄为stream的流中,该句柄的类型为cudaStream_t,并且假定此流已经事先创建:

kernel<<<grid, block, shared_mem_size, stream>>>(...);

核函数启动是异步的,函数调用会立即返回。假设核函数启动成功,核函数将在流stream中执行,而应用程序可以在核函数执行期间,在 CPU 上或在 GPU 的其他流中执行其他任务。

2.5.2.3. 在 CUDA 流中发起内存传输

要在流中发起内存传输,可以使用函数cudaMemcpyAsync()。此函数与cudaMemcpy()函数类似,但它额外接受一个参数,用于指定内存传输所使用的流。下面代码块中的函数调用在流stream中,将size字节从src指向的主机内存复制到dst指向的设备内存。

// Copy `size` bytes from `src` to `dst` in stream `stream`
cudaMemcpyAsync(dst, src, size, cudaMemcpyHostToDevice, stream);

与其他异步函数调用一样,此函数调用会立即返回,而cudaMemcpy()函数则会阻塞,直到内存传输完成。为了安全地访问传输结果,应用程序必须使用某种形式的同步来确定操作已经完成。

其他 CUDA 内存传输函数,例如cudaMemcpy2D(),也有异步变体。

注意

为了使涉及 CPU 内存的内存复制能够异步进行,主机缓冲区必须采用页锁定内存。如果使用未进行页锁定的主机内存,cudaMemcpyAsync()仍能正确工作,但会退回到同步行为,无法与其他工作重叠。这可能会限制使用异步内存传输所带来的性能收益。建议程序使用cudaMallocHost()来分配用于向 GPU 发送数据或从 GPU 接收数据的缓冲区。

2.5.2.4. 流同步

与流同步的最简单方式是等待流中不再有任务。这可以通过两种方式完成,即使用cudaStreamSynchronize()函数或cudaStreamQuery()函数。

cudaStreamSynchronize()函数会阻塞,直到流中的所有工作完成。

// Wait for the stream to be empty of tasks
cudaStreamSynchronize(stream);

// At this point the stream is done
// and we can access the results of stream operations safely

如果不希望阻塞,而只需要快速检查流是否为空,可以使用cudaStreamQuery()函数。

// Have a peek at the stream
// returns cudaSuccess if the stream is empty
// returns cudaErrorNotReady if the stream is not empty
cudaError_t status = cudaStreamQuery(stream);

switch (status) {
    case cudaSuccess:
        // The stream is empty
        std::cout << "The stream is empty" << std::endl;
        break;
    case cudaErrorNotReady:
        // The stream is not empty
        std::cout << "The stream is not empty" << std::endl;
        break;
    default:
        // An error occurred - we should handle this
        break;
};

2.5.3. CUDA 事件

CUDA 事件是一种向 CUDA 流中插入标记的机制。它们本质上类似于示踪粒子,可用于跟踪流中任务的进度。设想在一个流中启动两个核函数。如果没有这种跟踪事件,我们只能确定流是否为空。如果第三个操作,无论是在 CPU 上还是在另一个流中,依赖于第一个核函数的输出,那么只有在得知流已为空后,才能安全地开始该操作。流为空时,两个核函数都已完成。

使用 CUDA 事件可以做得更好。通过在第一个核函数之后、第二个核函数之前紧接着将一个事件排入流,我们可以让 CPU 线程或另一个流等待该事件到达流的队首。然后,我们就可以在已知第一个核函数完成、而第二个核函数尚未开始的情况下,安全地开始有依赖关系的操作。以这种方式使用 CUDA 事件可以构建操作和流之间的依赖关系图。这种图的类比直接对应于后面对CUDA 图的讨论。

CUDA 事件还会保存时间信息,可用于对核函数启动和内存传输计时。

2.5.3.1. 创建和销毁 CUDA 事件

可以使用cudaEventCreate()和cudaEventDestroy()函数创建和销毁 CUDA 事件。

cudaEvent_t event;

// Create the event
cudaEventCreate(&event);

// do some work involving the event

// Once the work is done and the event is no longer needed
// we can destroy the event
cudaEventDestroy(event);

应用程序负责在不再需要事件时销毁它们。

2.5.3.2. 向 CUDA 流中插入事件

可以使用cudaEventRecord()函数将 CUDA 事件插入流中。

cudaEvent_t event;
cudaStream_t stream;

// Create the event
cudaEventCreate(&event);

// Insert the event into the stream
cudaEventRecord(event, stream);

2.5.3.3. 对 CUDA 流中的操作计时

CUDA 事件可用于对各种流操作的执行计时,包括核函数。当事件到达流的队首时,它会记录一个时间戳。通过在流中的核函数前后各放置一个事件,可以准确测量核函数执行的持续时间,如下面的代码片段所示:

cudaStream_t stream;
cudaStreamCreate(&stream);

cudaEvent_t start;
cudaEvent_t stop;

// create the events
cudaEventCreate(&start);
cudaEventCreate(&stop);

 // record the start event
cudaEventRecord(start, stream);

// launch the kernel
kernel<<<grid, block, 0, stream>>>(...);

// record the stop event
cudaEventRecord(stop, stream);

// wait for the stream to complete
// both events will have been triggered
cudaStreamSynchronize(stream);

// get the timing
float elapsedTime;
cudaEventElapsedTime(&elapsedTime, start, stop);
std::cout << "Kernel execution time: " << elapsedTime << " ms" << std::endl;

// clean up
cudaEventDestroy(start);
cudaEventDestroy(stop);
cudaStreamDestroy(stream);

2.5.3.4. 检查 CUDA 事件的状态

与检查流状态的情况一样,可以通过阻塞或非阻塞方式检查事件的状态。

cudaEventSynchronize()函数会阻塞,直到事件完成。在下面的代码片段中,我们在流中启动一个核函数,接着放入一个事件,然后再启动第二个核函数。我们可以使用cudaEventSynchronize()函数等待第一个核函数之后的事件完成,并且原则上可以立即启动一个依赖于它的任务,时间上可能早于kernel2完成。

cudaEvent_t event;
cudaStream_t stream;

// create the stream
cudaStreamCreate(&stream);

// create the event
cudaEventCreate(&event);

// launch a kernel into the stream
kernel<<<grid, block, 0, stream>>>(...);

// Record the event
cudaEventRecord(event, stream);

// launch a kernel into the stream
kernel2<<<grid, block, 0, stream>>>(...);

// Wait for the event to complete
// Kernel 1 will be  guaranteed to have completed
// and we can launch the dependent task.
cudaEventSynchronize(event);
dependentCPUtask();

// Wait for the stream to be empty
// Kernel 2 is guaranteed to have completed
cudaStreamSynchronize(stream);

// destroy the event
cudaEventDestroy(event);

// destroy the stream
cudaStreamDestroy(stream);

可以使用cudaEventQuery()函数以非阻塞方式检查 CUDA 事件是否完成。在下面的示例中,我们在一个流中启动 2 个核函数。第一个核函数kernel1生成一些我们希望复制到主机的数据,但我们也有一些 CPU 端工作要做。在下面的代码中,我们依次将kernel1、一个事件(event)以及kernel2排入流stream1。随后,我们进入 CPU 工作循环,但会偶尔检查一下事件是否完成,这表明kernel1已经完成。如果是,就在流stream2中发起从设备到主机的复制。这种方式允许 CPU 工作与 GPU 核函数执行以及从设备到主机的复制重叠。

cudaEvent_t event;
cudaStream_t stream1;
cudaStream_t stream2;

size_t size = LARGE_NUMBER;
float* d_data;
float* h_data;

// Create some data
cudaMalloc(&d_data, size);
cudaMallocHost(&h_data, size);

// create the streams
cudaStreamCreate(&stream1);   // Processing stream
cudaStreamCreate(&stream2);   // Copying stream
bool copyStarted = false;

//  create the event
cudaEventCreate(&event);

// launch kernel1 into the stream
kernel1<<<grid, block, 0, stream1>>>(d_data, size);
// enqueue an event following kernel1
cudaEventRecord(event, stream1);

// launch kernel2 into the stream
kernel2<<<grid, block, 0, stream1>>>();

// while the kernels are running do some work on the CPU
// but check if kernel1 has completed because then we will start
// a device to host copy in stream2
while ( not allCPUWorkDone() || not copyStarted ) {
    doNextChunkOfCPUWork();

    // peek to see if kernel 1 has completed
    // if so enqueue a non-blocking copy into stream2
    if ( not copyStarted ) {
        if( cudaEventQuery(event) == cudaSuccess ) {
            cudaMemcpyAsync(h_data, d_data, size, cudaMemcpyDeviceToHost, stream2);
            copyStarted = true;
        }
    }
}

// wait for both streams to be done
cudaStreamSynchronize(stream1);
cudaStreamSynchronize(stream2);

// destroy the event
cudaEventDestroy(event);

// destroy the streams and free the data
cudaStreamDestroy(stream1);
cudaStreamDestroy(stream2);
cudaFree(d_data);
free(h_data);

2.5.4. 来自流的回调函数

CUDA 提供了一种从流内在主机上启动函数的机制。目前有两个函数可用于此目的:cudaLaunchHostFunc()和cudaAddCallback()。不过,cudaAddCallback()已计划弃用,因此应用程序应使用cudaLaunchHostFunc()。

使用cudaLaunchHostFunc()

cudaLaunchHostFunc()函数的签名如下:

cudaError_t cudaLaunchHostFunc(cudaStream_t stream, void (*func)(void *), void *data);

其中

  • stream:要将回调函数启动到其中的流。

  • func:要启动的回调函数。

  • data:指向要传递给回调函数的数据的指针。

主机函数本身是一个简单的 C 函数,其签名为:

void hostFunction(void *data);

其中,data参数指向一个用户定义的数据结构,函数可以解释该数据结构。使用此类回调函数时,需要牢记一些注意事项。特别是,主机函数不得调用任何 CUDA API。

为了与统一内存配合使用,提供了以下执行保证:

  • 在函数执行期间,流被视为空闲。因此,例如,函数始终可以使用关联到该函数所入队的流的内存。

  • 函数开始执行的效果,等同于对紧接在该函数之前记录在同一流中的事件进行同步。因此,它会同步在该函数之前已被“汇合”的流。

  • 向任何流添加设备工作,都不会使该流变为活动状态,直到此前所有主机函数和流回调均已执行完毕。因此,例如,即使已向另一个流添加工作,只要通过事件将该工作安排在函数调用之后执行,函数仍然可以使用全局关联的内存。

  • 除上述情况外,函数的完成不会使流变为活动状态。如果函数之后没有设备工作,则流将保持空闲;在连续的主机函数或流回调之间没有设备工作时,流也会始终保持空闲。因此,例如,可以通过流末尾的主机函数发出信号来完成流同步。

2.5.4.1. 使用cudaStreamAddCallback()

注意

cudaStreamAddCallback()函数已计划弃用并移除,这里讨论它是为了完整性,也因为现有代码中可能仍会出现它。应用程序应使用或改为使用cudaLaunchHostFunc()。

cudaStreamAddCallback()函数的签名如下:

cudaError_t cudaStreamAddCallback(cudaStream_t stream, cudaStreamCallback_t callback, void* userData, unsigned int flags);

其中

  • stream:要将回调函数启动到其中的流。

  • callback:要启动的回调函数。

  • userData:指向要传递给回调函数的数据的指针。

  • flags:目前,为了将来的兼容性,该参数必须为 0。

callback函数的签名与我们使用cudaLaunchHostFunc()函数时的情况略有不同。在这种情况下,回调函数是一个 C 函数,其签名为:

void callbackFunction(cudaStream_t stream, cudaError_t status, void *userData);

其中,现在传递给函数的是

  • stream:启动该回调函数的流的句柄。

  • status:触发该回调的流操作的状态。

  • userData:指向已传递给回调函数的数据的指针。

特别是,status参数将包含流的当前错误状态,该状态可能已由先前的操作设置。与cudaLaunchHostFunc()函数的情况类似,在主机函数完成之前,流不会变为活动状态并继续执行任务,而且在回调函数内部不得调用任何 CUDA 函数。

2.5.4.2. 异步错误处理

在 CUDA 流中,错误可能来自流中的任何操作,包括核函数启动和内存传输。在运行时,这些错误可能要等到流被同步时才会传播回用户,例如通过等待事件或调用cudaStreamSynchronize()进行同步。有两种方式可以了解流中可能发生的错误。

  • 使用cudaGetLastError()函数:此函数返回并清除当前上下文中任何流内遇到的最后一个错误。如果两次调用之间没有发生其他错误,那么立即再次调用 cudaGetLastError() 将返回cudaSuccess。

  • 使用cudaPeekAtLastError()函数:此函数返回当前上下文中的最后一个错误,但不清除它。

这两个函数都将错误作为cudaError_t类型的值返回。可以使用cudaGetErrorName()和cudaGetErrorString()函数生成错误的可打印名称名称。

下面展示了使用这些函数的示例:

代码清单 1使用 cudaGetLastError() 和 cudaPeekAtLastError() 的示例
// Some work occurs in streams.
cudaStreamSynchronize(stream);

// Look at the last error but do not clear it
cudaError_t err = cudaPeekAtLastError();
if (err != cudaSuccess) {
    printf("Error with name: %s\n", cudaGetErrorName(err));
    printf("Error description: %s\n", cudaGetErrorString(err));
}

// Look at the last error and clear it
cudaError_t err2 = cudaGetLastError();
if (err2 != cudaSuccess) {
    printf("Error with name: %s\n", cudaGetErrorName(err2));
    printf("Error description: %s\n", cudaGetErrorString(err2));
}

if (err2 != err) {
    printf("As expected, cudaPeekAtLastError() did not clear the error\n");
}

// Check again
cudaError_t err3 = cudaGetLastError();
if (err3 == cudaSuccess) {
    printf("As expected, cudaGetLastError() cleared the error\n");
}

提示

当错误在同步时出现,尤其是在包含许多操作的流中出现时,往往很难精确定位错误可能发生在流中的哪个位置。要调试这种情况,一个有用的技巧可能是设置环境变量CUDA_LAUNCH_BLOCKING=1,然后运行应用程序。该环境变量的作用是在每一次核函数启动后进行同步。这有助于追踪是哪个核函数或哪次传输导致了错误。同步的开销可能很高;设置此环境变量后,应用程序的运行速度可能会显著变慢。

2.5.5. CUDA 流顺序

既然已经讨论了流、事件和回调函数的基本机制,接下来就有必要考虑流中异步操作的顺序语义。这些语义旨在让应用程序程序员能够以安全的方式考量流中操作的顺序。在某些特殊情况下,为了优化性能,可以放宽这些语义,例如程序化依赖核函数启动的情况,它通过使用特殊属性和核函数启动机制,允许两个核函数重叠执行;或者使用异步批量内存复制函数对内存传输进行批处理的情况,此时运行时可以并发执行互不重叠的批量复制。

最重要的是,CUDA 流属于所谓的顺序流。这意味着流中操作的执行顺序与这些操作的入队顺序相同。流中的操作不能越过其他操作。内存操作(例如复制)由运行时跟踪,并且总是在下一个操作之前完成,以便依赖这些操作的核函数能够安全地访问正在传输的数据。

2.5.6. 阻塞流、非阻塞流和默认流

CUDA 中有两种类型的流:阻塞流和非阻塞流。这些名称可能略具误导性,因为阻塞和非阻塞语义仅指这些流如何与默认流同步。默认情况下,通过cudaStreamCreate()创建的流是阻塞流。要创建非阻塞流,必须使用带有cudaStreamNonBlocking标志的cudaStreamCreateWithFlags()函数:

cudaStream_t stream;
cudaStreamCreateWithFlags(&stream, cudaStreamNonBlocking);

而非阻塞流可以使用cudaStreamDestroy()按通常的方式销毁。

2.5.6.1. 传统默认流

阻塞流与非阻塞流之间的关键区别在于它们如何与默认流同步。CUDA 提供了一个传统默认流(也称为 NULL 流,或流 ID 为 0 的流),当核函数启动或阻塞式cudaMemcpy()调用未指定流时,就会使用该流。这个由所有主机线程共享的默认流是一个阻塞流。当一个操作被启动到此默认流中时,它会与所有其他阻塞流同步;换句话说,它必须等待所有其他阻塞流完成后才能执行。

cudaStream_t stream1, stream2;
cudaStreamCreate(&stream1);
cudaStreamCreate(&stream2);

kernel1<<<grid, block, 0, stream1>>>(...);
kernel2<<<grid, block>>>(...);
kernel3<<<grid, block, 0, stream2>>>(...);

cudaDeviceSynchronize();

默认流的行为意味着,在上面的上述代码片段中,kernel2将等待kernel1完成,而kernel3将等待kernel2完成,即使原则上这三个核函数可以并发执行。通过创建非阻塞流,我们可以避免这种同步行为。在下面的代码片段中,我们创建了两个非阻塞流。默认流将不再与这些流同步,原则上这三个核函数可以并发执行。因此,我们不能假定核函数有任何执行顺序,并且应执行显式同步(例如使用相当强制的cudaDeviceSynchronize()调用),以确保核函数已经完成。

cudaStream_t stream1, stream2;
cudaStreamCreateWithFlags(&stream1, cudaStreamNonBlocking);
cudaStreamCreateWithFlags(&stream2, cudaStreamNonBlocking);

kernel1<<<grid, block, 0, stream1>>>(...);
kernel2<<<grid, block>>>(...);
kernel3<<<grid, block, 0, stream2>>>(...);

cudaDeviceSynchronize();

2.5.6.2. 每线程默认流

从 CUDA-7 开始,CUDA 允许每个主机线程拥有各自独立的默认流,而不是共享的传统默认流。要启用此行为,必须使用nvcc编译器选项--default-stream per-thread,或者定义CUDA_API_PER_THREAD_DEFAULT_STREAM预处理器宏。启用此行为后,每个主机线程都将拥有各自独立的默认流,这些流不会像传统默认流那样与其他流同步。在这种情况下,传统默认流示例现在将表现出与非阻塞流示例相同的同步行为。

2.5.7. 显式同步

有多种方式可以显式地使流彼此同步。

cudaDeviceSynchronize()会等待,直到所有主机线程的所有流中此前的全部命令均已完成。

cudaStreamSynchronize()接受一个流作为参数,并等待给定流中此前的全部命令完成。它可用于将主机与某个特定流同步,同时允许其他流继续在设备上执行。

cudaStreamWaitEvent()接受一个流和一个事件作为参数(有关事件的说明,请参阅CUDA 事件),并使调用cudaStreamWaitEvent()之后添加到给定流中的所有命令延迟执行,直到给定事件完成。

cudaStreamQuery()为应用程序提供了一种获知流中此前的全部命令是否已经完成的方式。

2.5.8. 隐式同步

如果在来自不同流的两个操作之间提交了任何 NULL 流上的 CUDA 操作,那么这两个操作就不能并发运行,除非这些流是非阻塞流(使用cudaStreamNonBlocking标志创建)。

应用程序应遵循以下准则,以增加核函数并发执行的可能性:

  • 所有独立操作都应在具有依赖关系的操作之前发出,

  • 任何形式的同步都应尽可能推迟。

2.5.9. 其他主题与高级主题

2.5.9.1. 流优先级设置

如前所述,开发者可以为 CUDA 流分配优先级。带优先级的流需要使用cudaStreamCreateWithPriority()函数创建。该函数接受两个参数:流句柄和优先级。一般规则是,数值越小,优先级越高。可以使用cudaDeviceGetStreamPriorityRange()函数查询给定设备和上下文的优先级范围。流的默认优先级为 0。

int minPriority, maxPriority;

// Query the priority range for the device
cudaDeviceGetStreamPriorityRange(&minPriority, &maxPriority);

// Create two streams with different priorities
// cudaStreamDefault indicates the stream should be created with default flags
// in other words they will be blocking streams with respect to the legacy default stream
// One could also use the option `cudaStreamNonBlocking` here to create a non-blocking streams
cudaStream_t stream1, stream2;
cudaStreamCreateWithPriority(&stream1, cudaStreamDefault, minPriority);  // Lowest priority
cudaStreamCreateWithPriority(&stream2, cudaStreamDefault, maxPriority);  // Highest priority

应当注意,流的优先级只是对运行时的一个提示,通常主要适用于核函数启动,对于内存传输则可能不会被遵循。流优先级不会抢占已经在执行的工作,也不保证任何特定的执行顺序。

2.5.9.2. 通过流捕获介绍 CUDA 图

CUDA 流允许程序按顺序指定一系列操作,即核函数或内存复制。通过使用多个流以及通过cudaStreamWaitEvent建立的跨流依赖关系,应用程序可以指定一个完整的操作有向无环图(DAG)。某些应用程序可能有一个操作序列或操作 DAG,需要在执行过程中运行多次。

对于这种情况,CUDA 提供了一项称为 CUDA 图的功能。本节介绍 CUDA 图,以及一种称为流捕获的图创建机制。CUDA 图中对 CUDA 图进行了更详细的讨论。捕获或创建图有助于减少从主机线程反复调用同一串 API 调用的延迟和 CPU 开销。取而代之的是,可以只调用一次用于指定图操作的 API,然后多次执行所生成的图。

CUDA 图按以下方式工作:

  1. 由应用程序捕获图。此步骤在首次执行图时进行一次。也可以使用 CUDA 图 API 手动构建图。

  2. 对图进行实例化。此步骤在图捕获之后执行一次。此步骤可以设置执行图所需的各种运行时结构,以尽可能加快其组件的启动速度。

  3. 在其余步骤中,根据需要多次执行预先实例化的图。由于执行图操作所需的所有运行时结构都已就绪,因此图执行的 CPU 开销得以最小化。

代码清单 2使用 CUDA 图捕获、实例化和执行简单线性图的各个阶段(摘自CUDA 开发者技术博客,A. Gray,2019 年)
#define N 500000 // tuned such that kernel takes a few microseconds

// A very lightweight kernel
__global__ void shortKernel(float * out_d, float * in_d){
    int idx=blockIdx.x*blockDim.x+threadIdx.x;
    if(idx<N) out_d[idx]=1.23*in_d[idx];
}

bool graphCreated=false;
cudaGraph_t graph;
cudaGraphExec_t instance;

// The graph will be executed NSTEP times
for(int istep=0; istep<NSTEP; istep++){
    if(!graphCreated){
        // Capture the graph
        cudaStreamBeginCapture(stream, cudaStreamCaptureModeGlobal);

        // Launch NKERNEL kernels
        for(int ikrnl=0; ikrnl<NKERNEL; ikrnl++){
            shortKernel<<<blocks, threads, 0, stream>>>(out_d, in_d);
        }

        // End the capture
        cudaStreamEndCapture(stream, &graph);

        // Instantiate the graph
        cudaGraphInstantiate(&instance, graph, NULL, NULL, 0);
        graphCreated=true;
    }

    // Launch the graph
    cudaGraphLaunch(instance, stream);

    // Synchronize the stream
    cudaStreamSynchronize(stream);
}

CUDA 图中提供了更多关于 CUDA 图的详细信息。

2.5.10. 异步执行小结

本节的要点如下:

  • 异步 API 使我们能够表达任务的并发执行,为表达各种操作的重叠提供了途径。实际实现的并发性取决于可用的硬件资源和计算能力。

  • CUDA 中用于异步执行的关键抽象是流、事件和回调函数。

  • 可以在事件、流和设备级别进行同步

  • 默认流是一个阻塞流,它与所有其他阻塞流同步,但不与非阻塞流同步

  • 通过--default-stream per-thread编译器选项或 CUDA_API_PER_THREAD_DEFAULT_STREAM 预处理器宏使用每线程默认流,可以避免默认流的行为。

  • 可以创建具有不同优先级的流,这些优先级是对运行时的提示,对于内存传输则可能不会被遵循。

  • CUDA 提供了用于减少核函数启动和内存传输的开销或使这些开销重叠的 API 函数,例如 CUDA 图、批量内存传输和程序化依赖核函数启动。

2.6. 统一内存与系统内存

异构系统具有多个可存储数据的物理内存。主机 CPU 连接有 DRAM,系统中的每个 GPU 也都连接有各自的 DRAM。当数据驻留在访问它的处理器的内存中时,性能最佳。CUDA 提供了用于显式管理内存放置的 API,但这可能使代码冗长,并使软件设计复杂化。CUDA 提供的功能和能力旨在简化不同物理内存之间的数据分配、放置和迁移。

本章旨在介绍并解释这些功能,以及它们在功能和性能方面对应用程序开发者的意义。统一内存有几种不同的表现形式,具体取决于所使用的操作系统、驱动程序版本和 GPU。本章将展示如何确定适用的统一内存范式,以及统一内存的各项功能在每种范式下的行为。后面的统一内存章节将更详细地解释统一内存。

本章将定义并解释以下概念:

此外,这里还介绍讨论统一内存和系统内存时使用的以下术语:

  • 异构托管内存(HMM):Linux 内核的一项功能,为完整统一内存提供软件相干性

  • 地址转换服务(ATS):一项硬件功能,当 GPU 通过 NVLink Chip-to-Chip(C2C)互连连接到 CPU 时可用,为完整统一内存提供硬件相干性

2.6.1. 统一虚拟地址空间

在单个操作系统进程内,系统中的所有主机内存以及所有 GPU 上的全部全局内存都使用同一个虚拟地址空间。主机和所有设备上的全部内存分配都位于此虚拟地址空间中。无论使用 CUDA API(例如cudaMalloc、cudaMallocHost)还是系统分配 API(例如new、malloc、mmap)进行分配,都是如此。CPU 和每个 GPU 在统一虚拟地址空间中都拥有各自独有的范围。

这意味着:

  • 可以使用cudaPointerGetAttributes()根据指针的值确定任意内存的位置(即它位于 CPU 内存还是哪个 GPU 的内存中)

  • 可以将cudaMemcpy*()的cudaMemcpyKind参数设置为cudaMemcpyDefault,以便根据指针自动确定复制类型

2.6.2. 统一内存

统一内存是一项 CUDA 内存功能,它允许在 CPU 或 GPU 上运行的代码访问称为托管内存的内存分配。C++ 中的 CUDA 入门中展示过统一内存。所有受 CUDA 支持的系统都可使用统一内存。

在某些系统上,必须显式分配托管内存。在 CUDA 中,可以通过几种不同的方式显式分配托管内存:

  • CUDA API cudaMallocManaged

  • CUDA APIcudaMallocFromPoolAsync,使用创建时将allocType设置为cudaMemAllocationTypeManaged的内存池

  • 带有__managed__说明符的全局变量(请参阅内存空间说明符)

在具有HMM或ATS的系统上,所有系统内存都隐式地属于托管内存,无论其分配方式如何。无需特殊的分配方式。

2.6.2.1. 统一内存范式

统一内存的功能和行为会因操作系统、Linux 内核版本、GPU 硬件以及 GPU 与 CPU 之间的互连而异。可以使用cudaDeviceGetAttribute查询几个属性,以确定可用的统一内存形式:

  • cudaDevAttrConcurrentManagedAccess:1 表示完整的统一内存支持,0 表示有限支持

  • cudaDevAttrPageableMemoryAccess:1 表示所有系统内存都是获得完整支持的统一内存,0 表示只有显式分配为托管内存的内存才是获得完整支持的统一内存

  • cudaDevAttrPageableMemoryAccessUsesHostPageTables:指示 CPU/GPU 相干性的实现机制:1 表示硬件,0 表示软件。

图 21直观地展示了如何确定统一内存范式,其后给出了实现相同逻辑的代码示例。

统一内存的运行有四种范式:

当提供完整支持时,可能需要显式分配,也可能所有系统内存都隐式地属于统一内存。当所有内存都隐式地属于统一内存时,相干性机制可以是软件机制,也可以是硬件机制。Windows 和某些 Tegra 设备对统一内存的支持有限。

统一内存范式流程图

图 21 当前所有 GPU 都使用统一虚拟地址空间,并且都可使用统一内存。当cudaDevAttrConcurrentManagedAccess为 1 时,可获得完整的统一内存支持,否则只能获得有限支持。当提供完整支持时,如果cudaDevAttrPageableMemoryAccess也为 1,则所有系统内存都是统一内存。否则,只有使用 CUDA API(例如cudaMallocManaged)分配的内存才是统一内存。当所有系统内存都是统一内存时,cudaDevAttrPageableMemoryAccessUsesHostPageTables指示相干性是由硬件提供(值为 1 时)还是由软件提供(值为 0 时)。

图内文字中文对照
Unified Virtual Address Space
统一虚拟地址空间
Always 1
始终为 1
Unified Memory Available
统一内存可用
Limited Unified Memory
有限的统一内存
Full Unified Memory Support
完整的统一内存支持
Unified Memory Requires CUDA API Allocation
统一内存要求通过 CUDA API 分配
All CPU Memory is Unified Memory
所有 CPU 内存均为统一内存
Software Coherence
软件相干性
Hardware Coherence
硬件相干性

表 3以表格形式呈现了与图 21相同的信息,并提供了指向本章相关小节以及本指南后续章节中更完整文档的链接。

表 3 统一内存范式概述

统一内存范式

设备属性

完整文档

有限的统一内存支持

cudaDevAttrConcurrentManagedAccess为 0

对显式托管内存分配的完整支持

cudaDevAttrPageableMemoryAccess为 0
且cudaDevAttrConcurrentManagedAccess为 1

通过软件相干性为所有内存分配提供完整支持

cudaDevAttrPageableMemoryAccessUsesHostPageTables为 0
且cudaDevAttrPageableMemoryAccess为 1
且cudaDevAttrConcurrentManagedAccess为 1

通过硬件相干性为所有内存分配提供完整支持

cudaDevAttrPageableMemoryAccessUsesHostPageTables为 1
且cudaDevAttrPageableMemoryAccess为 1
且cudaDevAttrConcurrentManagedAccess为 1

2.6.2.1.1. 统一内存范式:代码示例

以下代码示例演示了如何按照图 21中的逻辑,针对系统中的每个 GPU 查询设备属性并确定统一内存范式。

void queryDevices()
{
    int numDevices = 0;
    cudaGetDeviceCount(&numDevices);
    for(int i=0; i<numDevices; i++)
    {
        cudaSetDevice(i);
        cudaInitDevice(0, 0, 0);
        int deviceId = i;

        int concurrentManagedAccess = -1;     
        cudaDeviceGetAttribute (&concurrentManagedAccess, cudaDevAttrConcurrentManagedAccess, deviceId);    
        int pageableMemoryAccess = -1;
        cudaDeviceGetAttribute (&pageableMemoryAccess, cudaDevAttrPageableMemoryAccess, deviceId);
        int pageableMemoryAccessUsesHostPageTables = -1;
        cudaDeviceGetAttribute (&pageableMemoryAccessUsesHostPageTables, cudaDevAttrPageableMemoryAccessUsesHostPageTables, deviceId);

        printf("Device %d has ", deviceId);
        if(concurrentManagedAccess){
            if(pageableMemoryAccess){
                printf("full unified memory support");
                if( pageableMemoryAccessUsesHostPageTables)
                    { printf(" with hardware coherency\n");  }
                else
                    { printf(" with software coherency\n"); }
            }
            else
                { printf("full unified memory support for CUDA-made managed allocations\n"); }
        }
        else
        {   printf("limited unified memory support: Windows, WSL, or Tegra\n");  }
    }
}

2.6.2.2. 统一内存功能的完整支持

大多数 Linux 系统都完整支持统一内存。如果设备属性cudaDevAttrPageableMemoryAccess为 1,则所有系统内存,无论是通过 CUDA API 还是系统 API 分配的,都作为具有完整功能支持的统一内存运行。这包括使用mmap创建的文件后备内存分配。

如果cudaDevAttrPageableMemoryAccess为 0,则只有由 CUDA 分配为托管内存的内存才具有统一内存的行为。通过系统 API 分配的内存不是托管内存,GPU 核函数不一定能够访问它。

一般而言,对于获得完整支持的统一内存分配:

  • 托管内存通常分配在首次访问它的处理器的内存空间中

  • 当托管内存被其当前所在处理器之外的处理器使用时,通常会发生迁移

  • 托管内存以内存页(软件相干性)或缓存行(硬件相干性)为粒度进行迁移或访问

  • 允许超额分配:应用程序分配的托管内存可以超过 GPU 上实际可用的物理内存容量

分配和迁移行为可能与上述情况不同。程序员可以使用提示和预取影响这些行为。有关完整统一内存支持的全面介绍,请参阅完整支持 CUDA 统一内存的设备上的统一内存。

2.6.2.2.1. 具有硬件相干性的完整统一内存

在 Grace Hopper 和 Grace Blackwell 等硬件上,使用的是 NVIDIA CPU,且 CPU 与 GPU 之间的互连为 NVLink Chip-to-Chip(C2C),因此可使用地址转换服务(ATS)。当 ATS 可用时,cudaDevAttrPageableMemoryAccessUsesHostPageTables为 1。

使用 ATS 时,除了为所有主机内存分配提供完整的统一内存支持之外,还具备以下能力:

  • 驻留在 GPU 上的托管内存分配(例如cudaMallocManaged)无需迁移即可从 CPU 访问(cudaDevAttrDirectManagedMemAccessFromHost将为 1)

  • CPU 与 GPU 之间的链路支持原生原子操作(cudaDevAttrHostNativeAtomicSupported将为 1)

  • 与软件相干性相比,硬件对相干性的支持可以提高性能

ATS 提供HMM的所有能力。当 ATS 可用时,HMM 会自动禁用。有关硬件相干性与软件相干性的进一步讨论,请参阅CPU 和 GPU 页表:硬件相干性与软件相干性。

注意

硬件相干性并不使主机能够访问仅供 GPU 使用的内存分配,例如使用cudaMalloc进行的分配。

2.6.2.2.2. HMM:具有软件相干性的完整统一内存

异构内存管理(HMM)是 Linux 操作系统(具有适当的内核版本)上提供的一项功能,可实现具有软件相干性的完整统一内存支持。异构内存管理为通过 PCIe 连接的 GPU 带来了 ATS 所提供的部分能力和便利。

在使用至少为 Linux 内核 6.1.24、6.2.11 或 6.3 或更高版本的 Linux 系统上,异构内存管理(HMM)可能可用。可以使用以下命令确定寻址模式是否为HMM。

$ nvidia-smi -q | grep Addressing
Addressing Mode : HMM

当 HMM 可用时,系统支持完整统一内存,且所有系统内存分配都隐式地属于统一内存。如果系统还具有ATS,则会禁用 HMM 并使用 ATS,因为 ATS 提供了 HMM 的全部能力以及更多能力。

2.6.2.3. 有限的统一内存支持

在 Windows(包括 Windows Subsystem for Linux,WSL)以及某些 Tegra 系统上,只能使用统一内存功能的一个有限子集。在这些系统上,托管内存可用,但 CPU 与 GPU 之间的迁移行为有所不同。

  • 托管内存首先在 CPU 的物理内存中分配

  • 托管内存以比虚拟内存页更大的粒度迁移

  • 当 GPU 开始执行时,托管内存会迁移到 GPU

  • 在 GPU 处于活动状态时,CPU 不得访问托管内存

  • 当对 GPU 进行同步时,托管内存会迁移回 CPU

  • 不允许超额分配 GPU 内存

  • 只有由 CUDA 显式分配为托管内存的内存才是统一内存

有关此范式的全面介绍,请参阅Windows、WSL 和 Tegra 上的统一内存。

2.6.2.4. 内存建议和预取

程序员可以向管理统一内存的 NVIDIA 驱动程序提供提示,以帮助其最大限度地提高应用程序性能。CUDA APIcudaMemAdvise允许程序员指定内存分配的属性,这些属性会影响内存分配的放置位置,以及从另一个设备访问时内存是否迁移。

cudaMemPrefetchAsync允许程序员建议启动将特定内存分配异步迁移到另一位置的操作。一种常见用途是在核函数启动之前,开始传输该核函数将要使用的数据。这样,便可在其他 GPU 核函数执行期间复制数据。

性能提示一节介绍了可以传递给cudaMemAdvise的各种提示,并展示了使用cudaMemPrefetchAsync的示例。

2.6.3. 页锁定主机内存

在入门代码示例中,使用了cudaMallocHost在 CPU 上分配内存。这会在主机上分配页锁定内存(也称为页锁定内存)。通过malloc、new或mmap等传统分配机制进行的主机内存分配不是页锁定的,这意味着操作系统可能将其换出到磁盘,或改变其物理位置。

CPU 与 GPU 之间的异步复制需要使用页锁定主机内存。页锁定主机内存还可以提高同步复制的性能。页锁定内存可以映射到 GPU,以供 GPU 核函数直接访问。

CUDA 运行时提供了用于分配页锁定主机内存或对现有内存分配进行页锁定的 API:

  • cudaMallocHost分配页锁定主机内存

  • cudaHostAlloc的默认行为与cudaMallocHost相同,但还接受用于指定其他内存参数的标志

  • cudaFreeHost释放使用cudaMallocHost或cudaHostAlloc分配的内存

  • cudaHostRegister对在 CUDA API 之外分配的一段现有内存进行页锁定,例如使用malloc或mmap分配的内存

cudaHostRegister允许对第三方库或开发者无法控制的其他代码所分配的主机内存进行页锁定,以便将其用于异步复制或进行映射。

注意

系统中的所有 GPU 都可以使用页锁定主机内存进行异步复制和内存映射。

在不具备 I/O 相干性的 Tegra 设备上,页锁定主机内存不会被缓存。此外,不具备 I/O 相干性的 Tegra 设备不支持cudaHostRegister()。

2.6.3.1. 映射内存

在具有HMM或ATS的系统上,可以在 GPU 上使用主机指针直接访问所有主机内存。当 ATS 或 HMM 不可用时,可以通过将主机内存映射到 GPU 的内存空间,使 GPU 能够访问主机内存分配。映射内存始终是页锁定的。

后面的代码示例将展示以下数组复制核函数如何直接操作映射的主机内存。

__global__ void copyKernel(float* a, float* b)
{
        int idx = threadIdx.x + blockDim.x * blockIdx.x;
        a[idx] = b[idx];
}

在某些情况下,需要从核函数访问未复制到 GPU 的特定数据,此时映射内存可能有用,但在核函数中访问映射内存需要通过 CPU 与 GPU 之间的互连(PCIe 或 NVLink C2C)进行事务。与访问设备内存相比,这些操作的延迟更高、带宽更低。对于核函数的大部分内存需求,不应将映射内存视为统一内存或显式内存管理的高性能替代方案。

2.6.3.1.1. cudaMallocHost 和 cudaHostAlloc

使用cudaMallocHost或cudaHostAlloc分配的主机内存会自动映射。这些 API 返回的指针可以直接用于核函数代码,以访问主机上的内存。对主机内存的访问通过 CPU 与 GPU 之间的互连进行。

void usingMallocHost() {
  float* a = nullptr;
  float* b = nullptr;
  
  CUDA_CHECK(cudaMallocHost(&a, vLen*sizeof(float)));
  CUDA_CHECK(cudaMallocHost(&b, vLen*sizeof(float)));

  initVector(b, vLen);
  memset(a, 0, vLen*sizeof(float));

  int threads = 256;
  int blocks = vLen/threads;
  copyKernel<<<blocks, threads>>>(a, b);
  CUDA_CHECK(cudaGetLastError());
  CUDA_CHECK(cudaDeviceSynchronize());

  printf("Using cudaMallocHost: ");
  checkAnswer(a,b);
}
void usingCudaHostAlloc() {
  float* a = nullptr;
  float* b = nullptr;

  CUDA_CHECK(cudaHostAlloc(&a, vLen*sizeof(float), cudaHostAllocMapped));
  CUDA_CHECK(cudaHostAlloc(&b, vLen*sizeof(float), cudaHostAllocMapped));

  initVector(b, vLen);
  memset(a, 0, vLen*sizeof(float));

  int threads = 256;
  int blocks = vLen/threads;
  copyKernel<<<blocks, threads>>>(a, b);
  CUDA_CHECK(cudaGetLastError());
  CUDA_CHECK(cudaDeviceSynchronize());

  printf("Using cudaHostAlloc: ");
  checkAnswer(a, b);
}

2.6.3.1.2. cudaHostRegister

当 ATS 和 HMM 不可用时,仍可使用cudaHostRegister对系统分配器分配的内存进行映射,以便 GPU 核函数直接访问。不过,与使用 CUDA API 创建的内存不同,核函数不能使用主机指针访问这类内存。必须使用cudaHostGetDevicePointer()获取设备内存区域中的指针,并且必须使用该指针在核函数代码中进行访问。

void usingRegister() {
  float* a = nullptr;
  float* b = nullptr;
  float* devA = nullptr;
  float* devB = nullptr;

  a = (float*)malloc(vLen*sizeof(float));
  b = (float*)malloc(vLen*sizeof(float));
  CUDA_CHECK(cudaHostRegister(a, vLen*sizeof(float), 0 ));
  CUDA_CHECK(cudaHostRegister(b, vLen*sizeof(float), 0  ));

  CUDA_CHECK(cudaHostGetDevicePointer((void**)&devA, (void*)a, 0));
  CUDA_CHECK(cudaHostGetDevicePointer((void**)&devB, (void*)b, 0));

  initVector(b, vLen);
  memset(a, 0, vLen*sizeof(float));

  int threads = 256;
  int blocks = vLen/threads;
  copyKernel<<<blocks, threads>>>(devA, devB);
  CUDA_CHECK(cudaGetLastError());
  CUDA_CHECK(cudaDeviceSynchronize());

  printf("Using cudaHostRegister: ");
  checkAnswer(a, b);
}

2.6.3.1.3. 统一内存与映射内存的比较

映射内存使 GPU 能够访问 CPU 内存,但并不保证所有系统都支持所有类型的访问,例如原子操作。统一内存保证支持所有访问类型。

映射内存始终位于 CPU 内存中,这意味着所有 GPU 访问都必须通过 CPU 与 GPU 之间的连接,即 PCIe 或 NVLink。通过这些链路进行访问的延迟显著高于访问 GPU 内存的延迟,而且可用的总带宽也更低。因此,所有核函数内存访问都使用映射内存不太可能充分利用 GPU 计算资源。

统一内存通常会迁移到访问它的处理器的物理内存中。首次迁移之后,核函数对同一内存页或缓存行的重复访问可以充分利用 GPU 内存带宽。

注意

在以前的文档中,映射内存也被称为零拷贝内存。

在所有 CUDA 应用程序都使用统一虚拟地址空间之前,需要额外的 API 来启用内存映射(cudaSetDeviceFlags搭配cudaDeviceMapHost)。这些 API 现在已不再需要。

从主机或其他 GPU 的角度来看,对映射主机内存执行操作的原子函数(参见原子函数)并不具有原子性。

CUDA 运行时要求,从主机和其他设备的角度来看,由设备发起的对主机内存的 1 字节、2 字节、4 字节、8 字节和 16 字节自然对齐的加载和存储必须保持为单次访问。在某些平台上,对内存的原子操作可能会被硬件拆分为独立的加载和存储操作。这些组成部分中的加载和存储操作在保持自然对齐访问方面有同样的要求。CUDA 运行时不支持 PCI Express 桥接器会拆分 8 字节自然对齐操作的 PCI Express 总线拓扑,并且 NVIDIA 尚不知道有任何拓扑会拆分 16 字节自然对齐的操作。

2.6.4. 总结

  • 在具有异构内存管理(HMM)或地址转换服务(ATS)的 Linux 平台上,所有由系统分配的内存都是托管内存

  • 在没有 HMM 或 ATS 的 Linux 平台、Tegra 处理器以及所有 Windows 平台上,必须使用 CUDA 分配托管内存:

    • cudaMallocManaged或

    • cudaMallocFromPoolAsync搭配使用allocType=cudaMemAllocationTypeManaged创建的内存池

    • 带有__managed__说明符的全局变量

  • 在 Windows 和 Tegra 处理器上,统一内存存在限制

  • 在具有 ATS 且通过 NVLINK C2C 连接的系统上,CPU 或其他 GPU 可以直接访问使用cudaMallocManaged分配的设备内存

2.7. NVCC:NVIDIA CUDA 编译器

NVIDIA CUDA 编译器nvcc是 NVIDIA 提供的工具链,用于编译 CUDA C/C++ 以及PTX代码。该工具链是CUDA Toolkit的一部分,由多个工具组成,包括编译器、链接器以及 PTX 和Cubin汇编器。顶层工具nvcc负责协调编译过程,为每个编译阶段调用相应的工具。

nvcc驱动 CUDA 代码的离线编译,与之相对的是由 CUDA 运行时编译器nvrtc驱动的在线编译或即时(JIT)编译。

本章介绍构建应用程序所需的nvcc最常见的用法和细节。有关nvcc的完整介绍,请参阅nvcc 文档。

2.7.1. CUDA 源文件和头文件

使用nvcc编译的源文件可以同时包含在 CPU 上执行的主机代码和在 GPU 上执行的设备代码。对于仅包含主机代码的文件,nvcc接受常见的 C/C++ 源文件扩展名.c、.cpp、.cc、.cxx;对于包含设备代码或混合主机代码和设备代码的文件,则接受.cu。包含设备代码的头文件通常采用.cuh扩展名,以区别于仅包含主机代码的头文件.h、.hpp、.hh、.hxx等。

文件扩展名

说明

内容

.c

C 源文件

仅主机代码

.cpp, .cc, .cxx

C++ 源文件

仅主机代码

.h, .hpp, .hh, .hxx

C/C++ 头文件

设备代码、主机代码、主机/设备混合代码

.cu

CUDA 源文件

设备代码、主机代码、主机/设备混合代码

.cuh

CUDA 头文件

设备代码、主机代码、主机/设备混合代码

2.7.2. NVCC 编译工作流

在初始阶段,nvcc将设备代码与主机代码分离,并分别将它们的编译工作交给 GPU 编译器和主机编译器。

为了编译主机代码,CUDA 编译器nvcc要求有可用的兼容主机编译器。CUDA Toolkit 定义了Linux和Windows平台的主机编译器支持策略。
仅包含主机代码的文件可以使用nvcc构建,也可以直接使用主机编译器构建。生成的目标文件可以在链接时与nvcc生成的包含 GPU 代码的目标文件组合。
GPU 编译器将 C/C++ 设备代码编译为 PTX 汇编代码。对于编译命令行中指定的每一种虚拟机指令集架构(例如compute_90),都会运行一次 GPU 编译器。
各份 PTX 代码随后被传递给ptxas工具,该工具为目标硬件 ISA 生成Cubin。硬件 ISA 由其SM 版本标识。
可以将多个 PTX 和 Cubin 目标嵌入到应用程序或库中的单个二进制Fatbin容器中,使单个二进制文件能够支持多种虚拟 ISA 和目标硬件 ISA。

上述工具的调用和协调由nvcc自动完成。-v选项可用于显示完整的编译工作流和工具调用。-keep选项可用于将编译过程中生成的中间文件保存在当前目录中,或者改为保存在--keep-dir指定的目录中。

以下示例说明了 CUDA 源文件example.cu的编译工作流:

// ----- example.cu -----
#include <stdio.h>
__global__ void kernel() {
    printf("Hello from kernel\n");
}

void kernel_launcher() {
    kernel<<<1, 1>>>();
    cudaDeviceSynchronize();
}

int main() {
    kernel_launcher();
    return 0;
}

nvcc的基本编译工作流:

nvcc 高层流程
图内文字中文对照
GPU Compiler
GPU 编译器
Host Compiler
主机编译器
CPU Binary Code
CPU 二进制代码
PTX Optimizing Assembler (ptxas)
PTX 优化汇编器(ptxas)
Executable / Library
可执行文件 / 库

nvcc针对多种 PTX 和 Cubin 架构的编译工作流:

面向多个架构的 nvcc 高层流程
图内文字中文对照
GPU Compiler
GPU 编译器
Host Compiler
主机编译器
CPU Binary Code
CPU 二进制代码
PTX Optimizing Assembler (ptxas)
PTX 优化汇编器(ptxas)
Executable
可执行文件

有关nvcc编译工作流的更详细说明,请参阅编译器文档。

2.7.3. NVCC 基本用法

使用nvcc编译 CUDA 源文件的基本命令为:

nvcc <source_file>.cu -o <output_file>

nvcc接受常见的编译器标志,用于指定包含目录-I <path>和库路径-L <path>、链接其他库-l<library>以及定义宏-D<macro>=<value>。

nvcc example.cu -I path_to_include/ -L path_to_library/ -lcublas -o <output_file>

2.7.3.1. NVCC 的 PTX 和 Cubin 生成

默认情况下,nvcc为 CUDA Toolkit 支持的最早 GPU 架构(最低的compute_XY和sm_XY版本)生成 PTX 和 Cubin,以最大限度地提高兼容性。

  • -arch选项可用于为特定的 GPU 架构生成 PTX 和 Cubin。

  • -gencode选项可用于为多种 GPU 架构生成 PTX 和 Cubin。

要获取所支持的虚拟 GPU 架构和真实 GPU 架构的完整列表,可以分别传递--list-gpu-code和--list-gpu-arch标志,或参阅nvcc文档中的虚拟架构列表和GPU 架构列表部分。

nvcc --list-gpu-code # list all supported real GPU architectures
nvcc --list-gpu-arch # list all supported virtual GPU architectures
nvcc example.cu -arch=compute_<XY> # e.g. -arch=compute_80 for NVIDIA Ampere GPUs and later
                                   # PTX-only, GPU forward compatible

nvcc example.cu -arch=sm_<XY>      # e.g. -arch=sm_80 for NVIDIA Ampere GPUs and later
                                   # PTX and Cubin, GPU forward compatible

nvcc example.cu -arch=native       # automatically detects and generates Cubin for the current GPU
                                   # no PTX, no GPU forward compatibility

nvcc example.cu -arch=all          # generate Cubin for all supported GPU architectures
                                   # also includes the latest PTX for GPU forward compatibility

nvcc example.cu -arch=all-major    # generate Cubin for all major supported GPU architectures, e.g. sm_80, sm_90,
                                   # also includes the latest PTX for GPU forward compatibility

更高级的用法允许分别指定 PTX 和 Cubin 目标:

# generate PTX for virtual architecture compute_80 and compile it to Cubin for real architecture sm_86, keep compute_80 PTX
nvcc example.cu -arch=compute_80 -gpu-code=sm_86,compute_80 # (PTX and Cubin)

# generate PTX for virtual architecture compute_80 and compile it to Cubin for real architecture sm_86, sm_89
nvcc example.cu -arch=compute_80 -gpu-code=sm_86,sm_89    # (no PTX)
nvcc example.cu -gencode=arch=compute_80,code=sm_86,sm_89 # same as above

# (1) generate PTX for virtual architecture compute_80 and compile it to Cubin for real architecture sm_86, sm_89
# (2) generate PTX for virtual architecture compute_90 and compile it to Cubin for real architecture sm_90
nvcc example.cu -gencode=arch=compute_80,code=sm_86,sm_89 -gencode=arch=compute_90,code=sm_90

有关用于控制 GPU 代码生成的nvcc命令行选项的完整参考资料,请参阅nvcc 文档。

2.7.3.2. 主机代码编译注意事项

不包含设备代码或符号的编译单元(即一个源文件及其头文件)可以直接使用主机编译器编译。如果任何编译单元使用 CUDA 运行时 API 函数,则应用程序必须链接 CUDA 运行时库。CUDA 运行时同时提供静态库和共享库,分别为libcudart_static和libcudart。默认情况下,nvcc链接静态 CUDA 运行时库。要使用 CUDA 运行时的共享库版本,请在编译或链接命令中向nvcc传递--cudart=shared标志。

nvcc允许通过-ccbin <compiler>参数指定用于主机函数的主机编译器。也可以定义环境变量NVCC_CCBIN来指定nvcc使用的主机编译器。nvcc的-Xcompiler参数会将参数透传给主机编译器。例如,在下面的示例中,-O3参数由nvcc传递给主机编译器。

nvcc example.cu -ccbin=clang++

export NVCC_CCBIN='gcc'
nvcc example.cu -Xcompiler=-O3

2.7.3.3. GPU 代码的分离编译

nvcc默认采用全程序编译,这种编译方式要求所有 GPU 代码和符号都存在于使用它们的编译单元中。CUDA 设备函数可以调用其他编译单元中定义的设备函数或访问其中定义的设备变量,但必须在nvcc命令行中指定-rdc=true标志或其别名-dc标志,才能启用来自不同编译单元的设备代码的链接。链接不同编译单元中的设备代码和符号的能力称为分离编译。

分离编译允许更灵活地组织代码,可以缩短编译时间,并且可以生成更小的二进制文件。与全程序编译相比,分离编译可能会带来一些构建时的复杂性。使用设备代码链接可能会影响性能,因此默认情况下不使用它。链接时优化(LTO)可以帮助减少分离编译的性能开销。

分离编译要求满足以下条件:

  • 在一个编译单元中定义的非const设备变量,在其他编译单元中必须使用extern关键字引用。

  • 所有const设备变量都必须使用extern关键字定义和引用。

  • 所有 CUDA 源文件.cu都必须使用-dc或-rdc=true标志进行编译。

主机函数和设备函数默认具有外部链接性,不需要extern关键字。请注意,从 CUDA 13 开始,__global__函数和__managed__/__device__/__constant__变量默认具有内部链接性。

在以下示例中,definition.cu定义了一个变量和一个函数,而example.cu引用了它们。两个文件分别编译,然后链接成最终的二进制文件。

// ----- definition.cu -----
extern __device__ int device_variable = 5;
__device__        int device_function() { return 10; }
// ----- example.cu -----
extern __device__ int  device_variable;
__device__        int device_function();

__global__ void kernel(int* ptr) {
    device_variable = 0;
    *ptr            = device_function();
}
nvcc -dc definition.cu -o definition.o
nvcc -dc example.cu    -o example.o
nvcc definition.o example.o -o program

2.7.4. 常用编译器选项

本节介绍可以与nvcc配合使用的最相关的编译器选项,涵盖语言特性、优化、调试、性能分析和构建等方面。所有选项的完整说明可在nvcc 文档中找到。

2.7.4.1. 语言特性

nvcc支持从 C++03 到C++23 语言特性的 C++ 核心语言特性。-std标志可用于指定要使用的语言标准:

  • --std={c++03|c++11|c++14|c++17|c++20|c++23}

此外,nvcc还支持以下语言扩展:

  • -restrict:断言所有核函数指针参数都是restrict指针。

  • -extended-lambda:允许在 lambda 声明中使用__host__、__device__注解。

  • -expt-relaxed-constexpr:(实验性标志)允许主机代码调用__device__ constexpr函数,并允许设备代码调用__host__ constexpr函数。

有关这些特性的更多详细信息,请参阅扩展 lambda和constexpr部分。

2.7.4.2. 调试选项

nvcc支持以下用于生成调试信息的选项:

  • -g:为主机代码生成调试信息。gdb/lldb和类似工具依靠此类信息来调试主机代码。

  • -G:为设备代码生成调试信息。cuda-gdb依靠此类信息来调试设备代码。该标志还会定义__CUDACC_DEBUG__宏。

  • -lineinfo:为设备代码生成行号信息。此选项不影响执行性能,与compute-sanitizer工具配合使用时,有助于跟踪核函数的执行。

nvcc默认对 GPU 代码使用最高优化级别-O3。调试标志-G会阻止某些编译器优化,因此调试代码的性能预计会低于非调试代码。可以定义-DNDEBUG标志来禁用运行时断言,因为这些断言也可能降低执行速度。

2.7.4.3. 优化选项

nvcc提供许多用于优化性能的选项。本节旨在简要概述一些开发者可能会觉得有用的可用选项,并提供更多信息的链接。完整介绍可在nvcc 文档中找到。

  • -Xptxas将参数传递给 PTX 汇编器工具ptxas。nvcc文档提供了ptxas的实用参数列表。例如,-Xptxas=-maxrregcount=N指定每个线程可使用的最大寄存器数量。

  • -extra-device-vectorization:启用更激进的设备代码向量化。

  • --apply-controls=/path/to/file将高级控制文件(ACF)传递给 nvcc 和 ptxas。此文件会更改默认的编译行为,使其更有针对性地适应特定工作负载。使用高级控制文件可能导致编译失败或运行时执行不正确。使用风险自负。有关如何生成高级控制文件的更多信息,请参阅CompileIQ Github 页面。

  • 有关对浮点行为提供细粒度控制的其他标志,请参阅浮点计算部分以及nvcc 文档。

以下标志可获取编译器的输出,这些输出对于更高级的代码优化可能很有用:

  • -res-usage:编译后打印资源使用报告。报告包括为每个核函数分配的寄存器数量以及共享内存、常量内存和局部内存的用量。

  • -opt-info=inline:打印有关内联函数的信息。

  • -Xptxas=-warn-lmem-usage:如果使用了局部内存,则发出警告。

  • -Xptxas=-warn-spills:如果寄存器溢出到局部内存,则发出警告。

2.7.4.5. 性能分析选项

可以使用Nsight Compute和Nsight Systems工具直接对 CUDA 应用程序进行性能分析,而无需在编译过程中添加额外的标志。不过,nvcc可以生成额外的信息,通过将源文件与生成的代码关联起来辅助性能分析:

  • -lineinfo:为设备代码生成行号信息;这允许在性能分析工具中查看源代码。性能分析工具要求原始源代码位于编译该代码时所在的同一位置。

  • -src-in-ptx:在 PTX 中保留原始源代码,避免上述-lineinfo的局限。需要-lineinfo。

2.7.4.6. Fatbin 压缩

nvcc默认会压缩存储在应用程序或库的二进制文件中的fatbin。可以使用以下选项控制 fatbin 压缩:

  • -no-compress:禁用 fatbin 压缩。

  • --compress-mode={default|size|speed|balance|none}:设置压缩模式。speed侧重于缩短解压缩时间,而size旨在减小 fatbin 大小。balance在速度和大小之间提供折中。默认模式为speed。none禁用压缩。

2.7.4.7. 编译器性能控制

nvcc提供了用于分析和加速编译过程本身的选项:

  • -t <N>:针对多个 GPU 架构并行编译单个编译单元时使用的 CPU 线程数。

  • -split-compile <N>:用于并行执行优化阶段的 CPU 线程数。

  • -split-compile-extended <N>:更激进的拆分编译形式。需要链接时优化。

  • -Ofc <N>:设备代码编译速度的级别。

  • -time <filename>:生成一个逗号分隔值(CSV)表,其中包含每个编译阶段所花费的时间。

  • -fdevice-time-trace:生成设备代码编译的时间跟踪记录。

3.1. 高级 CUDA API 和特性

本节将介绍更高级的 CUDA API 和特性的使用。这些主题涵盖的技术或特性通常不需要修改 CUDA 核函数,但仍然可以从主机端影响应用程序级别的行为,既涉及 GPU 工作的执行和性能,也涉及 CPU 端的性能。

3.1.1. cudaLaunchKernelEx

在最初的版本中引入三重尖括号表示法时,核函数的核函数配置只有四个可编程参数:

  • 线程块维度

  • 网格维度

  • 动态共享内存(可选,未指定时为 0)

  • 流(未指定时使用默认流)

某些 CUDA 特性可以受益于随核函数启动一起提供的附加属性和提示。cudaLaunchKernelEx使程序能够通过cudaLaunchConfig_t结构设置上述执行配置参数。此外,cudaLaunchConfig_t结构允许程序传入零个或多个cudaLaunchAttributes,以控制核函数启动的其他参数或为这些参数提供建议。例如,本章稍后讨论的cudaLaunchAttributePreferredSharedMemoryCarveout(请参阅配置 L1/共享内存平衡)使用cudaLaunchKernelEx指定。本章稍后讨论的cudaLaunchAttributeClusterDimension属性用于指定核函数启动所需的簇大小。

支持的属性的完整列表及其含义记录在CUDA 运行时 API 参考文档中。

3.1.2. 启动簇:

前面各节介绍的线程块簇是在计算能力 9.0 及更高版本中提供的一种可选线程块组织层级,使应用程序能够保证一个簇中的线程块在单个 GPC 上同时执行。这使得规模超过单个 SM 容量的线程组能够相互交换数据并同步。

第 2.1.10.1 节这一节展示了如何使用三重尖括号表示法指定并启动使用簇的核函数。在该节中,使用__cluster_dims__注解指定了启动核函数时必须使用的簇维度。使用三重尖括号表示法时,簇的大小是隐式确定的。

3.1.2.1. 使用 cudaLaunchKernelEx 以簇方式启动

与使用三重尖括号表示法以簇方式启动核函数不同,线程块簇的大小可以针对每次启动进行配置。下面的代码示例展示了如何使用cudaLaunchKernelEx启动簇核函数。

// Kernel definition
// No compile time attribute attached to the kernel
__global__ void cluster_kernel(float *input, float* output)
{

}

int main()
{
    float *input, *output;
    dim3 threadsPerBlock(16, 16);
    dim3 numBlocks(N / threadsPerBlock.x, N / threadsPerBlock.y);

    // Kernel invocation with runtime cluster size
    {
        cudaLaunchConfig_t config = {0};
        // The grid dimension is not affected by cluster launch, and is still enumerated
        // using number of blocks.
        // The grid dimension should be a multiple of cluster size.
        config.gridDim = numBlocks;
        config.blockDim = threadsPerBlock;

        cudaLaunchAttribute attribute[1];
        attribute[0].id = cudaLaunchAttributeClusterDimension;
        attribute[0].val.clusterDim.x = 2; // Cluster size in X-dimension
        attribute[0].val.clusterDim.y = 1;
        attribute[0].val.clusterDim.z = 1;
        config.attrs = attribute;
        config.numAttrs = 1;

        cudaLaunchKernelEx(&config, cluster_kernel, input, output);
    }
}

有两种cudaLaunchAttribute类型与线程块簇簇相关:cudaLaunchAttributeClusterDimension和cudaLaunchAttributePreferredClusterDimension。

属性 IDcudaLaunchAttributeClusterDimension指定执行簇时所需的维度。该属性的值clusterDim是一个三维值。网格的对应维度(x、y 和 z)必须能够被指定簇维度的相应维度整除。设置该属性类似于编译时在核函数定义上使用__cluster_dims__属性,如使用三重尖括号表示法以簇方式启动所示,但可以在运行时针对同一核函数的不同启动进行更改。

在计算能力为 10.0 及更高版本的 GPU 上,另一个属性 IDcudaLaunchAttributePreferredClusterDimension允许应用程序额外为簇指定首选维度。首选维度必须是核函数上的__cluster_dims__属性或传给cudaLaunchKernelEx的cudaLaunchAttributeClusterDimension属性所指定的最小簇维度的整数倍。也就是说,除了首选簇维度之外,还必须指定最小簇维度。网格的对应维度(x、y 和 z)必须能够被指定的首选簇维度的相应维度整除。

所有线程块都将在维度至少为最小簇维度的簇中执行。在可能的情况下,将使用首选维度的簇,但并不保证所有簇都以首选维度执行。所有线程块都将在具有最小簇维度或首选簇维度的簇中执行。对于使用首选簇维度的核函数,编写时必须确保它们在最小簇维度或首选簇维度下都能正确运行。

3.1.2.2. 将线程块视为簇

使用__cluster_dims__注解定义核函数时,网格中的簇数是隐式的,可以通过将网格大小除以指定的簇大小来计算。

__cluster_dims__((2, 2, 2)) __global__ void foo();

// 8x8x8 clusters each with 2x2x2 thread blocks.
foo<<<dim3(16, 16, 16), dim3(1024, 1, 1)>>>();

在上面的示例中,核函数作为一个包含 16x16x16 个线程块的网格启动,这意味着使用了一个包含 8x8x8 个簇的网格。

核函数也可以使用__block_size__注解,该注解在定义核函数时同时指定所需的线程块大小和簇大小。使用此注解时,三重尖括号启动中的网格维度将以簇而不是线程块为单位,如下所示。

// Implementation detail of how many threads per block and blocks per cluster
// is handled as an attribute of the kernel.
__block_size__((1024, 1, 1), (2, 2, 2)) __global__ void foo();

// 8x8x8 clusters.
foo<<<dim3(8, 8, 8)>>>();

__block_size__需要两个字段,每个字段都是一个包含 3 个元素的元组。第一个元组表示线程块维度,第二个元组表示簇大小。如果未传递第二个元组,则假定其为(1,1,1)。在核函数启动期间指定动态共享内存大小和/或流时,<<<>>>中的第二个参数必须是占位符1。任何其他值都会导致未定义行为。

注意,不允许同时指定__block_size__的第二个元组和__cluster_dims__。也不允许将__block_size__与空的__cluster_dims__一起使用。指定__block_size__的第二个元组时,意味着启用了“将线程块视为簇”,编译器会将<<<>>>中的第一个参数识别为簇数,而不是线程块数。

3.1.3. 关于流和事件的更多内容

CUDA 流介绍了 CUDA 流的基础知识。默认情况下,在给定 CUDA 流上提交的操作是串行化的:一个操作必须等到前一个操作完成后才能开始执行。唯一的例外是最近添加的程序化依赖启动与同步特性。使用多个 CUDA 流是启用并发执行的一种方式;另一种方式是使用CUDA 图。这两种方法也可以结合使用。

在不同 CUDA 流上提交的工作,在特定情况下可能会并发执行,例如,不存在事件依赖、不存在隐式同步、资源充足等。

如果在来自不同 CUDA 流的独立操作之间向 NULL 流提交了任何 CUDA 操作,则这些独立操作无法并发运行,除非这些流是非阻塞 CUDA 流。非阻塞 CUDA 流是使用带有cudaStreamNonBlocking标志的cudaStreamCreateWithFlags()运行时 API 创建的流。为了提高 GPU 工作并发执行的可能性,建议用户创建非阻塞 CUDA 流。

还建议用户选择足以解决其问题且适用范围最小的同步选项。例如,如果要求 CPU 等待(阻塞),直到特定 CUDA 流上的所有工作完成,那么对该流使用cudaStreamSynchronize()会优于使用cudaDeviceSynchronize(),因为后者会不必要地等待该设备所有 CUDA 流上的 GPU 工作完成。如果要求 CPU 以非阻塞方式等待,那么使用cudaStreamQuery()并在轮询循环中检查其返回值可能更为合适。

使用 CUDA 事件(CUDA 事件)也可以实现类似的同步效果,例如,在该流上记录一个事件,然后调用cudaEventSynchronize()以阻塞方式等待该事件所捕获的工作完成。同样,与使用cudaDeviceSynchronize()相比,这样做更合适,也更有针对性。调用cudaEventQuery()并检查其返回值(例如在轮询循环中)则是一种非阻塞的替代方式。

如果此操作位于应用程序的关键路径上,显式同步方法的选择就尤为重要。表 4概要总结了与主机同步的各种选项。

表 4 与主机进行显式同步的选项汇总

等待特定流

等待特定事件

等待设备上的所有工作

非阻塞(需要轮询循环)

cudaStreamQuery()

cudaEventQuery()

N/A

阻塞

cudaStreamSynchronize()

cudaEventSynchronize()

cudaDeviceSynchronize()

对于 CUDA 流之间的同步,即表达依赖关系,建议使用不计时的 CUDA 事件,如CUDA 事件中所述。用户可以调用cudaStreamWaitEvent(),强制以后在特定流上提交的操作等待先前记录的事件(例如,在另一个流上记录的事件)完成。注意,对于任何等待或查询事件的 CUDA API,用户都有责任确保已经调用了 cudaEventRecord API,因为未记录的事件将始终返回成功。

CUDA 事件默认带有计时信息,因为它们可以用于cudaEventElapsedTime()API 调用。然而,仅用于表达跨流依赖关系的 CUDA 事件不需要计时信息。对于此类情况,建议在创建事件时禁用计时信息,以提高性能。可以通过使用带有cudaEventDisableTiming标志的cudaEventCreateWithFlags()API 来实现这一点。

3.1.3.1. 流优先级

可以在创建流时使用cudaStreamCreateWithPriority()指定流的相对优先级。可以使用cudaDeviceGetStreamPriorityRange()函数获取允许的优先级范围,其顺序为 [最高优先级,最低优先级]。在运行时,GPU 调度器利用流优先级来确定任务执行顺序,但这些优先级只是提示,而非保证。在选择要启动的工作时,较高优先级流中等待执行的任务优先于较低优先级流中的任务。较高优先级任务不会抢占已经在运行的较低优先级任务。GPU 在任务执行期间不会重新评估工作队列,提高流的优先级也不会中断正在进行的工作。流优先级会影响任务执行,但不会强制实施严格的顺序,因此用户可以利用流优先级来影响任务执行,而不依赖严格的顺序保证。

以下代码示例获取当前设备允许的优先级范围,并创建两个非阻塞 CUDA 流,分别具有可用的最高和最低优先级。

// get the range of stream priorities for this device
int leastPriority, greatestPriority;
cudaDeviceGetStreamPriorityRange(&leastPriority, &greatestPriority);

// create streams with highest and lowest available priorities
cudaStream_t st_high, st_low;
cudaStreamCreateWithPriority(&st_high, cudaStreamNonBlocking, greatestPriority));
cudaStreamCreateWithPriority(&st_low, cudaStreamNonBlocking, leastPriority);

3.1.3.2. 显式同步

如前所述,流可以通过多种方式与其他流同步。下面给出了不同粒度下的常见方法:

  • cudaDeviceSynchronize()等待,直到所有主机线程的所有流中先前的命令全部完成。

  • cudaStreamSynchronize()接受一个流作为参数,并等待,直到给定流中先前的命令全部完成。它可用于使主机与特定流同步,同时允许其他流继续在设备上执行。

  • cudaStreamWaitEvent()接受一个流和一个事件作为参数(有关事件的说明,请参阅CUDA 事件),并使调用cudaStreamWaitEvent()之后添加到给定流中的所有命令延迟执行,直到给定事件完成。

  • cudaStreamQuery()为应用程序提供了一种了解流中先前的命令是否已全部完成的方法。

3.1.3.3. 隐式同步

如果主机线程在来自不同流的两个命令之间发出了以下任一操作,则这两个命令无法并发运行:

  • 分配页锁定主机内存

  • 分配设备内存

  • 设置设备内存

  • 在同一设备内存的两个地址之间进行内存复制

  • 向 NULL 流发出的任何 CUDA 命令

  • 在 L1/共享内存配置之间切换

需要依赖检查的操作包括:与所检查的启动位于同一流中的任何其他命令,以及对该流的任何cudaStreamQuery()调用。因此,应用程序应遵循以下准则,以提高核函数并发执行的可能性:

  • 所有独立操作都应在依赖操作之前发出,

  • 任何类型的同步都应尽可能推迟。

3.1.4. 程序化依赖核函数启动

如前所述,CUDA 流的语义要求核函数按顺序执行。这样一来,如果有两个连续的核函数,其中第二个核函数依赖第一个核函数的结果,程序员就可以确信,当第二个核函数开始执行时,所依赖的数据已经可用。然而,可能存在这样的情况:第一个核函数已经将后续核函数所依赖的数据写入全局内存,但它仍有更多工作要做。同样,具有依赖关系的第二个核函数在需要第一个核函数的数据之前,也可能有一些独立的工作。在这种情况下,两个核函数的执行可以部分重叠(假设硬件资源可用)。这种重叠也可以使第二个核函数的启动开销重叠。除了硬件资源的可用性之外,可以达到的重叠程度还取决于核函数的具体结构,例如

  • 第一个核函数在其执行过程中的何时完成第二个核函数所依赖的工作?

  • 第二个核函数在其执行过程中的何时开始处理来自第一个核函数的数据?

由于这在很大程度上取决于所涉及的具体核函数,因此很难完全自动化,所以 CUDA 提供了一种机制,允许应用程序开发者指定两个核函数之间的同步点。这是通过一种称为可编程依赖核函数启动的技术实现的。下图展示了这种情况。

可编程依赖核函数启动
图内文字中文对照
Duration without PDL
不使用 PDL 时的持续时间
Duration with PDL
使用 PDL 时的持续时间
Kernel 1
核函数 1
Dependent Kernel
依赖核函数
Data in HBM
HBM 中的数据
Kernel 1 finishes writing to HBM
核函数 1 完成向 HBM 写入
Dependent Kernel starts using from HBM
依赖核函数开始使用 HBM 中的数据

PDL 有三个主要组成部分。

  1. 第一个核函数(即所谓的主核函数)需要调用一个特殊函数,以表明它已完成后续具有依赖关系的核函数(也称为次级核函数)所需的全部工作。这通过调用函数cudaTriggerProgrammaticLaunchCompletion()来完成。

  2. 相应地,具有依赖关系的次级核函数需要表明,它已执行到其工作中独立于主核函数的部分,并且现在正在等待主核函数完成它所依赖的工作。这通过函数cudaGridDependencySynchronize()来完成。

  3. 第二个核函数需要使用特殊属性cudaLaunchAttributeProgrammaticStreamSerialization启动,并将该属性的programmaticStreamSerializationAllowed字段设置为“1”。

以下代码片段展示了如何实现这一点的示例。

代码清单 3 使用两个核函数进行可编程依赖核函数启动的示例
__global__ void primary_kernel() {
    // Initial work that should finish before starting secondary kernel

    // Trigger the secondary kernel
    cudaTriggerProgrammaticLaunchCompletion();

    // Work that can coincide with the secondary kernel
}

__global__ void secondary_kernel()
{
    // Initialization, Independent work, etc.

    // Will block until all primary kernels the secondary kernel is dependent on have
    // completed and flushed results to global memory
    cudaGridDependencySynchronize();

    // Dependent work
}

// Launch the secondary kernel with the special attribute

// Set Up the attribute
cudaLaunchAttribute attribute[1];
attribute[0].id = cudaLaunchAttributeProgrammaticStreamSerialization;
attribute[0].val.programmaticStreamSerializationAllowed = 1;

// Set the attribute in a kernel launch configuration
 cudaLaunchConfig_t config = {0};

// Base launch configuration
config.gridDim = grid_dim;
config.blockDim = block_dim;
config.dynamicSmemBytes= 0;
config.stream = stream;

// Add special attribute for PDL
config.attrs = attribute;
config.numAttrs = 1;

// Launch primary kernel
primary_kernel<<<grid_dim, block_dim, 0, stream>>>();

// Launch secondary (dependent) kernel using the configuration with
// the attribute
cudaLaunchKernelEx(&config, secondary_kernel);

3.1.5. 批量内存传输

CUDA 开发中的一种常见模式是使用批处理技术。宽泛地说,批处理是指将若干个(通常较小的)任务组合成一个(通常较大的)操作。一批任务中的各个组成部分不一定都必须相同,尽管它们通常是相同的。cuBLAS 提供的批量矩阵乘法操作就是这一思路的一个示例。

一般而言,与 CUDA Graphs 和 PDL 一样,批处理的目的是减少分别调度一批任务中的各个任务所带来的开销。对于内存传输,启动一次内存传输可能会产生一些 CPU 和驱动程序开销。此外,当前形式的常规cudaMemcpyAsync()函数不一定能为驱动程序提供足够的信息来优化传输,例如有关源和目标的提示。在 Tegra 平台上,可以选择使用 SM 或复制引擎(CE)执行传输。目前,具体选择哪一种由驱动程序中的启发式方法决定。这可能很重要,因为使用 SM 可能会使传输更快,但也会占用一部分可用的计算能力。另一方面,使用 CE 可能会使传输较慢,但应用程序的整体性能更高,因为这样可让 SM 空闲出来执行其他工作。

这些考虑促成了cudaMemcpyBatchAsync()函数(及其相关函数cudaMemcpyBatch3DAsync())的设计。这些函数允许优化批量内存传输。除了源指针和目标指针列表外,该 API 还使用内存复制属性来指定对顺序的预期,并提供源位置和目标位置的提示,以及是否倾向于使传输与计算重叠的提示(目前仅在具有 CE 的 Tegra 平台上支持这一点)。

首先考虑最简单的情况,即将数据从页锁定主机内存简单地批量传输到页锁定设备内存

代码清单 4 从页锁定主机内存到页锁定设备内存的同构批量内存传输示例
std::vector<void *> srcs(batch_size);
std::vector<void *> dsts(batch_size);
std::vector<size_t> sizes(batch_size);

// Allocate the source and destination buffers
// initialize with the stream number
for (size_t i = 0; i < batch_size; i++) {
    cudaMallocHost(&srcs[i], sizes[i]);
    cudaMalloc(&dsts[i], sizes[i]);
    cudaMemsetAsync(srcs[i], sizes[i], stream);
}

// Setup attributes for this batch of copies
cudaMemcpyAttributes attrs = {};
attrs.srcAccessOrder = cudaMemcpySrcAccessOrderStream;

// All copies in the batch have same copy attributes.
size_t attrsIdxs = 0;  // Index of the attributes

// Launch the batched memory transfer
cudaMemcpyBatchAsync(&dsts[0], &srcs[0], &sizes[0], batch_size,
    &attrs, &attrsIdxs, 1 /*numAttrs*/, nullptr /*failIdx*/, stream);

cudaMemcpyBatchAsync()函数的前几个参数看起来很容易理解。它们由包含源指针和目标指针以及传输大小的数组组成。每个数组都必须有 batch_size 个元素。新的信息来自属性。该函数需要一个指向属性数组的指针,以及一个与之对应的属性索引数组。原则上,还可以传入一个size_t数组,用于记录任何失败传输的索引,不过,在此处传入nullptr也是安全的,在这种情况下,只是不记录失败的索引。

再来看属性,在此示例中,传输是同构的。因此,我们仅使用一个属性,它将应用于所有传输。这由attrIndex参数控制。原则上,它可以是一个数组。数组的第i个元素包含属性数组中第i个元素所适用的第一个传输的索引。在本例中,attrIndex被视为一个单元素数组,其值为“0”,表示attribute[0]将应用于索引为 0 及以上的所有传输,换句话说,就是所有传输。

最后,请注意,我们已将srcAccessOrder属性设置为cudaMemcpySrcAccessOrderStream。这意味着将按照常规的流顺序访问源数据。换句话说,memcpy 将阻塞,直到先前处理这些源指针和目标指针中任意指针所指向数据的核函数完成为止。

在下一个示例中,我们将考虑一种更复杂的异构批量传输情况。

代码清单 5 使用部分临时主机内存向页锁定设备内存进行异构批量内存传输的示例
std::vector<void *> srcs(batch_size);
std::vector<void *> dsts(batch_size);
std::vector<size_t> sizes(batch_size);

// Allocate the src and dst buffers
for (size_t i = 0; i < batch_size - 10; i++) {
    cudaMallocHost(&srcs[i], sizes[i]);
    cudaMalloc(&dsts[i], sizes[i]);
}

int buffer[10];

for (size_t i = batch_size - 10; i < batch_size; i++) {
    srcs[i] = &buffer[10 - (batch_size - i];
    cudaMalloc(&dsts[i], sizes[i]);
}

// Setup attributes for this batch of copies
cudaMemcpyAttributes attrs[2] = {};
attrs[0].srcAccessOrder = cudaMemcpySrcAccessOrderStream;
attrs[1].srcAccessOrder = cudaMemcpySrcAccessOrderDuringApiCall;

size_t attrsIdxs[2];
attrsIdxs[0] = 0;
attrsIdxs[1] = batch_size - 10;

// Launch the batched memory transfer
cudaMemcpyBatchAsync(&dsts[0], &srcs[0], &sizes[0], batch_size,
    &attrs, &attrsIdxs, 2 /*numAttrs*/, nullptr /*failIdx*/, stream);

这里有两种传输:从页锁定主机内存到页锁定设备内存的batch_size-10次传输,以及从主机数组到页锁定设备内存的 10 次传输。此外,buffer数组不仅位于主机上,而且仅存在于当前作用域内,其地址就是所谓的临时指针。此指针在 API 调用完成后可能不再有效(该调用是异步的)。要使用此类临时指针执行复制,必须将属性中的srcAccessOrder设置为cudaMemcpySrcAccessOrderDuringApiCall。

现在我们有两个属性,第一个属性适用于索引从 0 开始且小于batch_size-10的所有传输。第二个属性适用于索引从batch_size-10开始且小于batch_size的所有传输。

如果我们不是从栈上分配buffer数组,而是使用malloc从堆上分配它,那么数据就不再是临时的。它会一直有效,直到该指针被显式释放。在这种情况下,如何暂存复制的最佳选择将取决于系统是否具有硬件托管内存,或者 GPU 是否能通过地址转换以相干方式访问主机内存;如果具备这些条件,则最好使用流顺序,否则立即暂存传输才最为合理。在这种情况下,应将属性的srcAccessOrder设为值cudaMemcpyAccessOrderAny。

cudaMemcpyBatchAsync函数还允许程序员提供有关源位置和目标位置的提示。这通过设置cudaMemcpyAttributes结构的srcLocation和dstLocation字段来完成。这两个srcLocation``and ``dstLocation字段的类型均为cudaMemLocation,它是一个包含位置类型和位置 ID 的结构。这与使用cudaMemPrefetchAsync()时可用于向运行时提供预取提示的cudaMemLocation结构相同。下面的代码示例展示了如何为从设备到主机特定 NUMA 节点的传输设置提示:

代码清单 6 设置源位置和目标位置提示的示例
// Allocate the source and destination buffers
std::vector<void *> srcs(batch_size);
std::vector<void *> dsts(batch_size);
std::vector<size_t> sizes(batch_size);

// cudaMemLocation structures we will use tp provide location hints
// Device device_id
cudaMemLocation srcLoc = {cudaMemLocationTypeDevice, dev_id};

// Host with numa Node numa_id
cudaMemLocation dstLoc = {cudaMemLocationTypeHostNuma, numa_id};

// Allocate the src and dst buffers
for (size_t i = 0; i < batch_size; i++) {
    cudaMallocManaged(&srcs[i], sizes[i]);
    cudaMallocManaged(&dsts[i], sizes[i]);

    cudaMemPrefetchAsync(srcs[i], sizes[i], srcLoc, 0, stream);
    cudaMemPrefetchAsync(dsts[i], sizes[i], dstLoc, 0, stream);
    cudaMemsetAsync(srcs[i], sizes[i], stream);
}

// Setup attributes for this batch of copies
cudaMemcpyAttributes attrs = {};

// These are managed memory pointers so Stream Order is appropriate
attrs.srcAccessOrder = cudaMemcpySrcAccessOrderStream;

// Now we can specify the location hints here.
attrs.srcLocHint = srcLoc;
attrs.dstlocHint = dstLoc;

// All copies in the batch have same copy attributes.
size_t attrsIdxs = 0;

// Launch the batched memory transfer
cudaMemcpyBatchAsync(&dsts[0], &srcs[0], &sizes[0], batch_size,
    &attrs, &attrsIdxs, 1 /*numAttrs*/, nullptr /*failIdx*/, stream);

最后要介绍的是用于提示我们希望使用 SM 还是 CE 进行传输的标志。用于此目的的字段是cudaMemcpyAttributesflags::flags,可能的取值如下:

  • cudaMemcpyFlagDefault:默认行为

  • cudaMemcpyFlagPreferOverlapWithCompute:这提示系统应优先使用 CE 进行传输,使传输与计算重叠。

总之,关于“cudaMemcpyBatchAsync”的要点如下:

  • cudaMemcpyBatchAsync函数(及其 3D 变体)允许程序员指定一批内存传输,从而分摊传输设置开销。

  • 除了源指针、目标指针和传输大小之外,该函数还可以接受一个或多个内存复制属性,用于提供有关所传输内存的类型及源指针对应的流顺序行为的信息、有关源位置和目标位置的提示,以及是否优先选择让传输与计算重叠(如果可能),或者是否使用 SM 进行传输的提示。

  • 根据上述信息,运行时可以尝试尽最大可能优化传输。

3.1.6. 环境变量

CUDA 提供了各种环境变量(参见第 5.2 节),这些变量可能会影响执行和性能。如果未显式设置它们,CUDA 会为其使用合理的默认值,但可能需要根据具体情况进行特殊处理,例如用于调试或提高性能。

例如,可能需要增大CUDA_DEVICE_MAX_CONNECTIONS环境变量的值,以降低来自不同 CUDA 流的独立工作因伪依赖而被串行化的可能性。当使用相同的底层资源时,可能会引入此类伪依赖。建议先使用默认值,仅在出现性能问题时才探索此环境变量的影响(例如,不同 CUDA 流中的独立工作出现了意外的串行化,而且无法归因于缺少可用 SM 资源等其他因素)。值得注意的是,在 MPS 情况下,此环境变量的默认值有所不同(更低)。

类似地,对于延迟敏感的应用程序,将CUDA_MODULE_LOADING环境变量设置为EAGER可能更为合适,以便将模块加载引起的所有开销移至应用程序的初始化阶段,避开其关键阶段。当前默认模式是延迟模块加载。在此默认模式下,可以在应用程序的初始化阶段添加对各个核函数的“预热”调用,强制模块加载更早发生,从而实现与立即模块加载类似的效果。

有关各种 CUDA 环境变量的更多详细信息,请参阅CUDA 环境变量。建议在启动应用程序之前将环境变量设置为新值;尝试在应用程序内部设置它们可能不会生效。

3.2. 高级核函数编程

本章将首先更深入地探讨 NVIDIA GPU 的硬件模型,然后介绍 CUDA 核函数代码中可用的一些旨在提高核函数性能的更高级功能。本章将介绍与线程作用域、异步执行以及相关同步原语有关的一些概念。这些概念性讨论为核函数代码中可用的一些高级性能功能提供了必要的基础。

本编程指南下一部分中专门介绍这些功能的章节,包含其中一些功能的详细说明。

3.2.1. 使用 PTX

并行线程执行(PTX)是 CUDA 用于抽象硬件 ISA 的虚拟机指令集架构(ISA),已在第 1.3.3 节中介绍。直接使用 PTX 编写代码是一项非常高级的优化技术,对大多数开发者而言并无必要,应将其视为最后才使用的手段。尽管如此,在某些情况下,直接编写 PTX 所提供的细粒度控制能够提高特定应用程序的性能。这些情况通常出现在应用程序中对性能非常敏感的部分,在这些部分中,任何不足百分之一的性能提升都会带来显著收益。所有可用的 PTX 指令均列于PTX ISA 文档中。

cuda::ptx命名空间

在代码中直接使用 PTX 的一种方法是使用libcu++中的cuda::ptx命名空间。该命名空间提供了直接映射到 PTX 指令的 C++ 函数,从而简化了在 C++ 应用程序中使用这些指令的方式。有关更多信息,请参阅cuda::ptx 命名空间文档。

内联 PTX

在代码中包含 PTX 的另一种方法是使用内联 PTX。相应的文档中详细描述了此方法。这与在 CPU 上编写汇编代码非常相似。

3.2.2. 硬件实现

流式多处理器(SM,参见GPU 硬件模型)旨在并发执行数百个线程。为了管理如此大量的线程,它采用了一种独特的并行计算模型,称为单指令、多线程,或SIMT,该模型在SIMT 执行模型中进行了说明。指令采用流水线执行,既利用单个线程内的指令级并行性,也通过同时硬件多线程利用广泛的线程级并行性,详见硬件多线程。与 CPU 核心不同,SM 按顺序发出指令,不执行分支预测或推测执行。

SIMT 执行模型和硬件多线程两节描述了所有设备共有的 SM 架构特性。计算能力一节提供了不同计算能力设备的具体信息。

NVIDIA GPU 架构使用小端表示法。

3.2.2.1. SIMT 执行模型

每个 SM 都以一组 32 个并行线程为单位创建、管理、调度和执行线程,这样的一组线程称为线程束。组成一个线程束的各个线程从相同的程序地址一起开始执行,但它们具有各自的指令地址计数器和寄存器状态,因此可以自由地独立分支和执行。线程束一词源于编织,这是最早的并行线技术。半线程束是一个线程束的前半部分或后半部分。四分之一线程束是一个线程束的第一个、第二个、第三个或第四个四分之一部分。

线程束一次执行一条共同的指令,因此,当线程束中的全部 32 个线程遵循相同的执行路径时,就能实现完全的执行效率。如果线程束中的线程通过依赖数据的条件分支发生分歧,线程束就会执行所采取的每条分支路径,并禁用不在该路径上的线程。分支分歧仅发生在线程束内部;不同线程束独立执行,无论它们执行的是相同的代码路径还是互不相交的代码路径。

SIMT 架构与 SIMD(单指令、多数据)向量组织方式相似,都是由一条指令控制多个处理单元。一个关键区别在于,SIMD 向量组织方式向软件暴露 SIMD 宽度,而 SIMT 指令则指定单个线程的执行和分支行为。与 SIMD 向量机不同,SIMT 既使程序员能够为独立的标量线程编写线程级并行代码,也能够为相互协调的线程编写数据并行代码。就正确性而言,程序员基本上可以忽略 SIMT 行为;但是,注意使代码很少要求线程束中的线程发生分歧,可以显著提高性能。在实践中,这类似于缓存行的作用:以正确性为目标进行设计时,可以放心地忽略缓存行大小,但以峰值性能为目标进行设计时,则必须在代码结构中考虑缓存行大小。另一方面,向量架构要求软件将加载合并为向量,并手动管理分歧。

3.2.2.1.1. 独立线程调度

在计算能力低于 7.0 的 GPU 上,线程束使用一个由线程束中全部 32 个线程共享的程序计数器,以及一个用于指定线程束中活跃线程的活跃掩码。因此,同一线程束中处于分歧区域或不同执行状态的线程无法相互发送信号或交换数据,而需要对由锁或互斥量保护的数据进行细粒度共享的算法可能导致死锁,具体取决于发生竞争的线程来自哪个线程束。

在计算能力为 7.0 及更高的 GPU 中,独立线程调度允许线程之间实现完全并发,而不受线程束的限制。使用独立线程调度时,GPU 为每个线程维护执行状态,包括程序计数器和调用栈,并且可以按单个线程的粒度让出执行权,以便更好地利用执行资源,或者允许一个线程等待另一个线程生成数据。调度优化器决定如何将同一线程束中的活跃线程组合为 SIMT 单元。这保留了此前 NVIDIA GPU 中 SIMT 执行的高吞吐量,但具有大得多的灵活性:线程现在可以在线程束以下的粒度上发生分歧和重新汇合。

独立线程调度可能破坏依赖以往 GPU 架构中隐式线程束同步行为的代码。线程束同步代码假定同一线程束中的线程在每条指令上都锁步执行,但线程能够在线程束以下的粒度上发生分歧和重新汇合,使得这些假定不再成立。这可能导致参与代码执行的线程集合与预期不同。应重新检查为计算能力 7.0 之前的 GPU 开发的所有线程束同步代码(例如无需同步的线程束内归约),以确保兼容性。开发者应使用__syncwarp()对此类代码进行显式同步,以确保它在所有代际的 GPU 上都能正确运行。

注意

线程束中参与当前指令的线程称为活跃线程,而不处于当前指令上的线程则是非活跃的(已禁用)。线程可能由于多种原因而处于非活跃状态,包括比其所在线程束中的其他线程更早退出,采取了不同于线程束当前所执行分支路径的分支路径,或者是线程数并非线程束大小整数倍的线程块中的最后几个线程。

如果线程束执行的一条非原子指令使该线程束中的多个线程写入全局内存或共享内存中的同一位置,那么对该位置发生的串行化写入次数可能因设备的计算能力而异。但是,对于所有计算能力,由哪个线程执行最后一次写入都是未定义的。

如果线程束执行的一条原子指令使该线程束中的多个线程读取、修改并写入全局内存中的同一位置,那么对该位置的每次读取/修改/写入都会发生,并且它们全部串行化,但发生的顺序是未定义的。

3.2.2.2. 硬件多线程

当一个 SM 被分配一个或多个线程块来执行时,它会将这些线程块划分为线程束,每个线程束由一个线程束调度器调度执行。线程块划分为线程束的方式始终相同;每个线程束包含线程 ID 连续递增的线程,第一个线程束包含线程 0。线程层次结构介绍了线程 ID 与线程块内线程索引之间的关系。

线程块中的线程束总数定义如下:

ceil(TWsize,1)\text{ceil}\left( \frac{T}{W_{size}}, 1 \right)

  • T是每个线程块中的线程数,

  • Wsize是线程束大小,等于 32,

  • ceil(x, y) 等于将 x 向上舍入到最近的 y 的整数倍。

一个线程块被划分为多个线程束,每个线程束包含 32 个线程。

图 22一个线程块被划分为多个各含 32 个线程的线程束。

图内文字中文对照
Thread Block
线程块
32 threads
32 个线程
SM
流式多处理器

SM 所处理的每个线程束的执行上下文(程序计数器、寄存器等)在该线程束的整个生命周期内都保留在芯片上。因此,在线程束之间切换不会产生任何开销。在每个指令发射周期,线程束调度器选择一个线程束,该线程束中有线程已准备好执行下一条指令(即线程束中的活跃线程),并向这些线程发射该指令。

每个 SM 都有一组在线程束之间分配的 32 位寄存器,以及一块在线程块之间分配的共享内存。对于给定的核函数,能够驻留在 SM 上并被并发处理的线程块数和线程束数,取决于核函数使用的寄存器和共享内存的数量,以及 SM 上可用的寄存器和共享内存的数量。每个 SM 上的驻留线程块数和线程束数也有最大值。这些限制以及 SM 上可用的寄存器和共享内存数量都取决于设备的计算能力,具体规定见计算能力。如果每个 SM 上没有足够的可用资源来处理至少一个线程块,核函数将无法启动。可以通过占用率一节中介绍的几种方式来确定为一个线程块分配的寄存器总数和共享内存总量。

3.2.2.3. 异步执行特性

近几代 NVIDIA GPU 都包含了异步执行能力,以便在 GPU 内部实现更多的数据移动、计算和同步重叠。这些能力使从 GPU 代码中调用的某些操作能够相对于同一线程块中的其他 GPU 代码异步执行。不应将这种异步执行与第 2.5 节中讨论的异步 CUDA API 混淆,后者使 GPU 核函数启动或内存操作能够相对于彼此或相对于 CPU 异步进行。

计算能力 8.0(NVIDIA Ampere GPU 架构)引入了由硬件加速的从全局内存到共享内存的异步数据复制,以及异步屏障(参见NVIDIA A100 张量核心 GPU 架构)。

计算能力 9.0(NVIDIA Hopper GPU 架构)通过张量内存加速器(TMA)单元、异步事务屏障和异步矩阵乘加操作扩展了异步执行特性,其中 TMA 单元可以将大块数据和多维张量从全局内存传输到共享内存,也可以反向传输(详情请参见深入剖析 Hopper 架构博文。)。

CUDA 提供了可由线程从设备代码中调用的 API,以使用这些特性。异步编程模型定义了异步操作相对于 CUDA 线程的行为。

异步操作是由一个 CUDA 线程发起、但以异步方式执行的操作,就像由另一个线程执行一样,我们将这个线程称为异步线程。在格式良好的程序中,一个或多个 CUDA 线程会与异步操作同步。发起异步操作的 CUDA 线程不必属于参与同步的线程。异步线程始终与发起该操作的 CUDA 线程相关联。

异步操作使用同步对象来发出其完成的信号,该对象可以是屏障,也可以是流水线。高级同步原语详细介绍了这些同步对象,异步数据复制则展示了它们在执行异步内存操作中的作用。

3.2.2.3.1. 异步线程与异步代理

异步操作访问内存的方式可能与常规操作不同。为了区分这些不同的内存访问方式,CUDA 引入了异步线程、通用代理和异步代理的概念。普通操作(加载和存储)通过通用代理进行。一些异步指令,例如LDGSTS和STAS/REDAS,使用在通用代理中运行的异步线程来建模。其他异步指令,例如使用 TMA 的批量异步复制以及某些张量核心操作(tcgen05.*、wgmma.mma_async.*),则使用在异步代理中运行的异步线程来建模。

在通用代理中运行的异步线程。发起异步操作时,该操作会与一个异步线程关联,该线程不同于发起操作的 CUDA 线程。保证对同一地址的先前通用代理(普通)加载和存储在顺序上位于该异步操作之前。但是,不保证对同一地址的后续普通加载和存储保持其顺序,因此在异步线程完成之前,可能产生竞争条件。

在异步代理中运行的异步线程。发起异步操作时,该操作会与一个异步线程关联,该线程不同于发起操作的 CUDA 线程。不保证对同一地址的先前和后续普通加载和存储保持其顺序。需要使用代理栅栏在不同代理之间同步这些操作,以确保正确的内存顺序。使用张量内存加速器(TMA)一节展示了如何使用代理栅栏,确保使用 TMA 执行异步复制时的正确性。

有关这些概念的更多详细信息,请参阅PTX ISA文档。

3.2.3. 线程作用域

CUDA 线程构成了一个线程层次结构,利用这一层次结构对于编写既正确又高性能的 CUDA 核函数至关重要。在这一层次结构中,内存操作的可见性和同步作用域可能各不相同。为处理这种非均一性,CUDA 编程模型引入了线程作用域的概念。线程作用域定义了哪些线程能够观察到某个线程的加载和存储,并规定了哪些线程能够使用原子操作和屏障等同步原语相互同步。每个作用域在内存层次结构中都有一个关联的相干点。

线程作用域在CUDA PTX中公开,也以扩展的形式在libcu++库中提供。下表定义了可用的线程作用域:

CUDA C++ 线程作用域

CUDA PTX 线程作用域

描述

内存层次结构中的相干点

cuda::thread_scope_thread

内存操作仅对本线程可见。

–

cuda::thread_scope_block

.cta

内存操作对同一线程块中的其他线程可见。

L1

.cluster

内存操作对同一线程块簇中的其他线程可见。

L2

cuda::thread_scope_device

.gpu

内存操作对同一 GPU 设备中的其他线程可见。

L2

cuda::thread_scope_system

.sys

内存操作对同一系统中的其他线程(CPU、其他 GPU)可见。

L2 + 相连接的缓存

高级同步原语和异步数据复制这两节展示了线程作用域的用法。

3.2.4. 高级同步原语

本节介绍三类同步原语:

  • 限定作用域的原子操作,它将 C++ 内存顺序与 CUDA 线程作用域相结合,以在线程块、簇、设备或系统作用域内在线程之间安全地通信(参见线程作用域)。

  • 异步屏障,它将同步分为到达和等待阶段,并且可用于跟踪异步操作的进度。

  • 流水线,它将工作划分为多个阶段并协调多缓冲区生产者-消费者模式,通常用于使计算与异步数据复制重叠。

3.2.4.1. 限定作用域的原子操作

第 5.4.5 节概述了 CUDA 中可用的原子函数。在本节中,我们将重点介绍支持C++ 标准原子内存语义的限定作用域的原子操作,这些操作可通过libcu++库或编译器内建函数使用。限定作用域的原子操作提供了在 CUDA 线程层次结构的适当层级实现高效同步的工具,从而兼顾复杂并行算法的正确性和性能。

3.2.4.1.1. 线程作用域与内存顺序

限定作用域的原子操作结合了两个关键概念:

#include <cuda/atomic>

__global__ void block_scoped_counter() {
    // Shared atomic counter visible only within this block
    __shared__ cuda::atomic<int, cuda::thread_scope_block> counter;

    // Initialize counter (only one thread should do this)
    if (threadIdx.x == 0) {
        counter.store(0, cuda::memory_order_relaxed);
    }
    __syncthreads();

    // All threads in block atomically increment
    int old_value = counter.fetch_add(1, cuda::memory_order_relaxed);

    // Use old_value...
}
__global__ void block_scoped_counter() {
    // Shared counter visible only within this block
    __shared__ int counter;

    // Initialize counter (only one thread should do this)
    if (threadIdx.x == 0) {
        __nv_atomic_store_n(&counter, 0,
                            __NV_ATOMIC_RELAXED,
                            __NV_THREAD_SCOPE_BLOCK);
    }
    __syncthreads();

    // All threads in block atomically increment
    int old_value = __nv_atomic_fetch_add(&counter, 1,
                                          __NV_ATOMIC_RELAXED,
                                          __NV_THREAD_SCOPE_BLOCK);

    // Use old_value...
}

此示例实现了一个线程块作用域的原子计数器,用于演示限定作用域的原子操作的基本概念:

  • 共享变量:使用__shared__内存,在线程块的所有线程之间共享单个计数器。

  • 原子类型声明:cuda::atomic<int, cuda::thread_scope_block>创建一个具有线程块级可见性的原子整数。

  • 单次初始化:只有线程 0 初始化计数器,以防止在设置期间发生竞争条件。

  • 线程块同步:__syncthreads()确保所有线程在继续执行之前都能看到已初始化的计数器。

  • 原子递增:每个线程都以原子方式递增计数器,并获得其先前的值。

这里选择cuda::memory_order_relaxed,因为我们只需要原子性(不可分割的读-改-写),而不需要对不同内存位置之间的操作施加顺序约束。由于这是一个简单的计数操作,递增操作的顺序不会影响正确性。

对于生产者-消费者模式,获取-释放语义可确保正确的顺序:

__global__ void producer_consumer() {
    __shared__ int data;
    __shared__ cuda::atomic<bool, cuda::thread_scope_block> ready;

    if (threadIdx.x == 0) {
        // Producer: write data then signal ready
        data = 42;
        ready.store(true, cuda::memory_order_release);  // Release ensures data write is visible
    } else {
        // Consumer: wait for ready signal then read data
        while (!ready.load(cuda::memory_order_acquire)) {  // Acquire ensures data read sees the write
            // spin wait
        }
        int value = data;
        // Process value...
    }
}
__global__ void producer_consumer() {
    __shared__ int data;
    __shared__ bool ready; // Only ready flag needs atomic operations

    if (threadIdx.x == 0) {
        // Producer: write data then signal ready
        data = 42;
        __nv_atomic_store_n(&ready, true,
                            __NV_ATOMIC_RELEASE,
                            __NV_THREAD_SCOPE_BLOCK);  // Release ensures data write is visible
    } else {
        // Consumer: wait for ready signal then read data
        while (!__nv_atomic_load_n(&ready,
                                   __NV_ATOMIC_ACQUIRE,
                                   __NV_THREAD_SCOPE_BLOCK)) {  // Acquire ensures data read sees the write
            // spin wait
        }
        int value = data;
        // Process value...
    }
}

3.2.4.1.2. 性能注意事项

  • 尽可能使用最小的作用域:线程块作用域的原子操作比系统作用域的原子操作快得多。

  • 优先使用较弱的顺序约束:只有在确保正确性所必需时,才使用更强的顺序约束。

  • 考虑内存位置:共享内存原子操作比全局内存原子操作更快。

3.2.4.2. 异步屏障

异步屏障与典型的单阶段屏障(__syncthreads())的区别在于,线程发出其已到达屏障的通知(“到达”)与等待其他线程到达屏障的操作(“等待”)是分离的。这种分离允许线程执行与屏障无关的其他操作,从而更有效地利用等待时间,提高执行效率。异步屏障可用于通过 CUDA 线程实现生产者-消费者模式,也可以让复制操作在完成时向屏障发出信号(“到达”屏障),从而支持内存层次结构中的异步数据复制。

计算能力为 7.0 或更高的设备支持异步屏障。计算能力为 8.0 或更高的设备为共享内存中的异步屏障提供硬件加速,并且允许对线程块内 CUDA 线程的任意子集进行硬件加速同步,从而显著提升了同步粒度。此前的架构只能在整个线程束(__syncwarp())或整个线程块(__syncthreads())级别加速同步。

CUDA 编程模型通过cuda::std::barrier提供异步屏障,它是libcu++库中提供的、符合 ISO C++ 标准的屏障。除了实现std::barrier之外,该库还提供了 CUDA 专用扩展,可选择屏障的线程作用域以提升性能,并公开了更底层的cuda::ptx API。cuda::barrier可以通过使用friend函数cuda::device::barrier_native_handle()获取屏障的原生句柄并将其传递给cuda::ptx函数,来与cuda::ptx互操作。CUDA 还为线程块作用域下共享内存中的异步屏障提供了原语 API。

下表概述了可用于在不同线程作用域下进行同步的异步屏障。

线程作用域

内存位置

到达屏障

在屏障处等待

硬件加速

CUDA API

线程块

本地共享内存

允许

允许

是(8.0+)

cuda::barrier、cuda::ptx、原语

簇

本地共享内存

允许

允许

是(9.0+)

cuda::barrier, cuda::ptx

簇

远程共享内存

允许

不允许

是(9.0+)

cuda::barrier, cuda::ptx

设备

全局内存

允许

允许

否

cuda::barrier

系统

全局内存/统一内存

允许

允许

否

cuda::barrier

同步在时间上的拆分

如果不使用异步到达-等待屏障,线程块内的同步可通过__syncthreads()实现,或者在使用协作组时通过block.sync()实现。

#include <cooperative_groups.h>

__global__ void simple_sync(int iteration_count) {
    auto block = cooperative_groups::this_thread_block();

    for (int i = 0; i < iteration_count; ++i) {
        /* code before arrive */

         // Wait for all threads to arrive here.
        block.sync();

        /* code after wait */
    }
}

线程会在同步点(block.sync())处阻塞,直到所有线程都到达该同步点。此外,同步点之前发生的内存更新保证在同步点之后对线程块中的所有线程可见。

此模式有三个阶段:

  • 同步之前的代码执行内存更新,这些更新将在同步之后被读取。

  • 同步点。

  • 同步之后的代码能够看到同步之前发生的内存更新。

如果改用异步屏障,在时间上拆分的同步模式如下所示。

#include <cuda/barrier>
#include <cooperative_groups.h>

__device__ void compute(float *data, int iteration);

__global__ void split_arrive_wait(int iteration_count, float *data)
{
  using barrier_t = cuda::barrier<cuda::thread_scope_block>;
  __shared__ barrier_t bar;
  auto block = cooperative_groups::this_thread_block();

  if (block.thread_rank() == 0)
  {
    // Initialize barrier with expected arrival count.
    init(&bar, block.size());
  }
  block.sync();

  for (int i = 0; i < iteration_count; ++i)
  {
    /* code before arrive */

    // This thread arrives. Arrival does not block a thread.
    barrier_t::arrival_token token = bar.arrive();

    compute(data, i);

    // Wait for all threads participating in the barrier to complete bar.arrive().
    bar.wait(std::move(token));

    /* code after wait */
  }
}
#include <cuda/ptx>
#include <cooperative_groups.h>

__device__ void compute(float *data, int iteration);

__global__ void split_arrive_wait(int iteration_count, float *data)
{
  __shared__ uint64_t bar;
  auto block = cooperative_groups::this_thread_block();

  if (block.thread_rank() == 0)
  {
    // Initialize barrier with expected arrival count.
    cuda::ptx::mbarrier_init(&bar, block.size());
  }
  block.sync();

  for (int i = 0; i < iteration_count; ++i)
  {
    /* code before arrive */

    // This thread arrives. Arrival does not block a thread.
    uint64_t token = cuda::ptx::mbarrier_arrive(&bar);

    compute(data, i);

    // Wait for all threads participating in the barrier to complete mbarrier_arrive().
    while(!cuda::ptx::mbarrier_try_wait(&bar, token)) {}

    /* code after wait */
  }
}
#include <cuda_awbarrier_primitives.h>
#include <cooperative_groups.h>

__device__ void compute(float *data, int iteration);

__global__ void split_arrive_wait(int iteration_count, float *data)
{
  __shared__ __mbarrier_t bar;
  auto block = cooperative_groups::this_thread_block();

  if (block.thread_rank() == 0)
  {
    // Initialize barrier with expected arrival count.
    __mbarrier_init(&bar, block.size());
  }
  block.sync();

  for (int i = 0; i < iteration_count; ++i)
  {
    /* code before arrive */

    // This thread arrives. Arrival does not block a thread.
    __mbarrier_token_t token = __mbarrier_arrive(&bar);

    compute(data, i);

    // Wait for all threads participating in the barrier to complete __mbarrier_arrive().
    while(!__mbarrier_try_wait(&bar, token, 1000)) {}

    /* code after wait */
  }
}

在此模式中,同步点被拆分为到达点(bar.arrive())和等待点(bar.wait(std::move(token)))。线程在首次调用bar.arrive()时开始参与cuda::barrier。当线程调用bar.wait(std::move(token))时,它将被阻塞,直到参与线程完成了预期次数的bar.arrive(),该次数就是传递给init()的预期到达次数参数。参与线程调用bar.arrive()之前发生的内存更新,保证在这些线程调用bar.wait(std::move(token))之后对它们可见。请注意,调用bar.arrive()不会阻塞线程,线程可以继续执行其他工作,只要这些工作不依赖于其他参与线程调用bar.arrive()之前发生的内存更新即可。

到达和等待模式有五个阶段:

  • 到达之前的代码执行内存更新,这些更新将在等待之后被读取。

  • 具有隐式内存栅栏的到达点(即等效于cuda::atomic_thread_fence(cuda::memory_order_seq_cst, cuda::thread_scope_block))。

  • 到达与等待之间的代码。

  • 等待点。

  • 等待之后的代码能够看到到达之前执行的更新。

有关如何使用异步屏障的完整指南,请参阅异步屏障。

3.2.4.3. 流水线

CUDA 编程模型提供了流水线同步对象,作为一种协调机制,将异步内存复制按顺序组织成多个阶段,从而便于实现双缓冲或多缓冲的生产者-消费者模式。流水线是一个具有头端和尾端的双端队列,按先进先出(FIFO)的顺序处理工作。生产者线程将工作提交到流水线的头端,而消费者线程从流水线的尾端提取工作。

流水线通过libcu++库中的cuda::pipeline API 以及原语 API提供。下表描述了这两种 API 的主要功能。

cuda::pipeline API

描述

producer_acquire

获取流水线内部队列中的一个可用阶段。

producer_commit

提交在producer_acquire调用之后、针对流水线当前已获取阶段发出的异步操作。

consumer_wait

等待流水线中最早阶段的异步操作完成。

consumer_release

将流水线中最早的阶段释放回流水线对象以便重用。随后,生产者可以获取已释放的阶段。

原语 API

描述

__pipeline_memcpy_async

请求提交一次从全局内存到共享内存的内存复制,以便异步执行。

__pipeline_commit

提交在此调用之前、针对流水线当前阶段发出的异步操作。

__pipeline_wait_prior(N)

等待除最后 N 次向流水线提交的操作之外的所有异步操作完成。

cuda::pipeline API 的接口更丰富,限制更少,而原语 API 仅支持跟踪从全局内存到共享内存的异步复制,并且有特定的大小和对齐要求。原语 API 提供的功能等效于使用cuda::thread_scope_thread的cuda::pipeline对象。

有关详细的使用模式和示例,请参阅流水线。

3.2.5. 异步数据复制

在内存层次结构内高效移动数据,是在 GPU 计算中实现高性能的基础。传统的同步内存操作会迫使线程在数据传输期间空闲等待。GPU 本身会通过并行性隐藏内存延迟。也就是说,在内存操作完成的过程中,SM 会切换去执行另一个线程束。即使通过并行性隐藏了延迟,内存延迟仍然可能成为内存带宽利用率和计算资源效率的瓶颈。为解决这些瓶颈,现代 GPU 架构提供了硬件加速的异步数据复制机制,允许内存传输独立进行,同时线程继续执行其他工作。

异步数据复制通过将内存传输的发起与等待其完成分离开来,使计算与数据移动能够重叠进行。这样,线程就可以在内存延迟期间执行有用的工作,从而提高总体吞吐量和资源利用率。

注意

虽然本节所依据的概念和原理与前面异步执行一章讨论的内容相似,但该章介绍的是核函数和内存传输的异步执行,例如由cudaMemcpyAsync调用的操作。这可以看作应用程序不同组成部分之间的异步性。

本节介绍的异步性,是指在不阻塞 GPU 线程的情况下,支持在 GPU 的 DRAM(即全局内存)与 SM 上的内存(例如共享内存或张量内存)之间传输数据。这是单次核函数启动的执行过程内部的异步性。

要理解异步复制如何提升性能,考察一种常见的 GPU 计算模式会有所帮助。CUDA 应用程序经常采用复制和计算模式,该模式:

  • 从全局内存获取数据,

  • 将数据存储到共享内存,然后

  • 对共享内存中的数据执行计算,并可能将结果写回全局内存。

此模式的复制阶段通常表示为shared[local_idx] = global[global_idx]。编译器会将这种从全局内存到共享内存的复制展开为先从全局内存读取到寄存器,再从寄存器写入共享内存。

当此模式出现在迭代算法中时,每个线程块都需要在shared[local_idx] = global[global_idx]赋值之后进行同步,以确保在计算阶段开始之前,对共享内存的所有写入都已完成。线程块还需要在计算阶段之后再次同步,以防止在所有线程完成计算之前覆盖共享内存。以下代码片段展示了此模式。

#include <cooperative_groups.h>

__device__ void compute(int* global_out, int const* shared_in) {
    // Computes using all values of current batch from shared memory.
    // Stores this thread's result back to global memory.
}

__global__ void without_async_copy(int* global_out, int const* global_in, size_t size, size_t batch_sz) {
  auto grid = cooperative_groups::this_grid();
  auto block = cooperative_groups::this_thread_block();
  assert(size == batch_sz * grid.size()); // Exposition: input size fits batch_sz * grid_size

  extern __shared__ int shared[]; // block.size() * sizeof(int) bytes

  size_t local_idx = block.thread_rank();

  for (size_t batch = 0; batch < batch_sz; ++batch) {
    // Compute the index of the current batch for this block in global memory.
    size_t block_batch_idx = block.group_index().x * block.size() + grid.size() * batch;
    size_t global_idx = block_batch_idx + threadIdx.x;
    shared[local_idx] = global_in[global_idx];

    // Wait for all copies to complete.
    block.sync();

    // Compute and write result to global memory.
    compute(global_out + block_batch_idx, shared);

    // Wait for compute using shared memory to finish.
    block.sync();
  }
}

使用异步数据复制,可以异步地将数据从全局内存移动到共享内存,从而在等待数据到达时更高效地利用 SM。

#include <cooperative_groups.h>
#include <cooperative_groups/memcpy_async.h>

__device__ void compute(int* global_out, int const* shared_in) {
    // Computes using all values of current batch from shared memory.
    // Stores this thread's result back to global memory.
}

__global__ void with_async_copy(int* global_out, int const* global_in, size_t size, size_t batch_sz) {
  auto grid = cooperative_groups::this_grid();
  auto block = cooperative_groups::this_thread_block();
  assert(size == batch_sz * grid.size()); // Exposition: input size fits batch_sz * grid_size

  extern __shared__ int shared[]; // block.size() * sizeof(int) bytes

  size_t local_idx = block.thread_rank();

  for (size_t batch = 0; batch < batch_sz; ++batch) {
    // Compute the index of the current batch for this block in global memory.
    size_t block_batch_idx = block.group_index().x * block.size() + grid.size() * batch;

    // Whole thread-group cooperatively copies whole batch to shared memory.
    cooperative_groups::memcpy_async(block, shared, global_in + block_batch_idx, block.size());

    // Compute on different data while waiting.

    // Wait for all copies to complete.
    cooperative_groups::wait(block);

    // Compute and write result to global memory.
    compute(global_out + block_batch_idx, shared);

    // Wait for compute using shared memory to finish.
    block.sync();
  }
}

cooperative_groups::memcpy_async函数将block.size()个元素从全局内存复制到shared数据中。此操作如同由另一个线程执行,该线程在复制完成后,与当前线程对cooperative_groups::wait的调用同步。在复制操作完成之前,修改全局数据或者读取或写入共享数据都会引发数据竞争。

此示例展示了所有异步复制操作背后的基本概念:它们将内存传输的发起与完成分离开来,允许线程在数据于后台移动的同时执行其他工作。CUDA 编程模型提供了多种 API 来使用这些功能,包括协作组和libcu++库中提供的memcpy_async函数,以及更底层的cuda::ptx和原语 API。这些 API 具有相似的语义:它们将对象从源位置复制到目标位置,如同由另一个线程执行一样,并且在复制完成后,可以通过不同的完成机制与该线程同步。

现代 GPU 架构为异步数据移动提供了多种硬件机制。

  • LDGSTS(计算能力 8.0+)支持从全局内存到共享内存的高效小规模异步传输。

  • 张量内存加速器(TMA,计算能力 9.0+)扩展了这些功能,提供了针对大规模多维数据传输优化的批量异步复制操作

  • STAS 指令(计算能力 9.0+)支持从寄存器到簇内分布式共享内存的小规模异步传输。

这些机制支持不同的数据路径、传输大小和对齐要求,使开发者能够针对特定的数据访问模式选择最合适的方法。下表概述了 GPU 内部异步复制所支持的数据路径。

表 5 异步复制及其可能的源内存空间和目标内存空间。空白单元格表示不支持该源-目标组合。

方向

复制机制

源

目标

异步复制

批量异步复制

全局内存

全局内存

shared::cta

全局内存

支持(TMA,9.0+)

全局内存

shared::cta

支持(LDGSTS,8.0+)

支持(TMA,9.0+)

全局内存

shared::cluster

支持(TMA,9.0+)

shared::cluster

shared::cta

支持(TMA,9.0+)

shared::cta

shared::cta

寄存器

shared::cluster

支持(STAS,9.0+)

使用 LDGSTS、使用张量内存加速器(TMA)和使用 STAS各节将更详细地介绍每种机制。

3.2.6. 配置 L1/共享内存的平衡

如L1 数据缓存中所述,SM 上的 L1 和共享内存使用同一物理资源,称为统一数据缓存。在大多数架构上,如果核函数使用的共享内存很少或完全不使用共享内存,就可以将统一数据缓存配置为提供该架构所允许的最大容量的 L1 缓存。

统一数据缓存中为共享内存预留的部分可以按核函数配置。应用程序可以在核函数启动之前调用cudaFuncSetAttribute函数,设置carveout,即首选共享内存容量。

cudaFuncSetAttribute(kernel_name, cudaFuncAttributePreferredSharedMemoryCarveout, carveout);

应用程序可以将carveout设置为该架构所支持的最大共享内存容量的整数百分比。除了整数百分比之外,还提供了三个便捷枚举值,用作划分值。

  • cudaSharedmemCarveoutDefault

  • cudaSharedmemCarveoutMaxL1

  • cudaSharedmemCarveoutMaxShared

支持的最大共享内存容量和支持的划分大小因架构而异;有关详细信息,请参阅各计算能力的共享内存容量。

如果所选的整数百分比划分值不能精确映射到某个受支持的共享内存容量,则使用下一个更大的容量。例如,对于计算能力为 12.0、最大共享内存容量为 100KB 的设备,将划分值设置为 50% 会得到 64KB 的共享内存,而不是 50KB,因为计算能力为 12.0 的设备支持的共享内存大小为 0、8、16、32、64 和 100。

传递给cudaFuncSetAttribute的函数必须使用__global__说明符声明。驱动程序将cudaFuncSetAttribute视为提示,如果执行核函数需要,驱动程序可能会选择不同的划分大小。

注意

另一个 CUDA APIcudaFuncSetCacheConfig也允许应用程序调整核函数的 L1 与共享内存之间的平衡。不过,此 API 对核函数启动时共享内存与 L1 之间的平衡设置了硬性要求。因此,交错执行具有不同共享内存配置的核函数,会不必要地使启动操作串行化,排在共享内存重新配置之后。更推荐使用cudaFuncSetAttribute,因为驱动程序可以在执行该函数所需时或为了避免抖动而选择不同的配置。

依赖于每个线程块分配超过 48 KB 共享内存的核函数是特定于架构的。因此,它们必须使用动态共享内存,而不能使用静态大小的数组,并且需要使用cudaFuncSetAttribute显式选择启用,如下所示。

// Device code
__global__ void MyKernel(...)
{
  extern __shared__ float buffer[];
  ...
}

// Host code
int maxbytes = 98304; // 96 KB
cudaFuncSetAttribute(MyKernel, cudaFuncAttributeMaxDynamicSharedMemorySize, maxbytes);
MyKernel <<<gridDim, blockDim, maxbytes>>>(...);

3.3. CUDA 驱动程序 API

本指南前面的各节介绍了 CUDA 运行时。如CUDA 运行时 API 和 CUDA 驱动程序 API中所述,CUDA 运行时是在更底层的 CUDA 驱动程序 API 之上编写的。本节介绍 CUDA 运行时 API 与驱动程序 API 之间的一些差异,以及如何混合使用它们。大多数应用程序无需与 CUDA 驱动程序 API 交互即可获得全部性能。不过,新的接口有时会先在驱动程序 API 中提供,之后才在运行时 API 中提供;某些高级接口(例如虚拟内存管理)则仅通过驱动程序 API 公开。

驱动程序 API 在cuda动态库(cuda.dll或cuda.so)中实现,该库会在安装设备驱动程序时复制到系统上。它的所有入口点均以 cu 为前缀。

它是基于句柄的命令式 API:大多数对象通过不透明句柄引用,可将这些句柄传递给函数以操作对象。

驱动程序 API 中可用的对象汇总于表 6。

表 6 CUDA 驱动程序 API 中可用的对象

对象

句柄

说明

设备

CUdevice

支持 CUDA 的设备

上下文

CUcontext

大致相当于 CPU 进程

模块

CUmodule

大致相当于动态库

函数

CUfunction

核函数

堆内存

CUdeviceptr

指向设备内存的指针

CUDA 数组

CUarray

用于存放设备上一维或二维数据的不透明容器,可通过纹理引用或表面引用读取

纹理对象

CUtexref

描述如何解释纹理内存数据的对象

表面引用

CUsurfref

描述如何读取或写入 CUDA 数组的对象

流

CUstream

描述 CUDA 流的对象

事件

CUevent

描述 CUDA 事件的对象

在调用驱动程序 API 中的任何函数之前,必须使用cuInit()初始化驱动程序 API。随后,必须创建一个附属于特定设备的 CUDA 上下文,并将其设为调用方主机线程的当前上下文,详见上下文。

在 CUDA 上下文中,核函数由主机代码以 PTX 或二进制对象的形式显式加载,如模块中所述。因此,用 C++ 编写的核函数必须单独编译为PTX或二进制对象。核函数通过 API 入口点启动,如核函数执行中所述。

任何希望在未来设备架构上运行的应用程序都必须加载PTX,而非二进制代码。这是因为二进制代码是特定于架构的,因此与未来的架构不兼容,而PTX代码则由设备驱动程序在加载时编译为二进制代码。

以下是使用驱动程序 API 编写的核函数中示例的主机代码:

int main()
{
    int N = ...;
    size_t size = N * sizeof(float);

    // Allocate input vectors h_A and h_B in host memory
    float* h_A = (float*)malloc(size);
    float* h_B = (float*)malloc(size);

    // Initialize input vectors
    ...

    // Initialize
    cuInit(0);

    // Get number of devices supporting CUDA
    int deviceCount = 0;
    cuDeviceGetCount(&deviceCount);
    if (deviceCount == 0) {
        printf("There is no device supporting CUDA.\n");
        exit (0);
    }

    // Get handle for device 0
    CUdevice cuDevice;
    cuDeviceGet(&cuDevice, 0);

    // Create context
    CUcontext cuContext;
    cuCtxCreate(&cuContext, 0, cuDevice);

    // Create module from binary file
    CUmodule cuModule;
    cuModuleLoad(&cuModule, "VecAdd.ptx");

    // Allocate vectors in device memory
    CUdeviceptr d_A;
    cuMemAlloc(&d_A, size);
    CUdeviceptr d_B;
    cuMemAlloc(&d_B, size);
    CUdeviceptr d_C;
    cuMemAlloc(&d_C, size);

    // Copy vectors from host memory to device memory
    cuMemcpyHtoD(d_A, h_A, size);
    cuMemcpyHtoD(d_B, h_B, size);

    // Get function handle from module
    CUfunction vecAdd;
    cuModuleGetFunction(&vecAdd, cuModule, "VecAdd");

    // Invoke kernel
    int threadsPerBlock = 256;
    int blocksPerGrid =
            (N + threadsPerBlock - 1) / threadsPerBlock;
    void* args[] = { &d_A, &d_B, &d_C, &N };
    cuLaunchKernel(vecAdd,
                   blocksPerGrid, 1, 1, threadsPerBlock, 1, 1,
                   0, 0, args, 0);

    ...
}

完整代码可在vectorAddDrvCUDA 示例中找到。

3.3.1. 上下文

CUDA 上下文类似于 CPU 进程。驱动程序 API 中的所有资源以及执行的操作都封装在 CUDA 上下文内,系统会在上下文销毁时自动清理这些资源。除模块以及纹理引用或表面引用等对象外,每个上下文还有自己独立的地址空间。因此,不同上下文中的CUdeviceptr值引用不同的内存位置。

一个主机线程在任一时刻只能有一个当前设备上下文。使用cuCtxCreate()创建上下文时,该上下文会被设为调用方主机线程的当前上下文。如果线程没有有效的当前上下文,则在上下文中操作的 CUDA 函数(大多数不涉及设备枚举或上下文管理的函数)将返回CUDA_ERROR_INVALID_CONTEXT。

每个主机线程都有一个当前上下文栈。cuCtxCreate()将新上下文压入栈顶。可以调用cuCtxPopCurrent()将上下文与主机线程分离。随后,该上下文处于“浮动”状态,可以被压入任意主机线程的栈中,成为其当前上下文。cuCtxPopCurrent()还会恢复先前的当前上下文(如果存在)。

每个上下文还维护着一个使用计数。cuCtxCreate()创建使用计数为 1 的上下文。cuCtxAttach()增加使用计数,cuCtxDetach()减少使用计数。在调用cuCtxDetach()或cuCtxDestroy()时,如果使用计数降至 0,则上下文会被销毁。

驱动程序 API 可与运行时互操作,并且可以从驱动程序 API 通过cuDevicePrimaryCtxRetain()访问由运行时管理的主上下文(参见运行时初始化)。

使用计数有助于在同一上下文中运行的第三方编写的代码之间实现互操作。例如,如果加载了三个库以使用同一个上下文,则每个库都会调用cuCtxAttach()来增加使用计数,并在库使用完该上下文后调用cuCtxDetach()来减少使用计数。对于大多数库,预期应用程序会在加载或初始化库之前创建上下文;这样,应用程序就可以使用自己的启发式策略来创建上下文,而库只需在传递给它的上下文上操作。如果库希望在其 API 客户端不知情的情况下创建自己的上下文(这些客户端可能已经创建了自己的上下文,也可能没有),则会使用cuCtxPushCurrent()和cuCtxPopCurrent(),如下图所示。

库的上下文管理

图 23 库的上下文管理

图内文字中文对照
Library Initialization Call
库初始化调用
Initialize context
初始化上下文
Library Call
库调用
Use context
使用上下文

3.3.2. 模块

模块是可动态加载的设备代码和数据包,类似于 Windows 中的 DLL,由 nvcc 输出(参见使用 NVCC 编译)。所有符号的名称(包括函数、全局变量以及纹理引用或表面引用)都在模块作用域内维护,因此,由独立第三方编写的模块可以在同一个 CUDA 上下文中互操作。

此代码示例加载一个模块,并获取某个核函数的句柄:

CUmodule cuModule;
cuModuleLoad(&cuModule, "myModule.ptx");
CUfunction myKernel;
cuModuleGetFunction(&myKernel, cuModule, "MyKernel");

此代码示例从 PTX 代码编译并加载一个新模块,并解析编译错误:

#define BUFFER_SIZE 8192
CUmodule cuModule;
CUjit_option options[3];
void* values[3];
char* PTXCode = "some PTX code";
char error_log[BUFFER_SIZE];
int err;
options[0] = CU_JIT_ERROR_LOG_BUFFER;
values[0]  = (void*)error_log;
options[1] = CU_JIT_ERROR_LOG_BUFFER_SIZE_BYTES;
values[1]  = (void*)BUFFER_SIZE;
options[2] = CU_JIT_TARGET_FROM_CUCONTEXT;
values[2]  = 0;
err = cuModuleLoadDataEx(&cuModule, PTXCode, 3, options, values);
if (err != CUDA_SUCCESS)
    printf("Link error:\n%s\n", error_log);

此代码示例从多份 PTX 代码编译、链接并加载一个新模块,并解析链接错误和编译错误:

#define BUFFER_SIZE 8192
CUmodule cuModule;
CUjit_option options[6];
void* values[6];
float walltime;
char error_log[BUFFER_SIZE], info_log[BUFFER_SIZE];
char* PTXCode0 = "some PTX code";
char* PTXCode1 = "some other PTX code";
CUlinkState linkState;
int err;
void* cubin;
size_t cubinSize;
options[0] = CU_JIT_WALL_TIME;
values[0] = (void*)&walltime;
options[1] = CU_JIT_INFO_LOG_BUFFER;
values[1] = (void*)info_log;
options[2] = CU_JIT_INFO_LOG_BUFFER_SIZE_BYTES;
values[2] = (void*)BUFFER_SIZE;
options[3] = CU_JIT_ERROR_LOG_BUFFER;
values[3] = (void*)error_log;
options[4] = CU_JIT_ERROR_LOG_BUFFER_SIZE_BYTES;
values[4] = (void*)BUFFER_SIZE;
options[5] = CU_JIT_LOG_VERBOSE;
values[5] = (void*)1;
cuLinkCreate(6, options, values, &linkState);
err = cuLinkAddData(linkState, CU_JIT_INPUT_PTX,
                    (void*)PTXCode0, strlen(PTXCode0) + 1, 0, 0, 0, 0);
if (err != CUDA_SUCCESS)
    printf("Link error:\n%s\n", error_log);
err = cuLinkAddData(linkState, CU_JIT_INPUT_PTX,
                    (void*)PTXCode1, strlen(PTXCode1) + 1, 0, 0, 0, 0);
if (err != CUDA_SUCCESS)
    printf("Link error:\n%s\n", error_log);
cuLinkComplete(linkState, &cubin, &cubinSize);
printf("Link completed in %fms. Linker Output:\n%s\n", walltime, info_log);
cuModuleLoadData(cuModule, cubin);
cuLinkDestroy(linkState);

通过使用多个线程,可以加速模块链接/加载过程中的某些部分,包括加载 cubin 时的某些部分。此代码示例使用CU_JIT_BINARY_LOADER_THREAD_COUNT来加速模块加载。

#define BUFFER_SIZE 8192
CUmodule cuModule;
CUjit_option options[3];
void* values[3];
char* cubinCode = "some cubin code";
char error_log[BUFFER_SIZE];
int err;
options[0] = CU_JIT_ERROR_LOG_BUFFER;
values[0]  = (void*)error_log;
options[1] = CU_JIT_ERROR_LOG_BUFFER_SIZE_BYTES;
values[1]  = (void*)BUFFER_SIZE;
options[2] = CU_JIT_BINARY_LOADER_THREAD_COUNT;
values[2]  = 0; // Use as many threads as CPUs on the machine
err = cuModuleLoadDataEx(&cuModule, cubinCode, 3, options, values);
if (err != CUDA_SUCCESS)
    printf("Link error:\n%s\n", error_log);

完整代码可在ptxjitCUDA 示例中找到。

3.3.3. 核函数执行

cuLaunchKernel()使用给定的执行配置启动核函数。

参数可以作为指针数组(cuLaunchKernel()的倒数第二个参数)传递,其中第 n 个指针对应于第 n 个参数,并指向用于复制该参数的源内存区域;也可以作为额外选项之一(cuLaunchKernel()的最后一个参数)传递。

当参数作为额外选项(CU_LAUNCH_PARAM_BUFFER_POINTER选项)传递时,它们以指向单个缓冲区的指针形式传递,其中假定各参数已按照设备代码中每种参数类型的对齐要求设置了适当的相对偏移。

内置向量类型在设备代码中的对齐要求列于表 43。对于所有其他基本类型,设备代码中的对齐要求与主机代码中的对齐要求一致,因此可使用__alignof()获得。唯一的例外是主机编译器将double和long long(以及 64 位系统上的long)对齐到单字边界而不是双字边界的情况(例如,使用gcc的编译标志-mno-align-double),因为在设备代码中,这些类型始终对齐到双字边界。

CUdeviceptr是一个整数,但表示一个指针,因此其对齐要求为__alignof(void*)。

以下代码示例使用一个宏(ALIGN_UP())调整每个参数的偏移量,以满足其对齐要求,并使用另一个宏(ADD_TO_PARAM_BUFFER())将每个参数添加到传递给CU_LAUNCH_PARAM_BUFFER_POINTER选项的参数缓冲区中。

#define ALIGN_UP(offset, alignment) \
      (offset) = ((offset) + (alignment) - 1) & ~((alignment) - 1)

char paramBuffer[1024];
size_t paramBufferSize = 0;

#define ADD_TO_PARAM_BUFFER(value, alignment)                   \
    do {                                                        \
        paramBufferSize = ALIGN_UP(paramBufferSize, alignment); \
        memcpy(paramBuffer + paramBufferSize,                   \
               &(value), sizeof(value));                        \
        paramBufferSize += sizeof(value);                       \
    } while (0)

int i;
ADD_TO_PARAM_BUFFER(i, __alignof(i));
float4 f4;
ADD_TO_PARAM_BUFFER(f4, 16); // float4's alignment is 16
char c;
ADD_TO_PARAM_BUFFER(c, __alignof(c));
float f;
ADD_TO_PARAM_BUFFER(f, __alignof(f));
CUdeviceptr devPtr;
ADD_TO_PARAM_BUFFER(devPtr, __alignof(devPtr));
float2 f2;
ADD_TO_PARAM_BUFFER(f2, 8); // float2's alignment is 8

void* extra[] = {
    CU_LAUNCH_PARAM_BUFFER_POINTER, paramBuffer,
    CU_LAUNCH_PARAM_BUFFER_SIZE,    &paramBufferSize,
    CU_LAUNCH_PARAM_END
};
cuLaunchKernel(cuFunction,
               blockWidth, blockHeight, blockDepth,
               gridWidth, gridHeight, gridDepth,
               0, 0, 0, extra);

结构体的对齐要求等于其各字段对齐要求的最大值。因此,包含内置向量类型、CUdeviceptr或未对齐的double和long long的结构体,在设备代码和主机代码中的对齐要求可能不同。此类结构体的填充方式也可能不同。例如,以下结构体在主机代码中完全不进行填充,但在设备代码中会在字段f之后填充 12 字节,因为字段f4的对齐要求为 16。

typedef struct {
    float  f;
    float4 f4;
} myStruct;

3.3.4. 运行时 API 与驱动程序 API 之间的互操作性

应用程序可以混合使用运行时 API 代码和驱动程序 API 代码。

如果通过驱动程序 API 创建上下文并将其设为当前上下文,则后续的运行时调用将使用此上下文,而不是创建新的上下文。

如果运行时已初始化,则可使用cuCtxGetCurrent()获取初始化期间创建的上下文。后续的驱动程序 API 调用可以使用此上下文。

由运行时隐式创建的上下文称为主上下文(参见运行时初始化)。可以从驱动程序 API 使用主上下文管理函数来管理它。

使用任一 API 都可以分配和释放设备内存。CUdeviceptr可以强制转换为普通指针,反之亦然:

CUdeviceptr devPtr;
float* d_data;

// Allocation using driver API
cuMemAlloc(&devPtr, size);
d_data = (float*)devPtr;

// Allocation using runtime API
cudaMalloc(&d_data, size);
devPtr = (CUdeviceptr)d_data;

具体而言,这意味着使用驱动程序 API 编写的应用程序可以调用使用运行时 API 编写的库(如 cuFFT、cuBLAS 等)。

参考手册中设备管理和版本管理各节的所有函数都可以互换使用。

3.4. 多 GPU 系统编程

多 GPU 编程通过利用多 GPU 系统提供的更高的总算术运算性能、更大的总内存容量和更高的总内存带宽,使应用程序能够处理超出单个 GPU 能力的问题规模,并达到超出单个 GPU 能力的性能水平。

CUDA 通过主机 API、驱动程序基础设施和配套的 GPU 硬件技术实现多 GPU 编程:

  • 主机线程的 CUDA 上下文管理

  • 系统中所有处理器的统一内存寻址

  • GPU 之间的对等批量内存传输

  • 细粒度的对等 GPU 加载/存储内存访问

  • 更高层次的抽象和配套系统软件,例如 CUDA 进程间通信、使用NCCL进行并行归约,以及通过NVSHMEM和 MPI 等 API,使用 NVLink 和/或 GPU-Direct RDMA 进行通信

在最基本的层面上,多 GPU 编程要求应用程序并发管理多个活动的 CUDA 上下文,将数据分发给各个 GPU,在 GPU 上启动核函数以完成其工作,并通信或收集结果,以便应用程序能够对这些结果采取操作。具体如何执行这些步骤,取决于如何将应用程序的算法、可用并行性和现有代码结构最有效地映射到合适的多 GPU 编程方法。一些最常见的多 GPU 编程方法包括:

  • 由单个主机线程驱动多个 GPU

  • 由多个主机线程各自驱动自己的 GPU

  • 由多个单线程主机进程各自驱动自己的 GPU

  • 多个主机进程包含多个线程,每个线程驱动自己的 GPU

  • 通过 NVLink 连接的多节点簇,其中 GPU 由分布在簇内各节点上的多个操作系统实例中运行的线程和进程驱动

GPU 可以通过设备内存之间的内存传输和对等访问相互通信,涵盖上述每种多设备工作分配方法。通过查询并启用对等 GPU 内存访问,并利用 NVLink 实现设备之间的高带宽传输和更细粒度的加载/存储操作,可以支持高性能、低延迟的 GPU 通信。

CUDA 统一虚拟寻址允许同一主机进程内的多个 GPU 之间进行通信,只需极少的额外步骤即可查询并启用高性能对等内存访问和传输,例如通过 NVLink 进行访问和传输。

通过使用进程间通信(IPC)和虚拟内存管理(VMM)API,可以支持由不同主机进程管理的多个 GPU 之间的通信。进程间通信一节介绍了 IPC 的高层概念,并讨论了节点内 CUDA IPC API。高级虚拟内存管理(VMM)API 同时支持节点内和多节点 IPC,可在 Linux 和 Windows 操作系统上使用,并允许以单次分配为粒度控制内存缓冲区的 IPC 共享,如虚拟内存管理中所述。

对于 NVLink 互连网络(fabric)上的 GPU,计算互连传输采用了不同的方法:它不像上述方法那样通过映射的虚拟地址访问对等设备的内存,而是使用逻辑端点 ID 为一组资源命名,通过偏移量指定该组中的某个资源,并通过显式的异步互连网络(fabric)操作访问该资源。参见下文的计算互连传输。

CUDA 本身提供了在一组 GPU 内实现集合操作所需的 API,这些操作也可能包括主机,但 CUDA 本身并不提供高层多 GPU 集合 API。多 GPU 集合操作由NCCL和NVSHMEM等更高抽象层次的 CUDA 通信库提供。

3.4.1. 多设备上下文与执行管理

应用程序要使用多个 GPU,首先需要枚举可用的 GPU 设备,根据这些设备的硬件属性、CPU 亲和性以及与对等设备的连接情况,从可用设备中作出适当选择,并为应用程序将使用的每个设备创建 CUDA 上下文。

3.4.1.1. 设备枚举

以下代码示例展示了如何查询支持 CUDA 的设备数量、枚举每个设备并查询其属性。

int deviceCount;
cudaGetDeviceCount(&deviceCount);
int device;
for (device = 0; device < deviceCount; ++device) {
    cudaDeviceProp deviceProp;
    cudaGetDeviceProperties(&deviceProp, device);
    printf("Device %d has compute capability %d.%d.\n",
           device, deviceProp.major, deviceProp.minor);
}

3.4.1.2. 设备选择

主机线程可以随时通过调用cudaSetDevice()来设置其当前操作的设备。设备内存分配和核函数启动在当前设备上进行;流和事件在创建时与当前设置的设备关联。在主机线程调用cudaSetDevice()之前,当前设备默认为设备 0。

以下代码示例说明了设置当前设备如何影响后续的内存分配和核函数执行操作。

size_t size = 1024 * sizeof(float);
cudaSetDevice(0);            // Set device 0 as current
float* p0;
cudaMalloc(&p0, size);       // Allocate memory on device 0
MyKernel<<<1000, 128>>>(p0); // Launch kernel on device 0

cudaSetDevice(1);            // Set device 1 as current
float* p1;
cudaMalloc(&p1, size);       // Allocate memory on device 1
MyKernel<<<1000, 128>>>(p1); // Launch kernel on device 1

3.4.1.3. 多设备流、事件和内存复制行为

如果向一个不与当前设备关联的流发出核函数启动请求,则启动将失败,如以下代码示例所示。

cudaSetDevice(0);               // Set device 0 as current
cudaStream_t s0;
cudaStreamCreate(&s0);          // Create stream s0 on device 0
MyKernel<<<100, 64, 0, s0>>>(); // Launch kernel on device 0 in s0

cudaSetDevice(1);               // Set device 1 as current
cudaStream_t s1;
cudaStreamCreate(&s1);          // Create stream s1 on device 1
MyKernel<<<100, 64, 0, s1>>>(); // Launch kernel on device 1 in s1

// This kernel launch will fail, since stream s0 is not associated to device 1:
MyKernel<<<100, 64, 0, s0>>>(); // Launch kernel on device 1 in s0

即使向一个不与当前设备关联的流发出内存复制请求,复制也会成功。

如果输入事件和输入流关联的是不同的设备,cudaEventRecord()将失败。

如果两个输入事件关联的是不同的设备,cudaEventElapsedTime()将失败。

即使输入事件关联的设备与当前设备不同,cudaEventSynchronize()和cudaEventQuery()也会成功。

即使输入流和输入事件关联的是不同的设备,cudaStreamWaitEvent()也会成功。因此,可以使用cudaStreamWaitEvent()使多个设备彼此同步。

每个设备都有自己的默认流,因此,向某个设备的默认流发出的命令,相对于向任何其他设备的默认流发出的命令,可能乱序执行或并发执行。

3.4.2. 多设备对等传输和内存访问

3.4.2.1. 对等内存传输

CUDA 可以在设备之间执行内存传输,并且在能够进行对等内存访问时,会利用专用复制引擎和 NVLink 硬件来最大限度地提高性能。

cudaMemcpy可以与复制类型cudaMemcpyDeviceToDevice或cudaMemcpyDefault一起使用。

否则,必须使用cudaMemcpyPeer()、cudaMemcpyPeerAsync()、cudaMemcpy3DPeer()或cudaMemcpy3DPeerAsync()执行复制,如以下代码示例所示。

cudaSetDevice(0);                   // Set device 0 as current
float* p0;
size_t size = 1024 * sizeof(float);
cudaMalloc(&p0, size);              // Allocate memory on device 0

cudaSetDevice(1);                   // Set device 1 as current
float* p1;
cudaMalloc(&p1, size);              // Allocate memory on device 1

cudaSetDevice(0);                   // Set device 0 as current
MyKernel<<<1000, 128>>>(p0);        // Launch kernel on device 0

cudaSetDevice(1);                   // Set device 1 as current
cudaMemcpyPeer(p1, 1, p0, 0, size); // Copy p0 to p1
MyKernel<<<1000, 128>>>(p1);        // Launch kernel on device 1

两个不同设备的内存之间的复制(在隐式NULL流中):

  • 只有在先前向这两个设备中的任一设备发出的所有命令都已完成后才会开始,并且

  • 只有在复制运行至完成后,在该复制之后向这两个设备中的任一设备发出的任何命令(参见异步执行)才能开始。

与流的正常行为一致,两个设备的内存之间的异步复制可以与另一个流中的复制或核函数重叠执行。

如果已在两个设备之间启用对等访问,例如按照对等内存访问中所述的方式启用,那么这两个设备之间的对等内存复制就不再需要通过主机暂存,因此速度更快。

3.4.2.2. 对等内存访问

根据系统属性,特别是 PCIe 和/或 NVLink 拓扑,设备能够对彼此的内存进行寻址(即,在一个设备上执行的核函数可以解引用指向另一个设备内存的指针)。如果cudaDeviceCanAccessPeer()对指定的设备返回 true,则这两个设备之间支持对等内存访问。

必须通过调用cudaDeviceEnablePeerAccess()来启用两个设备之间的对等内存访问,如以下代码示例所示。在未启用 NVSwitch 的系统上,每个设备在整个系统范围内最多可支持八个对等连接。

两个设备使用统一的虚拟地址空间(参见统一虚拟地址空间),因此可以在这两个设备上使用同一个指针对内存进行寻址,如下面的代码示例所示。

cudaSetDevice(0);                   // Set device 0 as current
float* p0;
size_t size = 1024 * sizeof(float);
cudaMalloc(&p0, size);              // Allocate memory on device 0
MyKernel<<<1000, 128>>>(p0);        // Launch kernel on device 0

cudaSetDevice(1);                   // Set device 1 as current
cudaDeviceEnablePeerAccess(0, 0);   // Enable peer-to-peer access
                                    // with device 0

// Launch kernel on device 1
// This kernel launch can access memory on device 0 at address p0
MyKernel<<<1000, 128>>>(p0);

注意

使用cudaDeviceEnablePeerAccess()启用对等内存访问会全局作用于对等设备上所有先前和后续的 GPU 内存分配。通过cudaDeviceEnablePeerAccess()启用对某个设备的对等访问,会增加该对等设备上设备内存分配操作的运行时开销,因为需要使分配的内存立即可供当前设备以及同样具有访问权限的任何其他对等设备访问,从而带来随对等设备数量增长的成倍开销。

与为所有设备内存分配启用对等内存访问相比,一种可扩展性更好的替代方法是使用 CUDA 虚拟内存管理 API,仅在需要时,在分配期间显式分配可供对等设备访问的内存区域。通过在内存分配期间显式请求对等可访问性,不可供对等设备访问的内存分配的运行时开销不会受到影响,同时可供对等设备访问的数据结构也能被正确限定作用域,从而改善软件调试并提高可靠性(参见 ref::virtual-memory-management)。

3.4.2.3. 对等内存一致性

对于分布在多个设备上的网格中并发执行的线程,必须使用同步操作来强制保证其内存访问的顺序和正确性。跨设备同步的线程在thread_scope_system同步作用域内操作。同样,内存操作属于thread_scope_system内存同步域。

当只有一个 GPU 访问对等设备内存中的某个对象时,CUDA原子函数可以对该对象执行读取-修改-写入操作。对等原子性的要求和限制在 CUDA 内存模型的原子性要求讨论中进行了说明。

3.4.2.4. 多设备托管内存

托管内存可以在支持对等访问的多 GPU 系统上使用。并发多设备托管内存访问的详细要求,以及用于 GPU 独占访问托管内存的 API,在多 GPU中进行了说明。

3.4.2.5. 主机 IOMMU 硬件、PCI 访问控制服务和虚拟机

具体而言,在 Linux 上,CUDA 和显示驱动程序不支持在启用 IOMMU 的裸机上进行 PCIe 对等内存传输。但是,CUDA 和显示驱动程序确实支持通过虚拟机直通使用 IOMMU。在裸机系统上运行 Linux 时,必须禁用 IOMMU,以防止设备内存发生静默损坏。相反,对于虚拟机,应启用 IOMMU,并使用 VFIO 驱动程序进行 PCIe 直通。

在 Windows 上,不存在上述 IOMMU 限制。

另请参见在 64 位平台上分配 DMA 缓冲区。

此外,可以在支持 IOMMU 的系统上启用 PCI 访问控制服务(ACS)。PCI ACS 功能会将所有 PCI 点对点流量重定向,经由 CPU 根联合体传输,这会降低总体对分带宽,从而可能造成显著的性能损失。

3.4.2.6. 计算互连传输

对等内存访问通过发起进程已预留并映射的虚拟地址访问对等设备上的内存分配。计算互连传输为大型 NVLink 互连网络(fabric)提供了一种替代方式:它使用逻辑端点 ID 标识对等端,并通过偏移量而不是映射地址来标识所访问的资源。它面向通信库和高级应用程序,详见计算互连传输。

3.5. CUDA 功能概览

本编程指南的第 1 至第 3 节介绍了 CUDA 和 GPU 编程,通过概念说明和简单的代码示例涵盖了基础主题。本指南第 4 部分中介绍特定 CUDA 功能的各节,假定读者已了解本指南第 1 至第 3 节所涵盖的概念。

CUDA 有许多适用于不同问题的功能。并非所有功能都适用于每一种用例。本章旨在介绍其中的每项功能,并说明其预期用途以及可能有助于解决的问题。各项功能根据其旨在解决的问题类型进行了粗略分类。某些功能(例如 CUDA 图)可能归入多个类别。

第 4 节更为完整、详细地介绍了这些 CUDA 功能。

3.5.1. 提高核函数性能

本节概述的所有功能都旨在帮助核函数开发者最大限度地提高其核函数的性能。

3.5.1.1. 异步屏障

异步屏障已在第 3.2.4.2 节中介绍,它允许对线程间同步进行更精细的控制。异步屏障将屏障的到达与等待分离。这使应用程序能够在等待其他线程到达时,执行不依赖于该屏障的工作。可以为不同的线程作用域指定异步屏障。有关异步屏障的完整详细信息,请参见第 4.10 节。

3.5.1.2. 异步数据复制与张量内存加速器(TMA)

在 CUDA 核函数代码中,异步数据复制是指在共享内存与 GPU DRAM 之间移动数据的同时仍能进行计算的能力。不应将其与 CPU 和 GPU 之间的异步内存复制混淆。此功能使用异步屏障。第 4.12 节详细介绍了异步复制的使用。

3.5.1.3. 流水线

流水线是一种用于将工作分阶段并协调多缓冲区生产者-消费者模式的机制,通常用于使计算与异步数据复制重叠执行。第 4.11 节提供了在 CUDA 中使用流水线的详细信息和示例。

3.5.1.4. 通过簇启动控制进行工作窃取

工作窃取是一种在不均衡工作负载中维持利用率的技术,已完成工作的工作者可以从其他工作者那里“窃取”任务。簇启动控制是计算能力 10.0(Blackwell)中引入的一项功能,它使核函数能够直接控制在途线程块调度,从而实时适应不均衡的工作负载。一个线程块可以取消另一个尚未开始的线程块或簇的启动,认领其索引,并立即开始执行窃取的工作。这种工作窃取流程可在数据不规则或运行时存在变化的情况下使 SM 保持忙碌并缩短空闲时间,实现更细粒度的负载均衡,而不必仅依赖硬件调度器。

第 4.13 节提供了如何使用此功能的详细信息。

3.5.2. 改善延迟

本节概述的功能有一个共同主题,即旨在降低某种类型的延迟,尽管各项功能所针对的延迟类型不同。总体而言,它们侧重于核函数启动层级或更高层级的延迟。核函数内部的 GPU 内存访问延迟不在此处考虑的延迟之列。

3.5.2.1. 绿色上下文

绿色上下文也称为执行上下文,是一项 CUDA 功能的名称,该功能使程序能够创建仅在 GPU 的一部分 SM 上执行工作的CUDA 上下文。默认情况下,一次核函数启动的线程块会被分派到 GPU 内任何能够满足该核函数资源要求的 SM 上。许多因素会影响哪些 SM 可以执行某个线程块,包括但不一定限于:共享内存使用量、寄存器使用量、簇的使用以及线程块中的线程总数。

执行上下文允许将核函数启动到专门创建的上下文中,该上下文会进一步限制可用于执行核函数的 SM 数量。重要的是,当程序创建使用某一组 SM 的绿色上下文时,GPU 上的其他上下文不会将线程块调度到分配给该绿色上下文的 SM 上。这也包括主上下文,即 CUDA 运行时使用的默认上下文。这使得这些 SM 可以为高优先级或延迟敏感的工作负载预留。

第 4.6 节提供了绿色上下文使用的完整详细信息。

3.5.2.2. 局部性域

局部性域是 GPU 内存和 SM 的一个子集。在具有多个局部性域的 GPU 上,在特定局部性域内分配内存,然后在仅包含同一局部性域中 SM 的绿色上下文中执行使用该内存的核函数,可能会带来好处。

局部性域是一项性能功能:未对内存或计算进行局部化的 CUDA 代码仍会正确执行。

第 1.2.3.2 节介绍了局部性域背后的概念,第 4.7 节则提供了更多信息,说明如何确定 GPU 具有多少个局部性域、如何将内存分配到特定局部性域,以及如何创建仅包含同一局部性域中 SM 的绿色上下文。

3.5.2.3. 流有序内存分配

流有序内存分配器允许程序将 GPU 内存的分配和释放按顺序排入CUDA 流。与立即执行的cudaMalloc和cudaFree不同,cudaMallocAsync和cudaFreeAsync会将内存分配或释放操作插入 CUDA 流中。第 4.3 节介绍了这些 API 的全部详细信息。

3.5.2.4. CUDA 图

CUDA 图使应用程序能够指定一系列 CUDA 操作(例如核函数启动或内存复制)及这些操作之间的依赖关系,以便在 GPU 上高效执行这些操作。使用CUDA 流可以实现类似的行为,实际上,创建图的机制之一称为流捕获,它可以将流上的操作记录到 CUDA 图中。也可以使用CUDA 图 API来创建图。

图创建后,即可将其实例化并多次执行。这有助于指定将重复执行的工作负载。图在性能方面具有一些优势,既能降低与调用 CUDA 操作相关的 CPU 启动开销,又能实现只有在预先指定整个工作负载时才可用的优化。

第 4.2 节说明并演示了如何使用 CUDA 图。

3.5.2.5. 程序化依赖启动

程序化依赖启动是一项 CUDA 功能,它允许依赖核函数(即依赖先前核函数输出的核函数)在其所依赖的主核函数完成之前开始执行。依赖核函数可以执行准备代码和无关工作,直到需要主核函数的数据时才在该处阻塞。主核函数可以在依赖核函数所需的数据就绪时发出信号,从而解除依赖核函数的阻塞,使其继续执行。这使核函数之间能够部分重叠执行,有助于保持较高的 GPU 利用率,同时最大限度地降低关键数据路径的延迟。第 4.5 节介绍了程序化依赖启动。

3.5.2.6. 延迟加载

延迟加载是一项允许控制 JIT 编译器在应用程序启动时如何工作的特性。如果在应用程序启动过程中对所有核函数进行 JIT 编译,那么对于包含许多需要从 PTX JIT 编译为 cubin 的核函数的应用程序,启动时间可能会很长。默认行为是在需要模块时才对其进行编译。可以使用环境变量更改这一行为,具体说明见第 4.8 节。

3.5.3. 功能性特性

此处描述的特性具有一个共同点,即它们旨在提供额外的能力或功能。

3.5.3.1. 扩展 GPU 内存

扩展 GPU 内存是通过 NVLink-C2C 连接的系统所具备的一项特性,它能够让 GPU 高效地访问系统内的所有内存。第 4.19 节详细介绍了 EGM。

3.5.3.2. 动态并行

CUDA 应用程序最常见的做法是从 CPU 上运行的代码中启动核函数。也可以从 GPU 上运行的核函数中创建新的核函数调用。这项特性称为CUDA 动态并行。第 4.20 节详细介绍了如何从 GPU 上运行的代码中创建新的 GPU 核函数启动。

3.5.4. CUDA 互操作性

3.5.4.1. CUDA 与其他 API 的互操作性

除了 CUDA 之外,还有其他在 GPU 上运行代码的机制。GPU 最初为之提供加速的应用领域,即计算机图形学,使用自己的一套 API,例如 Direct3D 和 Vulkan。应用程序可能希望在使用 CUDA 进行计算的同时,使用某个图形 API 进行 3D 渲染。CUDA 提供了在 CUDA 上下文与 3D API 所用的 GPU 上下文之间交换 GPU 上存储的数据的机制。例如,应用程序可以使用 CUDA 进行仿真,然后使用 3D API 对结果进行可视化。这是通过使某些缓冲区能够同时由 CUDA 和图形 API 读取和/或写入来实现的。

允许与图形 API 共享缓冲区的相同机制,也用于与通信机制共享缓冲区,这些通信机制可以在多节点环境中实现快速、直接的 GPU 到 GPU 通信。

第 4.21 节介绍了 CUDA 如何与其他 GPU API 互操作,以及如何在 CUDA 与其他 API 之间共享数据,并针对多种不同的 API 提供了具体示例。

3.5.4.2. 进程间通信

对于规模非常大的计算,通常会联合使用多个 GPU,以利用更多内存和更多计算资源来共同处理一个问题。在单个系统内,即集群计算术语中的单个节点内,可以在一个主机进程中使用多个 GPU。第 3.4 节对此进行了介绍。

另一种常见做法是使用分布在单台或多台计算机上的独立主机进程。当多个进程协同工作时,它们之间的通信称为进程间通信。CUDA 进程间通信(CUDA IPC)提供了在不同进程之间共享 GPU 缓冲区的机制。第 4.16 节解释并演示了如何使用 CUDA IPC 在不同主机进程之间进行协调和通信。

3.5.5. 细粒度控制

3.5.5.1. 虚拟内存管理

如第 2.6.1 节所述,系统中的所有 GPU 与 CPU 内存共享同一个统一虚拟地址空间。大多数应用程序可以使用 CUDA 提供的默认内存管理,而无需更改其行为。不过,CUDA 驱动 API为有需要的应用程序提供了对这一虚拟内存空间布局的高级、精细控制。这主要适用于控制在单个系统内以及跨多个系统的 GPU 之间共享缓冲区时的行为。

第 4.17 节介绍了 CUDA 驱动 API 提供的控制功能、这些功能的工作原理,以及开发者在何种情况下可能会发现它们有用。

3.5.5.2. 计算互连传输

第 4.18 节介绍了一种以资源为中心的模型,用于通过 NVLink 互连网络(fabric)进行由 GPU 发起的数据移动。在该模型中,对等方由逻辑端点 ID 和偏移量来标识,而不是由映射的虚拟地址来标识。该特性面向编写通信原语的开发者;当NCCL Device API或NVSHMEM中的任意一种符合应用程序的通信模式时,应用程序应优先使用它。

3.5.5.3. 驱动入口点访问

驱动入口点访问是指从 CUDA 11.3 开始提供的获取 CUDA 驱动 API 和 CUDA 运行时 API 函数指针的能力。它还允许开发者获取驱动函数特定变体的函数指针,以及访问比 CUDA 工具包中可用的驱动更新的驱动所提供的驱动函数。第 4.22 节介绍了驱动入口点访问。

3.5.5.4. 错误日志管理

错误日志管理提供了用于处理和记录 CUDA API 错误的实用工具。只需设置一个环境变量CUDA_LOG_FILE,即可将 CUDA 错误直接捕获到 stderr、stdout 或文件中。错误日志管理还允许应用程序注册一个回调,在 CUDA 遇到错误时触发该回调。第 4.9 节提供了有关错误日志管理的更多详细信息。

4. CUDA 特性

4.1. 统一内存

本节解释了各种可用的统一内存范式的详细行为和用法。前面有关统一内存的章节展示了如何确定适用的统一内存范式,并简要介绍了每种范式。

如前所述,统一内存编程有四种范式:

前面三种提供完整统一内存支持的范式具有非常相似的行为和编程模型,完全支持 CUDA 统一内存的设备上的统一内存中介绍了这些范式,并重点指出了它们之间的差异。

最后一种范式的统一内存支持有限,Windows、WSL 和 Tegra 上的统一内存中对此进行了详细讨论。

4.1.1. 完全支持 CUDA 统一内存的设备上的统一内存

这些系统包括 NVIDIA Grace Hopper 等硬件相干内存系统,以及启用了异构内存管理(HMM)的现代 Linux 系统。HMM 是一种基于软件的内存管理系统,提供与硬件相干内存系统相同的编程模型。

Linux HMM 要求 Linux 内核版本为 6.1.24+、6.2.11+ 或 6.3+,设备的计算能力为 7.5 或更高,并且使用开源内核模块安装了版本为 535+ 的 CUDA 驱动。

注意

我们将 CPU 和 GPU 共用一张页表的系统称为硬件相干系统。CPU 和 GPU 使用独立页表的系统则称为软件相干系统。

NVIDIA Grace Hopper 等硬件相干系统为 CPU 和 GPU 提供一张在逻辑上合并的页表,请参见CPU 和 GPU 页表:硬件相干性与软件相干性。以下章节仅适用于硬件相干系统:

4.1.1.1. 统一内存:深入示例

完全支持 CUDA 统一内存的系统允许设备访问与该设备交互的主机进程所拥有的任何内存,请参见统一内存范式概览表。

本节展示几个高级用例,使用的核函数只是将输入字符数组的前 8 个字符打印到标准输出流:

__global__ void kernel(const char* type, const char* data) {
  static const int n_char = 8;
  printf("%s - first %d characters: '", type, n_char);
  for (int i = 0; i < n_char; ++i) printf("%c", data[i]);
  printf("'\n");
}

以下选项卡展示了使用系统分配的内存调用此核函数的各种方式:

void test_malloc() {
  const char test_string[] = "Hello World";
  char* heap_data = (char*)malloc(sizeof(test_string));
  strncpy(heap_data, test_string, sizeof(test_string));
  kernel<<<1, 1>>>("malloc", heap_data);
  ASSERT(cudaDeviceSynchronize() == cudaSuccess,
    "CUDA failed with '%s'", cudaGetErrorString(cudaGetLastError()));
  free(heap_data);
}
void test_managed() {
  const char test_string[] = "Hello World";
  char* data;
  cudaMallocManaged(&data, sizeof(test_string));
  strncpy(data, test_string, sizeof(test_string));
  kernel<<<1, 1>>>("managed", data);
  ASSERT(cudaDeviceSynchronize() == cudaSuccess,
    "CUDA failed with '%s'", cudaGetErrorString(cudaGetLastError()));
  cudaFree(data);
}
void test_stack() {
  const char test_string[] = "Hello World";
  kernel<<<1, 1>>>("stack", test_string);
  ASSERT(cudaDeviceSynchronize() == cudaSuccess,
    "CUDA failed with '%s'", cudaGetErrorString(cudaGetLastError()));
}
void test_static() {
  static const char test_string[] = "Hello World";
  kernel<<<1, 1>>>("static", test_string);
  ASSERT(cudaDeviceSynchronize() == cudaSuccess,
    "CUDA failed with '%s'", cudaGetErrorString(cudaGetLastError()));
}
const char global_string[] = "Hello World";

void test_global() {
  kernel<<<1, 1>>>("global", global_string);
  ASSERT(cudaDeviceSynchronize() == cudaSuccess,
    "CUDA failed with '%s'", cudaGetErrorString(cudaGetLastError()));
}
// declared in separate file, see below
extern char* ext_data;

void test_extern() {
  kernel<<<1, 1>>>("extern", ext_data);
  ASSERT(cudaDeviceSynchronize() == cudaSuccess,
    "CUDA failed with '%s'", cudaGetErrorString(cudaGetLastError()));
}
/** This may be a non-CUDA file */
char* ext_data;
static const char global_string[] = "Hello World";

void __attribute__ ((constructor)) setup(void) {
  ext_data = (char*)malloc(sizeof(global_string));
  strncpy(ext_data, global_string, sizeof(global_string));
}

void __attribute__ ((destructor)) tear_down(void) {
  free(ext_data);
}

请注意,extern 变量可以由完全不与 CUDA 交互的第三方库声明,其内存也可以由该库拥有和管理。

还要注意,GPU 只能通过指针访问栈变量以及文件作用域和全局作用域变量。在这个特定示例中,这很方便,因为字符数组已经声明为指针:const char*。不过,请考虑下面这个使用全局作用域整数的示例:

// this variable is declared at global scope
int global_variable;

__global__ void kernel_uncompilable() {
  // this causes a compilation error: global (__host__) variables must not
  // be accessed from __device__ / __global__ code
  printf("%d\n", global_variable);
}

// On systems with pageableMemoryAccess set to 1, we can access the address
// of a global variable. The below kernel takes that address as an argument
__global__ void kernel(int* global_variable_addr) {
  printf("%d\n", *global_variable_addr);
}
int main() {
  kernel<<<1, 1>>>(&global_variable);
  ...
  return 0;
}

在上面的示例中,我们需要确保将指向全局变量的指针传递给核函数,而不是在核函数中直接访问全局变量。这是因为不带__managed__说明符的全局变量默认声明为仅限__host__使用,因此目前大多数编译器不允许在设备代码中直接使用这些变量。

4.1.1.1.1. 以文件为后备的统一内存

由于完全支持 CUDA 统一内存的系统允许设备访问主机进程所拥有的任何内存,因此设备可以直接访问以文件为后备的内存。

这里展示上一节中最初示例的一个修改版本,使用以文件为后备的内存,从 GPU 打印直接从输入文件读取的字符串。在以下示例中,内存以物理文件为后备,但该示例也适用于以内存为后备的文件。

__global__ void kernel(const char* type, const char* data) {
  static const int n_char = 8;
  printf("%s - first %d characters: '", type, n_char);
  for (int i = 0; i < n_char; ++i) printf("%c", data[i]);
  printf("'\n");
}
void test_file_backed() {
  int fd = open(INPUT_FILE_NAME, O_RDONLY);
  ASSERT(fd >= 0, "Invalid file handle");
  struct stat file_stat;
  int status = fstat(fd, &file_stat);
  ASSERT(status >= 0, "Invalid file stats");
  char* mapped = (char*)mmap(0, file_stat.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
  ASSERT(mapped != MAP_FAILED, "Cannot map file into memory");
  kernel<<<1, 1>>>("file-backed", mapped);
  ASSERT(cudaDeviceSynchronize() == cudaSuccess,
    "CUDA failed with '%s'", cudaGetErrorString(cudaGetLastError()));
  ASSERT(munmap(mapped, file_stat.st_size) == 0, "Cannot unmap file");
  ASSERT(close(fd) == 0, "Cannot close file");
}

请注意,不具备hostNativeAtomicSupported属性的系统(请参见主机原生原子操作),包括启用了 Linux HMM 的系统,不支持对以文件为后备的内存进行原子访问。

4.1.1.1.2. 使用统一内存进行进程间通信(IPC)

注意

目前,将 IPC 与统一内存结合使用可能对性能产生显著影响。

许多应用程序倾向于每个进程管理一个 GPU,但仍然需要使用统一内存,例如用于超额订用,并从多个 GPU 访问它。

CUDA IPC(请参见进程间通信)不支持托管内存:不能通过本节讨论的任何机制共享此类内存的句柄。在完全支持 CUDA 统一内存的系统上,系统分配的内存能够用于 IPC。一旦与其他进程共享了对系统分配的内存的访问权限,同样的编程模型便适用,类似于以文件为后备的统一内存。

有关在 Linux 下创建可用于 IPC 的系统分配内存的各种方式的更多信息,请参见以下参考资料:

请注意,无法使用这种技术在不同主机及其设备之间共享内存。

4.1.1.2. 性能调优

为了使统一内存获得良好的性能,以下几点很重要:

  • 了解分页在系统上的工作方式,以及如何避免不必要的缺页异常

  • 了解使数据保持在访问它的处理器本地的各种机制

  • 考虑根据系统的内存传输粒度对应用程序进行调优。

一般而言,性能提示(请参见性能提示)可能会提高性能,但使用不当可能使性能比默认行为更差。还要注意,任何提示都会在主机上带来相应的性能开销,因此有用的提示至少必须带来足以抵消这一开销的性能提升。

4.1.1.2.1. 内存分页与页大小

要更好地理解统一内存对性能的影响,了解虚拟寻址、内存页和页大小很重要。本小节尝试定义所有必要的术语,并解释为什么分页对性能很重要。

目前支持统一内存的所有系统都使用虚拟地址空间:这意味着应用程序使用的内存地址表示一个虚拟位置,该位置可能会被映射到内存实际驻留的物理位置。

目前支持的所有处理器,包括 CPU 和 GPU,还使用内存分页。由于所有系统都使用虚拟地址空间,因此存在两种类型的内存页:

  • 虚拟页:这是操作系统按进程跟踪的一块固定大小的连续虚拟内存,可以将其映射到物理内存中。请注意,虚拟页与映射相关联:例如,可以使用不同的页大小将同一个虚拟地址映射到物理内存中。

  • 物理页:这是处理器的主内存管理单元(MMU)支持的一块固定大小的连续内存,虚拟页可以映射到其中。

目前,所有 x86_64 CPU 使用的默认物理页大小为 4KiB。Arm CPU 支持多种物理页大小,包括 4KiB、16KiB、32KiB 和 64KiB,具体取决于 CPU 型号。最后,NVIDIA GPU 支持多种物理页大小,但更倾向于使用 2MiB 或更大的物理页。请注意,这些大小在未来的硬件中可能会改变。

虚拟页的默认页大小通常与物理页大小相对应,但只要操作系统和硬件支持,应用程序就可以使用不同的页大小。通常,受支持的虚拟页大小必须是 2 的幂,并且是物理页大小的倍数。

跟踪虚拟页到物理页映射的逻辑实体称为页表,而将具有给定虚拟大小的某个虚拟页映射到物理页的每个映射称为页表项(PTE)。所有受支持的处理器都为页表提供专用缓存,以加速虚拟地址到物理地址的转换。这些缓存称为转换后备缓冲区(TLB)。

应用程序性能调优有两个重要方面:

  • 虚拟页大小的选择,

  • 系统是提供一张由 CPU 和 GPU 共用的合并页表,还是为每个 CPU 和 GPU 分别提供独立页表。

4.1.1.2.1.1. 选择合适的页大小

一般而言,较小的页大小会带来较少的(虚拟)内存碎片,但会带来更多的 TLB 未命中;较大的页大小则会带来更多的内存碎片,但 TLB 未命中更少。此外,与较小的页大小相比,较大的页大小通常使内存迁移的开销更高,因为我们通常会迁移完整的内存页。这可能导致使用大页的应用程序出现更大的延迟峰值。有关缺页异常的更多详细信息,另请参见下一节。

性能调优的一个重要方面是,与 CPU 相比,GPU 上 TLB 未命中的开销通常要大得多。这意味着,如果 GPU 线程频繁访问使用足够小的页大小映射的统一内存中的随机位置,那么与对使用足够大的页大小映射的统一内存进行相同访问相比,其速度可能会慢得多。虽然当 CPU 线程随机访问使用小页大小映射的大块内存区域时,也可能出现类似的影响,但速度下降没有那么明显,因此应用程序可能希望在这种速度下降与减少内存碎片之间进行权衡。

请注意,一般而言,应用程序不应针对给定处理器的物理页大小进行性能调优,因为物理页大小可能因硬件而变化。上述建议仅适用于虚拟页大小。

4.1.1.2.1.2. CPU 和 GPU 页表:硬件相干性与软件相干性

NVIDIA Grace Hopper 等硬件相干系统为 CPU 和 GPU 提供了逻辑上合并的页表。这一点很重要,因为为了从 GPU 访问系统分配的内存,GPU 会使用 CPU 为所请求的内存创建的页表项。如果该页表项使用默认的 CPU 页大小 4KiB 或 64KiB,那么对大块虚拟内存区域的访问将导致大量 TLB 未命中,从而造成显著的速度下降。

另一方面,在 CPU 和 GPU 各自拥有独立逻辑页表的软件相干系统上,应考虑不同的性能调优方面:为了保证相干性,当处理器访问映射到另一处理器物理内存中的内存地址时,这些系统通常使用缺页异常。这种缺页异常意味着:

  • 需要通过删除或更新页表项,确保当前拥有该页的处理器(物理页当前所在的处理器)无法再访问该页。

  • 需要通过创建新的页表项或更新现有页表项,使其变为有效/活动状态,从而确保请求访问的处理器能够访问该页。

  • 支撑该虚拟页的物理页必须移动/迁移到请求访问的处理器:这可能是一项开销很大的操作,其工作量与页大小成正比。

总体而言,在 CPU 和 GPU 线程频繁并发访问同一内存页的情况下,与软件相干系统相比,硬件相干系统具有显著的性能优势:

  • 更少的缺页异常:这些系统无需使用缺页异常来模拟相干性或迁移内存,

  • 更少的争用:这些系统以缓存行粒度而非页大小粒度保持相干性,也就是说,当多个处理器在同一缓存行内发生争用时,只交换缓存行,而缓存行比最小的页大小还要小得多;当不同处理器访问同一页内的不同缓存行时,则不会发生争用。

这会影响以下场景的性能:

  • CPU 和 GPU 同时对同一地址进行原子更新

  • CPU 线程向 GPU 线程发送信号,或反过来。

4.1.1.2.1.3. 混合使用硬件相干性和软件相干性

某些具有硬件相干性的系统(例如 NVIDIA DGX Station)也支持安装独立的非相干 GPU 硬件。来自硬件相干 GPU 的访问将继续使用CPU 和 GPU 页表:硬件相干性与软件相干性中所述的基于硬件的相干性,而来自独立 GPU 的访问将使用基于软件的相干性。

这两类 GPU 之间允许共享统一地址空间。访问所用的 GPU 不同,会导致不同的性能和迁移行为。具体而言,来自软件相干 GPU 的访问会产生更多缺页异常和内存迁移,而来自硬件相干 GPU 的访问产生的缺页异常较少,并会在可能的情况下进行远程映射。

为了获得最佳性能,应限制两个 GPU 之间的数据共享,或通过显式复制进行共享。或者,应采取调用cudaMemAdviseSetPreferredLocation等措施,以确保频繁共享的数据在物理上仍驻留于 CPU 内存或相干 GPU 内存中,因为默认情况下,访问软件相干内存需要发生缺页异常并迁移数据。

在混合相干性系统上,对于软件相干 GPU,cudaHostRegister及其他主机内存访问 API 的行为也会发生变化。混合相干性系统上的软件相干 GPU 不使用页锁定映射,而是使用 CPU 页表的软件镜像。这意味着某些 GPU 访问可能发生缺页异常,而在非混合相干性系统上则不会,尽管此类缺页异常很少见,并且应仅在内存压力下发生。

4.1.1.2.2. 从主机直接访问统一内存

某些设备在硬件上支持主机对驻留于 GPU 的统一内存进行相干的读取、存储和原子访问。这些设备的属性cudaDevAttrDirectManagedMemAccessFromHost设置为 1。请注意,所有硬件相干系统都会为通过 NVLink 连接的设备设置此属性。在这些系统上,主机可以直接访问驻留于 GPU 的内存,而不会发生缺页异常和数据迁移。请注意,对于 CUDA 托管内存,必须使用位置类型为cudaMemLocationTypeHost的cudaMemAdviseSetAccessedBy提示,才能启用这种不发生缺页异常的直接访问,请参见下面的示例。

__global__ void write(int *ret, int a, int b) {
  ret[threadIdx.x] = a + b + threadIdx.x;
}

__global__ void append(int *ret, int a, int b) {
  ret[threadIdx.x] += a + b + threadIdx.x;
}

void test_malloc() {
  int *ret = (int*)malloc(1000 * sizeof(int));
  // for shared page table systems, the following hint is not necesary
  cudaMemLocation location = {.type = cudaMemLocationTypeHost};
  cudaMemAdvise(ret, 1000 * sizeof(int), cudaMemAdviseSetAccessedBy, location);

  write<<< 1, 1000 >>>(ret, 10, 100);            // pages populated in GPU memory
  cudaDeviceSynchronize();
  for(int i = 0; i < 1000; i++)
      printf("%d: A+B = %d\n", i, ret[i]);        // directManagedMemAccessFromHost=1: CPU accesses GPU memory directly without migrations
                                                  // directManagedMemAccessFromHost=0: CPU faults and triggers device-to-host migrations
  append<<< 1, 1000 >>>(ret, 10, 100);            // directManagedMemAccessFromHost=1: GPU accesses GPU memory without migrations
  cudaDeviceSynchronize();                        // directManagedMemAccessFromHost=0: GPU faults and triggers host-to-device migrations
  free(ret);
}
__global__ void write(int *ret, int a, int b) {
  ret[threadIdx.x] = a + b + threadIdx.x;
}

__global__ void append(int *ret, int a, int b) {
  ret[threadIdx.x] += a + b + threadIdx.x;
}

void test_managed() {
  int *ret;
  cudaMallocManaged(&ret, 1000 * sizeof(int));
  cudaMemLocation location = {.type = cudaMemLocationTypeHost};
  cudaMemAdvise(ret, 1000 * sizeof(int), cudaMemAdviseSetAccessedBy, location);  // set direct access hint

  write<<< 1, 1000 >>>(ret, 10, 100);            // pages populated in GPU memory
  cudaDeviceSynchronize();
  for(int i = 0; i < 1000; i++)
      printf("%d: A+B = %d\n", i, ret[i]);        // directManagedMemAccessFromHost=1: CPU accesses GPU memory directly without migrations
                                                  // directManagedMemAccessFromHost=0: CPU faults and triggers device-to-host migrations
  append<<< 1, 1000 >>>(ret, 10, 100);            // directManagedMemAccessFromHost=1: GPU accesses GPU memory without migrations
  cudaDeviceSynchronize();                        // directManagedMemAccessFromHost=0: GPU faults and triggers host-to-device migrations
  cudaFree(ret); 

write核函数完成后,ret将在 GPU 内存中创建并初始化。接下来,CPU 将访问ret,随后append核函数将再次使用同一块ret内存。根据系统架构和对硬件相干性的支持情况,此代码将表现出不同的行为:

  • 在具有directManagedMemAccessFromHost=1的设备上:CPU 对托管缓冲区的访问不会触发任何迁移;数据将保持驻留于 GPU 内存中,后续的任何 GPU 核函数都可以继续直接访问它,而不会引发缺页异常或迁移

  • 在具有directManagedMemAccessFromHost=0的设备上:CPU 对托管缓冲区的访问将发生缺页异常并启动数据迁移;任何尝试首次访问同一数据的 GPU 核函数都会发生缺页异常,并将页迁移回 GPU 内存。

4.1.1.2.3. 主机原生原子操作

某些设备(包括硬件相干系统中通过 NVLink 连接的设备)支持对驻留于 CPU 的内存进行硬件加速的原子访问。这意味着,对主机内存的原子访问不必通过缺页异常来模拟。对于这些设备,属性cudaDevAttrHostNativeAtomicSupported设置为 1。

4.1.1.2.4. 原子访问和同步原语

CUDA 统一内存支持主机线程和设备线程可用的所有原子操作,使所有线程都能通过并发访问同一共享内存位置来协作。libcu++库提供了许多针对主机线程与设备线程之间的并发使用而调优的异构同步原语,其中包括cuda::atomic、cuda::atomic_ref、cuda::barrier、cuda::semaphore等许多原语。

在软件相干系统上,不支持从设备对文件后备的主机内存进行原子访问。以下示例代码在硬件相干系统上有效,但在其他系统上会出现未定义行为:

#include <cuda/atomic>

#include <cstdio>
#include <fcntl.h>
#include <sys/mman.h>

#define ERR(msg, ...) { fprintf(stderr, msg, ##__VA_ARGS__); return EXIT_FAILURE; }

__global__ void kernel(int* ptr) {
  cuda::atomic_ref{*ptr}.store(2);
}

int main() {
  // this will be closed/deleted by default on exit
  FILE* tmp_file = tmpfile64();
  // need to allocate space in the file, we do this with posix_fallocate here
  int status = posix_fallocate(fileno(tmp_file), 0, 4096);
  if (status != 0) ERR("Failed to allocate space in temp file\n");
  int* ptr = (int*)mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_PRIVATE, fileno(tmp_file), 0);
  if (ptr == MAP_FAILED) ERR("Failed to map temp file\n");

  // initialize the value in our file-backed memory
  *ptr = 1;
  printf("Atom value: %d\n", *ptr);

  // device and host thread access ptr concurrently, using cuda::atomic_ref
  kernel<<<1, 1>>>(ptr);
  while (cuda::atomic_ref{*ptr}.load() != 2);
  // this will always be 2
  printf("Atom value: %d\n", *ptr);

  return EXIT_SUCCESS;
}

在软件相干系统上,对统一内存的原子访问可能引发缺页异常,从而导致显著的延迟。请注意,在这些系统上,并非所有对 CPU 内存的 GPU 原子操作都会出现这种情况:nvidia-smi -q | grep "Atomic Caps Outbound"列出的操作可能避免缺页异常。

在硬件相干系统上,主机与设备之间的原子操作不需要缺页异常,但仍可能由于会导致任何内存访问发生缺页异常的其他原因而发生缺页异常。

4.1.1.2.5. 使用统一内存时 Memcpy()/Memset() 的行为

cudaMemcpy*()和cudaMemset*()接受任何统一内存指针作为参数。

对于cudaMemcpy*(),以cudaMemcpyKind指定的方向是一种性能提示;如果任一参数是统一内存指针,该提示可能对性能产生更大的影响。

因此,建议遵循以下性能建议:

  • 当统一内存的物理位置已知时,使用准确的cudaMemcpyKind提示。

  • 优先使用cudaMemcpyDefault,而不是不准确的cudaMemcpyKind提示。

  • 始终使用已填充(已初始化)的缓冲区:避免使用这些 API 来初始化内存。

  • 如果两个指针都指向系统分配的内存,应避免使用cudaMemcpy*():改为启动核函数或使用std::memcpy等 CPU 内存复制算法。

4.1.1.2.6. 统一内存的内存分配器概述

对于完全支持 CUDA 统一内存的系统,可以使用各种不同的分配器来分配统一内存。下表概述了部分分配器及其各自的特性。请注意,本节中的所有信息都可能在未来的 CUDA 版本中发生变化。

表 7 不同分配器的统一内存支持概述

API

放置策略

可从何处访问

根据访问情况迁移[2]

页大小[4][5]

malloc, new, mmap

首次触碰/提示[1]

CPU、GPU

是[3]

系统页大小或大页大小[6]

cudaMallocManaged

首次触碰/提示

CPU、GPU

是

驻留于 CPU:系统页大小;驻留于 GPU:2MB

cudaMalloc

GPU

GPU

否

GPU 页大小:2MB

cudaMallocHost, cudaHostAlloc, cudaHostRegister

CPU

CPU、GPU

否

由 CPU 映射:系统页大小

由 GPU 映射:2MB

内存池,位置类型为主机:cuMemCreate、cudaMemPoolCreate

CPU

CPU、GPU

否

由 CPU 映射:系统页大小

由 GPU 映射:2MB

内存池,位置类型为设备:cuMemCreate、cudaMemPoolCreate、cudaMallocAsync

GPU

GPU

否

2MB

表不同分配器的统一内存支持概述展示了几种分配器在语义上的差异,可以考虑使用这些分配器来分配可由多个处理器(包括主机和设备)同时访问的数据。有关cudaMemPoolCreate的更多详细信息,请参见内存池一节;有关cuMemCreate的更多详细信息,请参见虚拟内存管理一节。

在设备内存作为 NUMA 域向系统公开的硬件相干系统上,可以使用numa_alloc_on_node等特殊分配器将内存固定到给定的 NUMA 节点,该节点可以是主机或设备。主机和设备都可以访问此内存,并且此内存不会迁移。同样,mbind可用于将内存固定到给定的一个或多个 NUMA 节点,并且可以使文件后备内存在首次被访问之前就放置到给定的一个或多个 NUMA 节点上。

以下内容适用于所分配内存用于共享的分配器:

  • mmap等系统分配器允许使用MAP_SHARED标志在进程之间共享内存。CUDA 支持此功能,可用于在连接到同一主机的不同设备之间共享内存。但是,目前不支持用它在多个主机以及多个设备之间共享内存。有关详细信息,请参见使用统一内存进行进程间通信(IPC)。

  • 若要在多个主机上通过网络访问统一内存或其他 CUDA 内存,请查阅所用通信库的文档,例如NCCL、NVSHMEM、OpenMPI、UCX等。

4.1.1.2.7. 局部性域和统一内存

不支持将托管内存分配局部化。局部性域不是统一内存的迁移目的地;cudaMemAllocationTypeManaged不能与局部化分配结合使用。需要在局部性域中放置内存的应用程序应改用局部化的页锁定内存池或局部化的 VMM 分配。

有关整体编程模型,请参见局部性域。

4.1.1.2.8. 访问计数器迁移

在硬件相干系统上,访问计数器功能会跟踪 GPU 对位于其他处理器上的内存的访问频率。这是确保将内存页移动到最频繁访问这些页的处理器的物理内存中所必需的。它可以指导 CPU 与 GPU 之间以及对等 GPU 之间的迁移,这一过程称为访问计数器迁移。

从 CUDA 12.4 开始,系统分配的内存支持访问计数器。请注意,文件后备内存不会根据访问情况迁移。对于系统分配的内存,可以通过使用cudaMemAdviseSetAccessedBy提示,以相应的设备 id 指定设备,来启用访问计数器迁移。如果访问计数器已启用,可以使用设置为主机的cudaMemAdviseSetPreferredLocation来防止迁移。默认情况下,cudaMallocManaged基于缺页异常后迁移的机制进行迁移。[7]

驱动程序也可能使用访问计数器,以便在抖动缓解或内存超额订用场景中提高效率。

4.1.1.2.9. 避免从 CPU 频繁写入驻留于 GPU 的内存

如果主机访问统一内存,缓存未命中可能会在主机与设备之间产生比预期更多的流量。许多 CPU 架构要求所有内存操作(包括写入)都经过缓存层次结构。如果系统内存驻留于 GPU,这意味着 CPU 对该内存的频繁写入可能导致缓存未命中,从而在将实际值写入所请求的内存范围之前,先将数据从 GPU 传输到 CPU。在软件相干系统上,这可能引入额外的缺页异常,而在硬件相干系统上,这可能导致 CPU 操作之间的延迟更高。因此,为了与设备共享主机生成的数据,可以考虑写入驻留于 CPU 的内存,并由设备直接读取这些值。下面的代码展示了如何使用统一内存实现这一点。

  size_t data_size = sizeof(int);
  int* data = (int*)malloc(data_size);
  // ensure that data stays local to the host and avoid faults
  cudaMemLocation location = {.type = cudaMemLocationTypeHost};
  cudaMemAdvise(data, data_size, cudaMemAdviseSetPreferredLocation, location);
  cudaMemAdvise(data, data_size, cudaMemAdviseSetAccessedBy, location);

  // frequent exchanges of small data: if the CPU writes to CPU-resident memory,
  // and GPU directly accesses that data, we can avoid the CPU caches re-loading
  // data if it was evicted in between writes
  for (int i = 0; i < 10; ++i) {
    *data = 42 + i;
    kernel<<<1, 1>>>(data);
    cudaDeviceSynchronize();
    // CPU cache potentially evicted data here
  }
  free(data);
  int* data;
  size_t data_size = sizeof(int);
  cudaMallocManaged(&data, data_size);
  // ensure that data stays local to the host and avoid faults
  cudaMemLocation location = {.type = cudaMemLocationTypeHost};
  cudaMemAdvise(data, data_size, cudaMemAdviseSetPreferredLocation, location);
  cudaMemAdvise(data, data_size, cudaMemAdviseSetAccessedBy, location);

  // frequent exchanges of small data: if the CPU writes to CPU-resident memory,
  // and GPU directly accesses that data, we can avoid the CPU caches re-loading
  // data if it was evicted in between writes
  for (int i = 0; i < 10; ++i) {
    *data = 42 + i;
    kernel<<<1, 1>>>(data);
    cudaDeviceSynchronize();
    // CPU cache potentially evicted data here
  }
  cudaFree(data);

4.1.1.2.10. 利用对系统内存的异步访问

如果应用程序需要与主机共享设备上工作的结果,有几种可行的选项:

  1. 设备将其结果写入驻留于 GPU 的内存,使用cudaMemcpy*传输该结果,然后主机读取传输过来的数据。

  2. 设备将其结果直接写入驻留于 CPU 的内存,然后主机读取该数据。

  3. 设备写入驻留于 GPU 的内存,然后主机直接访问该数据。

如果在向主机传输结果或主机访问结果的同时,可以在设备上调度独立的工作,则优先选择方案 1 或 3。如果在主机访问结果之前,设备一直没有工作可做,则方案 2 可能更合适。这是因为,除非使用许多主机线程来读取数据,否则设备写入的带宽通常高于主机读取的带宽。

void exchange_explicit_copy(cudaStream_t stream) {
  int* data, *host_data;
  size_t n_bytes = sizeof(int) * 16;
  // allocate receiving buffer
  host_data = (int*)malloc(n_bytes);
  // allocate, since we touch on the device first, will be GPU-resident
  cudaMallocManaged(&data, n_bytes);
  kernel<<<1, 16, 0, stream>>>(data);
  // launch independent work on the device
  // other_kernel<<<1024, 256, 0, stream>>>(other_data, ...);
  // transfer to host
  cudaMemcpyAsync(host_data, data, n_bytes, cudaMemcpyDeviceToHost, stream);
  // sync stream to ensure data has been transferred
  cudaStreamSynchronize(stream);
  // read transferred data
  printf("Got values %d - %d from GPU\n", host_data[0], host_data[15]);
  cudaFree(data);
  free(host_data);
}
void exchange_device_direct_write(cudaStream_t stream) {
  int* data;
  size_t n_bytes = sizeof(int) * 16;
  // allocate receiving buffer
  cudaMallocManaged(&data, n_bytes);
  // ensure that data is mapped and resident on the host
  cudaMemLocation location = {.type = cudaMemLocationTypeHost};
  cudaMemAdvise(data, n_bytes, cudaMemAdviseSetPreferredLocation, location);
  cudaMemAdvise(data, n_bytes, cudaMemAdviseSetAccessedBy, location);
  kernel<<<1, 16, 0, stream>>>(data);
  // sync stream to ensure data has been transferred
  cudaStreamSynchronize(stream);
  // read transferred data
  printf("Got values %d - %d from GPU\n", data[0], data[15]);
  cudaFree(data);
}
void exchange_host_direct_read(cudaStream_t stream) {
  int* data;
  size_t n_bytes = sizeof(int) * 16;
  // allocate receiving buffer
  cudaMallocManaged(&data, n_bytes);
  // ensure that data is mapped and resident on the device
  cudaMemLocation device_loc = {};
  cudaGetDevice(&device_loc.id);
  device_loc.type = cudaMemLocationTypeDevice;
  cudaMemAdvise(data, n_bytes, cudaMemAdviseSetPreferredLocation, device_loc);
  cudaMemAdvise(data, n_bytes, cudaMemAdviseSetAccessedBy, device_loc);
  kernel<<<1, 16, 0, stream>>>(data);
  // launch independent work on the GPU
  // other_kernel<<<1024, 256, 0, stream>>>(other_data, ...);
  // sync stream to ensure data may be accessed (has been written by device)
  cudaStreamSynchronize(stream);
  // read data directly from host
  printf("Got values %d - %d from GPU\n", data[0], data[15]);
  cudaFree(data);

最后,在上面的显式复制示例中,可以使用主机或设备核函数来显式执行此传输,而不是使用cudaMemcpy*来传输数据。对于连续数据,优先使用 CUDA 复制引擎,因为复制引擎执行的操作可以与主机和设备上的工作重叠。cudaMemcpy*和cudaMemPrefetchAsyncAPI 可能会使用复制引擎,但不能保证cudaMemcpy*API 调用会使用复制引擎。出于同样的原因,对于足够大的数据,显式复制优于主机直接读取:如果主机和设备所执行的工作都没有使其各自的内存系统达到饱和,复制引擎就可以在主机和设备执行工作的同时并发执行传输。

复制引擎通常既用于主机与设备之间的传输,也用于通过 NVLink 连接的系统中对等设备之间的传输。由于复制引擎的总数有限,某些系统中cudaMemcpy*的带宽可能低于使用设备显式执行传输时的带宽。在这种情况下,如果传输位于应用程序的关键路径上,则可能更适合采用基于设备的显式传输。

4.1.2. 仅支持 CUDA 托管内存的设备上的统一内存

对于计算能力为 6.x 或更高但不支持可分页内存访问的设备,参见表统一内存范式概述,CUDA 托管内存得到完整支持并且具有相干性,但 GPU 无法访问由系统分配的内存。统一内存的编程模型和性能调优与完整支持 CUDA 统一内存的设备上的统一内存一节中描述的模型基本相似,一个显著的例外是不能使用系统分配器来分配内存。因此,以下各小节不适用:

4.1.3. Windows、WSL 和 Tegra 上的统一内存

注意

本节仅讨论计算能力低于 6.0 的设备或 Windows 平台,即concurrentManagedAccess属性设为 0 的设备。

计算能力低于 6.0 的设备或 Windows 平台,即concurrentManagedAccess属性设为 0 的设备,参见统一内存范式概述,支持 CUDA 托管内存,但存在以下限制:

  • 数据迁移和相干性:不支持按需将托管数据以细粒度移动到 GPU。每当启动 GPU 核函数时,通常都必须将所有托管内存传输到 GPU 内存,以避免在访问内存时发生缺页故障。仅 CPU 端支持缺页故障。

  • GPU 内存超额订用:这些设备无法分配超过 GPU 内存物理大小的托管内存。

  • 相干性和并发性:无法同时访问托管内存,因为缺少 GPU 缺页故障机制,如果 CPU 在 GPU 核函数处于活动状态时访问统一内存分配,就无法保证相干性。

4.1.3.1. 多 GPU

在具有计算能力低于 6.0 的设备的系统上,或在 Windows 平台上,托管分配通过 GPU 的对等访问功能自动对系统中的所有 GPU 可见。托管内存分配的行为类似于使用cudaMalloc()分配的非托管内存:当前活动设备是物理分配的所在地,但系统中的其他 GPU 将通过 PCIe 总线以较低的带宽访问该内存。

在 Linux 上,只要程序正在使用的所有 GPU 都支持对等访问,托管内存就会在 GPU 内存中分配。如果应用程序在任何时候开始使用某个 GPU,而该 GPU 与已在其上进行托管分配的其他 GPU 中的任意一个不支持对等访问,那么驱动程序就会将所有托管分配迁移到系统内存。在这种情况下,所有 GPU 都会受到 PCIe 带宽的限制。

在 Windows 上,如果对等映射不可用(例如,在架构不同的 GPU 之间),那么系统将自动回退到使用映射内存,而不论程序是否实际使用这两个 GPU。如果实际只会使用一个 GPU,就必须在启动程序之前设置CUDA_VISIBLE_DEVICES环境变量。这会限制可见的 GPU,并允许在 GPU 内存中分配托管内存。

或者,在 Windows 上,用户也可以将CUDA_MANAGED_FORCE_DEVICE_ALLOC设为非零值,以强制驱动程序始终使用设备内存进行物理存储。当此环境变量设为非零值时,该进程中使用的所有支持托管内存的设备都必须彼此具备对等兼容性。如果使用了一个支持托管内存的设备,而它与该进程先前使用过的、支持托管内存的其他设备中的任意一个不具备对等兼容性,则会返回错误::cudaErrorInvalidDevice,即使已经在那些设备上调用过::cudaDeviceReset也是如此。CUDA 环境变量中介绍了这些环境变量。

4.1.3.2. 相干性和并发性

为了确保相干性,统一内存编程模型对 CPU 和 GPU 并发执行时的数据访问施加了约束。实际上,在执行任何核函数操作期间,GPU 都独占访问所有托管数据,CPU 不允许访问这些数据,无论具体的核函数是否正在使用这些数据。CPU/GPU 的并发访问,即使访问的是不同的托管内存分配,也会导致段错误,因为该页被视为 CPU 不可访问。

例如,以下代码在计算能力为 6.x 的设备上可以成功运行,因为 GPU 的缺页故障功能消除了对同时访问的所有限制,但在 6.x 之前的架构和 Windows 平台上会失败,因为当 CPU 访问y时,GPU 程序核函数仍处于活动状态:

__device__ __managed__ int x, y=2;
__global__  void  kernel() {
    x = 10;
}
int main() {
    kernel<<< 1, 1 >>>();
    y = 20;            // Error on GPUs not supporting concurrent access

    cudaDeviceSynchronize();
    return  0;
}

程序在访问y之前必须显式地与 GPU 同步(无论 GPU 核函数是否实际访问y(或任何托管数据):

__device__ __managed__ int x, y=2;
__global__  void  kernel() {
    x = 10;
}
int main() {
    kernel<<< 1, 1 >>>();
    cudaDeviceSynchronize();
    y = 20;            //  Success on GPUs not supporting concurrent access
    return  0;
}

请注意,任何在逻辑上保证 GPU 完成其工作的函数调用,都可以用来从逻辑上确保 GPU 工作已完成,参见显式同步。

请注意,如果在 GPU 处于活动状态时使用cudaMallocManaged()或cuMemAllocManaged()动态分配内存,那么在启动额外工作或同步 GPU 之前,该内存的行为是未指定的。在此期间尝试在 CPU 上访问该内存,可能会导致段错误,也可能不会。这不适用于使用标志cudaMemAttachHost或CU_MEM_ATTACH_HOST分配的内存。

4.1.3.3. 与流关联的统一内存

CUDA 编程模型提供流这一机制,供程序指示核函数启动之间的依赖性和独立性。保证启动到同一流中的核函数连续执行,而允许启动到不同流中的核函数并发执行。参见CUDA 流一节。

4.1.3.3.1. 流回调

CPU 可以在流回调内部合法地访问托管数据,前提是 GPU 上没有其他可能访问托管数据的流处于活动状态。此外,后面没有任何设备工作的回调可以用于同步:例如,在回调内部向条件变量发出信号;否则,CPU 访问仅在回调执行期间有效。有几个重要的注意事项:

  1. 当 GPU 处于活动状态时,始终允许 CPU 访问非托管映射内存中的数据。

  2. 当 GPU 正在运行任何核函数时,即使该核函数不使用托管数据,也会将 GPU 视为处于活动状态。如果核函数可能使用数据,则禁止访问

  3. 对于 GPU 之间对托管内存的并发访问,除了适用于多 GPU 访问非托管内存的约束之外,没有其他约束。

  4. 对于并发执行的 GPU 核函数访问托管数据,没有任何约束。

请注意,最后一点允许 GPU 核函数之间出现竞争,这与目前非托管 GPU 内存的情况相同。从 GPU 的角度看,托管内存的工作方式与非托管内存完全相同。以下代码示例说明了这些要点:

int main() {
    cudaStream_t stream1, stream2;
    cudaStreamCreate(&stream1);
    cudaStreamCreate(&stream2);
    int *non_managed, *managed, *also_managed;
    cudaMallocHost(&non_managed, 4);    // Non-managed, CPU-accessible memory
    cudaMallocManaged(&managed, 4);
    cudaMallocManaged(&also_managed, 4);
    // Point 1: CPU can access non-managed data.
    kernel<<< 1, 1, 0, stream1 >>>(managed);
    *non_managed = 1;
    // Point 2: CPU cannot access any managed data while GPU is busy,
    //          unless concurrentManagedAccess = 1
    // Note we have not yet synchronized, so "kernel" is still active.
    *also_managed = 2;      // Will issue segmentation fault
    // Point 3: Concurrent GPU kernels can access the same data.
    kernel<<< 1, 1, 0, stream2 >>>(managed);
    // Point 4: Multi-GPU concurrent access is also permitted.
    cudaSetDevice(1);
    kernel<<< 1, 1 >>>(managed);
    return  0;
}

4.1.3.3.2. 与流关联的托管内存允许更细粒度的控制

统一内存建立在流独立性模型之上,允许 CUDA 程序显式地将托管分配与 CUDA 流关联。这样,程序员便可根据核函数是否启动到指定流中来指示核函数对数据的使用情况。这使得可以根据程序特定的数据访问模式来实现并发。用于控制此行为的函数是:

cudaError_t cudaStreamAttachMemAsync(cudaStream_t stream,
                                     void *ptr,
                                     size_t length=0,
                                     unsigned int flags=0);

cudaStreamAttachMemAsync()函数将从 ptr 开始的 length 字节内存与指定流关联。这样,只要 stream 中的所有操作都已完成,就允许 CPU 访问该内存区域,而不论其他流是否处于活动状态。实际上,这将活动 GPU 对托管内存区域的独占所有权约束在每个流的活动范围内,而不是整个 GPU 的活动范围内。最重要的是,如果某个分配未与特定流关联,那么它对所有正在运行的核函数都可见,而不论这些核函数位于哪个流中。这是cudaMallocManaged()分配或__managed__变量的默认可见性;因此,在简单情况下,规则是只要有任何核函数正在运行,CPU 就不能访问这些数据。

注意

通过将分配与特定流关联,程序保证只有启动到该流中的核函数才会访问该数据。统一内存系统不会执行任何错误检查。

注意

除了允许更高的并发性之外,使用cudaStreamAttachMemAsync()还可以启用统一内存系统内的数据传输优化,这可能会影响延迟和其他开销。

以下示例展示了如何显式地将y与主机可访问性关联,从而使 CPU 在任何时候都能够访问。(请注意,核函数调用之后没有cudaDeviceSynchronize()。)现在,在 GPU 上运行的核函数对y的访问将产生未定义的结果。

__device__ __managed__ int x, y=2;
__global__  void  kernel() {
    x = 10;
}
int main() {
    cudaStream_t stream1;
    cudaStreamCreate(&stream1);
    cudaStreamAttachMemAsync(stream1, &y, 0, cudaMemAttachHost);
    cudaDeviceSynchronize();          // Wait for Host attachment to occur.
    kernel<<< 1, 1, 0, stream1 >>>(); // Note: Launches into stream1.
    y = 20;                           // Success – a kernel is running but “y”
                                      // has been associated with no stream.
    return  0;
}

4.1.3.3.3. 关于多线程主机程序的一个更详细的示例

cudaStreamAttachMemAsync()的主要用途是利用 CPU 线程实现独立任务的并行执行。在这样的程序中,CPU 线程通常会为其生成的所有工作创建自己的流,因为使用 CUDA 的 NULL 流会导致线程之间产生依赖关系。托管数据默认对任何 GPU 流都具有全局可见性,这可能使得多线程程序难以避免 CPU 线程之间的相互影响。因此,使用函数cudaStreamAttachMemAsync()将线程的托管分配与该线程自己的流关联,而且这种关联通常在线程的整个生命周期内都不会改变。这样的程序只需增加一次对cudaStreamAttachMemAsync()的调用,就可以使用统一内存进行数据访问:

// This function performs some task, in its own , in its own private stream and can be run in parallel
void run_task(int *in, int *out, int length) {
    // Create a stream for us to use.
    cudaStream_t stream;
    cudaStreamCreate(&stream);
    // Allocate some managed data and associate with our stream.
    // Note the use of the host-attach flag to cudaMallocManaged();
    // we then associate the allocation with our stream so that
    // our GPU kernel launches can access it.
    int *data;
    cudaMallocManaged((void **)&data, length, cudaMemAttachHost);
    cudaStreamAttachMemAsync(stream, data);
    cudaStreamSynchronize(stream);
    // Iterate on the data in some way, using both Host & Device.
    for(int i=0; i<N; i++) {
        transform<<< 100, 256, 0, stream >>>(in, data, length);
        cudaStreamSynchronize(stream);
        host_process(data, length);    // CPU uses managed data.
        convert<<< 100, 256, 0, stream >>>(out, data, length);
    }
    cudaStreamSynchronize(stream);
    cudaStreamDestroy(stream);
    cudaFree(data);
}

在此示例中,分配与流之间的关联只建立一次,然后主机和设备都会反复使用这些数据。与在主机和设备之间显式复制数据相比,得到的代码要简单得多,尽管结果相同。

函数cudaMallocManaged()指定了 cudaMemAttachHost 标志,这会创建一个最初对设备端执行不可见的分配。(默认分配对所有流上的所有 GPU 核函数都可见。)这可确保在分配数据之后、为特定流获取数据之前的这段时间内,不会与另一个线程的执行发生意外的相互影响。

如果没有此标志,而恰好有另一个线程启动的核函数正在运行,那么新分配将被视为正在由 GPU 使用。这可能影响该线程在能够将新分配的数据显式附加到私有流之前,从 CPU 访问这些数据的能力。因此,为了让线程之间能够安全地保持独立,应在分配时指定此标志。

另一种方法是在将分配附加到流之后,在所有线程之间设置一个进程范围的屏障。这将确保所有线程在启动任何核函数之前都完成其数据与流的关联,从而避免这一风险。在销毁流之前还需要第二个屏障,因为销毁流会使分配恢复其默认可见性。提供cudaMemAttachHost标志既是为了简化这一过程,也是因为并非总能在需要的位置插入全局屏障。

4.1.3.3.4. 与流关联的统一内存的数据移动

在未设置concurrentManagedAccess的设备上,Memcpy()/Memset() 对与流关联的统一内存的行为有所不同,适用以下规则:

如果指定了cudaMemcpyHostTo*且源数据是统一内存,那么,如果主机在复制流中能够以相干方式访问该数据,就会从主机访问该数据(1);否则,将从设备访问该数据。当指定了cudaMemcpy*ToHost且目标是统一内存时,类似的规则适用于目标。

如果指定了cudaMemcpyDeviceTo*且源数据是统一内存,那么将从设备访问该数据。设备必须能够在复制流中以相干方式访问源数据(2);否则,将返回错误。当指定了cudaMemcpy*ToDevice且目标是统一内存时,类似的规则适用于目标。

如果指定了cudaMemcpyDefault,那么,当无法在复制流中从设备以相干方式访问统一内存时(2),或者当数据的首选位置为cudaCpuDeviceId且能够在复制流中从主机以相干方式访问它时(1),将从主机访问统一内存;否则,将从设备访问它。

对统一内存使用cudaMemset*()时,必须能够在用于cudaMemset()操作的流中从设备以相干方式访问该数据(2);否则,将返回错误。

通过cudaMemcpy*或cudaMemset*从设备访问数据时,该操作所在的流被视为在 GPU 上处于活动状态。在此期间,如果 GPU 的设备属性concurrentManagedAccess的值为零,则 CPU 对与该流关联的数据或具有全局可见性的数据的任何访问都会导致段错误。程序必须进行适当的同步,以确保在从 CPU 访问任何关联数据之前,该操作已完成。

  1. 能够在给定流中从主机以相干方式访问,意味着该内存既不具有全局可见性,也不与给定流关联。

  1. 能够在给定流中从设备以相干方式访问,意味着该内存具有全局可见性或与给定流关联。

4.1.4. 性能提示

性能提示允许程序员向 CUDA 提供有关统一内存使用情况的更多信息。CUDA 使用性能提示来更高效地管理内存并提高应用程序性能。性能提示绝不会影响应用程序的正确性。性能提示只影响性能。

注意

只有在统一内存性能提示能够提高性能时,应用程序才应使用它们。

性能提示可用于任何统一内存分配,包括 CUDA 托管内存。在完全支持 CUDA 统一内存的系统上,性能提示可应用于所有由系统分配的内存。

4.1.4.1. 数据预取

cudaMemPrefetchAsyncAPI 是一个异步的流有序 API,它可能会迁移数据,使其驻留在更靠近指定处理器的位置。在预取数据期间仍可访问该数据。迁移只有在流中所有先前的操作都完成之后才会开始,并且会在流中任何后续操作开始之前完成。

cudaError_t cudaMemPrefetchAsync(const void *devPtr,
                                 size_t count,
                                 struct cudaMemLocation location,
                                 unsigned int flags,
                                 cudaStream_t stream=0);

在给定的stream中执行预取任务时,如果location.type为cudaMemLocationTypeDevice,则包含[devPtr, devPtr + count)的内存区域可能会被迁移到目标设备location.id;如果location.type为cudaMemLocationTypeHost,则可能会被迁移到 CPU。有关flags的详细信息,请参阅当前的CUDA 运行时 API 文档。

请考虑下面这个简单的代码示例:

void test_prefetch_sam(const cudaStream_t& s) {
  // initialize data on CPU
  char *data = (char*)malloc(dataSizeBytes);
  init_data(data, dataSizeBytes);                                     
  cudaMemLocation location = {.type = cudaMemLocationTypeDevice, .id = myGpuId};

  // encourage data to move to GPU before use
  const unsigned int flags = 0;
  cudaMemPrefetchAsync(data, dataSizeBytes, location, flags, s);      

  // use data on GPU
  const unsigned num_blocks = (dataSizeBytes + threadsPerBlock - 1) / threadsPerBlock;
  mykernel<<<num_blocks, threadsPerBlock, 0, s>>>(data, dataSizeBytes);  

  // encourage data to move back to CPU
  location = {.type = cudaMemLocationTypeHost};
  cudaMemPrefetchAsync(data, dataSizeBytes, location, flags, s);      
  
  cudaStreamSynchronize(s);

  // use data on CPU
  use_data(data, dataSizeBytes);                                      
  free(data);
}
void test_prefetch_managed(const cudaStream_t& s) {
  // initialize data on CPU
  char *data;
  cudaMallocManaged(&data, dataSizeBytes);
  init_data(data, dataSizeBytes);                                     
  cudaMemLocation location = {.type = cudaMemLocationTypeDevice, .id = myGpuId};

  // encourage data to move to GPU before use
  const unsigned int flags = 0;
  cudaMemPrefetchAsync(data, dataSizeBytes, location, flags, s);

  // use data on GPU
  const uinsigned num_blocks = (dataSizeBytes + threadsPerBlock - 1) / threadsPerBlock;
  mykernel<<<num_blocks, threadsPerBlock, 0, s>>>(data, dataSizeBytes); 

  // encourage data to move back to CPU
  location = {.type = cudaMemLocationTypeHost};
  cudaMemPrefetchAsync(data, dataSizeBytes, location, flags, s); 

  cudaStreamSynchronize(s);

  // use data on CPU
  use_data(data, dataSizeBytes);
  cudaFree(data);
}

4.1.4.2. 数据使用提示

当多个处理器同时访问同一份数据时,可使用cudaMemAdvise来提示将如何访问[devPtr, devPtr + count)处的数据:

cudaError_t cudaMemAdvise(const void *devPtr,
                          size_t count,
                          enum cudaMemoryAdvise advice,
                          struct cudaMemLocation location);

该示例展示了如何使用cudaMemAdvise:

  init_data(data, dataSizeBytes);                                     
  cudaMemLocation location = {.type = cudaMemLocationTypeDevice, .id = myGpuId};

  // encourage data to move to GPU before use
  const unsigned int flags = 0;
  cudaMemPrefetchAsync(data, dataSizeBytes, location, flags, s);

  // use data on GPU
  const uinsigned num_blocks = (dataSizeBytes + threadsPerBlock - 1) / threadsPerBlock;
  mykernel<<<num_blocks, threadsPerBlock, 0, s>>>(data, dataSizeBytes); 

  // encourage data to move back to CPU
  location = {.type = cudaMemLocationTypeHost};
  cudaMemPrefetchAsync(data, dataSizeBytes, location, flags, s); 

  cudaStreamSynchronize(s);

  // use data on CPU
  use_data(data, dataSizeBytes);
  cudaFree(data);
}
// test-prefetch-managed-end

static const int maxDevices = 1;
static const int maxOuterLoopIter = 3;
static const int maxInnerLoopIter = 4;

// test-advise-managed-begin
void test_advise_managed(cudaStream_t stream) {
  char *dataPtr;
  size_t dataSize = 64 * threadsPerBlock;  // 16 KiB

其中,advice可取以下值:

  • cudaMemAdviseSetReadMostly:

    这意味着该数据主要用于读取,仅偶尔进行写入。通常,它允许在该区域上以读取带宽为代价换取写入带宽。

  • cudaMemAdviseSetPreferredLocation:

    此提示将数据的首选位置设置为指定设备的物理内存。此提示鼓励系统将数据保留在首选位置,但并不保证这一点。为 location.type 传入cudaMemLocationTypeHost值会将首选位置设置为 CPU 内存。其他提示(如cudaMemPrefetchAsync)可能会覆盖此提示,并允许内存迁移到其首选位置之外。

  • cudaMemAdviseSetAccessedBy:

    在某些系统中,在从给定处理器访问数据之前建立到内存的映射,可能有利于性能。此提示告知系统,当location.type为cudaMemLocationTypeDevice时,location.id将频繁访问该数据,从而使系统能够假定创建这些映射是值得的。此提示并不指明数据应驻留在何处,但可以与cudaMemAdviseSetPreferredLocation结合使用来指定这一点。在硬件相干的系统上,此提示会开启访问计数器迁移,请参阅访问计数器迁移。

也可以通过使用下列值之一来取消设置各项建议:cudaMemAdviseUnsetReadMostly、cudaMemAdviseUnsetPreferredLocation和cudaMemAdviseUnsetAccessedBy。

该示例展示了如何使用cudaMemAdvise:

void test_advise_sam(cudaStream_t stream) {
  char *dataPtr;
  size_t dataSize = 64 * threadsPerBlock;  // 16 KiB
  
  // Allocate memory using malloc or cudaMallocManaged
  dataPtr = (char*)malloc(dataSize);

  // Set the advice on the memory region
  cudaMemLocation loc = {.type = cudaMemLocationTypeDevice, .id = myGpuId};
  cudaMemAdvise(dataPtr, dataSize, cudaMemAdviseSetReadMostly, loc);

  int outerLoopIter = 0;
  while (outerLoopIter < maxOuterLoopIter) {
    // The data is written by the CPU each outer loop iteration
    init_data(dataPtr, dataSize);

    // The data is made available to all GPUs by prefetching.
    // Prefetching here causes read duplication of data instead
    // of data migration
    cudaMemLocation location;
    location.type = cudaMemLocationTypeDevice;
    for (int device = 0; device < maxDevices; device++) {
      location.id = device;
      const unsigned int flags = 0;
      cudaMemPrefetchAsync(dataPtr, dataSize, location, flags, stream);
    }

    // The kernel only reads this data in the inner loop
    int innerLoopIter = 0;
    while (innerLoopIter < maxInnerLoopIter) {
      mykernel<<<32, threadsPerBlock, 0, stream>>>((const char *)dataPtr, dataSize);
      innerLoopIter++;
    }
    outerLoopIter++;
  }

  free(dataPtr);
}
void test_advise_managed(cudaStream_t stream) {
  char *dataPtr;
  size_t dataSize = 64 * threadsPerBlock;  // 16 KiB

  // Allocate memory using cudaMallocManaged
  // (malloc may be used on systems with full CUDA Unified memory support)
  cudaMallocManaged(&dataPtr, dataSize);

  // Set the advice on the memory region
  cudaMemLocation loc = {.type = cudaMemLocationTypeDevice, .id = myGpuId};
  cudaMemAdvise(dataPtr, dataSize, cudaMemAdviseSetReadMostly, loc);

  int outerLoopIter = 0;
  while (outerLoopIter < maxOuterLoopIter) {
    // The data is written by the CPU each outer loop iteration
    init_data(dataPtr, dataSize);

    // The data is made available to all GPUs by prefetching.
    // Prefetching here causes read duplication of data instead
    // of data migration
    cudaMemLocation location;
    location.type = cudaMemLocationTypeDevice;
    for (int device = 0; device < maxDevices; device++) {
      location.id = device;
      const unsigned int flags = 0;
      cudaMemPrefetchAsync(dataPtr, dataSize, location, flags, stream);
    }

    // The kernel only reads this data in the inner loop
    int innerLoopIter = 0;
    while (innerLoopIter < maxInnerLoopIter) {
      mykernel<<<32, threadsPerBlock, 0, stream>>>((const char *)dataPtr, dataSize);
      innerLoopIter++;
    }
    outerLoopIter++;
  }
  
  cudaFree(dataPtr);
}

4.1.4.3. 内存丢弃

cudaMemDiscardBatchAsyncAPI 允许应用程序通知 CUDA 运行时,指定内存范围中的内容已不再有用。统一内存驱动程序会因基于缺页的迁移或为支持设备内存超额使用而进行的内存逐出,执行自动内存传输。这些自动内存传输有时可能是多余的,这会严重降低性能。将一个地址范围标记为“丢弃”,会告知统一内存驱动程序,应用程序已使用完该范围中的内容,在预取时或为了给其他分配腾出空间而逐出页时,无需迁移这些数据。如果丢弃后没有进行写入访问或预取,读取被丢弃的页将得到不确定的值。而丢弃操作之后的任何新写入都保证能够被后续读取访问看到。对正在被丢弃的地址范围进行并发访问或预取将导致未定义行为。

cudaError_t cudaMemDiscardBatchAsync(void **dptrs,
                                    size_t *sizes,
                                    size_t count,
                                    unsigned long long flags,
                                    cudaStream_t stream);

此函数对dptrs和sizes数组中指定的地址范围执行一批内存丢弃操作。这两个数组的长度必须相同,均为count所指定的长度。每个内存范围都必须指向通过cudaMallocManaged分配或通过__managed__变量声明的托管内存。

cudaMemDiscardAndPrefetchBatchAsyncAPI 将丢弃操作和预取操作结合在一起。调用cudaMemDiscardAndPrefetchBatchAsync在语义上等同于先调用cudaMemDiscardBatchAsync再调用cudaMemPrefetchBatchAsync,但更为高效。当应用程序需要内存位于目标位置,但不需要内存中的内容时,这很有用。

cudaError_t cudaMemDiscardAndPrefetchBatchAsync(void **dptrs,
                                               size_t *sizes,
                                               size_t count,
                                               struct cudaMemLocation *prefetchLocs,
                                               size_t *prefetchLocIdxs,
                                               size_t numPrefetchLocs,
                                               unsigned long long flags,
                                               cudaStream_t stream);

prefetchLocs数组指定预取的目标位置,而prefetchLocIdxs指明每个预取位置适用于哪些操作。例如,如果一批操作共有 10 个,其中前 6 个应预取到一个位置,其余 4 个应预取到另一个位置,那么numPrefetchLocs应为 2,prefetchLocIdxs应为 {0, 6},而prefetchLocs应包含这两个目标位置。

重要注意事项:

  • 如果丢弃后没有进行写入或预取,从被丢弃的范围读取将返回不确定的值

  • 可以通过写入该范围或通过cudaMemPrefetchAsync预取该范围来撤销丢弃操作

  • 与丢弃操作同时发生的任何读取、写入或预取都会导致未定义行为

  • 所有设备的cudaDevAttrConcurrentManagedAccess值都必须非零

4.1.4.4. 查询托管内存的数据使用属性

程序可以使用以下 API,查询通过cudaMemAdvise或cudaMemPrefetchAsync为 CUDA 托管内存分配的内存范围属性:

cudaMemRangeGetAttribute(void *data,
                         size_t dataSize,
                         enum cudaMemRangeAttribute attribute,
                         const void *devPtr,
                         size_t count);

此函数查询从devPtr开始、大小为count字节的内存范围的某个属性。该内存范围必须指向通过cudaMallocManaged分配或通过__managed__变量声明的托管内存。可以查询以下属性:

  • cudaMemRangeAttributeReadMostly:如果整个内存范围都设置了cudaMemAdviseSetReadMostly属性,则返回 1,否则返回 0。

  • cudaMemRangeAttributePreferredLocation:如果整个内存范围都以相应的处理器作为首选位置,则返回的结果为 GPU 设备 ID 或cudaCpuDeviceId,否则返回cudaInvalidDeviceId。应用程序可以使用此查询 API,根据托管指针的首选位置属性,决定通过 CPU 还是 GPU 来暂存数据。请注意,查询时该内存范围的实际位置可能与首选位置不同。

  • cudaMemRangeAttributeAccessedBy:将返回已为该内存范围设置了该建议的设备列表。

  • cudaMemRangeAttributeLastPrefetchLocation:将返回最近一次使用cudaMemPrefetchAsync显式预取该内存范围时的目标位置。请注意,这仅返回应用程序最近一次请求将该内存范围预取到的位置。它并不表明到该位置的预取操作是否已完成,甚至不表明该操作是否已开始。

  • cudaMemRangeAttributePreferredLocationType:它返回首选位置的位置类型,取值如下:

    • cudaMemLocationTypeDevice:如果内存范围内的所有页都以同一个 GPU 作为首选位置,

    • cudaMemLocationTypeHost:如果内存范围内的所有页都以 CPU 作为首选位置,

    • cudaMemLocationTypeHostNuma:如果内存范围内的所有页都以同一个主机 NUMA 节点 ID 作为首选位置,

    • cudaMemLocationTypeInvalid:如果并非所有页都具有相同的首选位置,或者某些页根本没有首选位置。

  • cudaMemRangeAttributePreferredLocationId:如果对同一地址范围的cudaMemRangeAttributePreferredLocationType查询返回cudaMemLocationTypeDevice,则返回设备序号。如果首选位置类型为主机 NUMA 节点,则返回主机 NUMA 节点 ID。否则,应忽略该 ID。

  • cudaMemRangeAttributeLastPrefetchLocationType:返回最近一次通过cudaMemPrefetchAsync显式预取该内存范围内所有页时的目标位置类型。返回的值如下:

    • cudaMemLocationTypeDevice:如果内存范围内的所有页都被预取到了同一个 GPU,

    • cudaMemLocationTypeHost:如果内存范围内的所有页都被预取到了 CPU,

    • cudaMemLocationTypeHostNuma:如果内存范围内的所有页都被预取到了同一个主机 NUMA 节点 ID,

    • cudaMemLocationTypeInvalid:如果并非所有页都被预取到了同一个位置,或者某些页从未被预取过。

  • cudaMemRangeAttributeLastPrefetchLocationId:如果对同一地址范围的cudaMemRangeAttributeLastPrefetchLocationType查询返回cudaMemLocationTypeDevice,它将是有效的设备序号;如果查询返回cudaMemLocationTypeHostNuma,它将是有效的主机 NUMA 节点 ID。否则,应忽略该 ID。

此外,还可以使用相应的cudaMemRangeGetAttributes函数来查询多个属性。

4.1.4.5. GPU 内存超额使用

统一内存使应用程序能够超额使用任何单个处理器的内存:换言之,它们可以分配和共享大于系统中任何单个处理器内存容量的数组,从而能够对单个 GPU 无法容纳的数据集进行核外处理等操作,而不会显著增加编程模型的复杂性。

此外,还可以使用相应的cudaMemRangeGetAttributes函数来查询多个属性。

4.2. CUDA 图

CUDA 图提供了 CUDA 中用于提交工作的另一种模型。图是一系列通过依赖关系连接的操作,例如核函数启动、数据移动等。图的定义与其执行彼此分离。这使得图可以只定义一次,然后重复启动。将图的定义与其执行分离,可以实现多项优化:首先,与流相比,CPU 启动开销降低,因为大部分设置工作都是提前完成的;其次,将整个工作流呈现给 CUDA,可实现流的逐项工作提交机制可能无法实现的优化。

要了解图所能实现的优化,请考虑流中发生的情况:当你将一个核函数放入流中时,主机驱动程序会执行一系列操作,为该核函数在 GPU 上执行做好准备。这些操作是设置和启动核函数所必需的,是每个发出的核函数都必须承担的额外开销。对于执行时间较短的 GPU 核函数,这种额外开销可能占总体端到端执行时间的很大一部分。通过创建一个包含将被多次启动的工作流的 CUDA 图,可以在实例化期间为整个图一次性承担这些额外开销,随后就能以很小的额外开销重复启动图本身。

4.2.1. 图结构

一个操作构成图中的一个节点。操作之间的依赖关系就是边。这些依赖关系约束着操作的执行顺序。

一旦某个操作所依赖的节点完成,该操作就可以在任何时候被调度。调度由 CUDA 系统决定。

4.2.1.1. 节点类型

图节点可以是以下类型之一:

子图示例

图 24 子图示例

4.2.1.2. 边数据

CUDA 12.3 在 CUDA 图中引入了边数据。目前,非默认边数据的唯一用途是启用程序化依赖启动。

一般而言,边数据会修改一条边所指定的依赖关系,并由三部分组成:输出端口、输入端口和类型。输出端口指定关联的边何时被触发。输入端口指定节点的哪一部分依赖于关联的边。类型会修改端点之间的关系。

端口值取决于节点类型和方向,而边类型可能仅限于特定的节点类型。在所有情况下,零初始化的边数据都表示默认行为。输出端口 0 等待整个任务,输入端口 0 阻塞整个任务,而边类型 0 与具有内存同步行为的完全依赖关系相关联。

在各种图 API 中,可以通过与关联节点一一对应的数组来选择性地指定边数据。如果将其作为输入参数省略,则使用零初始化的数据。如果将其作为输出(查询)参数省略,那么当被忽略的边数据全部为零初始化时,API 会接受这种做法;如果该调用会丢失信息,则返回cudaErrorLossyQuery。

某些流捕获 API 也支持边数据:cudaStreamBeginCaptureToGraph()、cudaStreamGetCaptureInfo()和cudaStreamUpdateCaptureDependencies()。在这些情况下,下游节点尚不存在。数据与一条悬空边(半边)关联,该边将连接到今后捕获的节点,或者在流捕获终止时被丢弃。请注意,某些边类型不会等待上游节点完全完成。在判断流捕获是否已完全重新汇入原始流时,这些边会被忽略,并且不能在捕获结束时被丢弃。请参阅流捕获。

没有任何节点类型定义额外的输入端口,只有核函数节点定义额外的输出端口。有一种非默认依赖类型cudaGraphDependencyTypeProgrammatic,用于在两个核函数节点之间启用程序化依赖启动。

4.2.2. 构建和运行图

使用图提交工作分为三个不同的阶段:定义、实例化和执行。

  • 在定义或创建阶段,程序会创建对图中操作以及它们之间依赖关系的描述。

  • 实例化会获取图模板的快照,对其进行验证,并完成大量工作的设置和初始化,目的是尽可能减少启动时需要执行的操作。由此得到的实例称为可执行图。

  • 与任何其他 CUDA 工作类似,可执行图可以启动到流中。它可以启动任意多次,而无需重复实例化。

4.2.2.1. 图的创建

可以通过两种机制创建图:使用显式图 API,以及通过流捕获。

4.2.2.1.1. 图 API

以下示例展示了如何创建下图(省略了声明及其他样板代码)。请注意,示例使用cudaGraphCreate()创建图,使用cudaGraphAddNode()添加核函数节点及其依赖项。CUDA Runtime API 文档列出了可用于添加节点和依赖项的所有函数。

使用图 API 创建图的示例

图 25 使用图 API 创建图的示例

// Create the graph - it starts out empty
cudaGraphCreate(&graph, 0);

// Create the nodes and their dependencies
cudaGraphNode_t nodes[4];
cudaGraphNodeParams kParams = { cudaGraphNodeTypeKernel };
kParams.kernel.func         = (void *)kernelName;
kParams.kernel.gridDim.x    = kParams.kernel.gridDim.y  = kParams.kernel.gridDim.z  = 1;
kParams.kernel.blockDim.x   = kParams.kernel.blockDim.y = kParams.kernel.blockDim.z = 1;

cudaGraphAddNode(&nodes[0], graph, NULL, NULL, 0, &kParams);
cudaGraphAddNode(&nodes[1], graph, &nodes[0], NULL, 1, &kParams);
cudaGraphAddNode(&nodes[2], graph, &nodes[0], NULL, 1, &kParams);
cudaGraphAddNode(&nodes[3], graph, &nodes[1], NULL, 2, &kParams);

上面的示例展示了四个核函数节点及它们之间的依赖关系,以说明如何创建一个非常简单的图。典型的应用程序还会包含为内存操作(如 memcpy 和 memset)添加的节点。有关用于添加节点的所有图 API 函数的完整参考信息,请参阅CUDA Runtime API 文档。

注意

CUDA 图 API 最初使用单独的 API 函数来添加每种不同类型的节点。例如,cudaGraphAddKernelNode()或cudaGraphAddMemcpyNode()。这些 API 已被单一的多态 APIcudaGraphAddNode()取代,后者使用节点参数的.type字段来确定所添加节点的类型。非多态 API 将在未来弃用。使用 CUDA 图 API 向图中添加节点时,推荐使用cudaGraphAddNode()。

4.2.2.1.2. 流捕获

流捕获提供了一种利用现有的基于流的 API 创建图的机制。对于一段将工作启动到流中的代码(包括现有代码),可以在其前后分别调用cudaStreamBeginCapture()和cudaStreamEndCapture()。如下所示。

cudaGraph_t graph;

cudaStreamBeginCapture(stream);

kernel_A<<< ..., stream >>>(...);
kernel_B<<< ..., stream >>>(...);
libraryCall(stream);
kernel_C<<< ..., stream >>>(...);

cudaStreamEndCapture(stream, &graph);

调用cudaStreamBeginCapture()会使流进入捕获模式。当流正在被捕获时,启动到该流中的工作不会入队执行,而是会追加到一个正在逐步构建的内部图中。随后,调用cudaStreamEndCapture()会返回此图,同时结束该流的捕获模式。正在通过流捕获构建的图称为捕获图。

除cudaStreamLegacy(“NULL 流”)外,任何 CUDA 流都可以使用流捕获。请注意,它可以用于cudaStreamPerThread。如果程序正在使用传统流,则可能可以将流 0 重新定义为每线程流,而不改变任何功能。请参阅阻塞流、非阻塞流与默认流。

可以使用cudaStreamIsCapturing()查询流是否正在被捕获。

可以使用cudaStreamBeginCaptureToGraph()将工作捕获到现有图中。此时,工作不是捕获到内部图中,而是捕获到用户提供的图中。

4.2.2.1.2.1. 跨流依赖关系与事件

流捕获可以处理使用cudaEventRecord()和cudaStreamWaitEvent()表达的跨流依赖关系,前提是所等待的事件记录在同一个捕获图中。

在处于捕获模式的流中记录事件时,会产生一个已捕获事件。已捕获事件代表捕获图中的一组节点。

当流等待一个已捕获事件时,如果该流尚未处于捕获模式,就会使其进入捕获模式,并且流中的下一个工作项将额外依赖于已捕获事件中的节点。此时,这两个流就会被捕获到同一个捕获图中。

当流捕获中存在跨流依赖关系时,仍必须在调用cudaStreamBeginCapture()的同一个流中调用cudaStreamEndCapture();该流称为起始流。由于基于事件的依赖关系而被捕获到同一捕获图中的其他所有流,也必须重新汇合到起始流。下文对此进行了说明。调用cudaStreamEndCapture()后,被捕获到同一捕获图中的所有流都会退出捕获模式。未能重新汇合到起始流将导致整个捕获操作失败。

// stream1 is the origin stream
cudaStreamBeginCapture(stream1);

kernel_A<<< ..., stream1 >>>(...);

// Fork into stream2
cudaEventRecord(event1, stream1);
cudaStreamWaitEvent(stream2, event1);

kernel_B<<< ..., stream1 >>>(...);
kernel_C<<< ..., stream2 >>>(...);

// Join stream2 back to origin stream (stream1)
cudaEventRecord(event2, stream2);
cudaStreamWaitEvent(stream1, event2);

kernel_D<<< ..., stream1 >>>(...);

// End capture in the origin stream
cudaStreamEndCapture(stream1, &graph);

// stream1 and stream2 no longer in capture mode

上述代码返回的图如图 25所示。

注意

当流退出捕获模式时,即使中间的工作项已被移除,流中的下一个未捕获工作项(如果有)仍然会依赖于之前最近的未捕获工作项。

4.2.2.1.2.2. 禁止的操作与未处理的操作

对正在被捕获的流或已捕获事件执行同步或查询其执行状态是无效的,因为它们不代表已调度执行的工作项。对于涵盖正在进行的流捕获的更大范围句柄,查询其执行状态或对其执行同步同样无效,例如,当任意关联流处于捕获模式时的设备句柄或上下文句柄。

当同一上下文中的任意流正在被捕获,且该流并非使用cudaStreamNonBlocking创建时,任何尝试使用传统流的操作都是无效的。这是因为传统流句柄始终涵盖这些其他流;向传统流中入队工作会创建对正在被捕获的流的依赖关系,而查询传统流或对其执行同步,则会查询正在被捕获的流或对其执行同步。

因此,在这种情况下调用同步 API 也无效。同步 API 的一个例子是cudaMemcpy(),它会将工作入队到传统流中,并在返回之前对该流执行同步。

注意

一般而言,当某个依赖关系会将已捕获的工作与未被捕获而是入队执行的工作连接起来时,CUDA 更倾向于返回错误,而不是忽略该依赖关系。使流进入或退出捕获模式是一个例外;这会切断紧邻模式转换前后添加到流中的工作项之间的依赖关系。

如果一个正在被捕获的流与已捕获事件关联于不同的捕获图,那么通过在该流中等待该事件来合并两个独立的捕获图是无效的。在未指定cudaEventWaitExternal标志的情况下,在正在被捕获的流中等待未捕获事件也是无效的。

少数将异步操作入队到流中的 API 目前尚不受图支持,如果使用正在被捕获的流调用这些 API,就会返回错误,例如cudaStreamAttachMemAsync()。

4.2.2.1.2.3. 失效

在流捕获期间尝试执行无效操作时,任何关联的捕获图都会失效。捕获图失效后,在使用cudaStreamEndCapture()结束流捕获之前,进一步使用与该图关联的任何正在被捕获的流或已捕获事件都是无效的,并且会返回错误。此调用会使关联的流退出捕获模式,但也会返回一个错误值和一个 NULL 图。

4.2.2.1.2.4. 捕获内省

可以使用cudaStreamGetCaptureInfo()检查正在进行的流捕获操作。这使用户能够获取捕获的状态、捕获的唯一(在每个进程内唯一)ID、底层图对象,以及该流中下一个要捕获的节点的依赖项/边数据。可以利用这些依赖关系信息,获取流中最后捕获的一个或多个节点的句柄。

4.2.2.1.3. 综合运用

图 25中的示例是一个简化示例,旨在从概念上展示一个小型图。在使用 CUDA 图的应用程序中,无论使用图 API 还是流捕获,情况都会更加复杂。以下代码片段并列比较了使用图 API 和流捕获来创建 CUDA 图,以执行一个简单的两阶段归约算法。

图 26展示了这个 CUDA 图。它是通过对以下代码应用cudaGraphDebugDotPrint函数生成的,经过少量调整以提高可读性后,再使用Graphviz渲染而成。

使用两阶段归约核函数的 CUDA 图示例

图 26 使用两阶段归约核函数的 CUDA 图示例

图内文字中文对照
CUDA graph
CUDA 图
MEMSET
内存设置
MEMCPY
内存复制
HOST
主机
void cudaGraphsManual(float  *inputVec_h,
                      float  *inputVec_d,
                      double *outputVec_d,
                      double *result_d,
                      size_t  inputSize,
                      size_t  numOfBlocks)
{
    cudaStream_t                 streamForGraph;
    cudaGraph_t                  graph;
    std::vector<cudaGraphNode_t> nodeDependencies;
    cudaGraphNode_t              memcpyNode, kernelNode;
    double                       result_h = 0.0;

    cudaStreamCreate(&streamForGraph);

    // cudaGraphAddNode is the unified node-creation API: one call for every node type,
    // driven by a cudaGraphNodeParams struct (a .type tag + a union of per-type params).
    // It replaces the previously used cudaGraphAddMemcpyNode/cudaGraphAddKernelNode calls. The struct
    // has reserved fields that must be zero, so it is re-zeroed with "= {}" before each node below.
    cudaGraphNodeParams nodeParams   = {};
    cudaMemcpy3DParms   memcpyParams = {0};

    memcpyParams.srcArray = NULL;
    memcpyParams.srcPos   = make_cudaPos(0, 0, 0);
    memcpyParams.srcPtr   = make_cudaPitchedPtr(inputVec_h, sizeof(float) * inputSize, inputSize, 1);
    memcpyParams.dstArray = NULL;
    memcpyParams.dstPos   = make_cudaPos(0, 0, 0);
    memcpyParams.dstPtr   = make_cudaPitchedPtr(inputVec_d, sizeof(float) * inputSize, inputSize, 1);
    memcpyParams.extent   = make_cudaExtent(sizeof(float) * inputSize, 1, 1);
    memcpyParams.kind     = cudaMemcpyHostToDevice;

    // Create an empty graph; nodes and edges will be added below.
    cudaGraphCreate(&graph, 0);

    // Node 1: H2D memcpy — no dependencies (NULL, 0), so it can start immediately.
    // For a memcpy node the cudaMemcpy3DParms goes into nodeParams.memcpy.copyParams
    // (the .memcpy union member is a wrapper struct, so the copy descriptor nests one level deeper).
    nodeParams                   = {};
    nodeParams.type              = cudaGraphNodeTypeMemcpy;
    nodeParams.memcpy.copyParams = memcpyParams;
    cudaGraphAddNode(&memcpyNode, graph, NULL, /*dependencyData=*/NULL, 0, &nodeParams);

    // Make the next node wait for this memcpy to finish before starting.
    nodeDependencies.push_back(memcpyNode);

    void *kernelArgs[4] = {(void *)&inputVec_d, (void *)&outputVec_d, &inputSize, &numOfBlocks};

    // Node 2: first reduction kernel — depends on the H2D memcpy completing.
    // Kernel params are set directly on the .kernel union member.
    nodeParams                       = {};
    nodeParams.type                  = cudaGraphNodeTypeKernel;
    nodeParams.kernel.func           = (void *)reduce;
    nodeParams.kernel.gridDim        = dim3(numOfBlocks, 1, 1);
    nodeParams.kernel.blockDim       = dim3(THREADS_PER_BLOCK, 1, 1);
    nodeParams.kernel.sharedMemBytes = 0;
    nodeParams.kernel.kernelParams   = (void **)kernelArgs;
    nodeParams.kernel.extra          = NULL;
    cudaGraphAddNode(
        &kernelNode, graph, nodeDependencies.data(), /*dependencyData=*/NULL, nodeDependencies.size(), &nodeParams);

    // Move dependency forward: the next node waits for this kernel.
    nodeDependencies.clear();
    nodeDependencies.push_back(kernelNode);

    void *kernelArgs2[3] = {(void *)&outputVec_d, (void *)&result_d, &numOfBlocks};

    // Node 3: final reduction kernel — depends on Node 2.
    nodeParams                       = {};
    nodeParams.type                  = cudaGraphNodeTypeKernel;
    nodeParams.kernel.func           = (void *)reduceFinal;
    nodeParams.kernel.gridDim        = dim3(1, 1, 1);
    nodeParams.kernel.blockDim       = dim3(THREADS_PER_BLOCK, 1, 1);
    nodeParams.kernel.sharedMemBytes = 0;
    nodeParams.kernel.kernelParams   = kernelArgs2;
    nodeParams.kernel.extra          = NULL;
    cudaGraphAddNode(
        &kernelNode, graph, nodeDependencies.data(), /*dependencyData=*/NULL, nodeDependencies.size(), &nodeParams);
    nodeDependencies.clear();
    nodeDependencies.push_back(kernelNode);

    memset(&memcpyParams, 0, sizeof(memcpyParams));

    memcpyParams.srcArray = NULL;
    memcpyParams.srcPos   = make_cudaPos(0, 0, 0);
    memcpyParams.srcPtr   = make_cudaPitchedPtr(result_d, sizeof(double), 1, 1);
    memcpyParams.dstArray = NULL;
    memcpyParams.dstPos   = make_cudaPos(0, 0, 0);
    memcpyParams.dstPtr   = make_cudaPitchedPtr(&result_h, sizeof(double), 1, 1);
    memcpyParams.extent   = make_cudaExtent(sizeof(double), 1, 1);
    memcpyParams.kind     = cudaMemcpyDeviceToHost;

    // Node 4: D2H memcpy — copies the scalar result back to the host.
    // Again the copy descriptor nests in nodeParams.memcpy.copyParams.
    nodeParams                   = {};
    nodeParams.type              = cudaGraphNodeTypeMemcpy;
    nodeParams.memcpy.copyParams = memcpyParams;
    cudaGraphAddNode(&memcpyNode, graph, nodeDependencies.data(), /*dependencyData=*/NULL, nodeDependencies.size(), &nodeParams);
    nodeDependencies.clear();
    nodeDependencies.push_back(memcpyNode);

    cudaGraphNode_t hostNode;
    callBackData_t  hostFnData;
    hostFnData.data    = &result_h;
    hostFnData.fn_name = "cudaGraphsManual";

    // Node 5: host callback — runs on the CPU after the D2H copy completes.
    // The .host member is cudaHostNodeParamsV2 (fn + userData, plus a syncMode field
    // left at 0 by the zero-init above).
    nodeParams               = {};
    nodeParams.type          = cudaGraphNodeTypeHost;
    nodeParams.host.fn       = myHostNodeCallback;
    nodeParams.host.userData = &hostFnData;
    cudaGraphAddNode(&hostNode, graph, nodeDependencies.data(), /*dependencyData=*/NULL, nodeDependencies.size(), &nodeParams);

    size_t numNodes = 0;
    cudaGraphGetNodes(graph, NULL, &numNodes);
    printf("Graph node count: %zu\n", numNodes);

    // Instantiate: compile the graph into an executable form (one-time cost).
    // This is where CUDA optimizes the schedule; repeated launches reuse this.
    cudaGraphExec_t graphExec;
    cudaGraphInstantiate(&graphExec, graph, NULL, NULL, 0);

    // Demonstrates cudaGraphClone — in practice, cloning is useful when multiple CPU
    // threads need to launch the same graph concurrently, each with its own independent graphExec.
    cudaGraph_t     clonedGraph;
    cudaGraphExec_t clonedGraphExec;
    cudaGraphClone(&clonedGraph, graph);
    cudaGraphInstantiate(&clonedGraphExec, clonedGraph, NULL, NULL, 0);

    // Refill the host input before each launch so the graph's H2D copy processes
    // new data every time — one instantiated graph reused for different data.
    // The per-iteration sync ensures that copy finishes before we overwrite the buffer.
    for (int i = 0; i < GRAPH_LAUNCH_ITERATIONS; i++) {
        init_input(inputVec_h, inputSize);
        cudaGraphLaunch(graphExec, streamForGraph);
        cudaStreamSynchronize(streamForGraph);
    }

    printf("\nCloned graph:\n");
    for (int i = 0; i < GRAPH_LAUNCH_ITERATIONS; i++) {
        init_input(inputVec_h, inputSize);
        cudaGraphLaunch(clonedGraphExec, streamForGraph);
        cudaStreamSynchronize(streamForGraph);
    }

    cudaGraphExecDestroy(graphExec);
    cudaGraphExecDestroy(clonedGraphExec);
    cudaGraphDestroy(graph);
    cudaGraphDestroy(clonedGraph);
    cudaStreamDestroy(streamForGraph);
}
void cudaGraphsUsingStreamCapture(float  *inputVec_h,
                                  float  *inputVec_d,
                                  double *outputVec_d,
                                  double *result_d,
                                  size_t  inputSize,
                                  size_t  numOfBlocks)
{
    cudaStream_t stream1, streamForGraph;
    cudaGraph_t  graph;
    double       result_h = 0.0;

    cudaStreamCreate(&stream1);
    cudaStreamCreate(&streamForGraph);

    // Begin capture: operations issued to stream1 are recorded, not executed.
    cudaStreamBeginCapture(stream1, cudaStreamCaptureModeGlobal);

    cudaMemcpyAsync(inputVec_d, inputVec_h, sizeof(float) * inputSize, cudaMemcpyDefault, stream1);

    reduce<<<numOfBlocks, THREADS_PER_BLOCK, 0, stream1>>>(inputVec_d, outputVec_d, inputSize, numOfBlocks);

    reduceFinal<<<1, THREADS_PER_BLOCK, 0, stream1>>>(outputVec_d, result_d, numOfBlocks);
    cudaMemcpyAsync(&result_h, result_d, sizeof(double), cudaMemcpyDefault, stream1);

    callBackData_t hostFnData = {0};
    hostFnData.data           = &result_h;
    hostFnData.fn_name        = "cudaGraphsUsingStreamCapture";
    cudaLaunchHostFunc(stream1, myHostNodeCallback, &hostFnData);

    // End capture: the runtime builds a graph from everything recorded above.
    // The resulting graph is structurally identical to the one built manually.
    cudaStreamEndCapture(stream1, &graph);

    size_t numNodes = 0;
    cudaGraphGetNodes(graph, NULL, &numNodes);
    printf("Graph node count: %zu\n", numNodes);

    // Instantiate the captured graph into an executable form.
    cudaGraphExec_t graphExec;
    cudaGraphInstantiate(&graphExec, graph, NULL, NULL, 0);

    // Demonstrates cudaGraphClone — in practice, cloning is useful when multiple CPU
    // threads need to launch the same graph concurrently, each with its own independent graphExec.
    cudaGraph_t     clonedGraph;
    cudaGraphExec_t clonedGraphExec;
    cudaGraphClone(&clonedGraph, graph);
    cudaGraphInstantiate(&clonedGraphExec, clonedGraph, NULL, NULL, 0);

    // Refill the host input before each launch so the graph's H2D copy processes
    // new data every time — one instantiated graph reused for different data.
    // The per-iteration sync ensures that copy finishes before we overwrite the buffer.
    for (int i = 0; i < GRAPH_LAUNCH_ITERATIONS; i++) {
        init_input(inputVec_h, inputSize);
        cudaGraphLaunch(graphExec, streamForGraph);
        cudaStreamSynchronize(streamForGraph);
    }

    printf("\nCloned graph:\n");
    for (int i = 0; i < GRAPH_LAUNCH_ITERATIONS; i++) {
        init_input(inputVec_h, inputSize);
        cudaGraphLaunch(clonedGraphExec, streamForGraph);
        cudaStreamSynchronize(streamForGraph);
    }

    cudaGraphExecDestroy(graphExec);
    cudaGraphExecDestroy(clonedGraphExec);
    cudaGraphDestroy(graph);
    cudaGraphDestroy(clonedGraph);
    cudaStreamDestroy(stream1);
    cudaStreamDestroy(streamForGraph);
}

4.2.2.2. 图的实例化

无论是使用图 API 还是流捕获,创建图后,都必须通过实例化该图来创建可执行图,然后才能启动它。假设已成功创建cudaGraph_t graph,以下代码将实例化该图并创建可执行图cudaGraphExec_t graphExec:

cudaGraphExec_t graphExec;
cudaGraphInstantiate(&graphExec, graph, 0);

4.2.2.3. 图的执行

创建图并将其实例化为可执行图之后,就可以启动它。假设已成功创建cudaGraphExec_t graphExec,以下代码片段会将该图启动到指定的流中:

cudaGraphLaunch(graphExec, stream);

综合以上内容,并使用第 4.2.2.1.2 节中的流捕获示例,以下代码片段将创建一个图,对其实例化,然后启动它:

cudaGraph_t graph;

cudaStreamBeginCapture(stream);

kernel_A<<< ..., stream >>>(...);
kernel_B<<< ..., stream >>>(...);
libraryCall(stream);
kernel_C<<< ..., stream >>>(...);

cudaStreamEndCapture(stream, &graph);

cudaGraphExec_t graphExec;
cudaGraphInstantiate(&graphExec, graph, 0);
cudaGraphLaunch(graphExec, stream);

4.2.3. 更新已实例化的图

当工作流程发生变化时,图就会过时,必须加以修改。对图结构的重大更改(例如拓扑或节点类型的更改)需要重新实例化,因为必须重新应用与拓扑相关的优化。然而,通常只有节点参数(例如核函数参数和内存地址)发生变化,而图拓扑保持不变。针对这种情况,CUDA 提供了一种轻量级的“图更新”机制,允许就地修改某些节点参数,而无需重新构建整个图,这比重新实例化高效得多。

更新会在下一次启动图时生效,因此不会影响此前的图启动,即使更新时这些图启动仍在运行。可以反复更新并重新启动图,因此可以在一个流中将多次更新/启动排队。

CUDA 提供了两种更新已实例化图参数的机制:整图更新和单个节点更新。整图更新允许用户提供一个拓扑相同的cudaGraph_t对象,其节点包含更新后的参数。单个节点更新允许用户显式更新各个节点的参数。当需要更新大量节点,或者调用方不知道图拓扑时(即该图是对库调用进行流捕获的结果),使用更新后的cudaGraph_t更为方便。当更改数量较少,并且用户持有需要更新的节点的句柄时,首选单个节点更新。单个节点更新会跳过对未更改节点的拓扑检查和比较,因此在许多情况下可以更加高效。

CUDA 还提供了一种机制,可以在不影响节点当前参数的情况下启用和禁用单个节点。

以下各节将更详细地说明每种方法。

4.2.3.1. 整图更新

cudaGraphExecUpdate()允许使用一个拓扑相同的图(“更新图”)中的参数来更新已实例化的图(“原始图”)。更新图的拓扑必须与用于实例化cudaGraphExec_t的原始图完全相同。此外,指定依赖项的顺序也必须匹配。最后,CUDA 需要以一致的顺序排列汇节点(没有依赖项的节点)。CUDA 依赖于特定 API 调用的顺序来实现一致的汇节点排序。

更明确地说,遵循以下规则会使cudaGraphExecUpdate()以确定性的方式将原始图与更新图中的节点配对:

  1. 对于任何正在捕获的流,对该流进行操作的 API 调用都必须以相同的顺序进行,包括事件等待,以及其他不直接对应于节点创建的 API 调用。

  2. 直接操纵给定图节点入边的 API 调用(包括被捕获的流 API、节点添加 API 和边添加/移除 API)必须以相同的顺序进行。此外,当以数组形式向这些 API 指定依赖项时,在这些数组中指定依赖项的顺序也必须匹配。

  3. 汇节点的排列顺序必须一致。汇节点是指在调用cudaGraphExecUpdate()时的最终图中,没有依赖它的节点/出边的节点。以下操作(如果存在)会影响汇节点的排序,并且必须(作为一个组合后的集合)以相同的顺序进行:

    • 产生汇节点的节点添加 API。

    • 导致某个节点变为汇节点的边移除操作。

    • cudaStreamUpdateCaptureDependencies(),如果它从正在捕获的流的依赖项集合中移除了一个汇节点。

    • cudaStreamEndCapture().

以下示例展示了如何使用该 API 更新已实例化的图:

cudaGraphExec_t graphExec = NULL;

for (int i = 0; i < 10; i++) {
    cudaGraph_t graph;
    cudaGraphExecUpdateResult updateResult;
    cudaGraphNode_t errorNode;

    // In this example we use stream capture to create the graph.
    // You can also use the Graph API to produce a graph.
    cudaStreamBeginCapture(stream, cudaStreamCaptureModeGlobal);

    // Call a user-defined, stream based workload, for example
    do_cuda_work(stream);

    cudaStreamEndCapture(stream, &graph);

    // If we've already instantiated the graph, try to update it directly
    // and avoid the instantiation overhead
    if (graphExec != NULL) {
        // If the graph fails to update, errorNode will be set to the
        // node causing the failure and updateResult will be set to a
        // reason code.
        cudaGraphExecUpdate(graphExec, graph, &errorNode, &updateResult);
    }

    // Instantiate during the first iteration or whenever the update
    // fails for any reason
    if (graphExec == NULL || updateResult != cudaGraphExecUpdateSuccess) {

        // If a previous update failed, destroy the cudaGraphExec_t
        // before re-instantiating it
        if (graphExec != NULL) {
            cudaGraphExecDestroy(graphExec);
        }
        // Instantiate graphExec from graph. The error node and
        // error message parameters are unused here.
        cudaGraphInstantiate(&graphExec, graph, 0);
    }

    cudaGraphDestroy(graph);
    cudaGraphLaunch(graphExec, stream);
    cudaStreamSynchronize(stream);
}

典型的工作流程是使用流捕获或图 API 创建初始cudaGraph_t。然后照常对cudaGraph_t进行实例化并启动。在初次启动之后,使用与初始图相同的方法创建新的cudaGraph_t,并调用cudaGraphExecUpdate()。如果图更新成功(由上述示例中的updateResult参数指示),则启动更新后的cudaGraphExec_t。如果更新因任何原因失败,则调用cudaGraphExecDestroy()和cudaGraphInstantiate(),以销毁原始cudaGraphExec_t并实例化一个新的对象。

也可以直接更新cudaGraph_t节点(即使用cudaGraphKernelNodeSetParams()),随后再更新cudaGraphExec_t,但是使用下一节介绍的显式节点更新 API 更加高效。

条件句柄的标志和默认值会作为图更新的一部分进行更新。

有关用法和当前限制的更多信息,请参阅图 API。

4.2.3.2. 单个节点更新

可以直接更新已实例化图的节点参数。这消除了实例化的开销,也消除了创建新cudaGraph_t的开销。如果需要更新的节点数量相对于图中的节点总数较少,则最好逐个更新节点。以下方法可用于更新cudaGraphExec_t节点:

表 8 单个节点更新 API

API

节点类型

cudaGraphExecKernelNodeSetParams()

核函数节点

cudaGraphExecMemcpyNodeSetParams()

内存复制节点

cudaGraphExecMemsetNodeSetParams()

内存设置节点

cudaGraphExecHostNodeSetParams()

主机节点

cudaGraphExecChildGraphNodeSetParams()

子图节点

cudaGraphExecEventRecordNodeSetEvent()

事件记录节点

cudaGraphExecEventWaitNodeSetEvent()

事件等待节点

cudaGraphExecExternalSemaphoresSignalNodeSetParams()

外部信号量发信号节点

cudaGraphExecExternalSemaphoresWaitNodeSetParams()

外部信号量等待节点

有关用法和当前限制的更多信息,请参阅图 API。

4.2.3.3. 单个节点启用

可以使用cudaGraphNodeSetEnabled() API 启用或禁用已实例化图中的核函数节点、memset 节点和 memcpy 节点。这允许创建一个包含所需功能超集的图,并针对每次启动对其进行定制。可以使用cudaGraphNodeGetEnabled() API 查询节点的启用状态。

已禁用的节点在重新启用之前,在功能上等同于空节点。启用或禁用节点不会影响节点参数。单个节点更新或通过cudaGraphExecUpdate()进行的整个图更新都不会影响启用状态。在节点禁用期间进行的参数更新,将在该节点重新启用时生效。

有关用法和当前限制的更多信息,请参阅图 API。

4.2.3.4. 图更新限制

核函数节点:

  • 函数所属的上下文不能更改。

  • 如果节点的函数原本未使用 CUDA 动态并行,则不能将该节点更新为使用 CUDA 动态并行的函数。

cudaMemset和cudaMemcpy节点:

  • 操作数所分配到或映射到的 CUDA 设备不能更改。

  • 源内存和目标内存必须分别从与原始源内存和目标内存相同的上下文中分配。

  • 只能更改一维cudaMemset/cudaMemcpy节点。

memcpy 节点的额外限制:

  • 不支持更改源内存或目标内存的类型(即cudaPitchedPtr、cudaArray_t等),也不支持更改传输类型(即cudaMemcpyKind)。

外部信号量等待节点和记录节点:

  • 不支持更改信号量的数量。

条件节点:

  • 各图之间句柄的创建和指派顺序必须匹配。

  • 不支持更改节点参数(即条件节点中的图数量、节点上下文等)。

  • 更改条件主体图内节点的参数须遵守上述规则。

内存节点:

  • 如果cudaGraph_t当前已实例化为另一个cudaGraphExec_t,则无法使用cudaGraph_t更新cudaGraphExec_t。

对主机节点、事件记录节点或事件等待节点的更新没有限制。

4.2.4. 条件图节点

条件节点允许对其内部包含的图进行条件执行和循环执行。这使得动态和迭代工作流能够完全在图内表示,并让主机 CPU 可以腾出资源并行执行其他工作。

当条件节点的依赖关系得到满足时,设备会对条件值进行求值。条件节点可以是以下类型之一:

  • 条件IF 节点在节点执行时,如果条件值非零,则执行其主体图一次。还可以提供可选的第二个主体图,如果节点执行时条件值为零,则执行该主体图一次。

  • 条件WHILE 节点在节点执行时,如果条件值非零,则执行其主体图,并将持续执行其主体图,直到条件值为零。

  • 条件SWITCH 节点在条件值等于 n 时,执行从零开始编号的第 n 个主体图一次。如果条件值不对应任何主体图,则不会启动任何主体图。

条件值通过条件句柄访问,该句柄必须先于节点创建。设备代码可以使用cudaGraphSetConditional()设置条件值。还可以在创建句柄时指定一个默认值,该默认值会在每次图启动时应用。

创建条件节点时,会创建一个空图,并将句柄返回给用户,以便填充该图。可以使用图 API或cudaStreamBeginCaptureToGraph()填充此条件主体图。

条件节点可以嵌套。

4.2.4.1. 条件句柄

条件值由cudaGraphConditionalHandle表示,并由cudaGraphConditionalHandleCreate()创建。

句柄必须与单个条件节点关联。句柄无法销毁,因此无需跟踪它们。

如果在创建句柄时指定cudaGraphCondAssignDefault,则条件值会在每次图执行开始时初始化为指定的默认值。如果未提供此标志,则条件值在每次图执行开始时都是未定义的,代码不应假定条件值会在多次执行之间保留。

与句柄关联的默认值和标志将在整个图更新期间更新。

4.2.4.2. 条件节点主体图要求

一般要求:

  • 图的所有节点必须驻留在单个设备上。

  • 图只能包含核函数节点、空节点、memcpy 节点、memset 节点、子图节点和条件节点。

核函数节点:

  • 不允许图中的核函数使用 CUDA 动态并行或设备图启动。

  • 只要未使用 MPS,就允许协作式启动。

Memcpy/Memset 节点:

  • 仅允许涉及设备内存和/或映射到设备的页锁定主机内存的复制/memset 操作。

  • 不允许涉及 CUDA 数组的复制/memset 操作。

  • 在实例化时,当前设备必须能够访问这两个操作数。注意,复制操作将从图所驻留的设备执行,即使其目标是另一个设备上的内存也是如此。

4.2.4.3. 条件 IF 节点

如果 IF 节点执行时条件非零,则其主体图将执行一次。下图描绘了一个包含 3 个节点的图,其中中间节点 B 是条件节点:

../_images/conditional-if-node.png

图 27 条件 IF 节点

图内文字中文对照
IF
如果

以下代码展示了如何创建包含 IF 条件节点的图。条件的默认值使用上游核函数设置。条件主体使用图 API填充。

__global__ void setHandle(cudaGraphConditionalHandle handle, int value)
{
    ...
    // Set the condition value to the value passed to the kernel
    cudaGraphSetConditional(handle, value);
    ...
}

void graphSetup() {
    cudaGraph_t graph;
    cudaGraphExec_t graphExec;
    cudaGraphNode_t node;
    void *kernelArgs[2];
    int value = 1;

    // Create the graph
    cudaGraphCreate(&graph, 0);

    // Create the conditional handle; because no default value is provided, the condition value is undefined at the start of each graph execution
    cudaGraphConditionalHandle handle;
    cudaGraphConditionalHandleCreate(&handle, graph);

    // Use a kernel upstream of the conditional to set the handle value
    cudaGraphNodeParams params = { cudaGraphNodeTypeKernel };
    params.kernel.func = (void *)setHandle;
    params.kernel.gridDim.x = params.kernel.gridDim.y = params.kernel.gridDim.z = 1;
    params.kernel.blockDim.x = params.kernel.blockDim.y = params.kernel.blockDim.z = 1;
    params.kernel.kernelParams = kernelArgs;
    kernelArgs[0] = &handle;
    kernelArgs[1] = &value;
    cudaGraphAddNode(&node, graph, nullptr, nullptr, 0, &params);

    // Create and add the conditional node
    cudaGraphNodeParams cParams = { cudaGraphNodeTypeConditional };
    cParams.conditional.handle = handle;
    cParams.conditional.type   = cudaGraphCondTypeIf;
    cParams.conditional.size   = 1; // There is only an "if" body graph
    cudaGraphAddNode(&node, graph, &node, nullptr, 1, &cParams);

    // Get the body graph of the conditional node
    cudaGraph_t bodyGraph = cParams.conditional.phGraph_out[0];

    // Populate the body graph of the IF conditional node
    ...
    cudaGraphAddNode(&node, bodyGraph, nullptr, nullptr, 0, &params);

    // Instantiate and launch the graph
    cudaGraphInstantiate(&graphExec, graph, 0);
    cudaGraphLaunch(graphExec, 0);
    cudaDeviceSynchronize();

    // Clean up
    cudaGraphExecDestroy(graphExec);
    cudaGraphDestroy(graph);
}

IF 节点还可以有一个可选的第二个主体图,如果节点执行时条件值为零,则该主体图执行一次。

void graphSetup() {
    cudaGraph_t graph;
    cudaGraphExec_t graphExec;
    cudaGraphNode_t node;
    void *kernelArgs[2];
    int value = 1;

    // Create the graph
    cudaGraphCreate(&graph, 0);

    // Create the conditional handle; because no default value is provided, the condition value is undefined at the start of each graph execution
    cudaGraphConditionalHandle handle;
    cudaGraphConditionalHandleCreate(&handle, graph);

    // Use a kernel upstream of the conditional to set the handle value
    cudaGraphNodeParams params = { cudaGraphNodeTypeKernel };
    params.kernel.func = (void *)setHandle;
    params.kernel.gridDim.x = params.kernel.gridDim.y = params.kernel.gridDim.z = 1;
    params.kernel.blockDim.x = params.kernel.blockDim.y = params.kernel.blockDim.z = 1;
    params.kernel.kernelParams = kernelArgs;
    kernelArgs[0] = &handle;
    kernelArgs[1] = &value;
    cudaGraphAddNode(&node, graph, nullptr, nullptr, 0, &params);

    // Create and add the IF conditional node
    cudaGraphNodeParams cParams = { cudaGraphNodeTypeConditional };
    cParams.conditional.handle = handle;
    cParams.conditional.type   = cudaGraphCondTypeIf;
    cParams.conditional.size   = 2; // There is both an "if" and an "else" body graph
    cudaGraphAddNode(&node, graph, &node, nullptr, 1, &cParams);

    // Get the body graphs of the conditional node
    cudaGraph_t ifBodyGraph = cParams.conditional.phGraph_out[0];
    cudaGraph_t elseBodyGraph = cParams.conditional.phGraph_out[1];

    // Populate the body graphs of the IF conditional node
    ...
    cudaGraphAddNode(&node, ifBodyGraph, nullptr, nullptr, 0, &params);
    ...
    cudaGraphAddNode(&node, elseBodyGraph, nullptr, nullptr, 0, &params);

    // Instantiate and launch the graph
    cudaGraphInstantiate(&graphExec, graph, 0);
    cudaGraphLaunch(graphExec, 0);
    cudaDeviceSynchronize();

    // Clean up
    cudaGraphExecDestroy(graphExec);
    cudaGraphDestroy(graph);
}

4.2.4.4. 条件 WHILE 节点

只要条件非零,WHILE 节点的主体图就会执行。条件将在节点执行时以及主体图完成后求值。下图描绘了一个包含 3 个节点的图,其中中间节点 B 是条件节点:

../_images/conditional-while-node.png

图 28 条件 WHILE 节点

图内文字中文对照
WHILE
当条件成立时

以下代码展示了如何创建包含 WHILE 条件节点的图。创建句柄时使用cudaGraphCondAssignDefault,以避免需要上游核函数。条件主体使用图 API填充。

__global__ void loopKernel(cudaGraphConditionalHandle handle, char *dPtr)
{
   // Decrement the value of dPtr and set the condition value to 0 once dPtr is 0
   if (--(*dPtr) == 0) {
      cudaGraphSetConditional(handle, 0);
   }
}

void graphSetup() {
    cudaGraph_t graph;
    cudaGraphExec_t graphExec;
    cudaGraphNode_t node;
    void *kernelArgs[2];

    // Allocate a byte of device memory to use as input
    char *dPtr;
    cudaMalloc((void **)&dPtr, 1);

    // Create the graph
    cudaGraphCreate(&graph, 0);

    // Create the conditional handle with a default value of 1
    cudaGraphConditionalHandle handle;
    cudaGraphConditionalHandleCreate(&handle, graph, 1, cudaGraphCondAssignDefault);

    // Create and add the WHILE conditional node
    cudaGraphNodeParams cParams = { cudaGraphNodeTypeConditional };
    cParams.conditional.handle = handle;
    cParams.conditional.type   = cudaGraphCondTypeWhile;
    cParams.conditional.size   = 1;
    cudaGraphAddNode(&node, graph, nullptr, nullptr, 0, &cParams);

    // Get the body graph of the conditional node
    cudaGraph_t bodyGraph = cParams.conditional.phGraph_out[0];

    // Populate the body graph of the conditional node
    cudaGraphNodeParams params = { cudaGraphNodeTypeKernel };
    params.kernel.func = (void *)loopKernel;
    params.kernel.gridDim.x = params.kernel.gridDim.y = params.kernel.gridDim.z = 1;
    params.kernel.blockDim.x = params.kernel.blockDim.y = params.kernel.blockDim.z = 1;
    params.kernel.kernelParams = kernelArgs;
    kernelArgs[0] = &handle;
    kernelArgs[1] = &dPtr;
    cudaGraphAddNode(&node, bodyGraph, nullptr, nullptr, 0, &params);

    // Initialize device memory, instantiate, and launch the graph
    cudaMemset(dPtr, 10, 1); // Set dPtr to 10; the loop will run until dPtr is 0
    cudaGraphInstantiate(&graphExec, graph, 0);
    cudaGraphLaunch(graphExec, 0);
    cudaDeviceSynchronize();

    // Clean up
    cudaGraphExecDestroy(graphExec);
    cudaGraphDestroy(graph);
    cudaFree(dPtr);
}

4.2.4.5. 条件 SWITCH 节点

如果 SWITCH 节点执行时条件等于 n,则其从零开始编号的第 n 个主体图将执行一次。下图描绘了一个包含 3 个节点的图,其中中间节点 B 是条件节点:

../_images/conditional-switch-node.png

图 29 条件 SWITCH 节点

以下代码展示了如何创建包含 SWITCH 条件节点的图。条件值使用上游核函数设置。各条件主体使用图 API填充。

__global__ void setHandle(cudaGraphConditionalHandle handle, int value)
{
    ...
    // Set the condition value to the value passed to the kernel
    cudaGraphSetConditional(handle, value);
    ...
}

void graphSetup() {
    cudaGraph_t graph;
    cudaGraphExec_t graphExec;
    cudaGraphNode_t node;
    void *kernelArgs[2];
    int value = 1;

    // Create the graph
    cudaGraphCreate(&graph, 0);

    // Create the conditional handle; because no default value is provided, the condition value is undefined at the start of each graph execution
    cudaGraphConditionalHandle handle;
    cudaGraphConditionalHandleCreate(&handle, graph);

    // Use a kernel upstream of the conditional to set the handle value
    cudaGraphNodeParams params = { cudaGraphNodeTypeKernel };
    params.kernel.func = (void *)setHandle;
    params.kernel.gridDim.x = params.kernel.gridDim.y = params.kernel.gridDim.z = 1;
    params.kernel.blockDim.x = params.kernel.blockDim.y = params.kernel.blockDim.z = 1;
    params.kernel.kernelParams = kernelArgs;
    kernelArgs[0] = &handle;
    kernelArgs[1] = &value;
    cudaGraphAddNode(&node, graph, nullptr, nullptr, 0, &params);

    // Create and add the conditional SWITCH node
    cudaGraphNodeParams cParams = { cudaGraphNodeTypeConditional };
    cParams.conditional.handle = handle;
    cParams.conditional.type   = cudaGraphCondTypeSwitch;
    cParams.conditional.size   = 5;
    cudaGraphAddNode(&node, graph, &node, nullptr, 1, &cParams);

    // Get the body graphs of the conditional node
    cudaGraph_t *bodyGraphs = cParams.conditional.phGraph_out;

    // Populate the body graphs of the SWITCH conditional node
    ...
    cudaGraphAddNode(&node, bodyGraphs[0], nullptr, nullptr, 0, &params);
    ...
    cudaGraphAddNode(&node, bodyGraphs[4], NULL, 0, &params);

    // Instantiate and launch the graph
    cudaGraphInstantiate(&graphExec, graph, 0);
    cudaGraphLaunch(graphExec, 0);
    cudaDeviceSynchronize();

    // Clean up
    cudaGraphExecDestroy(graphExec);
    cudaGraphDestroy(graph);
}

4.2.5. 图内存节点

4.2.5.1. 简介

图内存节点允许图创建并拥有内存分配。图内存节点具有 GPU 有序的生命周期语义,该语义规定何时允许在设备上访问内存。这种 GPU 有序的生命周期语义支持由驱动程序管理的内存复用,并且与流有序分配 APIcudaMallocAsync和cudaFreeAsync的语义一致,这些 API 可以在创建图时被捕获。

图分配在图的整个生命周期内都具有固定地址,包括重复实例化和启动。这使得图中的其他操作可以直接引用该内存,而无需更新图,即使 CUDA 更改其后备物理内存也是如此。在一个图内,按图排序的生命周期不重叠的分配可以使用同一底层物理内存。

CUDA 可以为多个图中的分配复用同一物理内存,根据 GPU 有序的生命周期语义为虚拟地址映射设置别名。例如,当不同的图启动到同一个流中时,CUDA 可以为同一物理内存设置虚拟别名,以满足生命周期限于单个图的分配的需求。

4.2.5.2. API 基础

图内存节点是表示内存分配或释放操作的图节点。为简便起见,分配内存的节点称为分配节点。同样,释放内存的节点称为释放节点。由分配节点创建的分配称为图分配。CUDA 在节点创建时为图分配指派虚拟地址。尽管这些虚拟地址在分配节点的整个生命周期内固定不变,但分配的内容在释放操作之后不会持续保留,并且可能被引用其他分配的访问覆盖。

每次运行图时,图分配都被视为重新创建。图分配的生命周期不同于节点的生命周期,它始于 GPU 执行到分配图节点之时,并在发生以下任一情况时结束:

  • GPU 执行到释放图节点

  • GPU 执行到用于释放内存的cudaFreeAsync()流调用

  • 调用cudaFree()进行释放时立即结束

注意

销毁图虽然会结束分配节点的生命周期,但不会自动释放任何仍存活的图分配内存。随后必须在另一个图中释放该分配,或使用cudaFreeAsync()/cudaFree()释放。

与其他图结构一样,图内存节点在图内通过依赖边排序。程序必须保证访问图内存的操作:

  • 在顺序上位于分配节点之后

  • 在顺序上位于释放内存的操作之前

图分配的生命周期根据 GPU 执行(而不是 API 调用)开始,并且通常也根据 GPU 执行结束。GPU 排序是工作在 GPU 上运行的顺序,而不是工作入队或被描述的顺序。因此,图分配被视为“GPU 有序”。

4.2.5.2.1. 图节点 API

可以使用节点创建 APIcudaGraphAddNode显式创建图内存节点。添加cudaGraphNodeTypeMemAlloc节点时分配的地址,会通过所传入的cudaGraphNodeParams结构的alloc::dptr字段返回给用户。在执行分配的图内,所有使用图分配的操作都必须在顺序上位于分配节点之后。同样,任何释放节点都必须在顺序上位于图内对该分配的所有使用之后。释放节点使用cudaGraphAddNode以及cudaGraphNodeTypeMemFree节点类型创建。

下图是一个包含分配节点和释放节点的示例图。核函数节点a、b和c在顺序上位于分配节点之后、释放节点之前,因此这些核函数可以访问该分配。核函数节点e在顺序上未被置于分配节点之后,因此无法安全地访问该内存。核函数节点d在顺序上未被置于释放节点之前,因此无法安全地访问该内存。

核函数节点

图 30 核函数节点

图内文字中文对照
kernel a
核函数 a
kernel b
核函数 b
kernel c
核函数 c
kernel d
核函数 d
kernel e
核函数 e

以下代码片段构建了此图所示的图:

// Create the graph - it starts out empty
cudaGraphCreate(&graph, 0);

// parameters for a basic allocation
cudaGraphNodeParams params = { cudaGraphNodeTypeMemAlloc };
params.alloc.poolProps.allocType = cudaMemAllocationTypePinned;
params.alloc.poolProps.location.type = cudaMemLocationTypeDevice;
// specify device 0 as the resident device
params.alloc.poolProps.location.id = 0;
params.alloc.bytesize = size;

cudaGraphAddNode(&allocNode, graph, NULL, NULL, 0, &params);

// create a kernel node that uses the graph allocation
cudaGraphNodeParams nodeParams = { cudaGraphNodeTypeKernel };
nodeParams.kernel.kernelParams[0] = params.alloc.dptr;
// ...set other kernel node parameters...

// add the kernel node to the graph
cudaGraphAddNode(&a, graph, &allocNode, NULL, 1, &nodeParams);
cudaGraphAddNode(&b, graph, &a, NULL, 1, &nodeParams);
cudaGraphAddNode(&c, graph, &a, NULL, 1, &nodeParams);
cudaGraphNode_t dependencies[2];
// kernel nodes b and c are using the graph allocation, so the freeing node must depend on them.  Since the dependency of node b on node a establishes an indirect dependency, the free node does not need to explicitly depend on node a.
dependencies[0] = b;
dependencies[1] = c;
cudaGraphNodeParams freeNodeParams = { cudaGraphNodeTypeMemFree };
freeNodeParams.free.dptr = params.alloc.dptr;
cudaGraphAddNode(&freeNode, graph, dependencies, NULL, 2, freeNodeParams);
// free node does not depend on kernel node d, so it must not access the freed graph allocation.
cudaGraphAddNode(&d, graph, &c, NULL, 1, &nodeParams);

// node e does not depend on the allocation node, so it must not access the allocation.  This would be true even if the freeNode depended on kernel node e.
cudaGraphAddNode(&e, graph, NULL, NULL, 0, &nodeParams);

4.2.5.2.2. 流捕获

可以通过捕获相应的流有序分配和释放调用cudaMallocAsync与cudaFreeAsync来创建图内存节点。在这种情况下,被捕获的分配 API 返回的虚拟地址可供图内的其他操作使用。由于流有序依赖关系将被捕获到图中,因此流有序分配 API 的排序要求可保证图内存节点相对于被捕获的流操作具有正确的顺序(对于正确编写的流代码而言)。

为清晰起见,忽略核函数节点d和e,以下代码片段展示了如何使用流捕获来创建上一幅图中的图:

cudaMallocAsync(&dptr, size, stream1);
kernel_A<<< ..., stream1 >>>(dptr, ...);

// Fork into stream2
cudaEventRecord(event1, stream1);
cudaStreamWaitEvent(stream2, event1);

kernel_B<<< ..., stream1 >>>(dptr, ...);
// event dependencies translated into graph dependencies, so the kernel node created by the capture of kernel C will depend on the allocation node created by capturing the cudaMallocAsync call.
kernel_C<<< ..., stream2 >>>(dptr, ...);

// Join stream2 back to origin stream (stream1)
cudaEventRecord(event2, stream2);
cudaStreamWaitEvent(stream1, event2);

// Free depends on all work accessing the memory.
cudaFreeAsync(dptr, stream1);

// End capture in the origin stream
cudaStreamEndCapture(stream1, &graph);

4.2.5.2.3. 在执行分配的图之外访问和释放图内存

图分配不一定要由执行分配的图来释放。当图不释放某项分配时,该分配会在图执行结束后继续存在,并且可以由后续 CUDA 操作访问。只要通过 CUDA 事件和其他流排序机制,使访问操作在顺序上位于分配之后,就可以在另一个图中访问这些分配,也可以直接使用流操作访问。随后可以通过常规调用cudaFree、cudaFreeAsync来释放分配,也可以通过启动另一个具有相应释放节点的图来释放,或者通过随后再次启动执行分配的图来释放(如果该图使用cudaGraphInstantiateFlagAutoFreeOnLaunch标志进行了实例化)。在内存释放后访问它是非法的,必须使用图依赖关系、CUDA 事件及其他流排序机制,使释放操作在顺序上位于所有访问该内存的操作之后。

注意

由于图分配可能共享底层物理内存,因此释放操作在顺序上必须位于所有设备操作完成之后。带外同步(例如计算核函数内基于内存的同步)不足以对内存写入和释放操作进行排序。有关更多信息,请参阅虚拟别名支持中与一致性和相干性相关的规则。

以下三个代码片段演示了如何在执行分配的图之外访问图分配,并分别通过以下方式建立正确的顺序:使用单个流、在流之间使用事件,以及使用内嵌在分配图和释放图中的事件。

第一,通过使用单个流建立顺序:

// Contents of allocating graph
void *dptr;
cudaGraphNodeParams params = { cudaGraphNodeTypeMemAlloc };
params.alloc.poolProps.allocType = cudaMemAllocationTypePinned;
params.alloc.poolProps.location.type = cudaMemLocationTypeDevice;
params.alloc.bytesize = size;
cudaGraphAddNode(&allocNode, allocGraph, NULL, NULL, 0, &params);
dptr = params.alloc.dptr;

cudaGraphInstantiate(&allocGraphExec, allocGraph, 0);

cudaGraphLaunch(allocGraphExec, stream);
kernel<<< ..., stream >>>(dptr, ...);
cudaFreeAsync(dptr, stream);

第二,通过记录和等待 CUDA 事件建立顺序:

// Contents of allocating graph
void *dptr;

// Contents of allocating graph
cudaGraphAddNode(&allocNode, allocGraph, NULL, NULL, 0, &allocNodeParams);
dptr = allocNodeParams.alloc.dptr;

// contents of consuming/freeing graph
kernelNodeParams.kernel.kernelParams[0] = allocNodeParams.alloc.dptr;
cudaGraphAddNode(&freeNode, freeGraph, NULL, NULL, 1, dptr);

cudaGraphInstantiate(&allocGraphExec, allocGraph, 0);
cudaGraphInstantiate(&freeGraphExec, freeGraph, 0);

cudaGraphLaunch(allocGraphExec, allocStream);

// establish the dependency of stream2 on the allocation node
// note: the dependency could also have been established with a stream synchronize operation
cudaEventRecord(allocEvent, allocStream);
cudaStreamWaitEvent(stream2, allocEvent);

kernel<<< ..., stream2 >>> (dptr, ...);

// establish the dependency between the stream 3 and the allocation use
cudaStreamRecordEvent(streamUseDoneEvent, stream2);
cudaStreamWaitEvent(stream3, streamUseDoneEvent);

// it is now safe to launch the freeing graph, which may also access the memory
cudaGraphLaunch(freeGraphExec, stream3);

第三,通过使用图外部事件节点建立顺序:

// Contents of allocating graph
void *dptr;
cudaEvent_t allocEvent; // event indicating when the allocation will be ready for use.
cudaEvent_t streamUseDoneEvent; // event indicating when the stream operations are done with the allocation.

// Contents of allocating graph with event record node
cudaGraphAddNode(&allocNode, allocGraph, NULL, NULL, 0, &allocNodeParams);
dptr = allocNodeParams.alloc.dptr;
// note: this event record node depends on the alloc node

cudaGraphNodeParams allocEventNodeParams = { cudaGraphNodeTypeEventRecord };
allocEventNodeParams.eventRecord.event = allocEvent;
cudaGraphAddNode(&recordNode, allocGraph, &allocNode, NULL, 1, allocEventNodeParams);
cudaGraphInstantiate(&allocGraphExec, allocGraph, 0);

// contents of consuming/freeing graph with event wait nodes
cudaGraphNodeParams streamWaitEventNodeParams = { cudaGraphNodeTypeEventWait };
streamWaitEventNodeParams.eventWait.event = streamUseDoneEvent;
cudaGraphAddNode(&streamUseDoneEventNode, waitAndFreeGraph, NULL, NULL, 0, streamWaitEventNodeParams);

cudaGraphNodeParams allocWaitEventNodeParams = { cudaGraphNodeTypeEventWait };
allocWaitEventNodeParams.eventWait.event = allocEvent;
cudaGraphAddNode(&allocReadyEventNode, waitAndFreeGraph, NULL, NULL, 0, allocWaitEventNodeParams);

kernelNodeParams->kernelParams[0] = allocNodeParams.alloc.dptr;

// The allocReadyEventNode provides ordering with the alloc node for use in a consuming graph.
cudaGraphAddNode(&kernelNode, waitAndFreeGraph, &allocReadyEventNode, NULL, 1, &kernelNodeParams);

// The free node has to be ordered after both external and internal users.
// Thus the node must depend on both the kernelNode and the streamUseDoneEventNode.
dependencies[0] = kernelNode;
dependencies[1] = streamUseDoneEventNode;

cudaGraphNodeParams freeNodeParams = { cudaGraphNodeTypeMemFree };
freeNodeParams.free.dptr = dptr;
cudaGraphAddNode(&freeNode, waitAndFreeGraph, &dependencies, NULL, 2, freeNodeParams);
cudaGraphInstantiate(&waitAndFreeGraphExec, waitAndFreeGraph, 0);

cudaGraphLaunch(allocGraphExec, allocStream);

// establish the dependency of stream2 on the event node satisfies the ordering requirement
cudaStreamWaitEvent(stream2, allocEvent);
kernel<<< ..., stream2 >>> (dptr, ...);
cudaStreamRecordEvent(streamUseDoneEvent, stream2);

// the event wait node in the waitAndFreeGraphExec establishes the dependency on the "readyForFreeEvent" that is needed to prevent the kernel running in stream two from accessing the allocation after the free node in execution order.
cudaGraphLaunch(waitAndFreeGraphExec, stream3);

4.2.5.2.4. cudaGraphInstantiateFlagAutoFreeOnLaunch

在正常情况下,如果图存在尚未释放的内存分配,CUDA 会阻止重新启动该图,因为在同一地址进行多次分配会导致内存泄漏。使用cudaGraphInstantiateFlagAutoFreeOnLaunch标志实例化图,允许在图仍有尚未释放的分配时重新启动该图。在这种情况下,启动会自动插入对尚未释放的分配的异步释放操作。

启动时自动释放对于单生产者、多消费者算法很有用。在每次迭代中,生产者图会创建若干分配,而根据运行时条件,一组可能变化的消费者会访问这些分配。这种可变的执行序列意味着消费者不能释放这些分配,因为后续消费者可能需要访问它们。启动时自动释放意味着启动循环无需跟踪生产者的分配,而是将这些信息继续限定在生产者的创建和销毁逻辑中。一般而言,启动时自动释放简化了那些原本需要在每次重新启动之前释放图所拥有的所有分配的算法。

注意

cudaGraphInstantiateFlagAutoFreeOnLaunch标志不会改变图销毁的行为。即使对于使用该标志实例化的图,应用程序也必须显式释放尚未释放的内存,以避免内存泄漏。以下代码展示了如何使用cudaGraphInstantiateFlagAutoFreeOnLaunch简化单生产者/多消费者算法:

// Create producer graph which allocates memory and populates it with data
cudaStreamBeginCapture(cudaStreamPerThread, cudaStreamCaptureModeGlobal);
cudaMallocAsync(&data1, blocks * threads, cudaStreamPerThread);
cudaMallocAsync(&data2, blocks * threads, cudaStreamPerThread);
produce<<<blocks, threads, 0, cudaStreamPerThread>>>(data1, data2);
...
cudaStreamEndCapture(cudaStreamPerThread, &graph);
cudaGraphInstantiateWithFlags(&producer,
                              graph,
                              cudaGraphInstantiateFlagAutoFreeOnLaunch);
cudaGraphDestroy(graph);

// Create first consumer graph by capturing an asynchronous library call
cudaStreamBeginCapture(cudaStreamPerThread, cudaStreamCaptureModeGlobal);
consumerFromLibrary(data1, cudaStreamPerThread);
cudaStreamEndCapture(cudaStreamPerThread, &graph);
cudaGraphInstantiateWithFlags(&consumer1, graph, 0); //regular instantiation
cudaGraphDestroy(graph);

// Create second consumer graph
cudaStreamBeginCapture(cudaStreamPerThread, cudaStreamCaptureModeGlobal);
consume2<<<blocks, threads, 0, cudaStreamPerThread>>>(data2);
...
cudaStreamEndCapture(cudaStreamPerThread, &graph);
cudaGraphInstantiateWithFlags(&consumer2, graph, 0);
cudaGraphDestroy(graph);

// Launch in a loop
bool launchConsumer2 = false;
do {
    cudaGraphLaunch(producer, myStream);
    cudaGraphLaunch(consumer1, myStream);
    if (launchConsumer2) {
        cudaGraphLaunch(consumer2, myStream);
    }
} while (determineAction(&launchConsumer2));

cudaFreeAsync(data1, myStream);
cudaFreeAsync(data2, myStream);

cudaGraphExecDestroy(producer);
cudaGraphExecDestroy(consumer1);
cudaGraphExecDestroy(consumer2);

4.2.5.2.5. 子图中的内存节点

CUDA 12.9 引入了将子图所有权移交给父图的功能。移交给父图的子图允许包含内存分配和释放节点。这使得包含分配或释放节点的子图可以在添加到父图之前独立构建。

子图移交后适用以下限制:

  • 不能独立实例化或销毁。

  • 不能作为子图添加到另一个父图中。

  • 不能用作 cuGraphExecUpdate 的参数。

  • 不能再向其中添加内存分配或释放节点。

// Create the child graph
cudaGraphCreate(&child, 0);

// parameters for a basic allocation
cudaGraphNodeParams allocNodeParams = { cudaGraphNodeTypeMemAlloc };
allocNodeParams.alloc.poolProps.allocType = cudaMemAllocationTypePinned;
allocNodeParams.alloc.poolProps.location.type = cudaMemLocationTypeDevice;
// specify device 0 as the resident device
allocNodeParams.alloc.poolProps.location.id = 0;
allocNodeParams.alloc.bytesize = size;

cudaGraphAddNode(&allocNode, child, NULL, NULL, 0, &allocNodeParams);
// Additional nodes using the allocation could be added here
cudaGraphNodeParams freeNodeParams = { cudaGraphNodeTypeMemFree };
freeNodeParams.free.dptr = allocNodeParams.alloc.dptr;
cudaGraphAddNode(&freeNode, child, &allocNode, NULL, 1, freeNodeParams);

// Create the parent graph
cudaGraphCreate(&parent, 0);

// Move the child graph to the parent graph
cudaGraphNodeParams childNodeParams = { cudaGraphNodeTypeGraph };
childNodeParams.graph.graph = child;
childNodeParams.graph.ownership = cudaGraphChildGraphOwnershipMove;
cudaGraphAddNode(&parentNode, parent, NULL, NULL, 0, &childNodeParams);

4.2.5.3. 优化的内存复用

CUDA 通过两种方式复用内存:

  • 图内虚拟内存和物理内存的复用基于虚拟地址分配,与流有序分配器中的方式类似。

  • 图之间物理内存的复用通过虚拟别名实现:不同的图可以将同一物理内存映射到各自唯一的虚拟地址。

4.2.5.3.1. 图内地址复用

CUDA 可以将相同的虚拟地址范围分配给生命周期不重叠的不同内存分配,从而在图内复用内存。由于虚拟地址可能被复用,因此不能保证指向生命周期互不重叠的不同内存分配的指针是唯一的。

下图展示了添加一个新分配节点(2)的情况,该节点可以复用具有依赖关系的节点(1)所释放的地址。

添加新的分配节点 2

图 31 添加新分配节点 2

下图展示了添加一个新分配节点(4)的情况。新分配节点不依赖于释放节点(2),因此无法复用关联分配节点(2)的地址。如果分配节点(2)使用了释放节点(1)释放的地址,那么新分配节点 3 将需要一个新地址。

图内文字中文对照
Alloc
分配
Free
释放
New Alloc
新分配
添加新的分配节点 3

图 32 添加新分配节点 3

图内文字中文对照
Alloc
分配
Free
释放
New Alloc
新分配

4.2.5.3.2. 物理内存管理与共享

CUDA 负责在 GPU 执行顺序到达分配节点之前,将物理内存映射到虚拟地址。为了优化内存占用和映射开销,如果多个图不会同时运行,它们可以将同一物理内存用于不同的内存分配;但是,如果物理页同时绑定到多个正在执行的图,或绑定到尚未释放的图内存分配,则不能复用这些物理页。

CUDA 可以在图实例化、启动或执行期间的任何时刻更新物理内存映射。CUDA 还可以在后续图启动之间引入同步,以防止仍然存续的图内存分配引用同一物理内存。与任何分配、释放、再分配模式一样,如果程序在某次内存分配的生命周期之外访问指针,那么该错误访问可能会在不报错的情况下读取或写入另一内存分配所拥有的有效数据(即使该内存分配的虚拟地址是唯一的)。使用 compute sanitizer 工具可以捕获此错误。

下图展示了在同一流中依次启动的图。在此示例中,每个图都会释放其分配的全部内存。由于同一流中的图从不并发运行,因此 CUDA 可以而且应该使用同一物理内存来满足所有内存分配。

顺序启动的图

图 33 依次启动的图

图内文字中文对照
Graph1
图 1
Graph2
图 2
Graph3
图 3
Physical memory
物理内存

4.2.5.4. 性能注意事项

当多个图被启动到同一流中时,CUDA 会尝试为它们分配相同的物理内存,因为这些图的执行不可能重叠。作为一种优化,图的物理映射会在各次启动之间保留,以避免重新映射的开销。如果稍后某个图以其执行可能与其他图重叠的方式启动(例如,被启动到另一个流中),那么 CUDA 就必须执行一些重新映射,因为并发图需要不同的内存以避免数据损坏。

通常,CUDA 中的图内存重新映射可能由以下操作引起:

  • 更改图启动所使用的流

  • 对图内存池执行裁剪操作,该操作会显式释放未使用的内存(在物理内存占用中讨论)

  • 当另一个图中尚未释放的内存分配映射到同一内存时,重新启动一个图会导致在重新启动之前对内存进行重新映射

重新映射必须按执行顺序发生,但必须在该图此前的任何执行完成之后进行(否则,仍在使用的内存可能会被取消映射)。由于存在这种顺序依赖关系,而且映射操作是操作系统调用,因此映射操作的开销可能相对较高。应用程序可以通过始终将包含内存分配节点的图启动到同一流中来避免此开销。

4.2.5.4.1. 首次启动 / cudaGraphUpload

图实例化期间无法分配或映射物理内存,因为尚不知道图将在哪个流中执行。映射改为在图启动期间完成。调用cudaGraphUpload会立即执行该图的所有映射,并将该图与上传流关联,从而将分配开销从启动中分离出来。如果随后将图启动到同一流中,则启动时不会进行任何额外的重新映射。

为图上传和图启动使用不同流的行为与切换流类似,很可能导致重新映射操作。此外,允许不相关的内存池管理从空闲流中取走内存,这可能使上传的效果失效。

4.2.5.5. 物理内存占用

异步分配的内存池管理行为意味着,销毁包含内存节点的图(即使这些节点的内存分配已释放)并不会立即将物理内存归还给操作系统,供其他进程使用。要显式将内存释放回操作系统,应用程序应使用cudaDeviceGraphMemTrimAPI。

cudaDeviceGraphMemTrim将取消映射并释放图内存节点预留的、当前未被使用的所有物理内存。尚未释放的内存分配以及已调度或正在运行的图被视为正在使用物理内存,因此不会受到影响。使用裁剪 API 将使物理内存可供其他分配 API 以及其他应用程序或进程使用,但会导致 CUDA 在下次启动经过裁剪的图时重新分配并重新映射内存。请注意,cudaDeviceGraphMemTrim与cudaMemPoolTrimTo()操作的是不同的内存池。图内存池不向 steam 有序内存分配器开放。CUDA 允许应用程序通过cudaDeviceGetGraphMemAttributeAPI 查询其图内存占用。查询属性cudaGraphMemAttrReservedMemCurrent会返回驱动程序为当前进程中的图内存分配预留的物理内存量。查询cudaGraphMemAttrUsedMemCurrent会返回当前至少被一个图映射的物理内存量。这两个属性中的任意一个都可用于跟踪 CUDA 何时为执行分配的图获取了新的物理内存。这两个属性都有助于检查共享机制节省了多少内存。

4.2.5.6. 对等访问

图内存分配可以配置为允许多个 GPU 访问,在这种情况下,CUDA 将按需把这些内存分配映射到对等 GPU。CUDA 允许需要不同映射的图内存分配复用同一虚拟地址。发生这种情况时,该地址范围会映射到这些不同内存分配所要求的所有 GPU。这意味着,某次内存分配有时允许的对等访问范围可能比创建时请求的更大;但是,依赖这些额外映射仍然是错误的。

4.2.5.6.1. 通过图节点 API 进行对等访问

cudaGraphAddNodeAPI 通过分配节点参数结构体的accessDescs数组字段接收映射请求。嵌入的poolProps.location结构体指定该内存分配的驻留设备。由于假定需要允许执行分配的 GPU 进行访问,因此应用程序不必在accessDescs数组中为驻留设备指定条目。

cudaGraphNodeParams allocNodeParams = { cudaGraphNodeTypeMemAlloc };
allocNodeParams.alloc.poolProps.allocType = cudaMemAllocationTypePinned;
allocNodeParams.alloc.poolProps.location.type = cudaMemLocationTypeDevice;
// specify device 1 as the resident device
allocNodeParams.alloc.poolProps.location.id = 1;
allocNodeParams.alloc.bytesize = size;

// allocate an allocation resident on device 1 accessible from device 1
cudaGraphAddNode(&allocNode, graph, NULL, NULL, 0, &allocNodeParams);

cudaMemAccessDesc accessDescs[2];
// boilerplate for the access descs (only ReadWrite and Device access supported by the add node api)
accessDescs[0].flags = cudaMemAccessFlagsProtReadWrite;
accessDescs[0].location.type = cudaMemLocationTypeDevice;
accessDescs[1].flags = cudaMemAccessFlagsProtReadWrite;
accessDescs[1].location.type = cudaMemLocationTypeDevice;

// access being requested for device 0 & 2.  Device 1 access requirement left implicit.
accessDescs[0].location.id = 0;
accessDescs[1].location.id = 2;

// access request array has 2 entries.
allocNodeParams.accessDescCount = 2;
allocNodeParams.accessDescs = accessDescs;

// allocate an allocation resident on device 1 accessible from devices 0, 1 and 2. (0 & 2 from the descriptors, 1 from it being the resident device).
cudaGraphAddNode(&allocNode, graph, NULL, NULL, 0, &allocNodeParams);

4.2.5.6.2. 通过流捕获进行对等访问

对于流捕获,分配节点会记录执行分配的内存池在捕获时的对等可访问性。在捕获cudaMallocFromPoolAsync调用之后更改执行分配的内存池的对等可访问性,不会影响图将为该内存分配建立的映射。

// boilerplate for the access descs (only ReadWrite and Device access supported by the add node api)
accessDesc.flags = cudaMemAccessFlagsProtReadWrite;
accessDesc.location.type = cudaMemLocationTypeDevice;
accessDesc.location.id = 1;

// let memPool be resident and accessible on device 0

cudaStreamBeginCapture(stream);
cudaMallocFromPoolAsync(&dptr1, size, memPool, stream);
cudaStreamEndCapture(stream, &graph1);

cudaMemPoolSetAccess(memPool, &accessDesc, 1);

cudaStreamBeginCapture(stream);
cudaMallocFromPoolAsync(&dptr2, size, memPool, stream);
cudaStreamEndCapture(stream, &graph2);

//The graph node allocating dptr1 would only have the device 0 accessibility even though memPool now has device 1 accessibility.
//The graph node allocating dptr2 will have device 0 and device 1 accessibility, since that was the pool accessibility at the time of the cudaMallocFromPoolAsync call.

4.2.6. 设备图启动

许多工作流需要在运行时做出依赖于数据的决策,并根据这些决策执行不同的操作。用户可能更愿意在设备上执行这一决策过程,而不是将其交给主机,因为后者可能需要从设备到主机再返回设备的往返通信。为此,CUDA 提供了从设备启动图的机制。

设备图启动提供了一种在设备上执行动态控制流的便捷方式,无论是像循环这样简单的控制流,还是像设备端工作调度器这样复杂的控制流。

下文将能够从设备启动的图称为设备图,将不能从设备启动的图称为主机图。

设备图既可以从主机启动,也可以从设备启动,而主机图只能从主机启动。与主机启动不同,在图的前一次启动仍在运行时从设备启动该设备图,将导致错误并返回cudaErrorInvalidValue;因此,不能同时从设备启动同一个设备图两次。同时从主机和设备启动一个设备图将导致未定义行为。

4.2.6.1. 设备图创建

为了能够从设备启动图,必须显式地将其针对设备启动进行实例化。这通过向cudaGraphInstantiate()调用传递cudaGraphInstantiateFlagDeviceLaunch标志来实现。与主机图一样,设备图的结构在实例化时就已固定,不重新实例化就无法更新,而且实例化只能在主机上进行。为了能够针对设备启动进行实例化,图必须满足各种要求。

4.2.6.1.1. 设备图要求

一般要求:

  • 图的所有节点都必须驻留在同一个设备上。

  • 图只能包含核函数节点、memcpy 节点、memset 节点和子图节点。

核函数节点:

  • 不允许图中的核函数使用 CUDA 动态并行。

  • 只要未使用 MPS,就允许协作启动。

Memcpy 节点:

  • 仅允许涉及设备内存和/或映射到设备的页锁定主机内存的复制。

  • 不允许涉及 CUDA 数组的复制。

  • 在实例化时,当前设备必须能够访问这两个操作数。请注意,复制操作将由图所驻留的设备执行,即使其目标是另一个设备上的内存。

4.2.6.1.2. 设备图上传

为了在设备上启动图,必须先将其上传到设备,以填充必要的设备资源。这可以通过以下两种方式之一实现。

首先,可以显式上传图,方式是使用cudaGraphUpload(),或者通过cudaGraphInstantiateWithParams()请求在实例化过程中进行上传。

另一种方式是先从主机启动图,这会在启动过程中隐式执行此上传步骤。

以下给出了全部三种方法的示例:

// Explicit upload after instantiation
cudaGraphInstantiate(&deviceGraphExec1, deviceGraph1, cudaGraphInstantiateFlagDeviceLaunch);
cudaGraphUpload(deviceGraphExec1, stream);

// Explicit upload as part of instantiation
cudaGraphInstantiateParams instantiateParams = {0};
instantiateParams.flags = cudaGraphInstantiateFlagDeviceLaunch | cudaGraphInstantiateFlagUpload;
instantiateParams.uploadStream = stream;
cudaGraphInstantiateWithParams(&deviceGraphExec2, deviceGraph2, &instantiateParams);

// Implicit upload via host launch
cudaGraphInstantiate(&deviceGraphExec3, deviceGraph3, cudaGraphInstantiateFlagDeviceLaunch);
cudaGraphLaunch(deviceGraphExec3, stream);

4.2.6.1.3. 设备图更新

设备图只能从主机更新,而且在更新可执行图后,必须将其重新上传到设备,才能使更改生效。这可以使用设备图上传一节中概述的相同方法来实现。与主机图不同,在应用更新期间从设备启动设备图将导致未定义行为。

4.2.6.2. 设备启动

设备图既可以从主机通过cudaGraphLaunch()启动,也可以从设备通过它启动,该函数在设备上的签名与主机上的相同。设备图在主机和设备上通过同一个句柄启动。从设备启动设备图时,必须从另一个图中启动。

设备端图启动是逐线程进行的,不同线程可能同时发起多次启动,因此用户需要选择单个线程来启动给定的图。

与主机启动不同,设备图不能启动到常规 CUDA 流中,而只能启动到不同的命名流中,每种命名流表示一种特定的启动模式。下表列出了可用的启动模式。

表 9 仅限设备端的图启动流

流

启动模式

cudaStreamGraphFireAndForget

即发即弃启动

cudaStreamGraphTailLaunch

尾部启动

cudaStreamGraphFireAndForgetAsSibling

同级启动

4.2.6.2.1. 即发即弃启动

顾名思义,即发即弃启动会立即提交给 GPU,并且独立于发起启动的图运行。在即发即弃场景中,发起启动的图是父图,被启动的图是子图。

../_images/fire-and-forget-simple.png

图 34 即发即弃启动

上图可以由以下示例代码生成:

__global__ void launchFireAndForgetGraph(cudaGraphExec_t graph) {
    cudaGraphLaunch(graph, cudaStreamGraphFireAndForget);
}

void graphSetup() {
    cudaGraphExec_t gExec1, gExec2;
    cudaGraph_t g1, g2;

    // Create, instantiate, and upload the device graph.
    create_graph(&g2);
    cudaGraphInstantiate(&gExec2, g2, cudaGraphInstantiateFlagDeviceLaunch);
    cudaGraphUpload(gExec2, stream);

    // Create and instantiate the launching graph.
    cudaStreamBeginCapture(stream, cudaStreamCaptureModeGlobal);
    launchFireAndForgetGraph<<<1, 1, 0, stream>>>(gExec2);
    cudaStreamEndCapture(stream, &g1);
    cudaGraphInstantiate(&gExec1, g1, 0);

    // Launch the host graph, which will in turn launch the device graph.
    cudaGraphLaunch(gExec1, stream);
}

一个图在其执行过程中总共最多可以有 120 个即发即弃图。每次启动同一个父图时,此总数都会重置。

4.2.6.2.1.1. 图执行环境

要全面理解设备端同步模型,首先必须理解执行环境的概念。

从设备启动图时,图会启动到其自身的执行环境中。给定图的执行环境封装了该图中的所有工作,以及所生成的所有即发即弃工作。当该图已完成执行,并且所生成的所有子工作都已完成时,便可以认为该图已完成。

下图展示了上一节中的即发即弃示例代码将生成的环境封装。

../_images/fire-and-forget-environments.png

图 35 带有执行环境的即发即弃启动

图内文字中文对照
G Execution Environment
G 执行环境
X Execution Environment
X 执行环境

这些环境还具有层次结构,因此一个图环境可以包含由即发即弃启动产生的多层子环境。

../_images/fire-and-forget-nested-environments.png

图 36 嵌套的即发即弃环境

图内文字中文对照
G Execution Environment
G 执行环境
X Execution Environment
X 执行环境
Y Execution Environment
Y 执行环境

从主机启动图时,存在一个流环境,作为被启动图的执行环境的父环境。流环境封装了整个启动过程中生成的所有工作。当整个流环境被标记为已完成时,流启动即告完成(即下游依赖工作此时可以运行)。

../_images/device-graph-stream-environment.png

图 37 流环境的可视化表示

图内文字中文对照
Previous Stream Work
先前的流工作
Stream Environment
流环境
G Execution Environment
G 执行环境
Graph G
图 G
Future Stream Work
后续的流工作

4.2.6.2.2. 尾部启动

与在主机上不同,无法在 GPU 上通过cudaDeviceSynchronize()或cudaStreamSynchronize()等传统方法与设备图同步。相反,为了支持串行工作依赖关系,提供了另一种启动模式,即尾部启动,以提供类似的功能。

尾部启动在图的环境被视为完成时执行,即在图及其所有子图都完成时执行。当一个图完成时,尾部启动列表中下一个图的环境将替代已完成的环境,成为父环境的子环境。与即发即弃启动一样,一个图可以将多个图加入队列以进行尾部启动。

../_images/tail-launch-simple.png

图 38 简单的尾部启动

图内文字中文对照
Stream Environment
流环境
G₁ Execution Environment
G₁ 执行环境
G₂ Execution Environment
G₂ 执行环境

上述执行流程可由以下代码生成:

__global__ void launchTailGraph(cudaGraphExec_t graph) {
    cudaGraphLaunch(graph, cudaStreamGraphTailLaunch);
}

void graphSetup() {
    cudaGraphExec_t gExec1, gExec2;
    cudaGraph_t g1, g2;

    // Create, instantiate, and upload the device graph.
    create_graph(&g2);
    cudaGraphInstantiate(&gExec2, g2, cudaGraphInstantiateFlagDeviceLaunch);
    cudaGraphUpload(gExec2, stream);

    // Create and instantiate the launching graph.
    cudaStreamBeginCapture(stream, cudaStreamCaptureModeGlobal);
    launchTailGraph<<<1, 1, 0, stream>>>(gExec2);
    cudaStreamEndCapture(stream, &g1);
    cudaGraphInstantiate(&gExec1, g1, 0);

    // Launch the host graph, which will in turn launch the device graph.
    cudaGraphLaunch(gExec1, stream);
}

由某个图加入队列的尾部启动将按加入队列的顺序逐个执行。因此,第一个加入队列的图将首先运行,然后是第二个,依此类推。

../_images/tail-launch-ordering-simple.png

图 39 尾部启动的顺序

图内文字中文对照
G₁ Execution Environment
G₁ 执行环境
G₂ Execution Environment
G₂ 执行环境
G₃ Execution Environment
G₃ 执行环境
G₄ Execution Environment
G₄ 执行环境

由尾部图加入队列的尾部启动,将先于由尾部启动列表中此前的图加入队列的尾部启动执行。这些新的尾部启动将按加入队列的顺序执行。

../_images/tail-launch-ordering-complex.png

图 40 从多个图加入队列时的尾部启动顺序

图内文字中文对照
G₁ Execution Environment
G₁ 执行环境
G₂ Execution Environment
G₂ 执行环境
X Execution Environment
X 执行环境
Y Execution Environment
Y 执行环境
G₃ Execution Environment
G₃ 执行环境

一个图最多可以有 255 个待执行的尾部启动。

4.2.6.2.2.1. 尾部自启动

设备图可以将自身加入队列以进行尾部启动,不过,一个给定的图在同一时间只能有一个自启动处于队列中。为了查询当前正在运行的设备图,以便重新启动它,新增了一个设备端函数:

cudaGraphExec_t cudaGetCurrentGraphExec();

如果当前正在运行的图是设备图,此函数将返回该图的句柄。如果当前正在执行的核函数不是设备图中的节点,此函数将返回 NULL。

以下示例代码展示了如何使用此函数实现重新启动循环:

__device__ int relaunchCount = 0;

__global__ void relaunchSelf() {
    int relaunchMax = 100;

    if (threadIdx.x == 0) {
        if (relaunchCount < relaunchMax) {
            cudaGraphLaunch(cudaGetCurrentGraphExec(), cudaStreamGraphTailLaunch);
        }

        relaunchCount++;
    }
}

4.2.6.2.3. 同级启动

同级启动是即发即弃启动的一种变体,其中,图启动后并不是发起启动的图的执行环境的子环境,而是发起启动的图的父环境的子环境。同级启动等同于从发起启动的图的父环境进行即发即弃启动。

../_images/sibling-launch-simple.png

图 41 简单的同级启动

图内文字中文对照
Stream Environment
流环境
G Execution Environment
G 执行环境
X Execution Environment
X 执行环境

上图可由以下示例代码生成:

__global__ void launchSiblingGraph(cudaGraphExec_t graph) {
    cudaGraphLaunch(graph, cudaStreamGraphFireAndForgetAsSibling);
}

void graphSetup() {
    cudaGraphExec_t gExec1, gExec2;
    cudaGraph_t g1, g2;

    // Create, instantiate, and upload the device graph.
    create_graph(&g2);
    cudaGraphInstantiate(&gExec2, g2, cudaGraphInstantiateFlagDeviceLaunch);
    cudaGraphUpload(gExec2, stream);

    // Create and instantiate the launching graph.
    cudaStreamBeginCapture(stream, cudaStreamCaptureModeGlobal);
    launchSiblingGraph<<<1, 1, 0, stream>>>(gExec2);
    cudaStreamEndCapture(stream, &g1);
    cudaGraphInstantiate(&gExec1, g1, 0);

    // Launch the host graph, which will in turn launch the device graph.
    cudaGraphLaunch(gExec1, stream);
}

由于同级启动并非启动到发起启动的图的执行环境中,因此它们不会阻塞由发起启动的图加入队列的尾部启动。

4.2.7. 使用图 API

cudaGraph_t对象不是线程安全的。用户有责任确保多个线程不会并发访问同一个cudaGraph_t。

cudaGraphExec_t不能与自身并发运行。cudaGraphExec_t的一次启动将排在同一个可执行图此前的启动之后。

图在流中执行,以便与其他异步工作确定先后顺序。但是,流仅用于确定顺序;它不会限制图的内部并行性,也不会影响图节点的执行位置。

请参阅图 API。

4.2.8. CUDA 用户对象

CUDA 用户对象可用于帮助管理 CUDA 中异步工作所使用的资源的生命周期。特别是,此功能对CUDA 图和流捕获很有用。

各种资源管理方案与 CUDA 图并不兼容。例如,考虑基于事件的池,或同步创建、异步销毁的方案。

// Library API with pool allocation
void libraryWork(cudaStream_t stream) {
    auto &resource = pool.claimTemporaryResource();
    resource.waitOnReadyEventInStream(stream);
    launchWork(stream, resource);
    resource.recordReadyEvent(stream);
}
// Library API with asynchronous resource deletion
void libraryWork(cudaStream_t stream) {
    Resource *resource = new Resource(...);
    launchWork(stream, resource);
    cudaLaunchHostFunc(
        stream,
        [](void *resource) {
            delete static_cast<Resource *>(resource);
        },
        resource,
        0);
    // Error handling considerations not shown
}

这些方案很难与 CUDA 图配合使用,因为资源的指针或句柄并不固定,需要间接寻址或图更新,而且每次提交工作时都需要同步的 CPU 代码。如果这些考量对库的调用方是隐藏的,这些方案也无法与流捕获配合使用;在捕获期间使用了不允许的 API 也使它们无法与流捕获配合使用。存在多种解决方案,例如向调用方公开资源。CUDA 用户对象提供了另一种方法。

CUDA 用户对象将用户指定的析构回调与内部引用计数关联起来,类似于 C++ 的shared_ptr。引用可以由 CPU 上的用户代码持有,也可以由 CUDA 图持有。请注意,对于用户持有的引用,与 C++ 智能指针不同,没有表示该引用的对象;用户必须手动跟踪用户持有的引用。典型用例是在创建用户对象后,立即将用户持有的唯一引用移交给 CUDA 图。

当引用与 CUDA 图关联时,CUDA 将自动管理图操作。克隆得到的cudaGraph_t会保留源cudaGraph_t持有的每个引用的副本,且重数相同。实例化得到的cudaGraphExec_t会保留源cudaGraph_t中的每个引用的副本。当cudaGraphExec_t在未同步的情况下被销毁时,这些引用会一直保留到执行完成。

以下是一个使用示例。

cudaGraph_t graph;  // Preexisting graph

Object *object = new Object;  // C++ object with possibly nontrivial destructor
cudaUserObject_t cuObject;
cudaUserObjectCreate(
    &cuObject,
    object,  // Here we use a CUDA-provided template wrapper for this API,
             // which supplies a callback to delete the C++ object pointer
    1,  // Initial refcount
    cudaUserObjectNoDestructorSync  // Acknowledge that the callback cannot be
                                    // waited on via CUDA
);
cudaGraphRetainUserObject(
    graph,
    cuObject,
    1,  // Number of references
    cudaGraphUserObjectMove  // Transfer a reference owned by the caller (do
                             // not modify the total reference count)
);
// No more references owned by this thread; no need to call release API
cudaGraphExec_t graphExec;
cudaGraphInstantiate(&graphExec, graph, 0);  // Will retain a
                                                               // new reference
cudaGraphDestroy(graph);  // graphExec still owns a reference
cudaGraphLaunch(graphExec, 0);  // Async launch has access to the user objects
cudaGraphExecDestroy(graphExec);  // Launch is not synchronized; the release
                                  // will be deferred if needed
cudaStreamSynchronize(0);  // After the launch is synchronized, the remaining
                           // reference is released and the destructor will
                           // execute. Note this happens asynchronously.
// If the destructor callback had signaled a synchronization object, it would
// be safe to wait on it at this point.

子图节点中的图所持有的引用与子图关联,而不是与父图关联。如果子图被更新或删除,引用也会相应改变。如果使用cudaGraphExecUpdate或cudaGraphExecChildGraphNodeSetParams更新可执行图或子图,则会克隆新源图中的引用,并用它们替换目标图中的引用。在这两种情况下,如果此前的启动尚未同步,任何本应释放的引用都将被保留,直到这些启动执行完毕。

目前没有通过 CUDA API 等待用户对象析构函数的机制。用户可以从析构函数代码中手动向同步对象发送信号。此外,不允许从析构函数中调用 CUDA API,这与对cudaLaunchHostFunc的限制类似。这是为了避免阻塞 CUDA 内部的共享线程并阻碍执行继续推进。如果依赖关系是单向的,而且执行调用的线程不会阻碍 CUDA 工作继续推进,则允许向另一个线程发送信号,让其执行 API 调用。

用户对象通过cudaUserObjectCreate创建,这是浏览相关 API 的一个良好起点。

4.3. 流有序内存分配器

4.3.1. 简介

使用cudaMalloc和cudaFree管理内存分配会导致 GPU 在所有正在执行的 CUDA 流之间进行同步。流有序内存分配器使应用程序能够确定内存分配和释放与启动到 CUDA 流中的其他工作之间的先后顺序,这些工作包括核函数启动和异步复制。它利用流有序语义复用已分配的内存,从而改善应用程序的内存使用。分配器还允许应用程序控制分配器的内存缓存行为。设置适当的释放阈值后,当应用程序表示愿意接受更大的内存占用量时,缓存行为使分配器能够避免开销高昂的操作系统调用。该分配器还支持在进程之间轻松、安全地共享已分配的内存。

流有序内存分配器:

  • 减少了对自定义内存管理抽象的需求,并使有需要的应用程序能够更轻松地创建高性能的自定义内存管理。

  • 使多个库能够共享由驱动程序管理的公共内存池。这可以减少多余的内存消耗。

  • 允许驱动程序基于其对分配器和其他流管理 API 的了解来执行优化。

注意

自 CUDA 11.3 起,Nsight Compute 和 Next-Gen CUDA 调试器能够感知该分配器。

4.3.2. 内存管理

cudaMallocAsync和cudaFreeAsync是实现流有序内存管理的 API。cudaMallocAsync返回一块分配的内存,而cudaFreeAsync释放一块分配的内存。这两个 API 都接受流参数,以定义所分配的内存何时开始可用以及何时停止可用。这些函数允许将内存操作绑定到特定的 CUDA 流,使这些操作能够在不阻塞主机或其他流的情况下进行。通过避免cudaMalloc和cudaFree可能开销高昂的同步,可以提高应用程序性能。

这些 API 可通过内存池进一步优化性能,内存池管理和复用大块内存,以实现更高效的分配和释放。内存池有助于减少开销并防止碎片化,从而提高频繁进行内存分配操作的场景中的性能。

4.3.2.1. 分配内存

cudaMallocAsync函数在 GPU 上触发异步内存分配,并将其关联到特定的 CUDA 流。cudaMallocAsync允许在不妨碍主机或其他流的情况下进行内存分配,从而消除开销高昂的同步的必要性。

注意

cudaMallocAsync在确定所分配的内存将位于何处时,会忽略当前设备/上下文。相反,cudaMallocAsync会根据指定的内存池或提供的流来确定适当的设备。

以下代码清单展示了一种基本使用模式:内存被分配、使用,然后释放回同一流。

void *ptr;
size_t size = 512;
cudaMallocAsync(&ptr, size, cudaStreamPerThread);
// do work using the allocation
kernel<<<..., cudaStreamPerThread>>>(ptr, ...);
// An asynchronous free can be specified without synchronizing the cpu and GPU
cudaFreeAsync(ptr, cudaStreamPerThread);

注意

从执行分配的流以外的流访问所分配的内存时,用户必须保证访问发生在分配操作之后,否则行为未定义。

4.3.2.2. 释放内存

cudaFreeAsync()以流有序的方式异步释放设备内存,这意味着内存释放被分配给特定的 CUDA 流,并且不会阻塞主机或其他流。

用户必须保证释放操作发生在分配操作以及对所分配内存的任何使用之后。在释放操作开始后,对所分配内存的任何使用都会导致未定义的行为。

应使用事件和/或流同步操作,保证其他流对所分配内存的任何访问都在释放操作开始之前完成,如下例所示。

cudaMallocAsync(&ptr, size, stream1);
cudaEventRecord(event1, stream1);
//stream2 must wait for the allocation to be ready before accessing
cudaStreamWaitEvent(stream2, event1);
kernel<<<..., stream2>>>(ptr, ...);
cudaEventRecord(event2, stream2);
// stream3 must wait for stream2 to finish accessing the allocation before
// freeing the allocation
cudaStreamWaitEvent(stream3, event2);
cudaFreeAsync(ptr, stream3);

使用cudaMalloc()分配的内存可以使用cudaFreeAsync()释放。如上所述,对该内存的所有访问都必须在释放操作开始之前完成。

cudaMalloc(&ptr, size);
kernel<<<..., stream>>>(ptr, ...);
cudaFreeAsync(ptr, stream);

同样,使用cudaMallocAsync分配的内存可以使用cudaFree()释放。通过cudaFree()API 释放此类分配的内存时,驱动程序假定对所分配内存的所有访问都已完成,并且不再执行进一步的同步。用户可以使用cudaStreamQuery/cudaStreamSynchronize/cudaEventQuery/cudaEventSynchronize/cudaDeviceSynchronize来保证相应的异步工作已完成,且 GPU 不会尝试访问所分配的内存。

cudaMallocAsync(&ptr, size,stream);
kernel<<<..., stream>>>(ptr, ...);
// synchronize is needed to avoid prematurely freeing the memory
cudaStreamSynchronize(stream);
cudaFree(ptr);

4.3.3. 内存池

内存池封装了虚拟地址和物理内存资源,这些资源根据池的属性和特性进行分配和管理。内存池的主要方面是它所管理的内存的类型和位置。

对cudaMallocAsync的所有调用都使用内存池中的资源。如果未指定内存池,cudaMallocAsync将使用所提供流的设备的当前内存池。可以使用cudaDeviceSetMempool设置设备的当前内存池,并使用cudaDeviceGetMempool进行查询。每个设备都有一个默认内存池,如果尚未调用cudaDeviceSetMempool,则该默认内存池处于活动状态。

APIcudaMallocFromPoolAsync和cudaMallocAsync 的 c++ 重载允许用户指定一次分配所使用的池,而无需将其设为当前池。APIcudaDeviceGetDefaultMempool和cudaMemPoolCreate返回内存池的句柄。cudaMemPoolSetAttribute和cudaMemPoolGetAttribute控制内存池的属性。

注意

设备的当前内存池将位于该设备本地。因此,不指定内存池进行分配时,始终会得到位于流所属设备本地的内存分配。

4.3.3.1. 默认/隐式池

可以通过调用cudaDeviceGetDefaultMempool获取设备的默认内存池。从设备的默认内存池分配的内存是位于该设备上的不可迁移的设备内存分配。该设备始终可以访问这些内存分配。可以使用cudaMemPoolSetAccess修改默认内存池的可访问性,并使用cudaMemPoolGetAccess进行查询。由于默认池不需要显式创建,因此它们有时也称为隐式池。设备的默认内存池不支持 IPC。

4.3.3.2. 显式池

cudaMemPoolCreate创建一个显式池。这允许应用程序为其内存分配请求默认/隐式池所提供的特性之外的特性。这些特性包括 IPC 能力、最大池大小、在支持的平台上使所分配的内存驻留在特定 CPU NUMA 节点上等。

// create a pool similar to the implicit pool on device 0
int device = 0;
cudaMemPoolProps poolProps = { };
poolProps.allocType = cudaMemAllocationTypePinned;
poolProps.location.id = device;
poolProps.location.type = cudaMemLocationTypeDevice;

cudaMemPoolCreate(&memPool, &poolProps));

以下代码片段展示了在有效的 CPU NUMA 节点上创建支持 IPC 的内存池的示例。

// create a pool resident on a CPU NUMA node that is capable of IPC sharing (via a file descriptor).
int cpu_numa_id = 0;
cudaMemPoolProps poolProps = { };
poolProps.allocType = cudaMemAllocationTypePinned;
poolProps.location.id = cpu_numa_id;
poolProps.location.type = cudaMemLocationTypeHostNuma;
poolProps.handleType = cudaMemHandleTypePosixFileDescriptor;

cudaMemPoolCreate(&ipcMemPool, &poolProps));

4.3.3.3. 用于多 GPU 支持的设备可访问性

与通过虚拟内存管理 API 控制的内存分配可访问性一样,内存池分配的内存的可访问性不遵循cudaDeviceEnablePeerAccess或cuCtxEnablePeerAccess。对于内存池,APIcudaMemPoolSetAccess用于修改哪些设备可以访问从池中分配的内存。默认情况下,只有所分配内存所在的设备可以访问这些内存。此访问权限无法撤销。要启用其他设备的访问,进行访问的设备必须与内存池所在的设备具有对等访问能力。这可以通过cudaDeviceCanAccessPeer进行验证。如果未检查对等访问能力,设置访问权限的操作可能会失败并返回cudaErrorInvalidDevice。但是,如果尚未从该池进行过任何分配,则即使设备不具备对等访问能力,cudaMemPoolSetAccess调用也可能成功。在这种情况下,下一次从该池进行的分配将失败。

值得注意的是,cudaMemPoolSetAccess会影响从内存池进行的所有分配,而不仅仅是未来的分配。同样,cudaMemPoolGetAccess报告的可访问性适用于从池进行的所有分配,而不仅仅是未来的分配。不建议频繁更改池针对给定 GPU 的可访问性设置。也就是说,一旦某个池可由给定的 GPU 访问,在该池的整个生命周期内,该 GPU 都应能够访问它。

// snippet showing usage of cudaMemPoolSetAccess:
cudaError_t setAccessOnDevice(cudaMemPool_t memPool, int residentDevice,
              int accessingDevice) {
    cudaMemAccessDesc accessDesc = {};
    accessDesc.location.type = cudaMemLocationTypeDevice;
    accessDesc.location.id = accessingDevice;
    accessDesc.flags = cudaMemAccessFlagsProtReadWrite;

    int canAccess = 0;
    cudaError_t error = cudaDeviceCanAccessPeer(&canAccess, accessingDevice,
              residentDevice);
    if (error != cudaSuccess) {
        return error;
    } else if (canAccess == 0) {
        return cudaErrorPeerAccessUnsupported;
    }

    // Make the address accessible
    return cudaMemPoolSetAccess(memPool, &accessDesc, 1);
}

4.3.3.4. 为 IPC 启用内存池

可以为内存池启用进程间通信(IPC),以便在进程之间轻松、高效且安全地共享 GPU 内存。CUDA 的 IPC 内存池提供了与 CUDA 的虚拟内存管理 API相同的安全优势。

使用内存池在进程之间共享内存分为两个步骤:进程首先需要共享对内存池的访问权限,然后共享该池中的特定内存分配。第一步建立并强制实施安全机制。第二步协调每个进程使用哪些虚拟地址,以及导入进程中的映射何时需要有效。

4.3.3.4.1. 创建和共享 IPC 内存池

共享对内存池的访问权限涉及以下操作:使用cudaMemPoolExportToShareableHandle()获取内存池的操作系统原生句柄,使用操作系统原生 IPC 机制将句柄传递给导入进程,然后使用cudaMemPoolImportFromShareableHandle()API 创建导入内存池。要使cudaMemPoolExportToShareableHandle成功,必须在创建内存池时,在池属性结构体中指定所请求的句柄类型。

有关在进程之间传递操作系统原生句柄的适当 IPC 机制,请参阅示例。其余过程可在以下代码片段中找到。

// in exporting process
// create an exportable IPC capable pool on device 0
cudaMemPoolProps poolProps = { };
poolProps.allocType = cudaMemAllocationTypePinned;
poolProps.location.id = 0;
poolProps.location.type = cudaMemLocationTypeDevice;

// Setting handleTypes to a non zero value will make the pool exportable (IPC capable)
poolProps.handleTypes = CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR;

cudaMemPoolCreate(&memPool, &poolProps));

// FD based handles are integer types
int fdHandle = 0;


// Retrieve an OS native handle to the pool.
// Note that a pointer to the handle memory is passed in here.
cudaMemPoolExportToShareableHandle(&fdHandle,
             memPool,
             CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR,
             0);

// The handle must be sent to the importing process with the appropriate
// OS-specific APIs.
// in importing process
 int fdHandle;
// The handle needs to be retrieved from the exporting process with the
// appropriate OS-specific APIs.
// Create an imported pool from the shareable handle.
// Note that the handle is passed by value here.
cudaMemPoolImportFromShareableHandle(&importedMemPool,
          (void*)fdHandle,
          CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR,
          0);

4.3.3.4.2. 在导入进程中设置访问权限

导入内存池最初只能从其驻留设备访问。导入内存池不会继承导出进程设置的任何访问权限。导入进程需要使用cudaMemPoolSetAccess,为其计划用来访问该内存的每个 GPU 启用访问权限。

如果导入内存池所属的设备对导入进程不可见,用户必须使用cudaMemPoolSetAccessAPI,为将使用这些内存分配的 GPU 启用访问权限。(参见支持多 GPU 的设备访问权限)

4.3.3.4.3. 从导出的池中创建和共享内存分配

池共享后,导出进程中使用cudaMallocAsync()从该池分配的内存就可以与已导入该池的进程共享。由于池的安全策略是在池级别建立和验证的,因此操作系统无需额外的簿记操作来为特定的池内存分配提供安全保障。换言之,导入池内存分配所需的不透明cudaMemPoolPtrExportData可以通过任何机制发送给导入进程。

虽然可以在不以任何方式与分配流同步的情况下导出和导入内存分配,但导入进程在访问该内存分配时必须遵循与导出进程相同的规则。具体而言,对内存分配的访问必须发生在分配流中的分配操作执行之后。下面的两个代码片段展示了cudaMemPoolExportPointer()和cudaMemPoolImportPointer()如何共享内存分配,其中使用 IPC 事件来保证导入进程不会在内存分配准备就绪之前访问它。

// preparing an allocation in the exporting process
cudaMemPoolPtrExportData exportData;
cudaEvent_t readyIpcEvent;
cudaIpcEventHandle_t readyIpcEventHandle;

// ipc event for coordinating between processes
// cudaEventInterprocess flag makes the event an ipc event
// cudaEventDisableTiming  is set for performance reasons

cudaEventCreate(&readyIpcEvent, cudaEventDisableTiming | cudaEventInterprocess)

// allocate from the exporting mem pool
cudaMallocAsync(&ptr, size,exportMemPool, stream);

// event for sharing when the allocation is ready.
cudaEventRecord(readyIpcEvent, stream);
cudaMemPoolExportPointer(&exportData, ptr);
cudaIpcGetEventHandle(&readyIpcEventHandle, readyIpcEvent);

// Share IPC event and pointer export data with the importing process using
//  any mechanism. Here we copy the data into shared memory
shmem->ptrData = exportData;
shmem->readyIpcEventHandle = readyIpcEventHandle;
// signal consumers data is ready
// Importing an allocation
cudaMemPoolPtrExportData *importData = &shmem->prtData;
cudaEvent_t readyIpcEvent;
cudaIpcEventHandle_t *readyIpcEventHandle = &shmem->readyIpcEventHandle;

// Need to retrieve the ipc event handle and the export data from the
// exporting process using any mechanism.  Here we are using shmem and just
// need synchronization to make sure the shared memory is filled in.

cudaIpcOpenEventHandle(&readyIpcEvent, readyIpcEventHandle);

// import the allocation. The operation does not block on the allocation being ready.
cudaMemPoolImportPointer(&ptr, importedMemPool, importData);

// Wait for the prior stream operations in the allocating stream to complete before
// using the allocation in the importing process.
cudaStreamWaitEvent(stream, readyIpcEvent);
kernel<<<..., stream>>>(ptr, ...);

释放内存分配时,必须先在导入进程中释放该内存分配,然后才能在导出进程中释放。以下代码片段演示了如何使用 CUDA IPC 事件,在两个进程的cudaFreeAsync操作之间提供所需的同步。显然,从导入进程对内存分配的访问受到导入进程一侧的释放操作的限制。值得注意的是,可以使用cudaFree在两个进程中释放该内存分配,也可以使用其他流同步 API 来代替 CUDA IPC 事件。

// The free must happen in importing process before the exporting process
kernel<<<..., stream>>>(ptr, ...);

// Last access in importing process
cudaFreeAsync(ptr, stream);

// Access not allowed in the importing process after the free
cudaIpcEventRecord(finishedIpcEvent, stream);
// Exporting process
// The exporting process needs to coordinate its free with the stream order
// of the importing process’s free.
cudaStreamWaitEvent(stream, finishedIpcEvent);
kernel<<<..., stream>>>(ptrInExportingProcess, ...);

// The free in the importing process doesn’t stop the exporting process
// from using the allocation.
cudFreeAsync(ptrInExportingProcess,stream);

4.3.3.4.4. IPC 导出池的限制

IPC 池目前不支持将物理块释放回操作系统。因此,cudaMemPoolTrimToAPI 不起作用,cudaMemPoolAttrReleaseThreshold实际上也会被忽略。此行为由驱动程序而非运行时控制,并且可能会在未来的驱动程序更新中改变。

4.3.3.4.5. IPC 导入池的限制

不允许从导入池分配内存;具体而言,不能将导入池设为当前池,也不能在cudaMallocFromPoolAsyncAPI 中使用导入池。因此,分配重用策略属性对这些池没有意义。

IPC 导入池与 IPC 导出池一样,目前不支持将物理块释放回操作系统。

资源使用统计属性查询仅反映导入到该进程中的内存分配及其关联的物理内存。

4.3.4. 最佳实践与调优

4.3.4.1. 查询支持情况

应用程序可以通过使用设备属性cudaDevAttrMemoryPoolsSupported调用cudaDeviceGetAttribute()(参见开发者博客),来确定设备是否支持流有序内存分配器。

可以使用cudaDevAttrMemoryPoolSupportedHandleTypes设备属性查询 IPC 内存池的支持情况。此属性是在 CUDA 11.3 中添加的,旧版驱动程序在查询此属性时将返回cudaErrorInvalidValue。

int driverVersion = 0;
int deviceSupportsMemoryPools = 0;
int poolSupportedHandleTypes = 0;
cudaDriverGetVersion(&driverVersion);
if (driverVersion >= 11020) {
    cudaDeviceGetAttribute(&deviceSupportsMemoryPools,
                           cudaDevAttrMemoryPoolsSupported, device);
}
if (deviceSupportsMemoryPools != 0) {
    // `device` supports the Stream-Ordered Memory Allocator
}

if (driverVersion >= 11030) {
    cudaDeviceGetAttribute(&poolSupportedHandleTypes,
              cudaDevAttrMemoryPoolSupportedHandleTypes, device);
}
if (poolSupportedHandleTypes & cudaMemHandleTypePosixFileDescriptor) {
   // Pools on the specified device can be created with posix file descriptor-based IPC
}

在查询之前执行驱动程序版本检查,可以避免在尚未定义该属性的驱动程序上遇到cudaErrorInvalidValue错误。也可以使用cudaGetLastError来清除此错误,而不是避免它。

4.3.4.2. 物理页缓存行为

默认情况下,分配器会尝试尽可能减少池所拥有的物理内存。为了尽可能减少分配和释放物理内存的操作系统调用,应用程序必须为每个池配置内存占用量。应用程序可以使用释放阈值属性(cudaMemPoolAttrReleaseThreshold)来完成此操作。

释放阈值是池在尝试将内存释放回操作系统之前应保留的内存量,以字节为单位。当内存池持有的内存字节数超过释放阈值时,分配器将在下一次调用流、事件或设备同步时,尝试将内存释放回操作系统。将释放阈值设置为 UINT64_MAX 将阻止驱动程序在每次同步后尝试缩小池。

Cuuint64_t setVal = UINT64_MAX;
cudaMemPoolSetAttribute(memPool, cudaMemPoolAttrReleaseThreshold, &setVal);

将cudaMemPoolAttrReleaseThreshold设置得足够高以有效禁用内存池缩小的应用程序,可能希望显式缩减内存池的内存占用量。cudaMemPoolTrimTo允许应用程序这样做。在削减内存池的占用量时,minBytesToKeep参数允许应用程序保留指定数量的内存,例如它预期在后续执行阶段所需的内存量。

Cuuint64_t setVal = UINT64_MAX;
cudaMemPoolSetAttribute(memPool, cudaMemPoolAttrReleaseThreshold, &setVal);

// application phase needing a lot of memory from the stream-ordered allocator
for (i=0; i<10; i++) {
    for (j=0; j<10; j++) {
        cudaMallocAsync(&ptrs[j],size[j], stream);
    }
    kernel<<<...,stream>>>(ptrs,...);
    for (j=0; j<10; j++) {
        cudaFreeAsync(ptrs[j], stream);
    }
}

// Process does not need as much memory for the next phase.
// Synchronize so that the trim operation will know that the allocations are no
// longer in use.
cudaStreamSynchronize(stream);
cudaMemPoolTrimTo(mempool, 0);

// Some other process/allocation mechanism can now use the physical memory
// released by the trimming operation.

4.3.4.3. 资源使用统计

查询池的cudaMemPoolAttrReservedMemCurrent属性会报告该池当前消耗的物理 GPU 内存总量。查询池的cudaMemPoolAttrUsedMemCurrent会返回从该池分配且不可用于重用的所有内存的总大小。

cudaMemPoolAttr*MemHigh属性是水位标记,记录自上次重置以来相应cudaMemPoolAttr*MemCurrent属性达到的最大值。可以使用cudaMemPoolSetAttributeAPI 将其重置为当前值。

// sample helper functions for getting the usage statistics in bulk
struct usageStatistics {
    cuuint64_t reserved;
    cuuint64_t reservedHigh;
    cuuint64_t used;
    cuuint64_t usedHigh;
};

void getUsageStatistics(cudaMemoryPool_t memPool, struct usageStatistics *statistics)
{
    cudaMemPoolGetAttribute(memPool, cudaMemPoolAttrReservedMemCurrent, statistics->reserved);
    cudaMemPoolGetAttribute(memPool, cudaMemPoolAttrReservedMemHigh, statistics->reservedHigh);
    cudaMemPoolGetAttribute(memPool, cudaMemPoolAttrUsedMemCurrent, statistics->used);
    cudaMemPoolGetAttribute(memPool, cudaMemPoolAttrUsedMemHigh, statistics->usedHigh);
}


// resetting the watermarks will make them take on the current value.
void resetStatistics(cudaMemoryPool_t memPool)
{
    cuuint64_t value = 0;
    cudaMemPoolSetAttribute(memPool, cudaMemPoolAttrReservedMemHigh, &value);
    cudaMemPoolSetAttribute(memPool, cudaMemPoolAttrUsedMemHigh, &value);
}

4.3.4.4. 内存重用策略

为了满足分配请求,驱动程序在尝试从操作系统分配更多内存之前,会先尝试重用此前通过cudaFreeAsync()释放的内存。例如,在某个流中释放的内存可以立即用于该流中的后续分配请求。当流与 CPU 同步时,此前在该流中释放的内存就可供任意流中的分配重用。重用策略既可应用于默认内存池,也可应用于显式内存池。

流有序分配器具有几种可控制的分配策略。池属性cudaMemPoolReuseFollowEventDependencies、cudaMemPoolReuseAllowOpportunistic和cudaMemPoolReuseAllowInternalDependencies控制这些策略,具体说明见下文。可以通过调用cudaMemPoolSetAttribute来启用或禁用这些策略。升级到更新的 CUDA 驱动程序可能会更改、增强、扩充和/或重新排序所枚举的重用策略。

4.3.4.4.1. cudaMemPoolReuseFollowEventDependencies

在分配更多物理 GPU 内存之前,分配器会检查由 CUDA 事件建立的依赖关系信息,并尝试从另一个流中释放的内存进行分配。

cudaMallocAsync(&ptr, size, originalStream);
kernel<<<..., originalStream>>>(ptr, ...);
cudaFreeAsync(ptr, originalStream);
cudaEventRecord(event,originalStream);

// waiting on the event that captures the free in another stream
// allows the allocator to reuse the memory to satisfy
// a new allocation request in the other stream when
// cudaMemPoolReuseFollowEventDependencies is enabled.
cudaStreamWaitEvent(otherStream, event);
cudaMallocAsync(&ptr2, size, otherStream);

4.3.4.4.2. cudaMemPoolReuseAllowOpportunistic

启用cudaMemPoolReuseAllowOpportunistic策略时,分配器会检查已释放的内存分配,以确定释放操作的流顺序语义是否已得到满足,例如流是否已越过释放操作所指示的执行点。禁用此策略时,分配器仍会重用流与 CPU 同步时变为可用的内存。禁用此策略不会阻止cudaMemPoolReuseFollowEventDependencies的应用。

cudaMallocAsync(&ptr, size, originalStream);
kernel<<<..., originalStream>>>(ptr, ...);
cudaFreeAsync(ptr, originalStream);


// after some time, the kernel finishes running
wait(10);

// When cudaMemPoolReuseAllowOpportunistic is enabled this allocation request
// can be fulfilled with the prior allocation based on the progress of originalStream.
cudaMallocAsync(&ptr2, size, otherStream);

4.3.4.4.3. cudaMemPoolReuseAllowInternalDependencies

如果无法从操作系统分配并映射更多物理内存,驱动程序将查找其可用性取决于另一个流尚待执行的进度的内存。如果找到此类内存,驱动程序会将所需的依赖关系插入分配流并重用该内存。

cudaMallocAsync(&ptr, size, originalStream);
kernel<<<..., originalStream>>>(ptr, ...);
cudaFreeAsync(ptr, originalStream);

// When cudaMemPoolReuseAllowInternalDependencies is enabled
// and the driver fails to allocate more physical memory, the driver may
// effectively perform a cudaStreamWaitEvent in the allocating stream
// to make sure that future work in ‘otherStream’ happens after the work
// in the original stream that would be allowed to access the original allocation.
cudaMallocAsync(&ptr2, size, otherStream);

4.3.4.4.4. 禁用重用策略

虽然可控制的重用策略可以改善内存重用,但用户可能希望禁用它们。允许机会性重用(例如cudaMemPoolReuseAllowOpportunistic)会使分配模式基于 CPU 和 GPU 执行的交错情况,在各次运行之间产生差异。当用户更愿意在分配失败时显式同步事件或流时,内部依赖关系插入(例如cudaMemPoolReuseAllowInternalDependencies)可能会以出乎意料且可能不确定的方式使工作串行化。

4.3.4.5. 同步 API 的操作

分配器作为 CUDA 驱动程序的一部分所带来的一项优化是与同步 API 的集成。当用户请求 CUDA 驱动程序执行同步时,驱动程序会等待异步工作完成。在返回之前,驱动程序将确定哪些释放操作已由此次同步保证完成。无论指定了哪个流或禁用了哪些分配策略,这些内存分配都会变为可供分配的内存。驱动程序还会在此处检查cudaMemPoolAttrReleaseThreshold,并尽可能释放所有多余的物理内存。

4.3.5. 补充说明

4.3.5.1. cudaMemcpyAsync 对当前上下文/设备的敏感性

在当前的 CUDA 驱动程序中,任何涉及来自cudaMallocAsync的内存的异步memcpy操作,都应使用指定流的上下文作为调用线程的当前上下文来执行。对于cudaMemcpyPeerAsync,则不必如此,因为它引用的是 API 中指定的设备主上下文,而不是当前上下文。

4.3.5.2. cudaPointerGetAttributes 查询

对内存分配调用cudaFreeAsync之后,再对它调用cudaPointerGetAttributes会导致未定义行为。具体而言,即使仍然可以从给定流访问该内存分配,也不影响这一点:其行为仍然是未定义的。

4.3.5.3. cudaGraphAddMemsetNode

cudaGraphAddMemsetNode不适用于通过流有序分配器分配的内存。不过,可以通过流捕获来捕获对这些内存分配执行的 memset 操作。

4.3.5.4. 指针属性

cudaPointerGetAttributes查询适用于流有序内存分配。由于流有序内存分配不与上下文关联,查询CU_POINTER_ATTRIBUTE_CONTEXT会成功,但会在*data中返回 NULL。属性CU_POINTER_ATTRIBUTE_DEVICE_ORDINAL可用于确定内存分配的位置:在选择上下文以使用cudaMemcpyPeerAsync进行 p2h2p 复制时,这可能很有用。属性CU_POINTER_ATTRIBUTE_MEMPOOL_HANDLE是在 CUDA 11.3 中添加的,可用于调试,也可用于在执行 IPC 之前确认内存分配来自哪个池。

4.3.5.5. CPU 虚拟内存

使用 CUDA 流有序内存分配器 API 时,请避免通过“ulimit -v”设置 VRAM 限制,因为这种做法不受支持。

4.4. 协作组

4.4.1. 简介

协作组是 CUDA 编程模型的扩展,用于组织相互协作的线程组。协作组允许开发者控制线程协作的粒度,帮助他们表达更丰富、更高效的并行分解。协作组还提供扫描和并行归约等常见并行原语的实现。

过去,CUDA 编程模型提供了一种单一且简单的结构来同步相互协作的线程:一个覆盖线程块中所有线程的屏障,由__syncthreads()内建函数实现。为了表达更广泛的并行交互模式,许多以性能为导向的程序员不得不自行编写专用且不安全的原语,用于同步单个线程束内的线程,或在单个 GPU 上运行的多个线程块之间同步线程。尽管所取得的性能提升往往很有价值,但这也导致脆弱代码不断累积,随着时间推移和 GPU 代际更迭,编写、调优和维护这些代码的成本都很高。协作组提供了一种安全且面向未来的机制,用于编写高性能代码。

完整的协作组 API 可参见协作组 API。

4.4.2. 协作组句柄与成员函数

协作组通过协作组句柄进行管理。协作组句柄使参与线程能够获知自身在组中的位置、组大小以及其他组信息。下表列出了一些组成员函数。

表 10 部分成员函数

访问器

返回值

thread_rank()

调用线程的秩。

num_threads()

组中的线程总数。

thread_index()

线程在已启动的线程块内的三维索引。

dim_threads()

已启动的线程块的三维尺寸,以线程为单位。

完整的成员函数列表可参见协作组 API。

4.4.3. 默认行为/无组执行

表示网格和线程块的组会根据核函数启动配置隐式创建。这些“隐式”组提供了一个起点,开发者可以将其显式分解为粒度更细的组。可以使用以下方法访问隐式组:

表 11 由 CUDA 运行时隐式创建的协作组

访问器

组作用域

this_thread_block()

返回包含当前线程块中所有线程的组的句柄。

this_grid()

返回包含网格中所有线程的组的句柄。

coalesced_threads() [1]

返回由线程束中当前活跃线程组成的组的句柄。

this_cluster() [2]

返回由当前簇中的线程组成的组的句柄。

更多信息可参见协作组 API。

4.4.3.1. 尽早创建隐式组句柄

为了获得最佳性能,建议您预先创建隐式组的句柄(尽可能早,在任何分支发生之前),并在整个核函数中使用该句柄。

4.4.3.2. 仅按引用传递组句柄

将组句柄传入函数时,建议按引用向函数传递组句柄。由于没有默认构造函数,组句柄必须在声明时初始化。不建议通过复制构造来创建组句柄。

4.4.4. 创建协作组

组通过将父组划分为子组来创建。划分组时,会创建一个组句柄来管理得到的子组。开发者可以使用以下划分操作:

表 12 协作组划分操作

划分类型

描述

tiled_partition

将父组划分为一系列大小固定的子组,这些子组以一维行主序格式排列。

labeled_partition

根据条件标签将父组划分为一维子组,标签可以是任意整型。

binary_partition

标签划分的一种特化形式,其中标签只能为“0”或“1”。

以下示例展示了如何创建分块划分:

namespace cg = cooperative_groups;
// Obtain the current thread's cooperative group
cg::thread_block my_group = cg::this_thread_block();

// Partition the cooperative group into tiles of size 8
cg::thread_block_tile<8> my_subgroup = cg::tiled_partition<8>(my_group);

// do work as my_subgroup

最适合使用的划分策略取决于具体情境。更多信息请参阅协作组 API。

4.4.4.1. 避免组创建风险

划分组是一项集合操作,组中的所有线程都必须参与。如果在并非所有线程都会到达的条件分支中创建组,可能导致死锁或数据损坏。

4.4.5. 同步

在引入协作组之前,CUDA 编程模型只允许在核函数完成边界处进行线程块间同步。协作组允许开发者以不同的粒度同步由协作线程组成的组。

4.4.5.1. 同步

可以通过调用集合函数sync()来同步一个组。与__syncthreads()一样,sync()函数提供以下保证:

  • 组中线程在同步点之前进行的所有内存访问(例如读取和写入),在同步点之后对组中所有线程都可见。

  • 组中的所有线程都到达同步点之后,才允许任何线程越过该同步点继续执行。

以下示例展示了与__syncthreads()等效的cooperative_groups::sync()。

namespace cg = cooperative_groups;

cg::thread_block my_group = cg::this_thread_block();

// Synchronize threads in the block
cg::sync(my_group);

协作组可用于同步整个网格。从 CUDA 13 起,协作组不再能用于多设备同步。有关详细信息,请参阅大规模组一节。

有关同步的更多信息,请参阅协作组 API。

4.4.5.2. 屏障

协作组提供了一个类似于cuda::barrier的屏障 API,可用于更高级的同步。协作组屏障 API 与cuda::barrier在以下几个关键方面有所不同:

  • 协作组屏障会自动初始化

  • 组中的所有线程在每个阶段都必须到达屏障并在屏障处等待一次。

  • barrier_arrive返回一个arrival_token对象,该对象必须传入对应的barrier_wait,并在其中被消耗,此后不能再次使用。

程序员在使用协作组屏障时必须注意避免以下风险:

  • 在调用barrier_arrive之后、调用barrier_wait之前,组不能使用任何集合操作。

  • barrier_wait仅保证组中的所有线程都已调用barrier_arrive。barrier_wait并不保证所有线程都已调用barrier_wait。

namespace cg = cooperative_groups;

cg::thread_block my_group = this_block();
cg::cluster_group cluster = this_cluster();

auto token = cluster.barrier_arrive();

// Optional: Do some local processing to hide the synchronization latency
     local_processing(my_group);

// Make sure all other blocks in the cluster are running and initialized shared data before accessing dsmem
cluster.barrier_wait(std::move(token));

4.4.6. 集合操作

协作组包含一组可由一组线程执行的集合操作。这些操作需要指定组中的所有线程参与才能完成。

对于每次集合调用,组中的所有线程都必须为对应的参数传入相同的值,除非协作组 API明确允许不同的值。否则,该调用的行为未定义。

4.4.6.1. 归约

reduce函数用于对指定组中每个线程提供的数据执行并行归约。必须提供下表所示运算符之一来指定归约类型。

表 13 协作组归约运算符

运算符

返回值

plus

组中所有值的总和

less

最小值

greater

最大值

bit_and

按位与归约

bit_or

按位或归约

bit_xor

按位异或归约

归约会在硬件加速可用时使用硬件加速(要求计算能力为 8.0 或更高)。对于不具备硬件加速的较旧硬件,可以使用软件回退实现。只有 4 字节类型可以获得硬件加速。

有关归约的更多信息,请参阅协作组 API。

以下示例展示了如何使用cooperative_groups::reduce()在线程块范围内执行求和归约。

namespace cg = cooperative_groups;

cg::thread_block my_group = cg::this_thread_block();

int val = data[threadIdx.x];

int sum = cg::reduce(my_group, val, cg::plus<int>());

// Store the result from the reduction
if (my_group.thread_rank() == 0) {
   result[blockIdx.x] = sum;
}

4.4.6.2. 扫描

协作组包含可用于任意组大小的inclusive_scan和exclusive_scan实现。这些函数对指定组所包含的每个线程提供的数据执行扫描操作。

程序员可以选择指定上面的归约运算符表中列出的归约运算符。

namespace cg = cooperative_groups;

cg::thread_block my_group = cg::this_thread_block();

int val = data[my_group.thread_rank()];

int exclusive_sum = cg::exclusive_scan(my_group, val, cg::plus<int>());

result[my_group.thread_rank()] = exclusive_sum;

有关扫描的更多信息,请参阅协作组扫描 API。

4.4.6.3. 单线程调用

协作组提供了一个invoke_one函数,用于必须由单个线程代表一个组执行工作中的串行部分的情况。

  • invoke_one从调用组中任意选择一个线程,并使用该线程以所提供的参数调用所提供的可调用函数。

  • invoke_one_broadcast与invoke_one相同,只是调用结果还会广播到组中的所有线程。

不保证线程选择机制具有确定性。

以下示例展示了invoke_one的基本用法。

namespace cg = cooperative_groups;
cg::thread_block my_group = cg::this_thread_block();

// Ensure only one thread in the thread block prints the message
cg::invoke_one(my_group, []() {
   printf("Hello from one thread in the block!");
});

// Synchronize to make sure all threads wait until the message is printed
cg::sync(my_group);

可调用函数内部不允许在调用组内进行通信或同步。允许与调用组之外的线程进行通信。

4.4.7. 异步数据移动

CUDA 中协作组的memcpy_async功能提供了一种在全局内存与共享内存之间执行异步内存复制的方法。memcpy_async尤其适合优化内存传输,并使计算与数据传输重叠,从而提高性能。

memcpy_async函数用于启动从全局内存到共享内存的异步加载。memcpy_async旨在像“预取”一样使用,即在需要数据之前将其加载。

wait函数强制组中的所有线程等待,直到异步内存传输完成。组中的所有线程都必须调用wait,然后才能访问共享内存中的数据。

以下示例展示了如何使用memcpy_async和wait预取数据。

namespace cg = cooperative_groups;

cg::thread_group my_group = cg::this_thread_block();

__shared__ int shared_data[];

// Perform an asynchronous copy from global memory to shared memory
cg::memcpy_async(my_group, shared_data + my_group.rank(), input + my_group.rank(), sizeof(int));

// Hide latency by doing work here. Cannot use shared_data

// Wait for the asynchronous copy to complete
cg::wait(my_group);

// Prefetched data is now available

更多信息请参阅协作组 API。

4.4.7.1. 异步内存复制的对齐要求

只有当源为全局内存、目标为共享内存,且两者均至少按 4 字节对齐时,memcpy_async才是异步的。为获得最佳性能,建议共享内存和全局内存均按 16 字节对齐。

4.4.8. 大规模组

协作组允许使用跨越整个网格的大规模组。前面介绍的所有协作组功能都可用于这些大规模组,但有一个显著的例外:同步整个网格需要使用cudaLaunchCooperativeKernel运行时启动 API。

从 CUDA 13 起,协作组的多设备启动 API 及相关引用已被移除。

4.4.8.1. 何时使用cudaLaunchCooperativeKernel

cudaLaunchCooperativeKernel是一个 CUDA 运行时 API 函数,用于启动使用协作组的单设备核函数,专为执行需要线程块间同步的核函数而设计。此函数确保核函数中的所有线程都能在整个网格范围内同步并协作,而传统 CUDA 核函数只允许在各个线程块内部同步,无法做到这一点。cudaLaunchCooperativeKernel确保核函数启动是原子的,即如果 API 调用成功,则指定数量的线程块将在指定设备上启动。

良好的做法是首先通过查询设备属性cudaDevAttrCooperativeLaunch来确保设备支持协作启动:

int dev = 0;
int supportsCoopLaunch = 0;
cudaDeviceGetAttribute(&supportsCoopLaunch, cudaDevAttrCooperativeLaunch, dev);

如果设备 0 支持该属性,这会将supportsCoopLaunch设置为 1。仅支持计算能力为 6.0 及更高的设备。此外,还需要在以下环境之一中运行:

  • 不使用 MPS 的 Linux 平台

  • 使用 MPS 且设备计算能力为 7.0 或更高的 Linux 平台

  • 最新的 Windows 平台

4.5. 编程式依赖启动与同步

编程式依赖启动机制允许有依赖关系的次级核函数,在同一 CUDA 流中其所依赖的主核函数执行完成之前启动。该技术从计算能力为 9.0 的设备起可用;当次级核函数能够完成大量不依赖主核函数结果的工作时,它可以带来性能收益。

4.5.1. 背景

CUDA 应用程序通过在 GPU 上启动并执行多个核函数来利用 GPU。典型的 GPU 活动时间线如图 42所示。

GPU 活动时间线

图 42 GPU 活动时间线

图内文字中文对照
primary kernel
主核函数
secondary kernel
次核函数
other kernel
其他核函数

这里,secondary_kernel在primary_kernel执行完成后启动。通常必须串行执行,因为secondary_kernel依赖于primary_kernel产生的结果数据。如果secondary_kernel不依赖于primary_kernel,则可以使用CUDA 流并发启动两者。即使secondary_kernel依赖于primary_kernel,仍有一定的并发执行潜力。例如,几乎所有核函数都有某种前置部分,在其中执行将缓冲区清零或加载常量值等任务。

``secondary_kernel`` 的前置部分

图 43 secondary_kernel的前置部分

图内文字中文对照
primary kernel
主核函数
secondary kernel
次核函数
preamble
前导部分
other kernel
其他核函数

图 43展示了secondary_kernel中可在不影响应用程序的情况下并发执行的部分。请注意,并发启动还使我们能够通过primary_kernel的执行来隐藏secondary_kernel的启动延迟。

``primary_kernel`` 和 ``secondary_kernel`` 的并发执行

图 44 primary_kernel和secondary_kernel的并发执行

图内文字中文对照
primary kernel
主核函数
secondary kernel
次核函数
preamble
前导部分
other kernel
其他核函数

使用编程式依赖启动可以实现图 44所示的secondary_kernel的并发启动与执行。

编程式依赖启动对 CUDA 核函数启动 API 引入了更改,下一节将对此进行说明。这些 API 要求计算能力至少为 9.0 才能提供重叠执行。

4.5.2. API 说明

在编程式依赖启动中,主核函数和次级核函数在同一 CUDA 流中启动。当主核函数准备好让次级核函数启动时,其所有线程块都应执行cudaTriggerProgrammaticLaunchCompletion。次级核函数必须使用所示的可扩展启动 API 来启动。

__global__ void primary_kernel() {
   // Initial work that should finish before starting secondary kernel

   // Trigger the secondary kernel
   cudaTriggerProgrammaticLaunchCompletion();

   // Work that can coincide with the secondary kernel
}

__global__ void secondary_kernel()
{
   // Independent work

   // Will block until all primary kernels the secondary kernel is dependent on have completed and flushed results to global memory
   cudaGridDependencySynchronize();

   // Dependent work
}

cudaLaunchAttribute attribute[1];
attribute[0].id = cudaLaunchAttributeProgrammaticStreamSerialization;
attribute[0].val.programmaticStreamSerializationAllowed = 1;
configSecondary.attrs = attribute;
configSecondary.numAttrs = 1;

primary_kernel<<<grid_dim, block_dim, 0, stream>>>();
cudaLaunchKernelEx(&configSecondary, secondary_kernel);

当使用cudaLaunchAttributeProgrammaticStreamSerialization属性启动次级核函数时,CUDA 驱动程序可以安全地提前启动次级核函数,而不必等到主核函数完成并刷新内存后再启动次级核函数。

当主核函数的所有线程块都已启动并执行了cudaTriggerProgrammaticLaunchCompletion时,CUDA 驱动程序即可启动次级核函数。如果主核函数未执行触发操作,则该操作会在主核函数中的所有线程块退出后隐式发生。

无论哪种情况,次级核函数的线程块都可能在主核函数写入的数据变得可见之前启动。因此,当次级核函数配置为使用编程式依赖启动时,它必须始终使用cudaGridDependencySynchronize或其他方式来验证主核函数的结果数据是否可用。

请注意,这些方法为主核函数和次级核函数提供了并发执行的机会,但这种行为是机会性的,并不保证会实现核函数并发执行。依赖这种方式的并发执行是不安全的,可能导致死锁。

4.5.3. 在 CUDA 图中使用

可以通过流捕获,或直接通过边数据,在CUDA 图中使用编程式依赖启动。要在 CUDA 图中通过边数据编程实现此功能,请在连接两个核函数节点的边上将cudaGraphDependencyType的值设为cudaGraphDependencyTypeProgrammatic。这种边类型使上游核函数对下游核函数中的cudaGridDependencySynchronize()可见。此类型必须与cudaGraphKernelNodePortLaunchCompletion或cudaGraphKernelNodePortProgrammatic出端口一起使用。

流捕获所生成的等效图如下:

流代码(简略版)

生成的图边

cudaLaunchAttribute attribute;
attribute.id = cudaLaunchAttributeProgrammaticStreamSerialization;
attribute.val.programmaticStreamSerializationAllowed = 1;
cudaGraphEdgeData edgeData;
edgeData.type = cudaGraphDependencyTypeProgrammatic;
edgeData.from_port = cudaGraphKernelNodePortProgrammatic;
cudaLaunchAttribute attribute;
attribute.id = cudaLaunchAttributeProgrammaticEvent;
attribute.val.programmaticEvent.triggerAtBlockStart = 0;
cudaGraphEdgeData edgeData;
edgeData.type = cudaGraphDependencyTypeProgrammatic;
edgeData.from_port = cudaGraphKernelNodePortProgrammatic;
cudaLaunchAttribute attribute;
attribute.id = cudaLaunchAttributeProgrammaticEvent;
attribute.val.programmaticEvent.triggerAtBlockStart = 1;
cudaGraphEdgeData edgeData;
edgeData.type = cudaGraphDependencyTypeProgrammatic;
edgeData.from_port = cudaGraphKernelNodePortLaunchCompletion;

4.6. 绿色上下文

绿色上下文(GC)是一种轻量级上下文,从创建时起就与一组特定的 GPU 资源相关联。用户可以在创建绿色上下文时划分 GPU 资源,目前包括流式多处理器(SM)和工作队列(WQ),这样,以某个绿色上下文为目标的 GPU 工作就只能使用为其分配的 SM 和工作队列。这样做有助于减少或更好地控制因使用共同资源而造成的干扰。一个应用程序可以有多个绿色上下文。

使用绿色上下文无需更改任何 GPU 代码(核函数),只需在主机端进行少量更改(例如,创建绿色上下文并为此绿色上下文创建流)。绿色上下文功能在多种场景中都很有用。例如,在没有其他约束的前提下,它可以帮助确保始终有一些 SM 可供延迟敏感的核函数开始执行,或者提供一种无需修改任何核函数即可快速测试使用较少 SM 的效果的方法。

绿色上下文支持最初通过CUDA 驱动程序 API提供。从 CUDA 13.1 开始,CUDA 运行时通过执行上下文(EC)抽象来暴露上下文。目前,执行上下文可以对应于主上下文(运行时 API 用户一直隐式与之交互的上下文),也可以对应于绿色上下文。本节在指代绿色上下文时,将交替使用执行上下文和绿色上下文这两个术语。

随着绿色上下文在运行时中暴露,强烈建议直接使用 CUDA 运行时 API。本节也将仅使用 CUDA 运行时 API。

本节余下内容的组织如下:第 4.6.1 节提供一个说明使用动机的示例,第 4.6.2 节重点说明易用性,第 4.6.3 节介绍设备资源和资源描述符结构体。第 4.6.4 节说明如何创建绿色上下文,第 4.6.5 节说明如何启动以它为目标的工作,第 4.6.6 节重点介绍一些其他绿色上下文 API。最后,第 4.6.7 节以一个示例收尾。

4.6.1. 动机 / 何时使用

启动 CUDA 核函数时,用户无法直接控制该核函数将在多少个 SM 上执行。只能通过更改核函数的启动几何配置,或更改任何可能影响该核函数在每个 SM 上的最大活跃线程块数的因素,来间接影响这一点。此外,当多个核函数在 GPU 上并行执行时(核函数在不同的 CUDA 流中运行,或作为 CUDA 图的一部分运行),它们也可能争用相同的 SM 资源。

然而,在某些使用场景中,用户需要确保始终有可用的 GPU 资源,使延迟敏感的工作能够尽快开始,并由此尽快完成。绿色上下文通过对 SM 资源进行分区,提供了一种实现这一目标的方法,因此,给定的绿色上下文只能使用特定的 SM(即在创建该上下文时为其配置的那些 SM)。

图 45展示了这样一个示例。假设有一个应用程序,其中两个独立的核函数 A 和 B 在两个不同的非阻塞 CUDA 流上运行。核函数 A 先启动,并开始执行,占用所有可用的 SM 资源。随后,当延迟敏感的核函数 B 启动时,已没有可用的 SM 资源。因此,核函数 B 只能在核函数 A 逐渐结束,即核函数 A 中的线程块完成执行后,才能开始执行。第一幅图展示了关键工作 B 被延迟的这一场景。y 轴表示被占用的 SM 的百分比,x 轴表示时间。

绿色上下文的动机

图 45 动机:绿色上下文(GC)的静态资源分区使延迟敏感的工作 B 能够更早开始和完成

图内文字中文对照
Critical work B gets delayed
关键工作 B 被延迟
GPU utilization (%)
GPU 利用率(%)
independent + critical work B launched
启动独立且关键的工作 B
No resources available.
无可用资源。
time
时间
Work B complete
工作 B 完成
kernel A
核函数 A
kernel B (time critical)
核函数 B(时间关键)
Using 2 green contexts with 80% - 20% SM split
使用 2 个绿色上下文,按 80% - 20% 划分流式多处理器
At most 20%
最多 20%
At most 80%
最多 80%

使用绿色上下文,可以对 GPU 的 SM 进行分区,使核函数 A 所面向的绿色上下文 A 能够访问 GPU 的一部分 SM,而核函数 B 所面向的绿色上下文 B 能够访问其余 SM。在此设置下,无论核函数 A 的启动配置如何,它都只能使用为绿色上下文 A 配置的 SM。因此,当关键核函数 B 启动时,只要没有其他资源限制,就能保证有可用的 SM 供其立即开始执行。正如图 45中的第二幅图所示,尽管核函数 A 的持续时间可能增加,但延迟敏感的工作 B 将不再因没有可用的 SM 而被延迟。为了便于说明,图中为绿色上下文 A 配置的 SM 数量相当于 GPU 的 SM 总数的 80%。

无需对核函数 A 和 B 的代码进行任何修改,就可以实现这种行为。只需确保它们在属于相应绿色上下文的 CUDA 流上启动即可。每个绿色上下文能够访问的 SM 数量应由用户在创建绿色上下文时根据具体情况决定。

工作队列:

流式多处理器是可以为绿色上下文配置的一种资源类型。另一种资源类型是工作队列。可以将工作队列看作一种黑盒资源抽象,它也会与其他因素一起影响 GPU 工作执行的并发性。如果独立的 GPU 工作任务(例如在不同 CUDA 流上提交的核函数)映射到同一个工作队列,则可能在这些任务之间引入伪依赖,从而导致它们串行执行。用户可以通过CUDA_DEVICE_MAX_CONNECTIONS环境变量影响 GPU 上工作队列数量的上限(请参阅第 5.2 节、第 3.1 节)。

在前一个示例的基础上,假设工作 B 与工作 A 映射到同一个工作队列。在这种情况下,即使有可用的 SM 资源(绿色上下文的情况),工作 B 仍可能需要等待工作 A 全部完成。与 SM 类似,用户无法直接控制底层可能使用的具体工作队列。但是,绿色上下文允许用户通过预期的并发流有序工作负载数量,表达其期望的最大并发度。驱动程序随后可以将此值用作提示,尝试避免来自不同执行上下文的工作使用同一个或同一组工作队列,从而防止执行上下文之间出现不必要的干扰。

注意

即使为每个绿色上下文配置了不同的 SM 资源和工作队列,也不能保证独立的 GPU 工作能够并发执行。最好将绿色上下文一节中介绍的所有技术理解为消除可能阻止并发执行的因素(即减少潜在干扰)。

绿色上下文与 MIG 或 MPS 的比较

为求完整,本节简要比较绿色上下文与另外两种资源分区机制:MIG(多实例 GPU)和MPS(多进程服务)。

MIG 将支持 MIG 的 GPU 静态地划分为多个 MIG 实例(“更小的 GPU”)。这种分区必须在应用程序启动之前进行,不同的应用程序可以使用不同的 MIG 实例。对于应用程序始终未能充分利用可用 GPU 资源的用户,使用 MIG 可能会有所帮助;随着 GPU 规模的增大,这一问题会更加突出。借助 MIG,用户可以在不同的 MIG 实例上运行这些不同的应用程序,从而提高 GPU 利用率。MIG 对云服务提供商(CSP)可能很有吸引力,不仅因为它能够提高此类应用程序的 GPU 利用率,还因为它能够为在不同 MIG 实例上运行的客户端提供服务质量(QoS)保障和隔离。有关更多详细信息,请参阅上文链接的 MIG 文档。

但是,使用 MIG 无法解决前面描述的问题场景,即由于同一应用程序中的其他 GPU 工作占用了所有 SM 资源,关键工作 B 被延迟。对于在单个 MIG 实例上运行的应用程序,这一问题仍可能存在。为解决此问题,可以将绿色上下文与 MIG 一起使用。在这种情况下,可用于分区的 SM 资源将是给定 MIG 实例的资源。

MPS 主要面向不同的进程(例如 MPI 程序),使它们无需时间分片就能同时在 GPU 上运行。它要求 MPS 守护进程在应用程序启动之前就已运行。默认情况下,MPS 客户端将争用其所运行的 GPU 或 MIG 实例的所有可用 SM 资源。在这种具有多个客户端进程的设置中,MPS 可以使用活动线程百分比选项支持 SM 资源的动态分区,该选项对 MPS 客户端进程可使用的 SM 百分比设置上限。与绿色上下文不同,MPS 中的活动线程百分比分区是在进程级别进行的,并且该百分比通常在应用程序启动之前通过环境变量指定。MPS 活动线程百分比表示,给定客户端应用程序使用的 SM 不得超过 GPU 的 SM 总数的x%,设此数量为 N 个 SM。然而,这些 SM 可以是 GPU 上的任意 N 个 SM,并且也可以随时间变化。另一方面,在创建时配置了 N 个 SM 的绿色上下文只能使用这 N 个特定的 SM。

从 CUDA 13.1 开始,如果在启动 MPS 控制守护进程时显式启用静态分区,MPS 也支持静态分区。使用静态分区时,用户必须在应用程序启动时指定 MPS 客户端进程可以使用的静态分区。在这种情况下,使用活动线程百分比的动态共享不再适用。静态分区模式下的 MPS 与绿色上下文之间的一个关键区别在于,MPS 面向不同的进程,而绿色上下文也适用于单个进程内部。此外,与绿色上下文不同,使用静态分区的 MPS 不允许超额订阅 SM 资源。

使用 MPS 时,对于通过cuCtxCreate驱动程序 API 创建并具有执行亲和性的 CUDA 上下文,还可以通过编程方式对 SM 资源进行分区。这种编程式分区允许来自一个或多个进程的不同客户端 CUDA 上下文各自使用不超过指定数量的 SM。与活动线程百分比分区一样,这些 SM 可以是 GPU 上的任意 SM,并且可以随时间变化,这与绿色上下文的情况不同。即使存在静态 MPS 分区,也可以使用此选项。请注意,与 MPS 上下文相比,创建绿色上下文要轻量得多,因为许多底层结构由主上下文拥有,因而是共享的。

4.6.2. 绿色上下文:易用性

为了突出绿色上下文的易用性,假设您有以下代码片段,它创建两个 CUDA 流,然后调用一个函数,通过<<<>>>在这些 CUDA 流上启动核函数。如前所述,除了更改核函数的启动几何配置之外,无法影响这些核函数可以使用的 SM 数量。

int gpu_device_index = 0; // GPU ordinal
CUDA_CHECK(cudaSetDevice(gpu_device_index));

cudaStream_t strm1, strm2;
CUDA_CHECK(cudaStreamCreateWithFlags(&strm1, cudaStreamNonBlocking));
CUDA_CHECK(cudaStreamCreateWithFlags(&strm2, cudaStreamNonBlocking));

// No control over how many SMs kernel(s) running on each stream can use
code_that_launches_kernels_on_streams(strm1, strm2); // what is abstracted in this function + the kernels is the vast majority of your code

// cleanup code not shown

从 CUDA 13.1 开始,可以使用绿色上下文控制给定核函数能够访问的 SM 数量。下面的代码片段展示了实现这一点有多么简单。只需增加几行代码,而无需对核函数进行任何修改,就可以控制在这些不同流上启动的核函数能够使用的 SM 资源。

int gpu_device_index = 0; // GPU ordinal
CUDA_CHECK(cudaSetDevice(gpu_device_index));

/* ------------------ Code required to create green contexts --------------------------- */


// Get all available GPU SM resources
cudaDevResource initial_GPU_SM_resources {};
CUDA_CHECK(cudaDeviceGetDevResource(gpu_device_index, &initial_GPU_SM_resources, cudaDevResourceTypeSm));

// Split SM resources. This example creates one group with 16 SMs and one with 8. Assuming your GPU has >= 24 SMs
cudaDevSmResource result[2] {{}, {}};
cudaDevSmResourceGroupParams group_params[2] =  {
        {.smCount=16, .coscheduledSmCount=0, .preferredCoscheduledSmCount=0, .flags=0},
        {.smCount=8,  .coscheduledSmCount=0, .preferredCoscheduledSmCount=0, .flags=0}};
CUDA_CHECK(cudaDevSmResourceSplit(&result[0], 2, &initial_GPU_SM_resources, nullptr, 0, &group_params[0]));

// Generate resource descriptors for each resource
cudaDevResourceDesc_t resource_desc1 {};
cudaDevResourceDesc_t resource_desc2 {};
CUDA_CHECK(cudaDevResourceGenerateDesc(&resource_desc1, &result[0], 1));
CUDA_CHECK(cudaDevResourceGenerateDesc(&resource_desc2, &result[1], 1));

// Create green contexts
cudaExecutionContext_t my_green_ctx1 {};
cudaExecutionContext_t my_green_ctx2 {};
CUDA_CHECK(cudaGreenCtxCreate(&my_green_ctx1, resource_desc1, gpu_device_index, 0));
CUDA_CHECK(cudaGreenCtxCreate(&my_green_ctx2, resource_desc2, gpu_device_index, 0));

/* ------------------ Modified code --------------------------- */

// You just need to use a different CUDA API to create the streams
cudaStream_t strm1, strm2;
CUDA_CHECK(cudaExecutionCtxStreamCreate(&strm1, my_green_ctx1, cudaStreamDefault, 0));
CUDA_CHECK(cudaExecutionCtxStreamCreate(&strm2, my_green_ctx2, cudaStreamDefault, 0));

/* ------------------ Unchanged code --------------------------- */

// No need to modify any code in this function or in your kernel(s).
// Reminder: what is abstracted in this function + kernels is the vast majority of your code
// Now kernel(s) running on stream strm1 will use at most 16 SMs and kernel(s) on strm2 at most 8 SMs.
code_that_launches_kernels_on_streams(strm1, strm2);

// cleanup code not shown

各种执行上下文 API(其中一些已在前面的示例中展示)接受显式的cudaExecutionContext_t句柄,因此会忽略调用线程的当前上下文。在此之前,不使用驱动程序 API 的 CUDA 运行时用户默认情况下只会与主上下文交互,该主上下文通过cudaSetDevice()被隐式设置为线程的当前上下文。转向这种基于显式上下文的编程方式,可以提供更易于理解的语义,并且相比于此前依赖线程局部状态(TLS)的基于隐式上下文的编程方式,还可以带来其他好处。

以下各节将详细解释前一个代码片段中展示的所有步骤。

4.6.3. 绿色上下文:设备资源和资源描述符

绿色上下文的核心是与特定 GPU 设备绑定的设备资源(cudaDevResource)。可以将资源组合并封装到描述符(cudaDevResourceDesc_t)中。绿色上下文只能访问封装在用于创建它的描述符中的资源。

目前,cudaDevResource数据结构定义如下:

struct {
     enum cudaDevResourceType type;
     union {
         struct cudaDevSmResource sm;
         struct cudaDevWorkqueueConfigResource wqConfig;
         struct cudaDevWorkqueueResource wq;
     };
 };

支持的有效资源类型为cudaDevResourceTypeSm、cudaDevResourceTypeWorkqueueConfig和cudaDevResourceTypeWorkqueue,而cudaDevResourceTypeInvalid标识无效的资源类型。

有效的设备资源可以关联:

  • 一组特定的流式多处理器(SM)(资源类型为cudaDevResourceTypeSm),

  • 特定的工作队列配置(资源类型为cudaDevResourceTypeWorkqueueConfig),或

  • 预先存在的工作队列资源(资源类型为cudaDevResourceTypeWorkqueue)。

可以分别使用cudaExecutionCtxGetDevResource和cudaStreamGetDevResource API,查询给定执行上下文或 CUDA 流是否与给定类型的cudaDevResource资源相关联。执行上下文也可以与不同类型的设备资源(例如 SM 和工作队列)相关联,而流只能与 SM 类型的资源相关联。

默认情况下,给定 GPU 设备具有全部三种设备资源类型:包含 GPU 上所有 SM 的 SM 类型资源、包含所有可用工作队列的工作队列配置资源,以及其对应的工作队列资源。可以通过cudaDeviceGetDevResource API 获取这些资源。

相关设备资源结构体概述

不同资源类型的结构体具有由用户显式设置或由相关 CUDA API 调用设置的字段。建议对所有设备资源结构体进行零初始化。

  • SM 类型的设备资源(cudaDevSmResource)具有以下相关字段:

    • unsigned int smCount:此资源中可用的 SM 数量

    • unsigned int minSmPartitionSize:对此资源进行分区所需的最小 SM 数量

    • unsigned int smCoscheduledAlignment:资源中保证被协同调度到同一个 GPU 处理簇上的 SM 数量,这与线程块簇有关。当flags为零时,smCount是此值的倍数。

    • unsigned int flags:支持的标志为 0(默认)、cudaDevSmResourceGroupBackfill和cudaDevSmResourceGroupLocalityDomainId(请参阅cudaDevSmResourceGroup的标志)。

    上述字段将通过用于创建此 SM 类型资源的相应拆分 API(cudaDevSmResourceSplitByCount或cudaDevSmResourceSplit)来设置,或由获取给定 GPU 设备的 SM 资源的cudaDeviceGetDevResource API 填充。用户绝不应直接设置这些字段。有关更多详细信息,请参阅下一节。

  • 工作队列配置设备资源(cudaDevWorkqueueConfigResource)具有以下相关字段:

    • int device:工作队列资源所在的设备

    • unsigned int wqConcurrencyLimit:期望避免伪依赖的流有序工作负载的数量

    • enum cudaDevWorkqueueConfigScope sharingScope:工作队列资源的共享作用域。支持的值为:cudaDevWorkqueueConfigScopeDeviceCtx(默认)和cudaDevWorkqueueConfigScopeGreenCtxBalanced。使用默认选项时,所有工作队列资源在所有上下文之间共享,而使用均衡选项时,驱动程序会以用户指定的wqConcurrencyLimit作为提示,尽可能尝试在不同绿色上下文中使用互不重叠的工作队列资源。

    这些字段需要由用户设置。除了由cudaDeviceGetDevResource API 填充的工作队列配置资源之外,没有类似于拆分 API、能够生成工作队列配置资源的 CUDA API。该 API 可以获取给定 GPU 设备的工作队列配置资源。

  • 最后,预先存在的工作队列资源(cudaDevResourceTypeWorkqueue)没有可由用户设置的字段。与其他资源类型一样,cudaDevGetDevResource可以获取给定 GPU 设备的预先存在的工作队列资源。

4.6.4. 绿色上下文创建示例

创建绿色上下文涉及四个主要步骤:

  • 步骤 1:从一组初始资源开始,例如获取 GPU 的可用资源

  • 步骤 2:将 SM 资源划分为一个或多个分区(使用可用的拆分 API 之一)。

  • 步骤 3:创建资源描述符,并根据需要组合不同的资源

  • 步骤 4:根据描述符创建绿色上下文,为其配置资源

创建绿色上下文后,可以创建属于该绿色上下文的 CUDA 流。随后在这样的流上启动的 GPU 工作(例如通过<<< >>>启动的核函数)将只能访问为此绿色上下文配置的资源。只要用户向库传递属于绿色上下文的流,库也可以轻松利用绿色上下文。有关更多详细信息,请参阅绿色上下文:启动工作。

4.6.4.1. 步骤 1:获取可用的 GPU 资源

创建绿色上下文的第一步是获取可用的设备资源,并填充一个或多个cudaDevResource结构体。目前有三种可能的起点:设备、执行上下文或 CUDA 流。

相关的 CUDA 运行时 API 函数签名如下所示:

  • 对于设备:cudaError_t cudaDeviceGetDevResource(int device, cudaDevResource* resource, cudaDevResourceType type)

  • 对于执行上下文:cudaError_t cudaExecutionCtxGetDevResource(cudaExecutionContext_t ctx, cudaDevResource* resource, cudaDevResourceType type)

  • 对于流:cudaError_t cudaStreamGetDevResource(cudaStream_t hStream, cudaDevResource* resource, cudaDevResourceType type)

这些 API 都允许使用所有有效的cudaDevResourceType类型,但cudaStreamGetDevResource除外,它仅支持 SM 类型的资源。

通常,起点是 GPU 设备。下面的代码片段展示了如何获取给定 GPU 设备的可用 SM 资源。成功调用cudaDeviceGetDevResource后,用户可以查看此资源中可用的 SM 数量。

int current_device = 0; // assume device ordinal of 0
CUDA_CHECK(cudaSetDevice(current_device));

cudaDevResource initial_SM_resources = {};
CUDA_CHECK(cudaDeviceGetDevResource(current_device /* GPU device */,
                                   &initial_SM_resources /* device resource to populate */,
                                   cudaDevResourceTypeSm /* resource type*/));

std::cout << "Initial SM resources: " << initial_SM_resources.sm.smCount << " SMs" << std::endl; // number of available SMs

// Special fields relevant for partitioning (see Step 3 below)
std::cout << "Min. SM partition size: " <<  initial_SM_resources.sm.minSmPartitionSize << " SMs" << std::endl;
std::cout << "SM co-scheduled alignment: " <<  initial_SM_resources.sm.smCoscheduledAlignment << " SMs" << std::endl;

还可以获取可用的工作队列配置资源,如下面的代码片段所示。

int current_device = 0; // assume device ordinal of 0
CUDA_CHECK(cudaSetDevice(current_device));

cudaDevResource initial_WQ_config_resources = {};
CUDA_CHECK(cudaDeviceGetDevResource(current_device /* GPU device */,
                                   &initial_WQ_config_resources /* device resource to populate */,
                                   cudaDevResourceTypeWorkqueueConfig /* resource type*/));

std::cout << "Initial WQ config. resources: " << std::endl;
std::cout << "  - WQ concurrency limit: " << initial_WQ_config_resources.wqConfig.wqConcurrencyLimit << std::endl;
std::cout << "  - WQ sharing scope: " << initial_WQ_config_resources.wqConfig.sharingScope << std::endl;

成功调用cudaDeviceGetDevResource后,用户可以查看此资源的wqConcurrencyLimit。当起点为 GPU 设备时,wqConcurrencyLimit将与CUDA_DEVICE_MAX_CONNECTIONS环境变量的值或其默认值相同。

4.6.4.2. 步骤 2:划分 SM 资源

创建绿色上下文的第二步是将可用的cudaDevResourceSM 资源静态拆分为一个或多个分区,可能会有一些 SM 留在一个剩余分区中。可以使用cudaDevSmResourceSplitByCount()或cudaDevSmResourceSplit()API 进行这种划分。cudaDevSmResourceSplitByCount()API 只能创建一个或多个同构分区,以及一个可能存在的剩余分区,而cudaDevSmResourceSplit()API 还可以创建异构分区,以及一个可能存在的剩余分区。后续各节将详细介绍这两个 API 的功能。这两个 API 都仅适用于 SM 类型的设备资源。

cudaDevSmResourceSplitByCount API

cudaDevSmResourceSplitByCount运行时 API 的签名为:

cudaError_t cudaDevSmResourceSplitByCount(cudaDevResource* result, unsigned int* nbGroups, const cudaDevResource* input, cudaDevResource* remaining, unsigned int useFlags, unsigned int minCount)

如图 46所强调的,用户请求将inputSM 类型的设备资源拆分为*nbGroups个同构组,每组包含minCount个 SM。然而,最终结果将包含数量可能经过更新的*nbGroups个同构组,每组包含N个 SM。可能经过更新的*nbGroups将小于或等于最初请求的组数,而N将等于或大于minCount。这些调整可能是由一些特定于架构的粒度和对齐要求引起的。

使用 cudaDevSmResourceSplitByCount API 拆分 SM 资源

图 46 使用 cudaDevSmResourceSplitByCount API 拆分 SM 资源

图内文字中文对照
input cudaDevResource
输入 cudaDevResource
split into *nbGroups partitions
划分为 *nbGroups 个分区
N SMs
N 个流式多处理器
remaining SMs
剩余流式多处理器
remaining
剩余部分
result
结果

表 30列出了在默认useFlags=0情况下,所有当前支持的计算能力的最小 SM 分区大小以及 SM 协同调度的对齐要求。还可以通过cudaDevSmResource的minSmPartitionSize和smCoscheduledAlignment字段获取这些值,如步骤 1:获取可用的 GPU 资源所示。通过使用不同的useFlags值,可以降低其中一些要求。表 14提供了一些相关示例,突出展示了请求与最终结果之间的差异,并附有说明。该表重点介绍计算能力(CC 9.0),在该计算能力下,如果useFlags为零,则每个分区的 SM 数量最少为 8,且 SM 数量必须是 8 的倍数。

表 14 拆分功能

请求的结果

实际结果(对于具有 132 个 SM 的 GH200)

*nbGroups

minCount

useFlags

*nbGroups with N SMs

剩余 SM

原因

2

72

0

1 组,包含 72 个 SM

60

不能超过 132 个 SM

6

11

0

6 组,每组包含 16 个 SM

36

必须为 8 的倍数的要求

6

11

CU_DEV_SM_RESOURCE_SPLIT_IGNORE_SM_COSCHEDULING

6 组,每组包含 12 个 SM

60

要求降低为必须是 2 的倍数

2

1

0

2 组,每组包含 8 个 SM

116

至少包含 8 个 SM 的要求

以下代码片段请求将可用的 SM 资源拆分为五组,每组包含8 个 SM:

cudaDevResource avail_resources = {};
// Code that has populated avail_resources not shown

unsigned int min_SM_count = 8;
unsigned int actual_split_groups = 5; // may be updated

cudaDevResource actual_split_result[5] = {{}, {}, {}, {}, {}};
cudaDevResource remaining_partition = {};

CUDA_CHECK(cudaDevSmResourceSplitByCount(&actual_split_result[0],
                                         &actual_split_groups,
                                         &avail_resources,
                                         &remaining_partition,
                                         0 /*useFlags */,
                                         min_SM_count));

std::cout << "Split " << avail_resources.sm.smCount << " SMs into " << actual_split_groups << " groups " \
          << "with " << actual_split_result[0].sm.smCount << " each " \
          << "and a remaining group with " << remaining_partition.sm.smCount << " SMs" << std::endl;

请注意:

  • 可以使用result=nullptr来查询将要创建的组数

  • 如果不关心剩余分区的 SM,可以设置remaining=nullptr

  • remaining(剩余的)分区不具有与result中同构组相同的功能或性能保证。

  • 在默认情况下,useFlags应为 0,但也支持cudaDevSmResourceSplitIgnoreSmCoscheduling和cudaDevSmResourceSplitMaxPotentialClusterSize这两个值

  • 对于产生的任何cudaDevResource,必须先用它创建资源描述符和绿色上下文(即下面的步骤 3 和 4),才能重新对其进行划分

有关更多详细信息,请参阅cudaDevSmResourceSplitByCount运行时 API 参考。

cudaDevSmResourceSplit API

如前所述,单次cudaDevSmResourceSplitByCountAPI 调用只能创建同构分区,即具有相同 SM 数量的分区,以及剩余分区。这可能会限制异构工作负载,在这类工作负载中,在不同绿色上下文上运行的工作对 SM 数量有不同的要求。要使用按数量拆分的 API 实现异构分区,通常需要通过(多次)重复步骤 1 至 4 来重新划分现有资源。或者,在某些情况下,可以在步骤 2 中创建同构分区,使每个分区的 SM 数量等于所有异构分区 SM 数量的 GCD(最大公约数),然后在步骤 3 中将所需数量的这些分区合并在一起。不过,不建议采用后一种方法,因为如果一开始就请求较大的分区大小,CUDA 驱动程序可能能够创建更好的分区。

cudaDevSmResourceSplitAPI 旨在解决这些限制,它允许用户在单次调用中创建不重叠的异构分区。cudaDevSmResourceSplit运行时 API 的签名为:

cudaError_t cudaDevSmResourceSplit(cudaDevResource* result, unsigned int nbGroups, const cudaDevResource* input, cudaDevResource* remainder, unsigned int flags, cudaDevSmResourceGroupParams* groupParams)

此 API 将尝试根据groupParams数组中为每个资源指定的要求,将inputSM 类型资源划分为nbGroups个有效设备资源(组),并将其放入result数组中。也可以选择创建一个剩余分区。拆分成功后,如图 47所示,result中的每个资源可以具有不同数量的 SM,但 SM 数量绝不会为零。

使用 cudaDevSmResourceSplit API 拆分 SM 资源

图 47 使用 cudaDevSmResourceSplit API 拆分 SM 资源

图内文字中文对照
input cudaDevResource
输入 cudaDevResource
split into nbGroups partitions
划分为 nbGroups 个分区
X SMs
X 个流式多处理器
Y SMs
Y 个流式多处理器
Z SMs
Z 个流式多处理器
remaining SMs
剩余流式多处理器
remaining
剩余部分
result
结果

请求异构拆分时,需要为result中的每个资源指定 SM 数量(相应groupParams条目的smCount字段)。此 SM 数量应始终为 2 的倍数。对于上一幅图中的情形,groupParams[0].smCount将为X,groupParams[1].smCount将为Y,依此类推。但是,如果应用程序使用线程块簇,仅指定 SM 数量是不够的。由于一个簇中的所有线程块都保证会被协同调度,因此用户还需要指定给定资源组应支持的最大簇大小(如果支持簇)。这可以通过相应groupParams条目的coscheduledSmCount字段实现。对于计算能力为 10.0 及更高版本(CC 10.0+)的 GPU,簇还可以具有首选维度,该维度是其默认簇维度的倍数。在受支持系统上的单次核函数启动期间,如果能够使用这个较大的首选簇维度,就会尽可能使用它,否则使用较小的默认簇维度。用户可以通过相应groupParams条目的preferredCoscheduledSmCount字段表达此首选簇维度提示。最后,在某些情况下,用户可能希望放宽 SM 数量要求,将更多可用的 SM 纳入给定组中;用户可以将相应groupParams条目的flags字段设置为其非默认标志值,来表达此回填选项。

此 API 允许一个组以某个局部性域为目标。在cudaDevSmResourceGroupParams.flags字段中设置cudaDevSmResourceGroupLocalityDomainId,并将cudaDevSmResourceGroupParams.localityDomainId设置为该资源的有效局部性域 ID。随后,cudaDevSmResourceSplit会从该局部性域中选择 SM。如果还设置了cudaDevSmResourceGroupBackfill,CUDA 可能会在优先使用目标域之后,使用来自所请求局部性域之外的 SM 填充该组。

有关整体编程模型,请参阅局部性域。

为了提供更大的灵活性,cudaDevSmResourceSplitAPI 还具有发现模式,可在事先不知道一个或多个组的确切 SM 数量时使用。例如,用户可能希望创建一个设备资源,使其在满足某些协同调度要求(例如,允许大小为 4 的簇)的同时,包含尽可能多的 SM。要使用此发现模式,用户可以将相应的一个或多个groupParams条目的smCount字段设置为零。成功调用cudaDevSmResourceSplit后,groupParams的smCount字段将被填入一个有效的非零值;我们将此值称为实际smCount值。如果result不为空(因此这不是试运行),那么result中相应组的smCount也将被设置为相同的值。指定这nbGroups个groupParams条目的顺序很重要,因为它们是从左(索引 0)到右(索引 nbGroups-1)依次求值的。

表 15概述了cudaDevSmResourceSplitAPI 支持的参数。

表 15 cudaDevSmResourceSplit 拆分 API 概览

groupParams 数组;显示条目 i,其中 i [0, nbGroups)

result

nbGroups

input

remainder

flags

smCount

coscheduledSmCount

preferredCoscheduledSmCount

flags

进行探索性试运行时为 nullptr;否则为非空指针

组数

要拆分为 nbGroups 个组的资源

如果不需要剩余组,则为 nullptr

0

0 表示发现模式,或为其他有效的 smCount 值

0(默认值),或有效的协同调度 SM 数量

0(默认值),或有效的首选协同调度 SM 数量(提示)

0(默认值)、cudaDevSmResourceGroupBackfill 或 cudaDevSmResourceGroupLocalityDomainId

注意:

  1. cudaDevSmResourceSplitAPI 的返回值取决于result:

  • result != nullptr:只有在拆分成功且已创建nbGroups个满足指定要求的有效cudaDevResource组时,此 API 才会返回cudaSuccess;否则,它将返回错误。由于不同类型的错误可能返回相同的错误代码(例如cudaErrorInvalidResourceConfiguration),因此建议在开发期间使用CUDA_LOG_FILE环境变量来获取更详尽的错误描述。

  • result == nullptr:即使某个组产生的smCount为零,此 API 也可能返回cudaSuccess,而在result不为 nullptr 时,这种情况会返回错误。可以将此模式视为探索所支持功能时可使用的试运行测试,尤其是在发现模式下。

  1. 当 result != nullptr 且调用成功时,产生的result[i]设备资源(其中 i 位于[0, nbGroups)中)将具有cudaDevResourceTypeSm类型,并具有一个result[i].sm.smCount,其值将是用户指定的非零groupParams[i].smCount值或发现的值。无论哪种情况,result[i].sm.smCount都将满足以下全部约束:

  • 为multiple of 2,并且

  • 位于[2, input.sm.smCount]范围内,并且

  • 当未设置cudaDevSmResourceGroupBackfill时,为groupParams[i].coscheduledSmCount的倍数;当设置了回填时,大于或等于该值。

  1. 将coscheduledSmCount和preferredCoscheduledSmCount字段中的任意一个指定为零,表示应使用该字段的默认值;这些默认值可能因 GPU 而异。这两个默认值都等于通过cudaDeviceGetDevResourceAPI 为给定设备获取的 SM 资源的smCoscheduledAlignment(而不是任意 SM 资源的该值)。要查看这些默认值,可以先将它们设置为 0,然后在成功调用cudaDevSmResourceSplit后,检查相应groupParams条目中它们更新后的值;请参阅下文。

int gpu_device_index = 0;
cudaDevResource initial_GPU_SM_resources {};
CUDA_CHECK(cudaDeviceGetDevResource(gpu_device_index, &initial_GPU_SM_resources, cudaDevResourceTypeSm));
std::cout << "Default value will be equal to " << initial_GPU_SM_resources.sm.smCoscheduledAlignment << std::endl;

int default_split_flags = 0;
cudaDevSmResourceGroupParams group_params_tmp = {.smCount=0, .coscheduledSmCount=0, .preferredCoscheduledSmCount=0, .flags=0};
CUDA_CHECK(cudaDevSmResourceSplit(nullptr, 1, &initial_GPU_SM_resources, nullptr /*remainder*/, default_split_flags, &group_params_tmp));
std::cout << "coscheduledSmcount default value: " << group_params.coscheduledSmCount << std::endl;
std::cout << "preferredCoscheduledSmcount default value: " << group_params.preferredCoscheduledSmCount << std::endl;
  1. 如果存在剩余组,其 SM 数量或协同调度要求将不受任何约束。这需要由用户自行探索。

在提供各个cudaDevSmResourceGroupParams字段的更详细信息之前,表 16展示了在一些示例用例中这些值可能是什么。假定已经像前面的代码片段一样填充了initial_GPU_SM_resources设备资源,且它就是将要拆分的资源。表中的每一行都具有相同的起点。为简单起见,该表仅展示每个用例的nbGroups值和groupParams字段,这些值和字段可用于如下所示的代码片段。

int nbGroups = 2; // update as needed
unsigned int default_split_flags = 0;
cudaDevResource remainder {}; // update as needed
cudaDevResource result_use_case[2] = {{}, {}}; // Update depending on number of groups planned. Increase size if you plan to also use a workqueue resource
cudaDevSmResourceGroupParams group_params_use_case[2] = {{.smCount = X, .coscheduledSmCount=0, .preferredCoscheduledSmCount = 0, .flags = 0},
                                                         {.smCount = Y, .coscheduledSmCount=0, .preferredCoscheduledSmCount = 0, .flags = 0}}
CUDA_CHECK(cudaDevSmResourceSplit(&result_use_case[0], nbGroups, &initial_GPU_SM_resources, remainder, default_split_flags, &group_params_use_case[0]));
表 16 拆分 API 用例

groupParams[i] 字段(i 按升序显示;请参阅最后一列)

i

#

目标/用例

nbGroups

remainder

smCount

coscheduledSmCount

preferredCoscheduledSmCount

flags

1

具有 16 个 SM 的资源。不关心剩余 SM。可能使用簇。

1

nullptr

16

0

0

0

0

2a

一个资源具有 16 个 SM,另一个资源包含其余所有 SM。不会使用簇。

(注意:这里展示了两个选项:在选项 (2a) 中,第 2 个资源是 remainder;在选项 (2b) 中,它是 result_use_case[1]。)

1 (2a)

非 nullptr

16

2

2

0

0

2b

2 (2b)

nullptr

16

2

2

0

0

0

2

2

cudaDevSmResourceGroupBackfill

1

3

两个资源分别具有 28 个和 32 个 SM。将使用大小为 4 的簇。

2

nullptr

28

4

4

0

0

32

4

4

0

1

4

一个资源包含尽可能多的 SM,能够运行大小为 8 的簇,另有一个剩余资源。

1

非 nullptr

0

8

8

0

0

5

一个资源包含尽可能多的 SM,能够运行大小为 4 的簇,另一个资源具有 8 个 SM。

(注意:顺序很重要!更改 groupParams 数组中条目的顺序,可能意味着没有剩余 SM 可供那个需要 8 个 SM 的组使用)

2

nullptr

8

2

2

0

0

0

4

4

0

1

有关 cudaDevSmResourceGroupParams 结构体各个字段的详细信息

smCount:

  • 控制 result 中相应组的 SM 数量。

  • 取值:0(发现模式),或有效的非零值(非发现模式)

    • 有效的非零smCount值的要求:(multiple of 2) and in [2, input->sm.smCount] and ((flags == 0) ? multiple of actual coscheduledSmCount : greater than or equal to coscheduledSmCount)

  • 用例:在 SM 数量未知或不固定时,使用发现模式探索可行的情况;使用非发现模式请求特定数量的 SM。

  • 注意:在发现模式下,当 result 不为 nullptr 且拆分调用成功后,实际 SM 数量将满足有效非零值的要求

coscheduledSmCount:

  • 控制分组在一起(“协同调度”)的 SM 数量,以便在计算能力为 9.0 及更高版本的设备上启动不同的簇。因此,它可以影响所产生组中的 SM 数量及其能够支持的簇大小。

  • 取值:0(当前架构的默认值),或有效的非零值

    • 有效非零值的要求:(multiple of 2),不超过上限

  • 用例:对于簇,使用默认值或手动选择的值,同时要考虑给定架构上的最大可移植簇大小。如果代码不使用簇,可以使用所支持的最小值 2 或默认值。

  • 注意:使用默认值时,成功进行拆分调用后的实际coscheduledSmCount也将满足有效非零值的要求。如果 flags 不为零,则产生的 smCount 将 >= coscheduledSmCount。可以将 coscheduledSmCount 视为为产生的有效组提供某种有保证的底层“结构”(即在最坏情况下,该组至少可以运行一个大小为 coscheduledSmCount 的簇)。这类结构保证不适用于剩余组;对于剩余组,需要由用户自行探索能够启动哪些大小的簇。

preferredCoscheduledSmCount:

  • 用作给驱动程序的提示,使其在可能的情况下尝试将每组实际包含coscheduledSmCount个 SM 的组,合并为包含preferredCoscheduledSmCount个 SM 的更大组。这样可以让代码利用计算能力(CC)为 10.0 及更高版本的设备上提供的首选簇维度功能)。请参阅cudaLaunchAttributeValue::preferredClusterDim。

  • 取值:0(当前架构的默认值),或有效的非零值

    • 有效非零值的要求:(multiple of actual coscheduledSmCount)

  • 用例:如果使用首选簇,并且所用设备的计算能力为 10.0(Blackwell)或更高版本,则使用手动选择的大于 2 的值。如果不使用簇,请选择与coscheduledSmCount相同的值:或者选择所支持的最小值 2,或者两者都使用 0

  • 注意:使用默认值时,成功进行拆分调用后的实际preferredCoscheduledSmCount也将满足有效非零值的要求。

flags:

  • 控制一个组产生的 SM 数量是否为实际协同调度 SM 数量的倍数(默认情况),或者是否可以向该组回填 SM(回填情况)。在回填情况下,产生的 SM 数量(result[i].sm.smCount)将大于或等于指定的groupParams[i].smCount。

  • 取值:0(默认值)或cudaDevSmResourceGroupBackfill

  • 用例:使用零值(默认值),这样,生成的组便能保证具有支持多个大小为 coScheduledSmCount 的簇的灵活性。如果希望在组中获得尽可能多的 SM,则使用回填选项,其中一些 SM(回填的那些 SM)不提供任何协同调度保证。

  • 注意:使用回填标志创建的组仍然可以支持簇(例如,保证至少支持一个大小为 coscheduledSmCount 的簇)。

4.6.4.3. 步骤 2(续):添加工作队列资源

如果还希望指定工作队列资源,则需要显式执行此操作。以下示例展示如何为特定设备创建一个工作队列配置资源,该资源具有均衡的共享作用域,并发上限为四。

cudaDevResource split_result[2] = {{}, {}};
// code to populate split_result[0] not shown; used split API with nbGroups=1

// The last resource will be a workqueue resource.
split_result[1].type = cudaDevResourceTypeWorkqueueConfig;
split_result[1].wqConfig.device = 0; // assume device ordinal of 0
split_result[1].wqConfig.sharingScope = cudaDevWorkqueueConfigScopeGreenCtxBalanced;
split_result[1].wqConfig.wqConcurrencyLimit = 4;

工作队列并发上限为四,是向驱动程序提示用户期望最多有四个并发的流有序工作负载。驱动程序在分配工作队列时,会尽可能尝试遵循这一提示。

4.6.4.4. 步骤 3:创建资源描述符

拆分资源之后,下一步是使用cudaDevResourceGenerateDesc API,为预期可供绿色上下文使用的所有资源生成资源描述符。

相关的 CUDA 运行时 API 函数签名如下:

cudaError_t cudaDevResourceGenerateDesc(cudaDevResourceDesc_t *phDesc, cudaDevResource *resources, unsigned int nbResources)

可以组合多个cudaDevResource资源。例如,下面的代码片段展示如何生成封装了三组资源的资源描述符。只需确保这些资源在resources数组中全部连续分配即可。

cudaDevResource actual_split_result[5] = {};
// code to populate actual_split_result not shown

// Generate resource desc. to encapsulate 3 resources: actual_split_result[2] to [4]
cudaDevResourceDesc_t resource_desc;
CUDA_CHECK(cudaDevResourceGenerateDesc(&resource_desc, &actual_split_result[2], 3));

也支持组合不同类型的资源。例如,可以生成一个同时包含 SM 资源和工作队列资源的描述符。

要使cudaDevResourceGenerateDesc调用成功,需满足以下条件:

  • 所有nbResources资源都应属于同一 GPU 设备。

  • 如果组合多个 SM 类型的资源,它们应由同一次拆分 API 调用生成,并具有相同的coscheduledSmCount值(如果不属于剩余部分)

  • 只能存在一个工作队列配置资源或工作队列类型资源。

4.6.4.5. 步骤 4:创建绿色上下文

最后一步是使用cudaGreenCtxCreate API,根据资源描述符创建绿色上下文。该绿色上下文只能访问其创建时指定的资源描述符中封装的资源(例如,SM、工作队列)。这些资源将在此步骤中进行配置。

相关的 CUDA 运行时 API 函数签名如下:

cudaError_t cudaGreenCtxCreate(cudaExecutionContext_t *phCtx, cudaDevResourceDesc_t desc, int device, unsigned int flags)

flags参数应设置为 0。此外,建议在创建绿色上下文之前,通过cudaInitDevice API 或cudaSetDevice API 显式初始化设备的主上下文,这也会将主上下文设置为调用线程的当前上下文。这样可确保在创建绿色上下文期间不会产生额外的主上下文初始化开销。

请参见下面的代码片段。

int current_device = 0; // assume single GPU
CUDA_CHECK(cudaSetDevice(current_device)); // Or cudaInitDevice

cudaDevResourceDesc_t resource_desc {};
// Code to generate resource_desc not shown

// Create a green_ctx on GPU with current_device ID with access to resources from resource_desc
cudaExecutionContext_t green_ctx {};
CUDA_CHECK(cudaGreenCtxCreate(&green_ctx, resource_desc, current_device, 0));

成功创建绿色上下文后,用户可以针对每种资源类型,在该执行上下文上调用cudaExecutionCtxGetDevResource来验证其资源。

创建多个绿色上下文

应用程序可以拥有多个绿色上下文,在这种情况下,应重复上述某些步骤。对于大多数用例,每个绿色上下文都将拥有一组单独配置且互不重叠的 SM。例如,对于五个同质的cudaDevResource组(actual_split_result数组)的情况,一个绿色上下文的描述符可以封装 actual_split_result[2] 到 [4] 的资源,而另一个绿色上下文的描述符可以封装 actual_split_result[0] 到 [1]。在这种情况下,一个特定的 SM 将仅为该应用程序的两个绿色上下文之一配置。

不过,SM 超额订阅也是可能的,并且在某些情况下可以使用。例如,让第二个绿色上下文的描述符封装 actual_split_result[0] 到 [2] 可能是可以接受的。在这种情况下,actual_split_resource[2]cudaDevResource的所有 SM 都将被超额订阅,即为两个绿色上下文同时配置,而资源 actual_split_resource[0] 到 [1] 以及 actual_split_resource[3] 到 [4] 只能由这两个绿色上下文之一使用。应根据具体情况审慎使用 SM 超额订阅。

4.6.5. 绿色上下文:启动工作

要针对使用前述步骤创建的绿色上下文启动核函数,首先需要通过cudaExecutionCtxStreamCreate API 为该绿色上下文创建一个流。使用<<< >>>或cudaLaunchKernel API 在该流上启动核函数,将确保核函数只能使用通过该流的执行上下文向其提供的资源(SM、工作队列)。例如:

// Create green_ctx_stream CUDA stream for previously created green_ctx green context
cudaStream_t green_ctx_stream;
int priority = 0;
CUDA_CHECK(cudaExecutionCtxStreamCreate(&green_ctx_stream,
                                        green_ctx,
                                        cudaStreamDefault,
                                        priority));

// Kernel my_kernel will only use the resources (SMs, work queues, as applicable) available to green_ctx_stream's execution context
my_kernel<<<grid_dim, block_dim, 0, green_ctx_stream>>>();
CUDA_CHECK(cudaGetLastError());

鉴于green_ctx是绿色上下文,传递给上述流创建 API 的默认流创建标志等同于cudaStreamNonBlocking。

CUDA 图

对于作为 CUDA 图的一部分启动的核函数(参见CUDA 图),还有一些细微之处需要注意。与核函数不同,启动 CUDA 图所用的 CUDA 流并不决定所使用的 SM 资源,因为该流仅用于依赖关系跟踪。

核函数节点(以及其他适用的节点类型)将在哪个执行上下文上执行,是在创建节点时设置的。如果将使用流捕获来创建 CUDA 图,那么参与捕获的流的执行上下文将决定相关图节点的执行上下文。如果将使用图 API 来创建图,那么用户应为每个相关节点显式设置执行上下文。例如,要添加核函数节点,用户应使用多态cudaGraphAddNode API,将节点类型指定为cudaGraphNodeTypeKernel,并显式指定.kernel下的cudaKernelNodeParamsV2结构体的.ctx字段。cudaGraphAddKernelNode不允许用户指定执行上下文,因此应避免使用。请注意,同一图中的不同图节点可以属于不同的执行上下文。

为了验证,可以在节点跟踪模式(--cuda-graph-trace node)下使用 Nsight Systems,观察特定图节点将在哪些绿色上下文上执行。请注意,在默认的图跟踪模式下,整个图将显示在启动该图的流所属的绿色上下文下,但如前所述,这并不提供有关各个图节点执行上下文的任何信息。

要以编程方式进行验证,可以考虑使用 CUDA 驱动程序 APIcuGraphKernelNodeGetParams(graph_node, &node_params),并将node_params.ctx上下文句柄字段与该图节点的预期上下文句柄进行比较。由于CUgraphNode和cudaGraphNode_t可以互换使用,因此可以使用驱动程序 API,但用户需要包含相关的cuda.h头文件,并直接链接驱动程序(-lcuda)。

线程块簇

包含线程块簇的核函数(参见第 1.2.2.1.1 节)也可以像其他任何核函数一样,在绿色上下文流上启动,从而使用为该绿色上下文配置的资源。第 4.6.4.2 节展示了如何在拆分设备资源时指定需要协同调度的 SM 数量,以便支持簇。不过,与任何使用簇的核函数一样,用户应利用相关的占用率 API 来确定核函数的最大潜在簇大小(通过cudaOccupancyMaxPotentialClusterSize),并在需要时确定最大活跃簇数(cudaOccupancyMaxActiveClusters)。如果用户将绿色上下文流指定为相关cudaLaunchConfig的stream字段,则这些占用率 API 会将为该绿色上下文配置的 SM 资源纳入考虑。这一用例尤其适用于可能由用户传入绿色上下文 CUDA 流的库,以及使用剩余设备资源创建绿色上下文的情况。

下面的代码片段展示了如何使用这些 API。

// Assume cudaStream_t gc_stream  has already been created and a __global__ void cluster_kernel exists.

// Uncomment to support non portable cluster size, if possible
// CUDA_CHECK(cudaFuncSetAttribute(cluster_kernel, cudaFuncAttributeNonPortableClusterSizeAllowed, 1))

cudaLaunchConfig_t config = {0};
config.gridDim          = grid_dim; // has to be a multiple of cluster dim.
config.blockDim         = block_dim;
config.dynamicSmemBytes = expected_dynamic_shared_mem;

cudaLaunchAttribute attribute[1];
attribute[0].id = cudaLaunchAttributeClusterDimension;
attribute[0].val.clusterDim.x = 1;
attribute[0].val.clusterDim.y = 1;
attribute[0].val.clusterDim.z = 1;
config.attrs = attribute;
config.numAttrs = 1;

config.stream=gc_stream; // Need to pass the CUDA stream that will be used for that kernel

int max_potential_cluster_size = 0;
// the next call will ignore cluster dims in launch config
CUDA_CHECK(cudaOccupancyMaxPotentialClusterSize(&max_potential_cluster_size, cluster_kernel, &config));
std::cout << "max potential cluster size is " << max_potential_cluster_size << " for CUDA stream gc_stream" << std::endl;

// Could choose to update launch config's clusterDim with max_potential_cluster_size.
// Doing so would result in a successful cudaLaunchKernelEx call for the same kernel and launch config.

int num_clusters= 0;
CUDA_CHECK(cudaOccupancyMaxActiveClusters(&num_clusters, cluster_kernel, &config));
std::cout << "Potential max. active clusters count is " << num_clusters << std::endl;

验证绿色上下文的使用

除了通过实测观察绿色上下文资源配置对核函数执行时间的影响之外,用户还可以利用Nsight Systems或Nsight Compute CUDA 开发者工具,在一定程度上验证绿色上下文是否得到正确使用。

例如,在属于不同绿色上下文的 CUDA 流上启动的核函数,将显示在Nsight Systems报告的 CUDA 硬件时间线部分下不同的绿色上下文行中。Nsight Compute在其会话页面中提供绿色上下文资源概览,并在详细信息部分的启动统计信息下提供更新后的SM 数量。前者提供已配置资源的可视化位掩码。如果应用程序使用不同的绿色上下文,这一点尤其有用,因为用户可以确认不同 GC 之间的重叠情况是否符合预期(没有重叠,或者在 SM 被超额订阅时存在预期的非零重叠)。

图 48以一个示例描绘了这些资源,其中有两个绿色上下文,分别配置了 112 个和 16 个 SM,彼此之间没有 SM 重叠。所提供的视图可以帮助用户验证为每个绿色上下文配置的 SM 资源数量。它还有助于确认没有任何 SM 被超额订阅,因为没有任何方框在两个绿色上下文中都被标记为绿色(表示为该 GC 配置)。

Nsight Compute 中的绿色上下文资源

图 48Nsight Compute 中的绿色上下文资源部分

图内文字中文对照
Green Context Resources
绿色上下文资源
Overview of all Green Contexts with their respective resources included in the report.
报告中包含的所有绿色上下文及其各自资源的概览。
Green Context ID
绿色上下文 ID
Result IDs
结果 ID
# TPCs
TPC 数量
Enabled TPCs
已启用的 TPC

启动统计信息部分还显式列出了为该绿色上下文配置的 SM 数量,因此这些 SM 可供该核函数使用。请注意,这些是给定核函数在执行期间可以访问的 SM,而不是该核函数实际运行所用的 SM 数量。前面展示的资源概览也是如此。核函数实际使用的 SM 数量可能取决于多种因素,包括核函数本身(启动的几何配置等)、同时在 GPU 上运行的其他工作等。

4.6.6. 其他执行上下文 API

本节简要介绍一些其他绿色上下文 API。有关完整列表,请参阅 CUDA 运行时 API 的相关章节。

要使用 CUDA 事件进行同步,可以利用cudaError_t cudaExecutionCtxRecordEvent(cudaExecutionContext_t ctx, cudaEvent_t event)和cudaError_t cudaExecutionCtxWaitEvent(cudaExecutionCtxWaitEvent(cudaExecutionContext_t ctx, cudaEvent_t event) API。cudaExecutionCtxRecordEvent记录一个 CUDA 事件,捕获指定执行上下文在此调用时的所有工作/活动,而cudaExecutionCtxWaitEvent则使今后提交到该执行上下文的所有工作等待指定事件中捕获的工作。

如果执行上下文具有多个 CUDA 流,使用cudaExecutionCtxRecordEvent比cudaEventRecord更方便。若不使用此执行上下文 API 而要实现等效行为,则需要在该执行上下文的每个流上通过cudaEventRecord记录一个单独的 CUDA 事件,然后让依赖这些工作的工作分别等待所有这些事件。同样,如果需要该执行上下文的所有流等待某个事件完成,则cudaExecutionCtxWaitEvent比cudaStreamWaitEvent更方便。替代方法是为此执行上下文中的每个流分别调用cudaStreamWaitEvent。

要在 CPU 端进行阻塞同步,可以使用cudaError_t cudaExecutionCtxSynchronize(cudaExecutionContext_t ctx)。此调用将一直阻塞,直到指定的执行上下文完成其所有工作。如果指定的执行上下文不是通过cudaGreenCtxCreate创建的,而是通过cudaDeviceGetExecutionCtx获取的,因此是设备的主上下文,则调用该函数还将同步在同一设备上创建的所有绿色上下文。

要获取给定执行上下文所关联的设备,可以使用cudaExecutionCtxGetDevice。要获取给定执行上下文的唯一标识符,可以使用cudaExecutionCtxGetId。

最后,可以通过cudaError_t cudaExecutionCtxDestroy(cudaExecutionContext_t ctx) API 销毁显式创建的执行上下文。

4.6.7. 绿色上下文示例

本节说明绿色上下文如何使关键工作更早开始和完成。与第 4.6.1 节中使用的场景类似,该应用程序有两个核函数,将在两个不同的非阻塞 CUDA 流上运行。从 CPU 端来看,时间线如下。首先在 CUDA 流strm1上启动一个长时间运行的核函数(delay_kernel_us),它在整个 GPU 上需要多个波次才能完成。然后,在短暂等待(短于该核函数的持续时间)之后,在流strm2上启动一个持续时间较短但很关键的核函数(critical_kernel)。测量这两个核函数的 GPU 持续时间以及从 CPU 启动到完成的时间。

使用一个延迟核函数来代替长时间运行的核函数,其中每个线程块运行固定的微秒数,并且线程块数量超过 GPU 可用的 SM 数量。

起初不使用绿色上下文,但关键核函数会在优先级高于长时间运行的核函数所用流的 CUDA 流上启动。由于其流具有较高优先级,长时间运行的核函数的某些线程块一旦完成,关键核函数就可以开始执行。但是,它仍然需要等待一些可能长时间运行的线程块完成,这将推迟其开始执行的时间。

图 49在 Nsight Systems 报告中展示了这一场景。长时间运行的核函数在流 13 上启动,而持续时间较短但很关键的核函数在具有更高流优先级的流 14 上启动。如图中突出显示的那样,关键核函数要等待 0.9 毫秒(在本例中)才能开始执行。如果这两个流具有相同的优先级,关键核函数的执行时间就会晚得多。

不使用绿色上下文时的 Nsight Systems 时间线

图 49不使用绿色上下文时的 Nsight Systems 时间线

图内文字中文对照
Timeline View
时间线视图
Options...
选项
71.3% Stream 13
71.3% 流 13
28.6% Stream 14
28.6% 流 14
0.1% Stream 15
0.1% 流 15
(high prio)
(高优先级)
22.1% Green Context 2
22.1% 绿色上下文 2
0.2% Green Context 3
0.2% 绿色上下文 3
100.0% Kernels
100.0% 核函数
Threads (9)
线程(9)
OS runtime libraries
操作系统运行时库
~10ms duration
持续时间约 10 毫秒
critical kernel ~50us duration
关键核函数持续时间约 50 微秒
lost time (~0.9ms)
损失的时间(约 0.9 毫秒)
delay_kernel_us launch
启动 delay_kernel_us
critical_kernel launch
启动 critical_kernel

为了利用绿色上下文功能,创建了两个绿色上下文,并为每个上下文配置了一组彼此独立、互不重叠的 SM。在本例中,为了便于说明,对具有 132 个 SM 的 H100 采用的具体 SM 拆分方式是:为关键核函数(绿色上下文 3)配置 16 个 SM,为长时间运行的核函数(绿色上下文 2)配置 112 个 SM。如图 50所示,关键核函数现在几乎可以立即开始,因为有一些 SM 只能由绿色上下文 3 使用。

与单独运行时的持续时间相比,短核函数的持续时间可能会增加,因为它现在可使用的 SM 数量受到了限制。长时间运行的核函数也是如此,它无法再使用 GPU 的所有 SM,而是受到其绿色上下文所配置资源的约束。不过,关键结果在于,与之前相比,关键核函数的工作现在可以显著更早地开始和完成。这是在没有其他任何限制的前提下而言的,因为如前所述,无法保证并行执行。

使用绿色上下文时的 Nsight Systems 时间线

图 50使用绿色上下文时的 Nsight Systems 时间线

图内文字中文对照
Timeline View
时间线视图
Options...
选项
0.1% Stream 15
0.1% 流 15
22.1% Green Context 2
22.1% 绿色上下文 2
0.2% Green Context 3
0.2% 绿色上下文 3
100.0% Kernels
100.0% 核函数
Threads (9)
线程(9)
OS runtime libraries
操作系统运行时库
~12ms duration (+2ms from before)
持续时间约 12 毫秒(比之前增加 2 毫秒)
~95us duration; almost no lost time!
持续时间约 95 微秒;几乎没有时间损失!
delay_kernel launch
启动 delay_kernel
critical_kernel launch
启动 critical_kernel

在所有情况下,都应在实验之后,根据具体情况决定确切的 SM 拆分方式。

4.7. 局部性域

局部性域是 GPU 的一部分,其中包含流式多处理器(SM)和设备内存。应用程序可以在某个局部性域中分配设备内存,并创建一个具有同一局部性域中 SM 资源的绿色上下文。将计算放置在其所访问的内存附近,可以提高具有多个局部性域的设备上的性能。计算局部化 API 在计算资源局部化中介绍,内存局部化在分配局部化设备内存中介绍。

CUDA 通过从零开始的局部性域 ID标识设备的局部性域。局部性域 ID 只有与其设备 ID 结合使用时才有意义。对 SM 资源和内存进行局部化时使用相同的 ID,使应用程序能够请求将两者放置在匹配的位置。

4.7.1. 发现局部性域

使用cudaDeviceGetAttribute,并指定以下cudaDeviceAttr值:

  • cudaDevAttrLocalityDomainCount返回设备上的局部性域数量。

  • cudaDevAttrLocalityDomainMultiprocessorCount返回每个局部性域中的 SM 数量。

应用程序应查询这些属性,而不是假定某种特定的拓扑结构。设备上可用的局部性域数量受系统配置影响。在不同情形下,相同类型的设备可能呈现不同数量的局部性域(第 4.7.5.3 节)。所有设备,包括仅有一个可用局部性域的设备,都可以使用局部性域 API。在仅有一个局部性域的设备上,这些 API 与面向整个设备的操作相同。使用设备属性以编程方式查询设备的局部性域属性:

int localityDomainCount = 0;
int smsPerLocalityDomain = 0;

cudaDeviceGetAttribute(&localityDomainCount, cudaDevAttrLocalityDomainCount, device);
cudaDeviceGetAttribute(&smsPerLocalityDomain, cudaDevAttrLocalityDomainMultiprocessorCount, device);

4.7.2. 计算资源局部化

要将 SM 资源局部化到特定的局部性域,请使用 CUDA 绿色上下文 API。SM 的放置通过第 4.6 节中介绍的绿色上下文资源 API 来表达。构建局部化 SM 分区的主要步骤如下:

  1. 使用cudaDeviceGetDevResource获取设备的cudaDevResourceTypeSm资源。

  2. 使用cudaDevSmResourceGroupParams描述局部化 SM 组(详细信息)。

  3. 将组参数传递给cudaDevSmResourceSplit。

  4. 使用cudaDevResourceGenerateDesc生成资源描述符,使用cudaGreenCtxCreate创建绿色上下文,最后通过cudaExecutionCtxStreamCreate创建流。

  5. 在该流上启动核函数。

典型的绿色上下文创建过程在第 4.6.2 节中介绍。为使 SM 资源局部化而必须添加的内容如下所示:

要描述局部化 SM 组,请在flags中设置cudaDevSmResourceGroupLocalityDomainId,并在localityDomainId中设置 ID。有效 ID 介于0和cudaDevAttrLocalityDomainCount - 1之间。

cudaDevResource smResource;
cudaDeviceGetDevResource(device, &smResource, cudaDevResourceTypeSm);

{
    cudaDevSmResourceGroupParams params = {};
    // all fields in params must be zero-initialized
    params.flags = cudaDevSmResourceGroupLocalityDomainId;
    params.localityDomainId = localityDomainId;

    cudaDevResource result;
    cudaDevSmResourceSplit(&result, 1, &smResource, nullptr, 0, &params);
}

可以在请求局部性域的同时,在params中指定多个约束。例如:

smCount约束可以为0(发现模式)或正值。在发现模式下,API 在params.smCount中返回该局部性域中的 SM 数量。对于完整、未分区的设备资源,此值与cudaDevAttrLocalityDomainMultiprocessorCount相同。当smCount为正值时,API 最多分配cudaDevAttrLocalityDomainMultiprocessorCount所指定的数量。更大的smCount请求将失败。

当coscheduledSmCount为0时,API 不会根据协同调度能力限制 SM,因此可使用最大数量的局部化 SM。之后可以通过cudaOccupancyMaxPotentialClusterSize动态查询该分区的簇能力。当coscheduledSmCount非零时,API 将仅选择满足所请求的coscheduledSmCount的局部化 SM。

默认情况下,result中的所有 SM 都必须满足局部性域约束以及指定的任何附加约束。要放宽此行为,请将cudaDevSmResourceGroupBackfill添加到params.flags。启用回填后,API 首先从所请求的局部性域填充该组,然后使用不归属于任何局部性域的 SM,最后使用其他局部性域中的 SM。如果在所请求的局部性域中找不到任何 SM,该调用将失败并返回cudaErrorInvalidResourceConfiguration。

所有未在result中返回的未使用 SM 都会在 API 的remainder参数中返回。

result的cudaDevResource.sm.flags和cudaDevResource.sm.localityDomainId报告资源的放置情况。将 SM 资源组合到一个描述符中时,这些资源的约束必须匹配,包括flags和localityDomainId的值。

以下代码查询局部性域的数量,并为每个局部性域构建一个流。

std::vector<cudaExecutionContext_t> contexts(localityDomainCount);
std::vector<cudaStream_t> streams(localityDomainCount);

{
    std::vector<cudaDevSmResourceGroupParams> params(localityDomainCount);
    std::vector<cudaDevResource> result(localityDomainCount);

    for (int i = 0; i < localityDomainCount; i++) {
        params[i] = {};
        params[i].flags = cudaDevSmResourceGroupLocalityDomainId;
        params[i].localityDomainId = i;
    }

    // A single split produces non-overlapping resources for all domains.
    cudaDevSmResourceSplit(result.data(), localityDomainCount, &smResource, nullptr, 0, params.data());

    for (int i = 0; i < localityDomainCount; i++) {
        cudaDevResourceDesc_t desc;
        cudaDevResourceGenerateDesc(&desc, &result[i], 1);
        cudaGreenCtxCreate(&contexts[i], desc, device, 0);
        cudaExecutionCtxStreamCreate(&streams[i], contexts[i], cudaStreamDefault, 0);
    }
}

streams中的每个流都在具有对应索引的局部性域中的 SM 上执行。不再需要这些流时,使用cudaStreamDestroy销毁流,然后使用cudaExecutionCtxDestroy销毁其上下文。

4.7.3. 分配局部化设备内存

CUDA 使用type为cudaMemLocationTypeDeviceLocalityDomain的cudaMemLocation来表示局部化内存位置。对于这种位置类型,请初始化cudaMemLocation.localized的两个成员:

  • deviceId标识 GPU。

  • localityDomainId标识该 GPU 上的一个局部性域。

运行时 API 将此位置用于流有序内存池(第 4.3 节)。驱动程序 API 还将相应的CUmemLocation用于虚拟内存管理分配(第 4.17 节)。

通过其他机制(例如cudaMalloc或统一内存)创建的分配无法局部化。MLOPart 模式下的MPS(多进程服务)可用于对依赖cudaMalloc的应用程序透明地进行局部化。

4.7.3.1. 创建局部化内存池

要创建局部化内存池,请在cudaMemPoolProps中设置位置并调用cudaMemPoolCreate:

cudaMemPool_t pool {};
cudaMemPoolProps props {};

props.allocType = cudaMemAllocationTypePinned;
props.location.type = cudaMemLocationTypeDeviceLocalityDomain;
props.location.localized.deviceId = device;
props.location.localized.localityDomainId = localityDomainId;

cudaMemPoolCreate(&pool, &props);

使用cudaMallocFromPoolAsync从该池分配内存,并使用cudaFreeAsync释放分配。在为该局部性域选择默认或当前页锁定内存池时,也可以将该位置传递给cudaMemGetDefaultMemPool、cudaMemGetMemPool和cudaMemSetMemPool。有关通用内存池编程模型,请参见内存池。

4.7.3.2. 创建局部化 VMM 分配

驱动程序 API 可以将cuMemCreate创建的物理内存放置在某个局部性域中。将CUmemAllocationProp::location.type设置为CU_MEM_LOCATION_TYPE_DEVICE_LOCALITY_DOMAIN,并初始化location.localized.deviceId和location.localized.localityDomainId。所生成的物理分配按与其他 VMM 分配相同的方式进行预留、映射和访问授权。通过cuMemSetAccess设置的访问控制以整个设备为粒度,而不是以单个局部性域为粒度。有关完整的分配顺序和粒度要求,请参见局部化 VMM 分配一节。

托管内存无法局部化:使用cudaMemLocationTypeDeviceLocalityDomain请求cudaMemAllocationTypeManaged将返回CUDA_ERROR_NOT_SUPPORTED。同样,cudaMemPrefetchAsync和cudaMemAdvise不接受设备局部性域作为位置,并将返回CUDA_ERROR_NOT_SUPPORTED。

有关局部性域与统一内存之间的关系,请参见局部性域与统一内存。

4.7.4. 查询内存放置情况

使用cudaMemPoolGetAttribute并指定cudaMemPoolAttrLocalityDomainId来查询池。对于局部化池,它返回局部性域 ID;对于非局部化池,它返回-1。

使用cudaPointerGetAttributes通过指针查询分配。返回的cudaPointerAttributes.localityDomainOrdinal字段包含该分配的局部性域序号;如果该分配未局部化,则为-1:

cudaPointerAttributes attributes {};
cudaPointerGetAttributes(&attributes, ptr);
int localityDomainId = attributes.localityDomainOrdinal;

4.7.5. 编程指导

要使工作负载局部化,请使用与核函数的内存分配位于同一局部性域的计算资源来启动核函数。

仅对计算资源或仅对内存资源进行局部化通常不会提高性能。

如果核函数必须使用来自多个局部性域的内存,请测量计算局部化是否有益。

4.7.5.1. 采用分叉/汇合模式的局部化核函数

分叉/汇合模式使用事件和流等待来表达并行区域。应用程序使用cudaExecutionCtxStreamCreate从每个局部化绿色上下文创建一个流。一个单独的主流协调这些局部化流。使用cudaEventCreateWithFlags为主流创建一个分叉事件,并为每个局部化流创建一个汇合事件;当事件仅用于排序时,应禁用计时。

要进行分叉,请在父流上记录一个事件,并让每个局部化流等待该事件。因此,先前提交到父流的工作在局部化工作之前执行。将独立的工作提交到各个局部化流,并使用来自其对应局部性域的内存。

要进行汇合,请在每个局部化流的工作之后记录一个事件,并让父流等待所有这些事件。随后提交到父流的工作便会在局部化工作之后执行。

produceInputs<<<grid, block, 0, parentStream>>>(...);
cudaEventRecord(forkEvent, parentStream);

// localizedStreams[i] belongs to the green context for locality domain i.
for (int i = 0; i < localityDomainCount; i++) {
    cudaStreamWaitEvent(localizedStreams[i], forkEvent);
    processPartition<<<grid, block, 0, localizedStreams[i]>>>(...);
    cudaEventRecord(joinEvents[i], localizedStreams[i]);
}

for (int i = 0; i < localityDomainCount; i++) {
    cudaStreamWaitEvent(parentStream, joinEvents[i]);
}
consumeResults<<<grid, block, 0, parentStream>>>(...);

4.7.5.2. CUDA 图

CUDA 图(第 4.2 节)通过流捕获和手动图构建来支持局部化。对于某些应用程序,CUDA 图可能比基于流的编程提供更好的性能。

4.7.5.2.1. 流捕获

可以将分叉/汇合模式捕获为 CUDA 图。在父流上开始捕获,记录分叉事件,并在每个局部化流中等待该分叉事件。等待分叉事件会将局部化流纳入与父流相同的捕获图。提交到局部化流的操作成为并行的图分支。在局部化流上启动所有工作后,记录汇合事件,并在父流中等待所有汇合事件。这些等待会将所有分支重新连接到父流。在调用cudaStreamEndCapture之前,每个参与的流都必须汇合回起始流。

cudaStreamBeginCapture(parentStream, cudaStreamCaptureModeGlobal);

// Record forkEvent, submit work to each localized stream, record the
// joinEvents, and make parentStream wait as in the fork/join example above.

cudaGraph_t graph;
cudaStreamEndCapture(parentStream, &graph);

每个子流的局部性限制都会由捕获的核函数节点保留。即使在不同的流上启动可执行图,图重放仍使用原始的局部化 SM。图启动流提供排序,但不会替换节点中记录的执行资源。

4.7.5.2.2. 图构建

可以显式构建相同的图拓扑。在cudaKernelNodeParamsV2.ctx中使用相应的局部化执行上下文,为每个局部性域添加一个核函数节点。

cudaGraph_t graph = nullptr;
cudaGraphCreate(&graph, 0);

std::vector<cudaGraphNode_t> localizedNodes(localityDomainCount);
for (int i = 0; i < localityDomainCount; i++) {
    cudaGraphNodeParams params = { CU_GRAPH_NODE_TYPE_KERNEL };
    params.kernel.kern = kernel;
    params.kernel.gridDim = grid;
    params.kernel.blockDim = block;
    params.kernel.kernelParams = kernelArgs[i];
    params.kernel.ctx = localizedContexts[i];

    cudaGraphAddNode(&localizedNodes[i], graph, nullptr, nullptr, 0, &params);
}

4.7.5.3. 兼容性注意事项

vGPU 和 MIG 设备不支持局部化,并且仅报告 1 个局部性域。

Windows 上的 CUDA 不支持局部化,并且仅报告 1 个局部性域。

CUDA Checkpoint 不保证局部化绿色上下文在另一台设备上恢复时仍保留其局部化属性。应用程序在恢复后可能出现性能下降。

MLOPart 模式下的MPS(多进程服务)将每个 MLOPart 设备呈现为具有单个局部性域的 CUDA 设备。MPS 静态分区将整个设备视为单个局部性域。

4.8. 延迟加载

4.8.1. 简介

延迟加载通过等到需要 CUDA 模块时才加载它们,来减少程序初始化时间。对于仅使用其所包含核函数中一小部分的程序,延迟加载尤其有效,使用库时通常就是这种情况。延迟加载的设计目标是,在遵循 CUDA 编程模型时,用户无法察觉它的存在。潜在风险对此进行了详细说明。从 CUDA 12.3 起,所有平台都默认启用延迟加载,但可以通过CUDA_MODULE_LOADING环境变量进行控制。

4.8.2. 变更历史

表 17 按 CUDA 版本列出的部分延迟加载变更

CUDA 版本

变更

12.3

延迟加载性能得到改进。现在 Windows 默认启用延迟加载。

12.2

Linux 默认启用延迟加载。

11.7

首次引入延迟加载,默认禁用。

4.8.3. 延迟加载的要求

延迟加载是 CUDA 运行时和驱动程序共同提供的功能。只有满足运行时和驱动程序版本要求时,才能使用延迟加载。

4.8.3.1. CUDA 运行时版本要求

从 CUDA 运行时版本 11.7 开始提供延迟加载。由于 CUDA 运行时通常以静态方式链接到程序和库中,因此只有来自 CUDA 11.7 及更高版本工具包或使用这些工具包编译的程序和库才能受益于延迟加载。使用较旧 CUDA 运行时版本编译的库会立即加载所有模块。

4.8.3.2. CUDA 驱动程序版本要求

延迟加载要求驱动程序版本为 515 或更高。对于低于 515 的驱动程序版本,即使使用 CUDA 工具包 11.7 或更高版本,也无法使用延迟加载。

4.8.3.3. 编译器要求

延迟加载不需要任何编译器支持。使用 11.7 之前版本的编译器编译的 SASS 和 PTX 都可以在启用延迟加载的情况下加载,并且能够充分受益于该功能。不过,如上所述,仍然需要版本为 11.7 或更高的 CUDA 运行时。

4.8.3.4. 核函数要求

延迟加载不会影响包含托管变量的模块,这些模块仍会立即加载。

4.8.4. 用法

4.8.4.1. 启用与禁用

将CUDA_MODULE_LOADING环境变量设置为LAZY即可启用延迟加载。将CUDA_MODULE_LOADING环境变量设置为EAGER可以禁用延迟加载。从 CUDA 12.3 起,所有平台都默认启用延迟加载。

4.8.4.2. 在运行时检查是否启用了延迟加载

CUDA 驱动程序 API 中的cuModuleGetLoadingModeAPI 可用于确定是否启用了延迟加载。注意,运行此函数之前必须初始化 CUDA。下面的代码片段展示了用法示例。

#include "<cuda.h>"
#include "<assert.h>"
#include "<iostream>"

int main() {
        CUmoduleLoadingMode mode;

        assert(CUDA_SUCCESS == cuInit(0));
        assert(CUDA_SUCCESS == cuModuleGetLoadingMode(&mode));

        std::cout << "CUDA Module Loading Mode is " << ((mode == CU_MODULE_LAZY_LOADING) ? "lazy" : "eager") << std::endl;

        return 0;
}

4.8.4.3. 在运行时强制立即加载模块

核函数和变量会自动加载,无需进行任何显式加载。即使不执行核函数,也可以通过以下方式显式加载它们:

  • cuModuleGetFunction()函数会使模块加载到设备内存中

  • cudaFuncGetAttributes()函数会使核函数加载到设备内存中

注意

cuModuleLoad()不保证模块会立即加载。

4.8.5. 潜在风险

延迟加载的设计目标是,使用它应不需要对应用程序做任何修改。尽管如此,仍有一些注意事项,特别是在应用程序未完全遵循 CUDA 编程模型时,如下所述。

4.8.5.1. 对核函数并发执行的影响

一些程序错误地假定核函数并发执行是有保证的。如果需要跨核函数同步,但核函数执行已被串行化,则可能发生死锁。要尽量减小延迟加载对核函数并发执行的影响,请执行以下操作:

  • 在启动希望并发执行的所有核函数之前,预加载这些核函数;或者

  • 使用CUDA_MODULE_LOADING = EAGER运行应用程序,以强制立即加载数据,而不强制立即加载每个函数

4.8.5.2. 大规模内存分配

延迟加载将 CUDA 模块的内存分配从程序初始化时推迟到更接近执行的时间。如果应用程序在启动时分配了全部显存,CUDA 可能在运行时无法为模块分配内存。可能的解决方案:

  • 使用cudaMallocAsync(),而不是使用在启动时分配全部显存的分配器

  • 留出一些余量,以应对核函数的延迟加载

  • 在尝试初始化分配器之前,预加载程序中将使用的所有核函数

4.8.5.3. 对性能测量的影响

延迟加载可能将 CUDA 模块初始化移入测量的执行时间窗口,从而使性能测量产生偏差。为避免这种情况:

  • 在测量之前至少进行一次预热迭代

  • 在启动接受基准测试的核函数之前,先预加载该核函数

4.9. 错误日志管理

错误日志管理允许用通俗英语报告 CUDA API 错误,与仅使用 API 返回值相比,能够提供有关错误原因的更多信息。

4.9.1. 背景

传统上,CUDA API 调用失败的唯一指示是非零返回码。许多 CUDA 错误码只描述一类故障,而不指出是哪个参数或哪项要求导致了故障。错误日志管理在这些返回码之外补充了描述性消息,旨在帮助开发者诊断 API 使用错误。

4.9.2. 启用

将环境变量CUDA_LOG_FILE设置为stdout、stderr或可写文件的路径。CUDA 会将生成的每条日志消息写入所选目标。

CUDA 还会将最近的消息存储在内部缓冲区中。即使在应用程序启动前未设置CUDA_LOG_FILE,应用程序也可以使用错误日志管理 API 注册回调或转储此缓冲区。无错误的执行可能不会生成任何消息。

4.9.3. 输出

写入CUDA_LOG_FILE或由转储 API 返回的消息使用以下格式:

[Time][TID][Source][Severity] Message

例如,调用cuLogsDumpToMemory时将缓冲区参数设为空指针,可能会生成类似以下内容的消息:

[22:21:32.099][25642][CUDA][E] buffer cannot be NULL
[22:21:32.099][25642][CUDA][E] Returning 1 (CUDA_ERROR_INVALID_VALUE) from cuLogsDumpToMemory

如果不使用错误日志管理,该调用会返回CUDA_ERROR_INVALID_VALUE。调用cuGetErrorString会提供通用描述invalid argument,但不会指出哪个参数无效。

4.9.4. API 说明

CUDA Driver API 和 Runtime API 均提供错误日志管理功能。Runtime 接口可处理 Runtime API 和 Driver API 生成的消息;这两个接口使用相同的底层日志缓冲区。

4.9.4.1. 回调注册

Driver API 的回调类型和注册函数如下:

typedef void (CUDA_CB *CUlogsCallback)(
    void *data, CUlogLevel logLevel, char *message, size_t length);

CUresult CUDAAPI cuLogsRegisterCallback(
    CUlogsCallback callbackFunc,
    void *userData,
    CUlogsCallbackHandle *callback_out);

CUresult CUDAAPI cuLogsUnregisterCallback(
    CUlogsCallbackHandle callback);

对应的 Runtime API 回调类型和函数如下:

typedef void (CUDART_CB *cudaLogsCallback_t)(
    void *data, cudaLogLevel logLevel, char *message, size_t length);

cudaError_t CUDARTAPI cudaLogsRegisterCallback(
    cudaLogsCallback_t callbackFunc,
    void *userData,
    cudaLogsCallbackHandle *callback_out);

cudaError_t CUDARTAPI cudaLogsUnregisterCallback(
    cudaLogsCallbackHandle callback);

Driver 回调将错误报告为CU_LOG_LEVEL_ERROR,将警告报告为CU_LOG_LEVEL_WARNING。对应的 Runtime 值为cudaLogLevelError和cudaLogLevelWarning。格式化输出分别将这些级别表示为E和W。

CUDA 将userData原样传递给回调。callback_out参数是可选的,但如果应用程序打算注销该回调,就必须保留返回的句柄。

回调接收消息文本及其明确的字节长度。消息不包含添加到格式化文件输出和转储输出中的时间戳、线程 ID、来源或严重性前缀。除非 API 参考明确保证该行为,否则应用程序不应依赖特定的回调线程或调用时机。

4.9.4.2. 迭代器与日志转储

日志迭代器标识内部缓冲区中的一个位置。调用cuLogsCurrent或cudaLogsCurrent会将迭代器设置为缓冲区的当前末尾,因此,之后使用该迭代器进行转储时,只会返回随后生成的消息。

Driver API 函数如下:

CUresult CUDAAPI cuLogsCurrent(
    CUlogIterator *iterator_out, unsigned int flags);

CUresult CUDAAPI cuLogsDumpToFile(
    CUlogIterator *iterator,
    const char *pathToFile,
    unsigned int flags);

CUresult CUDAAPI cuLogsDumpToMemory(
    CUlogIterator *iterator,
    char *buffer,
    size_t *size,
    unsigned int flags);

对应的 Runtime API 函数如下:

cudaError_t CUDARTAPI cudaLogsCurrent(
    cudaLogIterator *iterator_out, unsigned int flags);

cudaError_t CUDARTAPI cudaLogsDumpToFile(
    cudaLogIterator *iterator,
    const char *pathToFile,
    unsigned int flags);

cudaError_t CUDARTAPI cudaLogsDumpToMemory(
    cudaLogIterator *iterator,
    char *buffer,
    size_t *size,
    unsigned int flags);

所有获取当前位置的函数和转储函数中的flags参数均保留供将来使用,且必须为零。

以下 Runtime API 示例标记日志的当前末尾,并在之后仅转储该位置之后生成的消息:

#include <cstdio>
#include <cuda_runtime_api.h>

cudaError_t printNewCudaLogs()
{
    cudaLogIterator iterator;
    cudaError_t status = cudaLogsCurrent(&iterator, 0);
    if (status != cudaSuccess) {
        return status;
    }

    int deviceCount;
    status = cudaGetDeviceCount(&deviceCount);
    cudaError_t deviceCountStatus = status;

    char buffer[25600];
    size_t size = sizeof(buffer);
    status = cudaLogsDumpToMemory(&iterator, buffer, &size, 0);
    if (status != cudaSuccess) {
        return status;
    }
    if (size != 0) {
        std::fwrite(buffer, 1, size, stderr);
    }
    return deviceCountStatus;
}

在两者之间执行的 CUDA 调用仍然需要正常处理返回码。此示例演示了迭代器如何选择一段日志消息;它不要求故意执行无效的 CUDA 调用。该固定大小的数组使用文档规定的最大转储容量,即 25,600 字节。

4.9.4.3. 转储行为

如果iterator为空,转储会请求内部缓冲区当前保留的所有条目。如果提供了迭代器,转储就从该位置开始。成功转储后,CUDA 会将所提供的迭代器推进到日志的当前末尾。

内部缓冲区最多保留 100 个条目。如果请求的消息已被覆盖而不再保留,CUDA 会添加一条声明,指出输出已被截断,并返回仍然可用的条目。

cuLogsDumpToMemory和cudaLogsDumpToMemory还有以下要求:

  1. size最初指定buffer的容量。

  2. CUDA 会以空字符终止该缓冲区。各条目之间以换行符而非空字符分隔。

  3. 25,600 字节的输出缓冲区足以容纳所有保留的日志数据。

  4. 成功时,size包含已写入的字节数,不包括终止空字符。

  5. 如果没有消息可供转储,函数会成功并将size设置为零。

  6. 如果缓冲区只能容纳部分而非全部请求的消息,CUDA 会在输出前添加一条截断声明,省略无法容纳的最旧消息,并在输出中保留最新的消息。

  7. 如果缓冲区无法容纳任何消息,函数会返回CUDA_ERROR_INVALID_VALUE或cudaErrorInvalidValue,并将size设置为零。

4.9.5. 限制与已知问题

  1. 达到 100 个条目的上限时,CUDA 会替换最旧的条目。

  2. 目前并非所有 CUDA API 都会生成描述性消息。覆盖范围仍在持续扩大。

  3. CUDA 直到生成消息时才会验证CUDA_LOG_FILE目标。

  4. 日志消息未进行本地化;CUDA 以美式英语提供这些消息。

4.10. 异步屏障

高级同步原语中介绍的异步屏障将 CUDA 的同步能力扩展到了__syncthreads()和__syncwarp()之外,能够实现细粒度的非阻塞协调,并使通信与计算更好地重叠。

本节详细介绍如何主要通过cuda::barrier API 使用异步屏障(在适用之处提供指向cuda::ptx及原语的参考)。

4.10.1. 初始化

初始化必须在任何线程开始参与屏障之前完成。

#include <cuda/barrier>
#include <cooperative_groups.h>

__global__ void init_barrier()
{
  __shared__ cuda::barrier<cuda::thread_scope_block> bar;
  auto block = cooperative_groups::this_thread_block();

  if (block.thread_rank() == 0)
  {
    // A single thread initializes the total expected arrival count.
    init(&bar, block.size());
  }
  block.sync();
}
#include <cuda/ptx>
#include <cooperative_groups.h>

__global__ void init_barrier()
{
  __shared__ uint64_t bar;
  auto block = cooperative_groups::this_thread_block();

  if (block.thread_rank() == 0)
  {
    // A single thread initializes the total expected arrival count.
    cuda::ptx::mbarrier_init(&bar, block.size());
  }
  block.sync();
}
#include <cuda_awbarrier_primitives.h>
#include <cooperative_groups.h>

__global__ void init_barrier()
{
  __shared__ uint64_t bar;
  auto block = cooperative_groups::this_thread_block();

  if (block.thread_rank() == 0)
  {
    // A single thread initializes the total expected arrival count.
    __mbarrier_init(&bar, block.size());
  }
  block.sync();
}

在任何线程可以参与屏障之前,必须使用友元函数cuda::barrier::init()初始化该屏障。这必须在线程到达屏障之前完成。这带来了一个自举难题:线程必须在参与屏障之前同步,但线程创建屏障正是为了同步。在此示例中,将参与的线程属于一个协作组,并使用block.sync()来引导初始化。由于整个线程块都参与该屏障,因此也可以使用__syncthreads()。

init()的第二个参数是预期到达计数,即在一个参与线程从其对bar.wait(std::move(token))的调用中解除阻塞之前,参与线程将调用bar.arrive()的次数。在本示例和前面的示例中,屏障使用线程块中的线程数(即cooperative_groups::this_thread_block().size())进行初始化,以便线程块内的所有线程都能参与屏障。

异步屏障可灵活指定线程如何参与(拆分到达与等待)以及哪些线程参与。相比之下,this_thread_block.sync()或__syncthreads()适用于整个线程块,而__syncwarp(mask)适用于线程束的指定子集。不过,如果用户的意图是同步整个线程块或整个线程束,我们建议分别使用__syncthreads()和__syncwarp()以获得更好的性能。

4.10.2. 屏障的阶段:到达、递减计数、完成与重置

随着参与线程调用bar.arrive(),异步屏障从预期到达计数递减至零。当计数减至零时,屏障的当前阶段就已完成。当最后一次bar.arrive()调用使计数减至零时,该递减计数会以原子方式自动重置。重置会将递减计数设为预期到达计数,并使屏障进入下一阶段。

由token=bar.arrive()返回的cuda::barrier::arrival_token类的token对象与屏障的当前阶段相关联。当屏障处于当前阶段时,即当令牌关联的阶段与屏障的阶段相匹配时,调用bar.wait(std::move(token))会阻塞调用线程。如果阶段在调用bar.wait(std::move(token))之前推进了(由于计数减至零),那么线程不会阻塞;如果阶段在线程阻塞于bar.wait(std::move(token))期间推进了,那么该线程会解除阻塞。

必须清楚何时可能发生重置、何时不可能发生重置,尤其是在复杂的到达/等待同步模式中。

  • 线程对token=bar.arrive()和bar.wait(std::move(token))的调用必须按顺序安排,使token=bar.arrive()发生在屏障的当前阶段,而bar.wait(std::move(token))发生在同一阶段或下一阶段。

  • 线程对bar.arrive()的调用必须在屏障计数器非零时发生。屏障初始化后,如果线程对bar.arrive()的调用使计数减至零,那么在屏障能够被后续的bar.arrive()调用复用之前,必须先调用bar.wait(std::move(token))。

  • 只能使用当前阶段或紧邻的前一阶段的token对象调用bar.wait()。对于token对象的任何其他值,其行为均未定义。

对于简单的到达/等待同步模式,遵守这些使用规则很容易。

4.10.2.1. 线程束纠缠

线程束分歧会影响到达操作更新屏障的次数。如果发起调用的线程束完全收敛,则屏障会更新一次。如果发起调用的线程束完全分歧,则会对屏障执行 32 次单独的更新。

注意

建议由收敛的线程调用arrive-on(bar),以尽量减少对屏障对象的更新。当这些操作之前的代码导致线程分歧时,应在调用到达操作之前通过__syncwarp使线程束重新收敛。

4.10.3. 显式阶段跟踪

异步屏障可以有多个阶段,具体取决于它用于同步线程和内存操作的次数。我们可以使用通过cuda::ptx和原语 API 提供的mbarrier_try_wait_parity()系列函数来直接跟踪阶段,而不必使用令牌来跟踪屏障的阶段翻转。

在最简单的形式中,cuda::ptx::mbarrier_try_wait_parity(uint64_t* bar, const uint32_t& phaseParity)函数等待具有特定奇偶性的阶段。phaseParity操作数是屏障对象当前阶段或紧邻的前一阶段的整数奇偶值。偶数阶段的整数奇偶值为 0,奇数阶段的整数奇偶值为 1。初始化屏障时,其阶段的奇偶值为 0。因此,phaseParity的有效值为 0 和 1。跟踪异步内存操作时,显式阶段跟踪可能很有用,因为它允许仅由一个线程到达屏障并设置事务计数,而其他线程只需等待基于奇偶性的阶段翻转。这可能比让所有线程都到达屏障并使用令牌更高效。此功能仅适用于线程块和簇作用域的共享内存屏障。

#include <cuda/ptx>
#include <cooperative_groups.h>

__device__ void compute(float *data, int iteration);

__global__ void split_arrive_wait(int iteration_count, float *data)
{
  using barrier_t = cuda::barrier<cuda::thread_scope_block>;
  __shared__ barrier_t bar;
  int parity = 0; // Initial phase parity is 0.
  auto block = cooperative_groups::this_thread_block();

  if (block.thread_rank() == 0)
  {
    // Initialize barrier with expected arrival count.
    init(&bar, block.size());
  }
  block.sync();

  for (int i = 0; i < iteration_count; ++i)
  {
    /* code before arrive */

    // This thread arrives. Arrival does not block a thread.
    // Get a handle to the native barrier to use with cuda::ptx API.
    (void)cuda::ptx::mbarrier_arrive(cuda::device::barrier_native_handle(bar));

    compute(data, i);

    // Wait for all threads participating in the barrier to complete mbarrier_arrive().
    // Get a handle to the native barrier to use with cuda::ptx API.
    while (!cuda::ptx::mbarrier_try_wait_parity(cuda::device::barrier_native_handle(bar), parity)) {}
    // Flip parity.
    parity ^= 1;

    /* code after wait */
  }
}
#include <cuda/ptx>
#include <cooperative_groups.h>

__device__ void compute(float *data, int iteration);

__global__ void split_arrive_wait(int iteration_count, float *data)
{
  __shared__ uint64_t bar;
  int parity = 0; // Initial phase parity is 0.
  auto block = cooperative_groups::this_thread_block();

  if (block.thread_rank() == 0)
  {
    // Initialize barrier with expected arrival count.
    cuda::ptx::mbarrier_init(&bar, block.size());
  }
  block.sync();

  for (int i = 0; i < iteration_count; ++i)
  {
    /* code before arrive */

    // This thread arrives. Arrival does not block a thread.
    (void)cuda::ptx::mbarrier_arrive(&bar);

    compute(data, i);

    // Wait for all threads participating in the barrier to complete mbarrier_arrive().
    while (!cuda::ptx::mbarrier_try_wait_parity(&bar, parity)) {}
    // Flip parity.
    parity ^= 1;

    /* code after wait */
  }
}
#include <cuda_awbarrier_primitives.h>
#include <cooperative_groups.h>

__device__ void compute(float *data, int iteration);

__global__ void split_arrive_wait(int iteration_count, float *data)
{
  __shared__ __mbarrier_t bar;
  bool parity = false; // Initial phase parity is false.
  auto block = cooperative_groups::this_thread_block();

  if (block.thread_rank() == 0)
  {
    // Initialize barrier with expected arrival count.
    __mbarrier_init(&bar, block.size());
  }
  block.sync();

  for (int i = 0; i < iteration_count; ++i)
  {
    /* code before arrive */

    // This thread arrives. Arrival does not block a thread.
    (void)__mbarrier_arrive(&bar);

    compute(data, i);

    // Wait for all threads participating in the barrier to complete __mbarrier_arrive().
    while(!__mbarrier_try_wait_parity(&bar, parity, 1000)) {}
    parity ^= 1;

    /* code after wait */
  }
}

4.10.4. 提前退出

当参与一系列同步的线程必须提前退出该同步序列时,该线程必须在退出之前显式停止参与。其余参与线程可以正常继续后续的到达和等待操作。

#include <cuda/barrier>
#include <cooperative_groups.h>

__device__ bool condition_check();

__global__ void early_exit_kernel(int N)
{
  __shared__ cuda::barrier<cuda::thread_scope_block> bar;
  auto block = cooperative_groups::this_thread_block();

  if (block.thread_rank() == 0)
  {
    init(&bar, block.size());
  }
  block.sync();

  for (int i = 0; i < N; ++i)
  {
    if (condition_check())
    {
      bar.arrive_and_drop();
      return;
    }
    // Other threads can proceed normally.
    auto token = bar.arrive();

    /* code between arrive and wait */

    // Wait for all threads to arrive.
    bar.wait(std::move(token));

    /* code after wait */
  }
}
#include <cuda_awbarrier_primitives.h>
#include <cooperative_groups.h>

__device__ bool condition_check();

__global__ void early_exit_kernel(int N)
{
  __shared__ __mbarrier_t bar;
  auto block = cooperative_groups::this_thread_block();

  if (block.thread_rank() == 0)
  {
    __mbarrier_init(&bar, block.size());
  }
  block.sync();

  for (int i = 0; i < N; ++i)
  {
    if (condition_check())
    {
      __mbarrier_token_t token = __mbarrier_arrive_and_drop(&bar);
      return;
    }
    // Other threads can proceed normally.
    __mbarrier_token_t token = __mbarrier_arrive(&bar);

    /* code between arrive and wait */

    // Wait for all threads to arrive.
    while (!__mbarrier_try_wait(&bar, token, 1000)) {}

    /* code after wait */
  }
}

bar.arrive_and_drop()操作到达屏障,履行参与线程在当前阶段到达的义务,然后递减下一阶段的预期到达计数,从而不再预期此线程到达屏障。

4.10.5. 完成函数

cuda::barrier API 支持一个可选的完成函数。cuda::barrier<Scope, CompletionFunction>的CompletionFunction在每个阶段执行一次,时间是在最后一个线程到达之后、任何线程从wait解除阻塞之前。在该阶段到达barrier的线程所执行的内存操作,对执行CompletionFunction的线程可见;在CompletionFunction内执行的所有内存操作,则对所有在barrier处等待的线程可见,前提是这些线程已从wait解除阻塞。

#include <cuda/barrier>
#include <cooperative_groups.h>
#include <functional>
namespace cg = cooperative_groups;

__device__ int divergent_compute(int *, int);
__device__ int independent_computation(int *, int);

__global__ void psum(int *data, int n, int *acc)
{
  auto block = cg::this_thread_block();

  constexpr int BlockSize = 128;
  __shared__ int smem[BlockSize];
  assert(BlockSize == block.size());
  assert(n % BlockSize == 0);

  auto completion_fn = [&]
  {
    int sum = 0;
    for (int i = 0; i < BlockSize; ++i)
    {
      sum += smem[i];
    }
    *acc += sum;
  };

  /* Barrier storage.
     Note: the barrier is not default-constructible because
           completion_fn is not default-constructible due
           to the capture. */
  using completion_fn_t = decltype(completion_fn);
  using barrier_t = cuda::barrier<cuda::thread_scope_block,
                                  completion_fn_t>;
  __shared__ std::aligned_storage<sizeof(barrier_t),
                                  alignof(barrier_t)>
      bar_storage;

  // Initialize barrier.
  barrier_t *bar = (barrier_t *)&bar_storage;
  if (block.thread_rank() == 0)
  {
    assert(*acc == 0);
    assert(blockDim.x == blockDim.y == blockDim.y == 1);
    new (bar) barrier_t{block.size(), completion_fn};
    /* equivalent to: init(bar, block.size(), completion_fn); */
  }
  block.sync();

  // Main loop.
  for (int i = 0; i < n; i += block.size())
  {
    smem[block.thread_rank()] = data[i] + *acc;
    auto token = bar->arrive();
    // We can do independent computation here.
    bar->wait(std::move(token));
    // Shared-memory is safe to re-use in the next iteration
    // since all threads are done with it, including the one
    // that did the reduction.
  }
}

4.10.6. 跟踪异步内存操作

异步屏障可用于跟踪异步内存复制。当异步复制操作绑定到屏障时,该复制操作会在启动时自动递增当前屏障阶段的预期计数,并在完成时递减该计数。此机制确保屏障的wait()操作会阻塞,直到所有关联的异步内存复制都已完成,从而提供一种同步多个并发内存操作的便捷方式。

从计算能力 9.0 开始,位于共享内存中、具有线程块或簇作用域的异步屏障可以显式跟踪异步内存操作。我们将这些屏障称为异步事务屏障。除了预期到达计数外,屏障对象还可以接受一个事务计数,该计数可用于跟踪异步事务的完成情况。事务计数跟踪尚未完成的异步事务数量,单位由异步内存操作指定(通常为字节)。当前阶段要跟踪的事务计数可以在到达时通过cuda::device::barrier_arrive_tx()设置,也可以直接通过cuda::device::barrier_expect_tx()设置。当屏障使用事务计数时,它会在等待操作处阻塞线程,直到所有生产者线程都执行了到达操作,并且所有事务计数之和达到预期值。

#include <cuda/barrier>
#include <cooperative_groups.h>

__global__ void track_kernel()
{
  __shared__ cuda::barrier<cuda::thread_scope_block> bar;
  auto block = cooperative_groups::this_thread_block();

  if (block.thread_rank() == 0)
  {
    init(&bar, block.size());
  }
  block.sync();

  auto token = cuda::device::barrier_arrive_tx(bar, 1, 0);

  bar.wait(cuda::std::move(token));
}
#include <cuda/ptx>
#include <cooperative_groups.h>

__global__ void track_kernel()
{
  __shared__ uint64_t bar;
  auto block = cooperative_groups::this_thread_block();

  if (block.thread_rank() == 0)
  {
    cuda::ptx::mbarrier_init(&bar, block.size());
  }
  block.sync();

  uint64_t token = cuda::ptx::mbarrier_arrive_expect_tx(cuda::ptx::sem_release, cuda::ptx::scope_cluster, cuda::ptx::space_shared, &bar, 1, 0);

  while (!cuda::ptx::mbarrier_try_wait(&bar, token)) {}
}

在此示例中,cuda::device::barrier_arrive_tx()操作构造一个与当前阶段的阶段同步点相关联的到达令牌对象。随后,将到达计数减 1,并将预期事务计数加 0。由于事务计数的更新量为 0,该屏障不跟踪任何事务。后续的使用张量内存加速器(TMA)一节包含跟踪异步内存操作的示例。

4.10.7. 使用屏障的生产者-消费者模式

可以对线程块进行空间划分,以允许不同的线程执行独立的操作。最常见的做法是将线程块内不同线程束中的线程分配给特定任务。这项技术称为线程束专门化。

本节展示了生产者-消费者模式中的空间划分示例,其中一个线程子集生成数据,另一个与其不相交的线程子集并发消费这些数据。生产者-消费者空间划分模式需要两次单边同步,以管理生产者和消费者之间的数据缓冲区。

生产者

消费者

等待缓冲区准备好被填充

发出缓冲区已准备好被填充的信号

生成数据并填充缓冲区

发出缓冲区已填满的信号

等待缓冲区被填满

消费已填满缓冲区中的数据

生产者线程等待消费者线程发出缓冲区已准备好被填充的信号;但是,消费者线程不等待此信号。消费者线程等待生产者线程发出缓冲区已填满的信号;但是,生产者线程不等待此信号。为了实现生产者与消费者的完全并发,此模式采用(至少)双缓冲,其中每个缓冲区需要两个屏障。

#include <cuda/barrier>

using barrier_t = cuda::barrier<cuda::thread_scope_block>;

__device__ void produce(barrier_t ready[], barrier_t filled[], float *buffer, int buffer_len, float *in, int N)
{
  for (int i = 0; i < N / buffer_len; ++i)
  {
    ready[i % 2].arrive_and_wait(); /* wait for buffer_(i%2) to be ready to be filled */
    /* produce, i.e., fill in, buffer_(i%2)  */
    barrier_t::arrival_token token = filled[i % 2].arrive(); /* buffer_(i%2) is filled */
  }
}

__device__ void consume(barrier_t ready[], barrier_t filled[], float *buffer, int buffer_len, float *out, int N)
{
  barrier_t::arrival_token token1 = ready[0].arrive(); /* buffer_0 is ready for initial fill */
  barrier_t::arrival_token token2 = ready[1].arrive(); /* buffer_1 is ready for initial fill */
  for (int i = 0; i < N / buffer_len; ++i)
  {
    filled[i % 2].arrive_and_wait(); /* wait for buffer_(i%2) to be filled */
    /* consume buffer_(i%2) */
    barrier_t::arrival_token token3 = ready[i % 2].arrive(); /* buffer_(i%2) is ready to be re-filled */
  }
}

__global__ void producer_consumer_pattern(int N, float *in, float *out, int buffer_len)
{
  constexpr int warpSize = 32;

  /* Shared memory buffer declared below is of size 2 * buffer_len
     so that we can alternatively work between two buffers.
     buffer_0 = buffer and buffer_1 = buffer + buffer_len */
  __shared__ extern float buffer[];

  /* bar[0] and bar[1] track if buffers buffer_0 and buffer_1 are ready to be filled,
     while bar[2] and bar[3] track if buffers buffer_0 and buffer_1 are filled-in respectively */
  #pragma nv_diag_suppress static_var_with_dynamic_init
  __shared__ barrier_t bar[4];

  if (threadIdx.x < 4)
  {
    init(bar + threadIdx.x, blockDim.x);
  }
  __syncthreads();

  if (threadIdx.x < warpSize)
  { produce(bar, bar + 2, buffer, buffer_len, in, N); }
  else
  { consume(bar, bar + 2, buffer, buffer_len, out, N); }
}
#include <cuda/ptx>

__device__ void produce(barrier ready[], barrier filled[], float *buffer, int buffer_len, float *in, int N)
{
  for (int i = 0; i < N / buffer_len; ++i)
  {
    uint64_t token1 = cuda::ptx::mbarrier_arrive(ready[i % 2]);
    while(!cuda::ptx::mbarrier_try_wait(&ready[i % 2], token1)) {} /* wait for buffer_(i%2) to be ready to be filled */
    /* produce, i.e., fill in, buffer_(i%2)  */
    uint64_t token2 = cuda::ptx::mbarrier_arrive(&filled[i % 2]); /* buffer_(i%2) is filled */
  }
}

__device__ void consume(barrier ready[], barrier filled[], float *buffer, buffer_len, float *out, int N)
{
  uint64_t token1 = cuda::ptx::mbarrier_arrive(&ready[0]); /* buffer_0 is ready for initial fill */
  uint64_t token2 = cuda::ptx::mbarrier_arrive(&ready[1]); /* buffer_1 is ready for initial fill */
  for (int i = 0; i < N / buffer_len; ++i)
  {
    uint64_t token3 = cuda::ptx::mbarrier_arrive(&filled[i % 2]);
    while(!cuda::ptx::mbarrier_try_wait(&filled[i % 2], token3x)) {} /* wait for buffer_(i%2) to be filled */
    /* consume buffer_(i%2) */
    uint64_t token4 = cuda::ptx::mbarrier_arrive(&ready[i % 2]); /* buffer_(i%2) is ready to be re-filled */
  }
}

__global__ void producer_consumer_pattern(int N, float *in, float *out, int buffer_len)
{
  constexpr int warpSize = 32;

  /* Shared memory buffer declared below is of size 2 * buffer_len
     so that we can alternatively work between two buffers.
     buffer_0 = buffer and buffer_1 = buffer + buffer_len */
  __shared__ extern float buffer[];

  /* bar[0] and bar[1] track if buffers buffer_0 and buffer_1 are ready to be filled,
     while bar[2] and bar[3] track if buffers buffer_0 and buffer_1 are filled-in respectively */
  #pragma nv_diag_suppress static_var_with_dynamic_init
  __shared__ uint64_t bar[4];

  if (threadIdx.x < 4)
  {
    cuda::ptx::mbarrier_init(bar + block.thread_rank(), block.size());
  }
  __syncthreads();

  if (threadIdx.x < warpSize)
  {  produce(bar, bar + 2, buffer, buffer_len, in, N); }
  else
  {  consume(bar, bar + 2, buffer, buffer_len, out, N); }
}
#include <cuda_awbarrier_primitives.h>

__device__ void produce(__mbarrier_t ready[], __mbarrier_t filled[], float *buffer, int buffer_len, float *in, int N)
{
  for (int i = 0; i < N / buffer_len; ++i)
  {
    __mbarrier_token_t token1 = __mbarrier_arrive(&ready[i % 2]); /* wait for buffer_(i%2) to be ready to be filled */
    while(!__mbarrier_try_wait(&ready[i % 2], token1, 1000)) {}
    /* produce, i.e., fill in, buffer_(i%2)  */
    __mbarrier_token_t token2 = __mbarrier_arrive(filled[i % 2]);  /* buffer_(i%2) is filled */
  }
}

__device__ void consume(__mbarrier_t ready[], __mbarrier_t filled[], float *buffer, int buffer_len, float *out, int N)
{
  __mbarrier_token_t token1 = __mbarrier_arrive(&ready[0]); /* buffer_0 is ready for initial fill */
  __mbarrier_token_t token2 = __mbarrier_arrive(&ready[1]); /* buffer_1 is ready for initial fill */
  for (int i = 0; i < N / buffer_len; ++i)
  {
    __mbarrier_token_t token3 = __mbarrier_arrive(&filled[i % 2]);
    while(!__mbarrier_try_wait(&filled[i % 2], token3, 1000)) {}
    /* consume buffer_(i%2) */
    __mbarrier_token_t token4 = __mbarrier_arrive(&ready[i % 2]); /* buffer_(i%2) is ready to be re-filled */
  }
}

__global__ void producer_consumer_pattern(int N, float *in, float *out, int buffer_len)
{
  constexpr int warpSize = 32;

  /* Shared memory buffer declared below is of size 2 * buffer_len
     so that we can alternatively work between two buffers.
     buffer_0 = buffer and buffer_1 = buffer + buffer_len */
  __shared__ extern float buffer[];

  /* bar[0] and bar[1] track if buffers buffer_0 and buffer_1 are ready to be filled,
     while bar[2] and bar[3] track if buffers buffer_0 and buffer_1 are filled-in respectively */
  #pragma nv_diag_suppress static_var_with_dynamic_init
  __shared__ __mbarrier_t bar[4];

  if (threadIdx.x < 4)
  {
    __mbarrier_init(bar + threadIdx.x, blockDim.x);
  }
  __syncthreads();

  if (threadIdx.x < warpSize)
  { produce(bar, bar + 2, buffer, buffer_len, in, N); }
  else
  { consume(bar, bar + 2, buffer, buffer_len, out, N); }
}

在此示例中,第一个线程束专门充当生产者,其余线程束专门充当消费者。所有生产者线程和消费者线程都参与这四个屏障中的每一个(调用bar.arrive()或bar.arrive_and_wait()),因此预期到达计数均等于block.size()。

生产者线程等待消费者线程发出共享内存缓冲区可以填充的信号。为了等待某个屏障,生产者线程必须先到达该ready[i%2].arrive()以获取令牌,然后使用该令牌执行ready[i%2].wait(token)。为简单起见,ready[i%2].arrive_and_wait()将这些操作合并在一起。

bar.arrive_and_wait();
/* is equivalent to */
bar.wait(bar.arrive());

生产者线程进行计算并填充已就绪的缓冲区,然后通过到达表示已填充的屏障filled[i%2].arrive(),发出缓冲区已填充的信号。生产者线程此时并不等待,而是等待下一次迭代的缓冲区(双缓冲)准备好接受填充。

消费者线程首先发出两个缓冲区均已准备好接受填充的信号。消费者线程此时并不等待,而是等待本次迭代的缓冲区被填充,即filled[i%2].arrive_and_wait()。消费者线程消费缓冲区后,会发出缓冲区已准备好再次接受填充的信号,即ready[i%2].arrive(),然后等待下一次迭代的缓冲区被填充。

4.11. 流水线

高级同步原语中介绍的流水线是一种对工作进行分阶段处理并协调多缓冲区生产者-消费者模式的机制,通常用于使计算与异步数据复制重叠。

本节详细介绍如何主要通过cuda::pipeline API 使用流水线(并在适用时提供指向原语的参考)。

4.11.1. 初始化

可以在不同的线程作用域创建cuda::pipeline。对于cuda::thread_scope_thread以外的作用域,需要一个cuda::pipeline_shared_state<scope, count>对象来协调参与的线程。此状态封装了有限的资源,使流水线能够处理最多count个并发阶段。

// Create a pipeline at thread scope
constexpr auto scope = cuda::thread_scope_thread;
cuda::pipeline<scope> pipeline = cuda::make_pipeline();
// Create a pipeline at block scope
constexpr auto scope = cuda::thread_scope_block;
constexpr auto stages_count = 2;
__shared__ cuda::pipeline_shared_state<scope, stages_count> shared_state;
auto pipeline = cuda::make_pipeline(group, &shared_state);

流水线可以是统一式的,也可以是分区式的。在统一流水线中,所有参与线程既是生产者,也是消费者。在分区流水线中,每个参与线程要么是生产者,要么是消费者,其角色在流水线对象的生存期内不能改变。线程局部流水线不能分区。要创建分区流水线,我们需要向cuda::make_pipeline()提供生产者的数量或线程的角色。

// Create a partitioned pipeline at block scope where only thread 0 is a producer
constexpr auto scope = cuda::thread_scope_block;
constexpr auto stages_count = 2;
__shared__ cuda::pipeline_shared_state<scope, stages_count> shared_state;
auto thread_role = (group.thread_rank() == 0) ? cuda::pipeline_role::producer : cuda::pipeline_role::consumer;
auto pipeline = cuda::make_pipeline(group, &shared_state, thread_role);

为支持分区,共享的cuda::pipeline会引入额外开销,包括为每个阶段使用一组共享内存屏障进行同步。即使流水线是统一式的、原本可以改用__syncthreads(),也会使用这些屏障。因此,应尽可能优先使用线程局部流水线,以避免这些开销。

4.11.2. 提交工作

向流水线阶段提交工作涉及以下步骤:

  • 由一组生产者线程使用pipeline.producer_acquire(),集体获取流水线的头部。

  • 向流水线头部提交异步操作,例如memcpy_async。

  • 使用pipeline.producer_commit(),集体提交(推进)流水线头部。

如果所有资源均在使用中,pipeline.producer_acquire()会阻塞生产者线程,直到消费者线程释放下一个流水线阶段的资源。

4.11.3. 消费工作

从先前已提交的阶段中消费工作涉及以下步骤:

  • 由一组消费者线程集体等待该阶段完成,例如使用pipeline.consumer_wait()等待尾部(最早的)阶段。

  • 使用pipeline.consumer_release(),集体释放该阶段。

使用cuda::pipeline<cuda:thread_scope_thread>时,还可以使用cuda::pipeline_consumer_wait_prior<N>()友元函数,等待除最后 N 个阶段以外的所有阶段完成,这类似于原语 API 中的__pipeline_wait_prior(N)。

4.11.4. 线程束纠缠

同一线程束中的 CUDA 线程共享流水线机制。这种共享会导致线程束内已提交操作的序列相互纠缠,在某些情况下可能影响性能。

提交。提交操作会被合并,使得对于所有调用提交操作的汇聚线程,流水线的序列号仅递增一次,且这些线程提交的操作会被合为一个批次。如果线程束完全汇聚,序列号递增 1,所有已提交的操作都将作为同一批次放入流水线的同一阶段;如果线程束完全分歧,序列号递增 32,所有已提交的操作都将分散到不同阶段。

  • 设PB为线程束共享流水线的实际操作序列。

    PB = {BP0, BP1, BP2, …, BPL}

  • 设TB为某个线程感知到的操作序列,仿佛序列号仅因该线程调用提交操作而递增。

    TB = {BT0, BT1, BT2, …, BTL}

pipeline::producer_commit()的返回值来自该线程感知到的批次序列。

  • 线程感知序列中的索引始终对应于实际线程束共享序列中相等或更大的索引。仅当所有提交操作均由完全汇聚的线程调用时,这两个序列才相等。

    BTn ≡ BPm,其中n <= m

例如,当线程束完全分歧时:

  • 线程束共享流水线的实际序列将为:PB = {0, 1, 2, 3, ..., 31}(PL=31)。

  • 此线程束中每个线程感知到的序列将为:

    • 线程 0:TB = {0}(TL=0)

    • 线程 1:TB = {0}(TL=0)

    • …

    • 线程 31:TB = {0}(TL=0)

等待。CUDA 线程调用pipeline::consumer_wait()或pipeline_consumer_wait_prior<N>(),以等待感知序列TB中的批次完成。请注意,pipeline::consumer_wait()等价于pipeline_consumer_wait_prior<N>(),其中N = PL。

等待先前批次的变体至少会等待实际序列中截至并包括PL-N的批次。由于TL <= PL,等待截至并包括PL-N的批次也包含了等待批次TL-N。因此,当TL < PL时,线程会无意中等待额外的、更新的批次。在上述线程束完全分歧的极端示例中,每个线程都可能等待全部 32 个批次。

注意

建议由汇聚的线程调用提交操作,使线程感知到的批次序列与实际序列保持一致,从而避免过度等待。

当这些操作之前的代码使线程发生分歧时,应先通过__syncwarp使线程束重新汇聚,再调用提交操作。

4.11.5. 提前退出

当参与流水线的线程必须提前退出时,该线程必须在退出之前使用cuda::pipeline::quit()显式停止参与。其余参与线程可以正常继续执行后续操作。

4.11.6. 跟踪异步内存操作

以下示例演示如何通过异步内存复制,集体将数据从全局内存复制到共享内存,并使用流水线跟踪复制操作。每个线程使用自己的流水线独立提交内存复制,然后等待复制完成并消费数据。有关异步数据复制的更多详细信息,请参阅第 3.2.5 节。

#include <cuda/pipeline>

__global__ void example_kernel(const float *in)
{
    constexpr int block_size = 128;
    __shared__ __align__(sizeof(float)) float buffer[4 * block_size];

    // Create a unified pipeline per thread
    cuda::pipeline<cuda::thread_scope_thread> pipeline = cuda::make_pipeline();

    // First stage of memory copies
    pipeline.producer_acquire();
    // Every thread fetches one element of the first block
    cuda::memcpy_async(buffer, in, sizeof(float), pipeline);
    pipeline.producer_commit();

    // Second stage of memory copies
    pipeline.producer_acquire();
    // Every thread fetches one element of the second and third block
    cuda::memcpy_async(buffer + block_size, in + block_size, sizeof(float), pipeline);
    cuda::memcpy_async(buffer + 2 * block_size, in + 2 * block_size, sizeof(float), pipeline);
    pipeline.producer_commit();

    // Third stage of memory copies
    pipeline.producer_acquire();
    // Every thread fetches one element of the last block
    cuda::memcpy_async(buffer + 3 * block_size, in + 3 * block_size, sizeof(float), pipeline);
    pipeline.producer_commit();

    // Wait for the oldest stage (waits for first stage)
    pipeline.consumer_wait();
    pipeline.consumer_release();

    // __syncthreads();
    // Use data from the first stage

    // Wait for the oldest stage (waits for second stage)
    pipeline.consumer_wait();
    pipeline.consumer_release();

    // __syncthreads();
    // Use data from the second stage

    // Wait for the oldest stage (waits for third stage)
    pipeline.consumer_wait();
    pipeline.consumer_release();

    // __syncthreads();
    // Use data from the third stage
}
#include <cuda_pipeline.h>

__global__ void example_kernel(const float *in)
{
    constexpr int block_size = 128;
    __shared__ __align__(sizeof(float)) float buffer[4 * block_size];

    // First batch of memory copies
    // Every thread fetches one element of the first block
    __pipeline_memcpy_async(buffer, in, sizeof(float));
    __pipeline_commit();

    // Second batch of memory copies
    // Every thread fetches one element of the second and third block
    __pipeline_memcpy_async(buffer + block_size, in + block_size, sizeof(float));
    __pipeline_memcpy_async(buffer + 2 * block_size, in + 2 * block_size, sizeof(float));
    __pipeline_commit();

    // Third batch of memory copies
    // Every thread fetches one element of the last block
    __pipeline_memcpy_async(buffer + 3 * block_size, in + 3 * block_size, sizeof(float));
    __pipeline_commit();

    // Wait for all except the last two batches of memory copies (waits for first batch)
    __pipeline_wait_prior(2);

    // __syncthreads();
    // Use data from the first batch

    // Wait for all except the last batch of memory copies (waits for second batch)
    __pipeline_wait_prior(1);

    // __syncthreads();
    // Use data from the second batch

    // Wait for all batches of memory copies (waits for third batch)
    __pipeline_wait_prior(0);

    // __syncthreads();
    // Use data from the last batch
}

4.11.7. 使用流水线的生产者-消费者模式

在第 4.10.7 节中,我们展示了如何在空间上划分线程块,以使用异步屏障实现生产者-消费者模式。借助cuda::pipeline,可以使用单个分区流水线来简化这一过程,为每个数据缓冲区配置一个阶段,而不是为每个缓冲区配置两个异步屏障。

#include <cuda/pipeline>
#include <cooperative_groups.h>

#pragma nv_diag_suppress static_var_with_dynamic_init

using pipeline = cuda::pipeline<cuda::thread_scope_block>;

__device__ void produce(pipeline &pipe, int num_stages, int stage, int num_batches, int batch, float *buffer, int buffer_len, float *in, int N)
{
  if (batch < num_batches)
  {
    pipe.producer_acquire();
    /* copy data from in(batch) to buffer(stage) using asynchronous memory copies */
    pipe.producer_commit();
  }
}

__device__ void consume(pipeline &pipe, int num_stages, int stage, int num_batches, int batch, float *buffer, int buffer_len, float *out, int N)
{
  pipe.consumer_wait();
  /* consume buffer(stage) and update out(batch) */
  pipe.consumer_release();
}

__global__ void producer_consumer_pattern(float *in, float *out, int N, int buffer_len)
{
  auto block = cooperative_groups::this_thread_block();

  /* Shared memory buffer declared below is of size 2 * buffer_len
     so that we can alternatively work between two buffers.
     buffer_0 = buffer and buffer_1 = buffer + buffer_len */
  __shared__ extern float buffer[];

  const int num_batches = N / buffer_len;

  // Create a partitioned pipeline with 2 stages where half the threads are producers and the other half are consumers.
  constexpr auto scope = cuda::thread_scope_block;
  constexpr int num_stages = 2;
  cuda::std::size_t producer_count = block.size() / 2;
  __shared__ cuda::pipeline_shared_state<scope, num_stages> shared_state;
  pipeline pipe = cuda::make_pipeline(block, &shared_state, producer_count);

  // Fill the pipeline
  if (block.thread_rank() < producer_count)
  {
    for (int s = 0; s < num_stages; ++s)
    {
      produce(pipe, num_stages, s, num_batches, s, buffer, buffer_len, in, N);
    }
  }

  // Process the batches
  int stage = 0;
  for (size_t b = 0; b < num_batches; ++b)
  {
    if (block.thread_rank() < producer_count)
    {
      // Prefetch the next batch
      produce(pipe, num_stages, stage, num_batches, b + num_stages, buffer, buffer_len, in, N);
    }
    else
    {
      // Consume the oldest batch
      consume(pipe, num_stages, stage, num_batches, b, buffer, buffer_len, out, N);
    }
    stage = (stage + 1) % num_stages;
  }
}

在此示例中,我们使用线程块中一半的线程作为生产者,另一半作为消费者。第一步,我们需要创建一个cuda::pipeline对象。由于我们希望部分线程为生产者,部分线程为消费者,因此需要使用带有cuda::thread_scope_block的分区流水线。分区流水线需要一个cuda::pipeline_shared_state来协调参与线程。我们在线程块作用域内为一个 2 阶段流水线初始化状态,然后调用cuda::make_pipeline()。接下来,生产者线程通过提交从in到buffer的异步复制来填满流水线。此时所有数据复制都在进行中。最后,在主循环中,我们遍历所有数据批次,并根据线程是生产者还是消费者,或者为未来的批次提交另一次异步复制,或者消费当前批次。

4.12. 异步数据复制

在第 3.2.5 节的基础上,本节提供了在 GPU 内存层次结构中进行异步数据移动的详细指导和示例。本节涵盖用于逐元素复制的 LDGSTS、用于批量(一维和多维)传输的张量内存加速器(TMA),以及用于从寄存器复制到分布式共享内存的 STAS,并展示这些机制如何与异步屏障和流水线集成。

4.12.1. 使用 LDGSTS

许多 CUDA 应用程序需要频繁地在全局内存与共享内存之间移动数据。这通常涉及复制较小的数据元素,或执行不规则的内存访问模式。LDGSTS(计算能力 8.0 及以上,参见PTX 文档)的主要目标是为较小的逐元素数据传输提供一种从全局内存到共享内存的高效异步数据传输机制,同时通过重叠执行来提高计算资源的利用率。

尺寸。LDGSTS 支持复制 4、8 或 16 字节。复制 4 或 8 字节时始终采用所谓的 L1 ACCESS 模式,在这种情况下,数据也会缓存在 L1 中;而复制 16 字节则允许启用 L1 BYPASS 模式,在这种情况下不会污染 L1。

源和目标。使用 LDGSTS 的异步复制操作仅支持从全局内存到共享内存这一方向。指针需要根据所复制数据的大小对齐到 4、8 或 16 字节。当共享内存和全局内存均按 128 字节对齐时,可以获得最佳性能。

异步性。使用 LDGSTS 的数据传输是异步的,并被建模为异步线程操作(参见异步线程与异步代理)。这允许发起线程在硬件异步复制数据时继续计算。数据传输在实际中是否异步发生取决于硬件实现,并且未来可能发生变化。

LDGSTS 必须在操作完成时发出信号。LDGSTS 可以使用共享内存屏障或流水线作为提供完成信号的机制。默认情况下,每个线程只等待自己的 LDGSTS 复制。因此,如果使用 LDGSTS 预取将与其他线程共享的数据,则在与 LDGSTS 完成机制同步之后,还必须执行__syncthreads()。

表 18 使用 LDGSTS 的异步复制及其可能的源和目标内存空间与完成机制。空白单元格表示不支持相应的源-目标组合。

方向

异步复制(LDGSTS,计算能力 8.0 及以上)

源

目标

完成机制

API

全局内存

全局内存

shared::cta

全局内存

全局内存

shared::cta

共享内存屏障、流水线

cuda::memcpy_async,

cooperative_groups::memcpy_async、__pipeline_memcpy_async

全局内存

shared::cluster

shared::cluster

shared::cta

shared::cta

shared::cta

在以下各节中,我们将通过示例演示如何使用 LDGSTS,并解释不同 API 之间的区别。

4.12.1.1. 在条件代码中批量执行加载

在这个模板计算示例中,线程块的第一个线程束负责从中心区域以及左右两侧的边界扩展区集体加载所有所需数据。使用同步复制时,由于代码具有条件分支,编译器可能会选择生成一系列从全局内存加载(LDG)、向共享内存存储(STS)的指令,而不是先执行 3 条 LDG 再执行 3 条 STS,后者才是加载数据以隐藏全局内存延迟的最佳方式。

__global__ void stencil_kernel(const float *left, const float *center, const float *right)
{
    // Left halo (8 elements) - center (32 elements) - right halo (8 elements)
    __shared__ float buffer[8 + 32 + 8];
    const int tid = threadIdx.x;

    if (tid < 8) {
        buffer[tid] = left[tid]; // Left halo
    } else if (tid >= 32 - 8) {
        buffer[tid + 16] = right[tid]; // Right halo
    }
    if (tid < 32) {
      buffer[tid + 8] = center[tid]; // Center
    }
    __syncthreads();

    // Compute stencil
}

为确保以最佳方式加载数据,我们可以将同步内存复制替换为直接从全局内存向共享内存加载数据的异步复制。这不仅通过将数据直接复制到共享内存来减少寄存器用量,还能确保所有从全局内存加载的操作都处于进行中。

#include <cooperative_groups.h>
#include <cuda/barrier>

__global__ void stencil_kernel(const float *left, const float *center, const float *right)
{
    auto block = cooperative_groups::this_thread_block();
    auto thread = cooperative_groups::this_thread();
    using barrier_t = cuda::barrier<cuda::thread_scope_block>;
    __shared__ barrier_t barrier;
    __shared__ float buffer[8 + 32 + 8];
    
    // Initialize synchronization object.
    if (block.thread_rank() == 0) {
        init(&barrier, block.size());
    }
    __syncthreads();

    // Version 1: Issue the copies in individual threads.
    if (tid < 8) {
        cuda::memcpy_async(buffer + tid, left + tid, cuda::aligned_size_t<4>(sizeof(float)), barrier); // Left halo
        // or cuda::memcpy_async(thread, buffer + tid, left + tid, cuda::aligned_size_t<4>(sizeof(float)), barrier);
    } else if (tid >= 32 - 8) {
        cuda::memcpy_async(buffer + tid + 16, right + tid, cuda::aligned_size_t<4>(sizeof(float)), barrier); // Right halo
        // or cuda::memcpy_async(thread, buffer + tid + 16, right + tid, cuda::aligned_size_t<4>(sizeof(float)), barrier);
    }
    if (tid < 32) {
        cuda::memcpy_async(buffer + 40, right + tid, cuda::aligned_size_t<4>(sizeof(float)), barrier); // Center
        // or cuda::memcpy_async(thread, buffer + 40, right + tid, cuda::aligned_size_t<4>(sizeof(float)), barrier);
    }
    
    // Version 2: Cooperatively issue the copies across all threads.
    cuda::memcpy_async(block, buffer, left, cuda::aligned_size_t<4>(8 * sizeof(float)), barrier); // Left halo
    cuda::memcpy_async(block, buffer + 8, center, cuda::aligned_size_t<4>(32 * sizeof(float)), barrier); // Center
    cuda::memcpy_async(block, buffer + 40, right, cuda::aligned_size_t<4>(8 * sizeof(float)), barrier); // Right halo
    
    // Wait for all copies to complete.
    barrier.arrive_and_wait();
    __syncthreads();

    // Compute stencil      
}
#include <cooperative_groups.h>
#include <cooperative_groups/memcpy_async.h>

namespace cg = cooperative_groups;

__global__ void stencil_kernel(const float *left, const float *center, const float *right)
{
    cg::thread_block block = cg::this_thread_block();
    // Left halo (8 elements) - center (32 elements) - right halo (8 elements). 
    __shared__ float buffer[8 + 32 + 8];

    // Cooperatively issue the copies across all threads.
    cg::memcpy_async(block, buffer, left, 8 * sizeof(float)); // Left halo
    cg::memcpy_async(block, buffer + 8, center, 32 * sizeof(float)); // Center
    cg::memcpy_async(block, buffer + 40, right, 8 * sizeof(float)); // Right halo
    cg::wait(block); // Waits for all copies to complete.
    __syncthreads();

    // Compute stencil.
}
#include <cuda_pipeline.h>

__global__ void stencil_kernel(const float *left, const float *center, const float *right)
{
    // Left halo (8 elements) - center (32 elements) - right halo (8 elements).
    __shared__ float buffer[8 + 32 + 8];
    const int tid = threadIdx.x;

    if (tid < 8) {
        __pipeline_memcpy_async(buffer + tid, left + tid, sizeof(float)); // Left halo
    } else if (tid >= 32 - 8) {
        __pipeline_memcpy_async(buffer + tid + 16, right + tid, sizeof(float)); // Right halo
    }
    if (tid < 32) {
        __pipeline_memcpy_async(buffer + tid + 8, center + tid, sizeof(float)); // Center
    }
    __pipeline_commit();
    __pipeline_wait_prior(0);
    __syncthreads();

    // Compute stencil.
}

针对cuda::barrier的cuda::memcpy_async重载支持使用异步屏障同步异步数据传输。此重载在执行复制操作时,仿佛该操作由绑定到屏障的另一个线程执行:在创建时增加当前阶段的预期计数,并在复制操作完成时减少该计数,使得只有在参与屏障的所有线程均已到达,并且绑定到屏障当前阶段的所有memcpy_async均已完成时,barrier的阶段才会推进。我们使用一个线程块范围的barrier,块中的所有线程均参与其中,并使用arrive_and_wait将屏障的到达与等待合并,因为我们在这些阶段之间不执行任何工作。

请注意,我们既可以使用线程级复制(版本 1),也可以使用集体复制(版本 2)来获得相同结果。在版本 2 中,API 会自动处理底层如何执行复制。在两个版本中,我们都使用cuda::aligned_size_t<4>()告知编译器数据按 4 字节对齐,且要复制的数据大小是 4 的倍数,以便启用 LDGSTS。请注意,为了与cuda::barrier互操作,此处使用的是cuda/barrier头文件中的cuda::memcpy_async。

cooperative_groups::memcpy_async实现在线程块的所有线程之间集体协调内存传输,但使用cg::wait(block)而不是显式屏障操作来同步完成。

基于低级原语的实现使用__pipeline_memcpy_async()发起逐元素内存传输,使用__pipeline_commit()提交这一批复制,并使用__pipeline_wait_prior(0)等待流水线中的所有操作完成。这提供了最直接的控制,但与更高级别的 API 相比,代价是代码更加冗长。它还确保底层会使用 LDGSTS,而更高级别的 API 并不提供这一保证。

注意

在此示例中,cooperative_groups::memcpy_async API 的效率低于其他 API,因为它会在每次复制操作启动后立即自动提交该操作,从而无法像其他 API 那样,通过在一次提交操作之前批量执行多次复制来进行优化。

4.12.1.2. 预取数据

在此示例中,我们将演示如何使用异步数据复制将数据从全局内存预取到共享内存。在迭代式复制与计算模式中,这允许利用当前迭代的计算隐藏未来迭代的数据传输延迟,从而有可能增加正在传输的字节数。

#include <cooperative_groups.h>
#include <cuda/pipeline>

template <size_t num_stages = 2 /* Pipeline with num_stages stages */>
__global__ void prefetch_kernel(int* global_out, int const* global_in, size_t size, size_t batch_size) {
    auto grid = cooperative_groups::this_grid();
    auto block = cooperative_groups::this_thread_block();
    auto thread = cooperative_groups::this_thread();
    assert(size == batch_size * grid.size()); // Assume input size fits batch_size * grid_size

    extern __shared__ int shared[]; // num_stages * block.size() * sizeof(int) bytes
    size_t shared_offset[num_stages];
    for (int s = 0; s < num_stages; ++s) shared_offset[s] = s * block.size();

    cuda::pipeline<cuda::thread_scope_thread> pipeline = cuda::make_pipeline();

    auto block_batch = [&](size_t batch) -> int {
        return block.group_index().x * block.size() + grid.size() * batch;
    };

    // Fill the pipeline with the first ``num_stages`` batches.
    for (int s = 0; s < num_stages; ++s) {
        pipeline.producer_acquire();
        cuda::memcpy_async(shared + shared_offset[s] + tid, global_in + block_batch(s) + tid, cuda::aligned_size_t<4>(sizeof(int)), pipeline);
        pipeline.producer_commit();
    }

    int stage = 0;

    // compute_batch: next batch to process
    // fetch_batch:   next batch to fetch from global memory
    for (size_t compute_batch = 0, fetch_batch = num_stages; compute_batch < batch_size; ++compute_batch, ++fetch_batch) {
        // Wait for the first requested stage to complete.
        constexpr size_t pending_batches = num_stages - 1;
        cuda::pipeline_consumer_wait_prior<pending_batches>(pipeline);
        __syncthreads(); // Not required if each thread works on the data it copied.

        // Compute on the current batch
        compute(global_out + block_batch(compute_batch) + tid, shared + shared_offset[stage] + tid);
        
        // Release the current stage.
        pipeline.consumer_release();
        __syncthreads(); // Not required if each thread works on the data it copied.

        // Load future stage ``num_stages`` ahead of current compute batch.
        pipeline.producer_acquire();
        if (fetch_batch < batch_size) {
            cuda::memcpy_async(shared + shared_offset[stage] + tid, global_in + block_batch(fetch_batch) + tid, cuda::aligned_size_t<4>(sizeof(int)), pipeline);
        }
        pipeline.producer_commit();
        stage = (stage + 1) % num_stages;
    }
}
#include <cooperative_groups.h>
#include <cooperative_groups/memcpy_async.h>

namespace cg = cooperative_groups;

template <size_t num_stages = 2 /* Pipeline with num_stages stages */>
__global__ void prefetch_kernel(int* global_out, int const* global_in, size_t size, size_t batch_size) {
    auto grid = cooperative_groups::this_grid();
    auto block = cooperative_groups::this_thread_block();
    assert(size == batch_size * grid.size()); // Assume input size fits batch_size * grid_size

    extern __shared__ int shared[]; // num_stages * block.size() * sizeof(int) bytes
    size_t shared_offset[num_stages];
    for (int s = 0; s < num_stages; ++s) shared_offset[s] = s * block.size();

    cuda::pipeline<cuda::thread_scope_thread> pipeline = cuda::make_pipeline();

    auto block_batch = [&](size_t batch) -> int {
        return block.group_index().x * block.size() + grid.size() * batch;
    };

    // Fill the pipeline with the first ``num_stages`` batches.
    for (int s = 0; s < num_stages; ++s) {
        size_t block_batch_idx = block_batch(s);
        cg::memcpy_async(block, shared + shared_offset[s], global_in + block_batch_idx, cuda::aligned_size_t<4>(sizeof(int));
    }

    int stage = 0;

    // compute_batch: next batch to process
    // fetch_batch:   next batch to fetch from global memory
    for (size_t compute_batch = 0, fetch_batch = num_stages; compute_batch < batch_size; ++compute_batch, ++fetch_batch) {
        // Wait for the first requested stage to complete.
        size_t pending_batches = (fetch_batch < batch_size - num_stages) ? num_stages - 1 : batch_size - fetch_batch - 1;
        cg::wait_prior(pending_batches);
        __syncthreads(); // Not required if each thread works on the data it copied.

        // Compute on the current batch.
        compute(global_out + block_batch(compute_batch) + tid, shared + shared_offset[stage] + tid);
        
        __syncthreads(); // Not required if each thread works on the data it copied.

        // Load future stage ``num_stages`` ahead of current compute batch.
        size_t fetch_batch_idx = block_batch(fetch_batch);
        if (fetch_batch < batch_size) {
            cg::memcpy_async(block, shared + shared_offset[stage], global_in + block_batch(fetch_batch), cuda::aligned_size_t<4>(sizeof(int)) * block.size());
        }
        stage = (stage + 1) % num_stages;
    }
}
#include <cooperative_groups.h>
#include <cuda_awbarrier_primitives.h>

template <size_t num_stages = 2 /* Pipeline with num_stages stages */>
__global__ void prefetch_kernel(int* global_out, int const* global_in, size_t size, size_t batch_size) {
    auto grid = cooperative_groups::this_grid();
    auto block = cooperative_groups::this_thread_block();
    assert(size == batch_size * grid.size()); // Assume input size fits batch_size * grid_size

    extern __shared__ int shared[]; // num_stages * block.size() * sizeof(int) bytes
    size_t shared_offset[num_stages];
    for (int s = 0; s < num_stages; ++s) shared_offset[s] = s * block.size();

    auto block_batch = [&](size_t batch) -> int {
        return block.group_index().x * block.size() + grid.size() * batch;
    };

    // Fill the pipeline with the first ``num_stages`` batches.
    for (int s = 0; s < num_stages; ++s) {
        __pipeline_memcpy_async(shared + shared_offset[s] + tid, global_in + block_batch(s)+ tid, cuda::aligned_size_t<4>(sizeof(int)));
        __pipeline_commit();
    }

    // compute_batch: next batch to process
    // fetch_batch:   next batch to fetch from global memory
    for (size_t compute_batch = 0, fetch_batch = num_stages; compute_batch < batch_size; ++compute_batch, ++fetch_batch) {
        // Wait for the first requested stage to complete.
        constexpr size_t pending_batches = num_stages - 1;
        __pipeline_wait_prior<pending_batches>();
        __syncthreads(); // Not required if each thread works on the data it copied.

        // Compute on the current batch.
        compute(global_out + block_batch(compute_batch) + tid, shared + shared_offset[stage] + tid);
        
        __syncthreads(); // Not required if each thread works on the data it copied.

        // Load future stage ``num_stages`` ahead of current compute batch.
        if (fetch_batch < batch_size) {
            __pipeline_memcpy_async(shared + shared_offset[stage] + tid, global_in + block_batch(fetch_batch) + tid, cuda::aligned_size_t<4>(sizeof(int)));
        }
        __pipeline_commit();
        stage = (stage + 1) % num_stages;
    }
}

cuda::memcpy_async实现演示了借助cuda::memcpy_async,使用cuda::pipeline(参见流水线)进行多阶段数据预取。它:

  • 初始化一个线程局部的流水线。

  • 通过调度num_stages个memcpy_async操作来启动流水线。

  • 遍历所有批次:阻塞所有线程,直到当前批次完成,然后对当前批次执行计算,最后调度下一个memcpy_async(如果还有)。

cooperative_groups::memcpy_async实现演示了使用cooperative_groups::memcpy_async进行多阶段数据预取。与前一种实现的主要区别在于,我们不使用流水线对象,而是依靠cooperative_groups::memcpy_async在底层分阶段调度内存传输。

CUDA C 原语实现以与第一种实现非常相似的方式,演示了使用低级原语进行多阶段数据预取。

在此示例中,实现高效代码生成的一个重要细节是,即使没有更多批次需要获取,也要在流水线中保持num_stages个批次。实现方法是,即使没有更多批次需要获取,也向流水线提交(pipeline.producer_commit()或__pipeline_commit())。注意,使用协作组 API 无法做到这一点,因为我们无法访问内部流水线。

4.12.1.3. 通过线程束专门化实现生产者-消费者模式

在此示例中,我们将演示如何实现一种生产者-消费者模式:将单个线程束专门用作生产者,执行从全局内存到共享内存的异步数据复制,而其余线程束则消费共享内存中的数据并执行计算。为了使生产者线程与消费者线程能够并发执行,我们在共享内存中使用双缓冲。当消费者线程束处理一个缓冲区中的数据时,生产者线程束会异步地将下一批数据获取到另一个缓冲区中。

#include <cooperative_groups.h>
#include <cuda/pipeline>

#pragma nv_diag_suppress static_var_with_dynamic_init

using pipeline = cuda::pipeline<cuda::thread_scope_block>;

__device__ void produce(pipeline &pipe, int num_stages, int stage, int num_batches, int batch, float *buffer, int buffer_len, float *in, int N)
{
  if (batch < num_batches)
  {
    pipe.producer_acquire();
    /* copy data from in(batch) to buffer(stage) using asynchronous memory copies */
    cuda::memcpy_async(buffer + stage * buffer_len + threadIdx.x, in + batch * buffer_len + threadIdx.x, cuda::aligned_size_t<4>(sizeof(float)), pipe);
    pipe.producer_commit();
  }
}

__device__ void consume(pipeline &pipe, int num_stages, int stage, int num_batches, int batch, float *buffer, int buffer_len, float *out, int N)
{
  pipe.consumer_wait();
  /* consume buffer(stage) and update out(batch) */
  pipe.consumer_release();
}

__global__ void producer_consumer_pattern(float *in, float *out, int N, int buffer_len)
{
  auto block = cooperative_groups::this_thread_block();
  constexpr int warpSize = 32;

  /* Shared memory buffer declared below is of size 2 * buffer_len
     so that we can alternatively work between two buffers.
     buffer_0 = buffer and buffer_1 = buffer + buffer_len */
  __shared__ extern float buffer[];

  const int num_batches = N / buffer_len;

  // Create a partitioned pipeline with 2 stages where the first warp is the producer and the other warps are consumers.
  constexpr auto scope = cuda::thread_scope_block;
  constexpr int num_stages = 2;
  cuda::std::size_t producer_count = warpSize;
  __shared__ cuda::pipeline_shared_state<scope, num_stages> shared_state;
  pipeline pipe = cuda::make_pipeline(block, &shared_state, producer_count);

  // Producer fills the pipeline
  if (block.thread_rank() < producer_count)
    for (int s = 0; s < num_stages; ++s)
      produce(pipe, num_stages, s, num_batches, s, buffer, buffer_len, in, N);

  // Process the batches
  int stage = 0;
  for (size_t b = 0; b < num_batches; ++b)
  {
    if (block.thread_rank() < producer_count)
    {
      // Producers prefetch the next batch
      produce(pipe, num_stages, stage, num_batches, b + num_stages, buffer, buffer_len, in, N);
    }
    else
    {
      // Consumers consume the oldest batch
      consume(pipe, num_stages, stage, num_batches, b, buffer, buffer_len, out, N);
    }
    stage = (stage + 1) % num_stages;
  }
}
#include <cooperative_groups.h>
#include <cuda_awbarrier_primitives.h>

__device__ void produce(__mbarrier_t ready[], __mbarrier_t filled[], float *buffer, int buffer_len, float *in, int N)
{
  for (int i = 0; i < N / buffer_len; ++i)
  {
    __mbarrier_token_t token = __mbarrier_arrive(&ready[i % 2]); /* wait for buffer_(i%2) to be ready to be filled */
    while(!__mbarrier_try_wait(&ready[i % 2], token, 1000)) {}
    /* produce, i.e., fill in, buffer_(i%2)  */
    __pipeline_memcpy_async(buffer + i * buffer_len + threadIdx.x, in + i * buffer_len + threadIdx.x, cuda::aligned_size_t<4>(sizeof(float)));
    __pipeline_arrive_on(filled[i % 2]);
    __mbarrier_arrive(filled[i % 2]);  /* buffer_(i%2) is filled */
  }
}

__device__ void consume(__mbarrier_t ready[], __mbarrier_t filled[], float *buffer, int buffer_len, float *out, int N)
{
  __mbarrier_arrive(&ready[0]); /* buffer_0 is ready for initial fill */
  __mbarrier_arrive(&ready[1]); /* buffer_1 is ready for initial fill */
  for (int i = 0; i < N / buffer_len; ++i)
  {
    __mbarrier_token_t token = __mbarrier_arrive(&filled[i % 2]);
    while(!__mbarrier_try_wait(&filled[i % 2], token, 1000)) {}
    /* consume buffer_(i%2) */
    __mbarrier_arrive(&ready[i % 2]); /* buffer_(i%2) is ready to be re-filled */
  }
}

__global__ void producer_consumer_pattern(int N, float *in, float *out, int buffer_len)
{

  /* Shared memory buffer declared below is of size 2 * buffer_len
     so that we can alternatively work between two buffers.
     buffer_0 = buffer and buffer_1 = buffer + buffer_len */
  __shared__ extern float buffer[];

  /* bar[0] and bar[1] track if buffers buffer_0 and buffer_1 are ready to be filled,
     while bar[2] and bar[3] track if buffers buffer_0 and buffer_1 are filled-in respectively */
  __shared__ __mbarrier_t bar[4];

  // Initialize the barriers
  auto block = cooperative_groups::this_thread_block();
  if (block.thread_rank() < 4)
    __mbarrier_init(bar + block.thread_rank(), block.size());
  __syncthreads();

  if (block.thread_rank() < warpSize)
    produce(bar, bar + 2, buffer, buffer_len, in, N);
  else
    consume(bar, bar + 2, buffer, buffer_len, out, N);
}

cuda::memcpy_async实现通过cuda::memcpy_async和一个具有 2 个阶段的cuda::pipeline,演示了抽象层次最高的 API。它使用分区流水线(参见流水线),其中第一个线程束充当生产者,其余线程束充当消费者。生产者最初填充流水线的两个阶段。然后,在主处理循环中,当消费者处理当前批次时,生产者为后续批次获取数据,从而保持稳定的工作流。

基于原语的 CUDA C 原语实现将__pipeline_memcpy_async()与用作完成机制的共享内存屏障结合,以协调异步内存传输。__pipeline_arrive_on()函数将内存复制与屏障关联起来。它将屏障到达计数加一,当顺序排在它之前的所有异步操作都完成后,到达计数会自动减一,因此对到达计数的净影响为零。出于这一原因,我们还需要使用__mbarrier_arrive()显式到达屏障。

4.12.2. 使用张量内存加速器(TMA)

许多应用程序需要在全局内存与其他内存之间搬移大量数据。通常,数据在全局内存中以多维数组的形式布局,并采用非顺序的数据访问模式。为了减少全局内存访问,会先将此类数组的子分块复制到共享内存,再用于计算。加载和存储涉及的地址计算容易出错且重复繁琐。为了卸载这些计算,计算能力 9.0(Hopper)及更高版本(参见PTX 文档)具有张量内存加速器(TMA)。TMA 的主要目标是为多维数组提供从全局内存到共享内存的高效数据传输机制。

命名。张量内存加速器(TMA)是一个广义术语,用于指代本节描述的功能。为了实现前向兼容并减少与 PTX ISA 的差异,本节根据所使用的具体复制类型,将 TMA 操作称为批量异步复制或批量张量异步复制。使用“批量”一词是为了将这些操作与上一节描述的异步内存操作区分开来。

维度。TMA 支持复制一维数组和多维数组(最多 5 维)。一维连续数组的批量异步复制编程模型与多维数组的批量张量异步复制编程模型不同。要对多维数组执行批量张量异步复制,硬件需要一个张量映射。该对象描述多维数组在全局内存和共享内存中的布局。张量映射通常在主机上使用cuTensorMapEncode API创建,然后作为带有__grid_constant__注解的const核函数参数从主机传输到设备(参见__grid_constant__ 参数)。张量映射作为带有__grid_constant__注解的const核函数参数从主机传输到设备,并且可以在设备上用于在共享内存与全局内存之间复制一个数据分块。相比之下,对连续的一维数组执行批量异步复制不需要张量映射:只需提供指针和大小参数,即可在设备上执行。

源和目标。TMA 操作的源地址和目标地址可以位于共享内存或全局内存中。这些操作可以将数据从全局内存读取到共享内存,将数据从共享内存写入全局内存,还可以将数据从共享内存复制到同一簇中另一个线程块的分布式共享内存。此外,在簇中时,可以将批量异步张量操作指定为多播。在这种情况下,可以将数据从全局内存传输到簇内多个线程块的共享内存中。多播功能针对当前设备的全局内存访问进行了优化,也就是说,并非针对通过 NVLink 访问对等设备的情况,而且还针对PTX 多播目标 ISA 说明中列出的某些数据中心 GPU 目标架构进行了优化。在其他目标上,其可能出现性能显著降低的情况。因此,建议仅将其用于访问本地 GPU 上的全局内存,并且仅在具有PTX 多播目标 ISA 说明中所述推荐计算能力的 GPU 上使用。

异步性。使用 TMA 进行的数据传输是异步的,并被建模为异步代理操作(参见异步线程与异步代理)。这使发起线程可以在硬件异步复制数据的同时继续计算。数据传输在实践中是否异步发生取决于硬件实现,并且未来可能发生变化。批量异步操作可以使用多种完成机制来发出操作已完成的信号。当操作将数据从全局内存读取到共享内存时,线程块中的任何线程都可以通过等待共享内存屏障,来等待数据在共享内存中变得可读。当批量异步操作将数据从共享内存写入全局内存或分布式共享内存时,只有发起线程能够等待操作完成。这通过基于批量异步组的完成机制实现。下文和PTX ISA中提供了描述这些完成机制的表格。

表 19 使用 TMA 的异步复制及其可能的源内存空间、目标内存空间和完成机制。空单元格表示不支持相应的源-目标组合。

方向

异步复制(TMA,计算能力 9.0 及更高版本)

源

目标

完成机制

全局内存

全局内存

shared::cta

全局内存

批量异步组

全局内存

shared::cta

共享内存屏障

全局内存

shared::cluster

共享内存屏障(多播)

shared::cta

shared::cluster

共享内存屏障

shared::cta

shared::cta

4.12.2.1. 使用 TMA 传输一维数组

下表汇总了批量异步 TMA 可能的源内存空间、目标内存空间和完成机制,以及提供相应功能的 API。

表 20 使用批量异步 TMA 的异步复制及其可能的源内存空间、目标内存空间和完成机制。空单元格表示不支持相应的源-目标组合。

方向

批量异步复制(TMA,计算能力 9.0 及更高版本)

源

目标

完成机制

API

全局内存

全局内存

shared::cta

全局内存

批量异步组

cuda::ptx::cp_async_bulk

全局内存

shared::cta

共享内存屏障

cuda::memcpy_async、cuda::device::memcpy_async_tx、cuda::ptx::cp_async_bulk

全局内存

shared::cluster

共享内存屏障

cuda::ptx::cp_async_bulk

shared::cta

shared::cluster

共享内存屏障

cuda::ptx::cp_async_bulk

shared::cta

shared::cta

某些功能需要内联 PTX,目前这些功能通过CUDA Standard C++库中的cuda::ptx命名空间提供。可以使用以下代码检查这些封装的可用性:

#if defined(__CUDA_MINIMUM_ARCH__) && __CUDA_MINIMUM_ARCH__ < 900
static_assert(false, "Device code is being compiled with older architectures that are incompatible with TMA.");
#endif // __CUDA_MINIMUM_ARCH__

注意,如果源地址和目标地址均按 16 字节对齐,且大小为 16 字节的倍数,cuda::memcpy_async就会使用 TMA,否则会回退到同步复制。另一方面,cuda::device::memcpy_async_tx和cuda::ptx::cp_async_bulk始终使用 TMA,如果不满足这些要求,则会导致未定义行为。

下文通过一个示例演示如何使用批量异步复制。该示例对一维数组进行读取、修改和写回。核函数执行以下步骤:

  1. 初始化一个共享内存屏障,作为从全局内存到共享内存的批量异步复制的完成机制。

  2. 发起将一块内存从全局内存复制到共享内存的操作。

  3. 到达共享内存屏障并在该屏障上等待复制完成。

  4. 递增共享内存缓冲区中的值。

  5. 使用代理栅栏,确保共享内存写入(通用代理)对后续的批量异步复制(异步代理)可见。

  6. 发起将共享内存中的缓冲区复制到全局内存的批量异步复制。

  7. 等待批量异步复制完成对共享内存的读取。

#include <cuda/barrier>
#include <cuda/ptx>

using barrier = cuda::barrier<cuda::thread_scope_block>;
namespace ptx = cuda::ptx;

static constexpr size_t buf_len = 1024;

__device__ inline bool is_elected()
{
    unsigned int tid = threadIdx.x;
    unsigned int warp_id = tid / 32;
    unsigned int uniform_warp_id = __shfl_sync(0xFFFFFFFF, warp_id, 0); // Broadcast from lane 0.
    return (uniform_warp_id == 0 && ptx::elect_sync(0xFFFFFFFF)); // Elect a leader thread among warp 0.
}

__global__ void add_one_kernel(int* data, size_t offset)
{
  // Shared memory buffer. The destination shared memory buffer of
  // a bulk operation should be 16 byte aligned.
  __shared__ alignas(16) int smem_data[buf_len];

  // 1. Initialize shared memory barrier with the number of threads participating in the barrier.
  #pragma nv_diag_suppress static_var_with_dynamic_init
  __shared__ barrier bar;
  if (threadIdx.x == 0) {
    init(&bar, blockDim.x);
  }
  __syncthreads();

  // 2. Initiate TMA transfer to copy global to shared memory from a single thread.
  if (is_elected()) {
    // Launch the async copy and communicate how many bytes are expected to come in (the transaction count).
    
    // Version 1: cuda::memcpy_async
    cuda::memcpy_async(
        smem_data, data + offset, 
        cuda::aligned_size_t<16>(sizeof(smem_data)),
        bar);
    
    // Version 2: cuda::device::memcpy_async_tx
    // cuda::device::memcpy_async_tx(
    //   smem_data, data + offset, 
    //   cuda::aligned_size_t<16>(sizeof(smem_data)),
    //   bar);
    // cuda::device::barrier_expect_tx(
    //     cuda::device::barrier_native_handle(bar),
    //     sizeof(smem_data));

    // Version 3: cuda::ptx::cp_async_bulk
    // ptx::cp_async_bulk(
    //     ptx::space_shared, ptx::space_global,
    //     smem_data, data + offset, 
    //     sizeof(smem_data), 
    //     cuda::device::barrier_native_handle(bar));
    // cuda::device::barrier_expect_tx(
    //     cuda::device::barrier_native_handle(bar),
    //     sizeof(smem_data));
  }
  
  // 3a. All threads arrive on the barrier.
  barrier::arrival_token token = bar.arrive();
  
  // 3b. Wait for the data to have arrived.
  bar.wait(std::move(token));

  // 4. Compute saxpy and write back to shared memory.
  for (int i = threadIdx.x; i < buf_len; i += blockDim.x) {
    smem_data[i] += 1;
  }

  // 5. Wait for shared memory writes to be visible to TMA engine.
  ptx::fence_proxy_async(ptx::space_shared);
  __syncthreads();
  // After syncthreads, writes by all threads are visible to TMA engine.

  // 6. Initiate TMA transfer to copy shared memory to global memory.
  if (is_elected()) {
    ptx::cp_async_bulk(
        ptx::space_global, ptx::space_shared,
        data + offset, smem_data, sizeof(smem_data));
    // 7. Wait for TMA transfer to have finished reading shared memory.
    // Create a "bulk async-group" out of the previous bulk copy operation.
    ptx::cp_async_bulk_commit_group();
    // Wait for the group to have completed reading from shared memory.
    ptx::cp_async_bulk_wait_group_read(ptx::n32_t<0>());
  }
}

屏障初始化。使用线程块中参与的线程数初始化屏障。因此,只有所有线程都到达此屏障后,屏障才会翻转。共享内存屏障中更详细地介绍了共享内存屏障。

TMA 读取。批量异步复制指令指示硬件将一大块数据复制到共享内存中,并在完成读取后更新共享内存屏障的事务计数。通常,以尽可能少的次数发出大小尽可能大的批量复制,会获得最佳性能。由于硬件可以异步执行复制,因此无需将复制拆分成更小的块。

发起批量异步复制操作的线程还会告知屏障预期到达的事务(tx)数量。在此情况下,事务以字节为单位计数。cuda::memcpy_async会自动执行此操作,但cuda::device::memcpy_async_tx和cuda::ptx::cp_async_bulk不会,因此在调用它们之后,我们需要显式调用cuda::ptx::mbarrier_expect_tx。如果多个线程更新事务计数,则预期事务计数为各次更新之和。只有当所有线程都已到达并且所有字节都已到达时,屏障才会翻转。一旦屏障翻转,线程和后续的批量异步复制都可以安全地从共享内存中读取这些字节。有关屏障事务计数的更多信息,请参见跟踪异步内存操作。

屏障等待。使用令牌配合bar.wait()来等待屏障翻转。对屏障使用显式阶段跟踪可能更高效(参见显式阶段跟踪)。

共享内存写入与同步。递增缓冲区中的值会读写共享内存。为了使写入对后续的批量异步复制可见,需要使用cuda::ptx::fence_proxy_async函数。这会将共享内存写入排序在批量异步复制操作的后续读取之前,这些读取通过异步代理进行。因此,每个线程首先通过cuda::ptx::fence_proxy_async,在异步代理中对共享内存对象的写入进行排序,然后使用__syncthreads(),将所有线程的这些操作排序在线程 0 执行的异步操作之前。

TMA 写入与同步。从共享内存到全局内存的写入同样由单个线程发起。写入的完成情况不由共享内存屏障跟踪,而是使用线程局部机制。可以将多个写入合并到一个所谓的批量异步组中。之后,该线程可以等待此组中的所有操作完成对共享内存的读取(如上面的代码所示),也可以等待它们完成对全局内存的写入,使这些写入对发起线程可见。有关更多信息,请参阅cp.async.bulk.wait_group的 PTX ISA 文档。注意,批量异步复制指令与非批量异步复制指令具有不同的异步组:cp.async.wait_group和cp.async.bulk.wait_group指令同时存在。

注意

建议由线程块中的单个线程发起 TMA 操作。虽然使用if (threadIdx.x == 0)似乎就已足够,但编译器无法验证确实只有一个线程在发起复制,并且可能插入一个遍历所有活跃线程的剥离循环,从而导致线程束串行化并降低性能。为防止这种情况,我们定义了is_elected()辅助函数,它使用cuda::ptx::elect_sync从线程束 0 中选择一个线程来执行复制,编译器能够获知这一点,从而可以生成更高效的代码。另一种方法是使用cooperative_groups::invoke_one来达到同样的效果。

批量异步指令对其源地址和目标地址有特定的对齐要求。更多信息见下表。

表 21 一维批量异步操作的对齐要求。

地址 / 大小

对齐

全局内存地址

必须按 16 字节对齐。

共享内存地址

必须按 16 字节对齐。

共享内存屏障地址

必须按 8 字节对齐(由cuda::barrier保证)。

传输大小

必须为 16 字节的倍数。

4.12.2.1.1. 预取数据

在本示例中,我们将演示如何使用 TMA 将数据从全局内存预取到共享内存。在迭代式复制和计算模式中,这使得当前迭代的计算能够掩盖后续迭代的数据传输延迟,从而有可能增加在途字节数。

#include <cooperative_groups.h>
#include <cuda/barrier>
#include <cuda/ptx>

namespace ptx = cuda::ptx;
namespace cg = cooperative_groups;

__device__ inline bool is_elected()
{
    unsigned int tid = threadIdx.x;
    unsigned int warp_id = tid / 32;
    unsigned int uniform_warp_id = __shfl_sync(0xFFFFFFFF, warp_id, 0); // Broadcast from lane 0.
    return (uniform_warp_id == 0 && ptx::elect_sync(0xFFFFFFFF)); // Elect a leader thread among warp 0.
}

template <int block_size, int num_stages>
__global__ void prefetch_kernel(int* global_out, int const* global_in, size_t size, size_t batch_size) {
    auto grid = cg::this_grid();
    auto block = cg::this_thread_block();
    const int tid = threadIdx.x;
    assert(size == batch_size * grid.size()); // Assume input size fits batch_size * grid_size

    // 1. Initialization Phase
    __shared__ int shared[num_stages * block_size];
    size_t shared_offset[num_stages];
    for (int s = 0; s < num_stages; ++s) shared_offset[s] = s * block.size();

    auto block_batch = [&](size_t batch) -> int {
        return block.group_index().x * block.size() + grid.size() * batch;
    };

    // Initialize shared memory barrier with the number of threads participating in the barrier.
    // We will use explicit phase tracking for the barrier, which allows us to have only one 
    // thread arrive on the barrier to set the transaction count and other threads wait for 
    // a parity-based phase flip.
    #pragma nv_diag_suppress static_var_with_dynamic_init
    __shared__ cuda::barrier<cuda::thread_scope_block> bar[num_stages];
    if (tid == 0) {
        #pragma unroll num_stages
        for (int i = 0; i < num_stages; i++) {
            init(&bar[i], 1);
        }
    }
    __syncthreads();

    // Fill the pipeline with the first ``num_stages`` batches.
    if (is_elected()) {
        size_t num_bytes = block_size * sizeof(int);

        #pragma unroll num_stages
        for (int s = 0; s < num_stages; ++s) {
            cuda::device::memcpy_async_tx(&shared[shared_offset[s]], &global_in[block_batch(s)], cuda::aligned_size_t<16>(num_bytes), bar[s]);
            (void)cuda::device::barrier_arrive_tx(bar[s], 1, num_bytes);
        }
    }

    // 2. Main Processing Loop.
    // compute_batch: next batch to process.
    // fetch_batch:   next batch to fetch from global memory.
    int stage = 0;       // current stage in the shared memory buffer.
    uint32_t parity = 0; // barrierparity
    for (size_t compute_batch = 0, fetch_batch = num_stages; compute_batch < batch_size; ++compute_batch, ++fetch_batch) {
        // (a) Wait on current batch.
        while (!ptx::mbarrier_try_wait_parity(ptx::sem_acquire, ptx::scope_cta, cuda::device::barrier_native_handle(bar[stage]), parity)) {}

        // (b) Compute on the current batch.
        compute(global_out + block_batch(compute_batch) + tid, shared + shared_offset[stage] + tid);
        __syncthreads();

        // (c) Load next stage ``num_stages`` ahead of current compute batch.
        if (is_elected() && fetch_batch < batch_size) {
            size_t num_bytes = block_size * sizeof(int);
            cuda::device::memcpy_async_tx(&shared[shared_offset[stage]], &global_in[block_batch(fetch_batch)], cuda::aligned_size_t<16>(num_bytes), bar[stage]);
            (void)cuda::device::barrier_arrive_tx(bar[stage], 1, num_bytes);
        }

        // (d) Stage management.
        stage++;
        if (stage == num_stages) {
            stage = 0;
            parity ^= 1;
        }
    }
}

本示例使用cuda::device::memcpy_async_tx执行 TMA 复制,实现多阶段数据预取,并采用显式跟踪阶段的共享内存屏障来同步这些复制操作。

  1. 初始化阶段:设置共享内存屏障(每个阶段一个),并将最初的num_stages个批次预加载到共享内存的不同区域中。

  2. 主处理循环:

    1. 等待:使用mbarrier_try_wait_parity()等待当前批次完成复制。

    2. 计算:处理当前批次的数据。

    3. 预取:为后续数据调度下一次memcpy_async_tx操作(保持提前num_stages)。

    4. 阶段管理:使用循环缓冲区方式循环遍历各个阶段,并跟踪屏障的奇偶性。

4.12.2.2. 使用 TMA 传输多维数组

在本节中,我们将重点介绍多维 TMA 复制。一维与多维情况的主要区别在于,必须在主机上创建张量映射并将其传递给 CUDA 核函数。

下表汇总了批量张量异步 TMA 可能使用的源内存空间、目标内存空间和完成机制,以及在设备代码中提供此功能的 API。

表 22使用批量张量异步 TMA 进行异步复制时,可能使用的源内存空间、目标内存空间和完成机制。空白单元格表示不支持该源与目标的组合。

方向

批量张量异步复制(TMA,CC9.0+)

源

目标

完成机制

API

全局内存

全局内存

shared::cta

全局内存

批量异步组

cuda::ptx::cp_async_bulk_tensor

全局内存

shared::cta

共享内存屏障

cuda::ptx::cp_async_bulk_tensor

全局内存

shared::cluster

共享内存屏障

cuda::ptx::cp_async_bulk_tensor

shared::cta

shared::cluster

共享内存屏障

cuda::ptx::cp_async_bulk_tensor

shared::cta

shared::cta

所有功能都需要内联 PTX,目前这些功能通过CUDA Standard C++库中的cuda::ptx命名空间提供。

下文将介绍如何使用 CUDA 驱动程序 API 创建张量映射、如何将其传递到设备,以及如何在设备上使用它。

驱动程序 API。使用cuTensorMapEncodeTiled驱动程序 API 创建张量映射。可以通过直接链接到驱动程序(-lcuda)或使用cudaGetDriverEntryPointByVersionAPI 来访问此 API。下面我们将展示如何获取指向cuTensorMapEncodeTiledAPI 的指针。有关更多信息,请参阅驱动程序入口点访问。

#include <cudaTypedefs.h> // PFN_cuTensorMapEncodeTiled, CUtensorMap

PFN_cuTensorMapEncodeTiled_v12000 get_cuTensorMapEncodeTiled() {
  // Get pointer to cuTensorMapEncodeTiled
  cudaDriverEntryPointQueryResult driver_status;
  void* cuTensorMapEncodeTiled_ptr = nullptr;
  CUDA_CHECK(cudaGetDriverEntryPointByVersion("cuTensorMapEncodeTiled", &cuTensorMapEncodeTiled_ptr, 12000, cudaEnableDefault, &driver_status));
  assert(driver_status == cudaDriverEntryPointSuccess);

  return reinterpret_cast<PFN_cuTensorMapEncodeTiled_v12000>(cuTensorMapEncodeTiled_ptr);
}

创建。创建张量映射需要许多参数,其中包括指向全局内存中数组的基指针、数组的大小(以元素数计)、从一行到下一行的步长(以字节计)以及共享内存缓冲区的大小(以元素数计)。以下代码创建一个张量映射,用于描述大小为GMEM_HEIGHT x GMEM_WIDTH的二维行主序数组。请注意参数的顺序:变化最快的维度排在最前面。

  CUtensorMap tensor_map{};
  // rank is the number of dimensions of the array.
  constexpr uint32_t rank = 2;
  uint64_t size[rank] = {GMEM_WIDTH, GMEM_HEIGHT};
  // The stride is the number of bytes to traverse from the first element of one row to the next.
  // It must be a multiple of 16.
  uint64_t stride[rank - 1] = {GMEM_WIDTH * sizeof(int)};
  // The box_size is the size of the shared memory buffer that is used as the
  // destination of a TMA transfer.
  uint32_t box_size[rank] = {SMEM_WIDTH, SMEM_HEIGHT};
  // The distance between elements in units of sizeof(element). A stride of 2
  // can be used to load only the real component of a complex-valued tensor, for instance.
  uint32_t elem_stride[rank] = {1, 1};

  // Get a function pointer to the cuTensorMapEncodeTiled driver API.
  auto cuTensorMapEncodeTiled = get_cuTensorMapEncodeTiled();

  // Create the tensor descriptor.
  CUresult res = cuTensorMapEncodeTiled(
    &tensor_map,                // CUtensorMap *tensorMap,
    CUtensorMapDataType::CU_TENSOR_MAP_DATA_TYPE_INT32,
    rank,                       // cuuint32_t tensorRank,
    tensor_ptr,                 // void *globalAddress,
    size,                       // const cuuint64_t *globalDim,
    stride,                     // const cuuint64_t *globalStrides,
    box_size,                   // const cuuint32_t *boxDim,
    elem_stride,                // const cuuint32_t *elementStrides,
    // Interleave patterns can be used to accelerate loading of values that
    // are less than 4 bytes long.
    CUtensorMapInterleave::CU_TENSOR_MAP_INTERLEAVE_NONE,
    // Swizzling can be used to avoid shared memory bank conflicts.
    CUtensorMapSwizzle::CU_TENSOR_MAP_SWIZZLE_NONE,
    // L2 Promotion can be used to widen the effect of a cache-policy to a wider
    // set of L2 cache lines.
    CUtensorMapL2promotion::CU_TENSOR_MAP_L2_PROMOTION_NONE,
    // Any element that is outside of bounds will be set to zero by the TMA transfer.
    CUtensorMapFloatOOBfill::CU_TENSOR_MAP_FLOAT_OOB_FILL_NONE
  );

从主机到设备的传输。有三种方式可让设备代码访问张量映射。建议将张量映射作为 const__grid_constant__参数传递给核函数。其他可选方式是使用cudaMemcpyToSymbol将张量映射复制到设备的__constant__内存中,或者通过全局内存访问它。将张量映射作为参数传递时,某些版本的 GCC C++ 编译器会发出警告:“用于传递 64 字节对齐参数的 ABI 在 GCC 4.6 中已更改”。此警告可以忽略。

#include <cuda.h>

__global__ void kernel(const __grid_constant__ CUtensorMap tensor_map)
{
   // Use tensor_map here.
}
int main() {
  CUtensorMap map;
  // [ ..Initialize map.. ]
  kernel<<<1, 1>>>(map);
}

可以使用全局__constant__变量来替代__grid_constant__核函数参数。下方给出了一个示例。

#include <cuda.h>

__constant__ CUtensorMap global_tensor_map;
__global__ void kernel()
{
  // Use global_tensor_map here.
}
int main() {
  CUtensorMap local_tensor_map;
  // [ ..Initialize map.. ]
  cudaMemcpyToSymbol(global_tensor_map, &local_tensor_map, sizeof(CUtensorMap));
  kernel<<<1, 1>>>();
}

最后,还可以将张量映射复制到全局内存。使用指向设备全局内存中张量映射的指针时,必须在每个线程块中的任何线程使用更新后的张量映射之前,在该线程块中执行栅栏操作。除非张量映射再次被修改,否则该线程块后续使用此张量映射时不需要执行栅栏操作。请注意,此机制可能比上述两种机制慢。

#include <cuda.h>
#include <cuda/ptx>
namespace ptx = cuda::ptx;

__device__ CUtensorMap global_tensor_map;
__global__ void kernel(CUtensorMap *tensor_map)
{
  // Fence acquire tensor map:
  ptx::n32_t<128> size_bytes;
  // Since the tensor map was modified from the host using cudaMemcpy,
  // the scope should be .sys.
  ptx::fence_proxy_tensormap_generic(
     ptx::sem_acquire, ptx::scope_sys, tensor_map, size_bytes
 );
 // Safe to use tensor_map after fence inside this thread.
}
int main() {
  CUtensorMap local_tensor_map;
  // [ ..Initialize map.. ]
  cudaMemcpy(&global_tensor_map, &local_tensor_map, sizeof(CUtensorMap), cudaMemcpyHostToDevice);
  kernel<<<1, 1>>>(global_tensor_map);
}

使用。以下核函数从一个较大的二维数组中加载大小为SMEM_HEIGHT x SMEM_WIDTH的二维分块。该分块的左上角由索引x和y指定。分块被加载到共享内存中,经过修改后写回全局内存。

#include <cuda.h>         // CUtensormap
#include <cuda/barrier>

using barrier = cuda::barrier<cuda::thread_scope_block>;
namespace ptx = cuda::ptx;

__device__ inline bool is_elected()
{
    unsigned int tid = threadIdx.x;
    unsigned int warp_id = tid / 32;
    unsigned int uniform_warp_id = __shfl_sync(0xFFFFFFFF, warp_id, 0); // Broadcast from lane 0.
    return (uniform_warp_id == 0 && ptx::elect_sync(0xFFFFFFFF)); // Elect a leader thread among warp 0.
}

__global__ void kernel(const __grid_constant__ CUtensorMap tensor_map, int x, int y) {
  // The destination shared memory buffer of a bulk tensor operation should be
  // 128 byte aligned.
  __shared__ alignas(128) int smem_buffer[SMEM_HEIGHT][SMEM_WIDTH];

  // Initialize shared memory barrier with the number of threads participating in the barrier.
  #pragma nv_diag_suppress static_var_with_dynamic_init
  __shared__ barrier bar;

  if (threadIdx.x == 0) {
    // Initialize barrier. All `blockDim.x` threads in block participate.
    init(&bar, blockDim.x);
  }
  // Syncthreads so initialized barrier is visible to all threads.
  __syncthreads();

  barrier::arrival_token token;
  if (is_elected()) {
    // Initiate bulk tensor copy.
    int32_t tensor_coords[2] = { x, y };
    ptx::cp_async_bulk_tensor(
      ptx::space_shared, ptx::space_global,
      &smem_buffer, &tensor_map, tensor_coords,
      cuda::device::barrier_native_handle(bar));
    // Arrive on the barrier and tell how many bytes are expected to come in.
    token = cuda::device::barrier_arrive_tx(bar, 1, sizeof(smem_buffer));
  } else {
    // Other threads just arrive.
    token = bar.arrive();
  }
  // Wait for the data to have arrived.
  bar.wait(std::move(token));

  // Symbolically modify a value in shared memory.
  smem_buffer[0][threadIdx.x] += threadIdx.x;

  // Wait for shared memory writes to be visible to TMA engine.
  ptx::fence_proxy_async(ptx::space_shared);
  __syncthreads();
  // After syncthreads, writes by all threads are visible to TMA engine.

  // Initiate TMA transfer to copy shared memory to global memory
  if (is_elected()) {
    int32_t tensor_coords[2] = { x, y };
    ptx::cp_async_bulk_tensor(
      ptx::space_global, ptx::space_shared,
      &tensor_map, tensor_coords, &smem_buffer);
    // Wait for TMA transfer to have finished reading shared memory.
    // Create a "bulk async-group" out of the previous bulk copy operation.
    ptx::cp_async_bulk_commit_group();
    // Wait for the group to have completed reading from shared memory.
    ptx::cp_async_bulk_wait_group_read(ptx::n32_t<0>());
  }

  // Destroy barrier. This invalidates the memory region of the barrier. If
  // further computations were to take place in the kernel, this allows the
  // memory location of the shared memory barrier to be reused.
  if (threadIdx.x == 0) {
    (&bar)->~barrier();
  }
}

负索引与越界。当从全局内存读取到共享内存的分块中有一部分越界时,与越界区域对应的共享内存将填充为零。分块左上角的索引也可以为负数。从共享内存写入全局内存时,分块的某些部分可以越界,但左上角的任何索引都不能为负数。

大小与步长。张量的大小是沿某一维度的元素数。所有大小都必须大于一。步长是同一维度的元素之间的字节数。例如,一个4 x 4整数矩阵的大小分别为 4 和 4。由于每个元素占 4 字节,步长分别为 4 字节和 16 字节。由于对齐要求,一个4 x 3行主序整数矩阵的步长也必须分别为 4 字节和 16 字节。每行额外填充 4 字节,以确保下一行的起始位置按 16 字节对齐。有关对齐要求的更多信息,请参见下表。

表 23多维批量张量异步复制操作的对齐要求。

地址 / 大小

对齐

全局内存地址

必须按 16 字节对齐。

全局内存大小

必须大于或等于一。不必是 16 字节的倍数。

全局内存步长

必须是 16 字节的倍数。

共享内存地址

必须按 128 字节对齐。

共享内存屏障地址

必须按 8 字节对齐(这由cuda::barrier保证)。

传输大小

必须是 16 字节的倍数。

4.12.2.2.1. 在设备上编码张量映射

前面的各节已介绍如何使用 CUDA 驱动程序 API 在主机上创建张量映射。

本节说明如何在设备上编码分块类型的张量映射。当传输张量映射的常规方式(使用const __grid_constant__核函数参数)不合适时,这种方法会很有用,例如在一次核函数启动中处理一批大小各异的张量时。

建议采用以下模式:

  1. 在主机上使用驱动程序 API 创建张量映射“模板”template_tensor_map。

  2. 在设备核函数中,复制template_tensor_map,修改副本,将其存储到全局内存中,并执行适当的栅栏操作。

  3. 在核函数中使用该张量映射,并执行适当的栅栏操作。

代码的总体结构如下:

// Initialize device context:
CUDA_CHECK(cudaDeviceSynchronize());

// Create a tensor map template using the cuTensorMapEncodeTiled driver function
CUtensorMap template_tensor_map = make_tensormap_template();

// Allocate tensor map and tensor in global memory
CUtensorMap* global_tensor_map;
CUDA_CHECK(cudaMalloc(&global_tensor_map, sizeof(CUtensorMap)));
char* global_buf;
CUDA_CHECK(cudaMalloc(&global_buf, 8 * 256));

// Fill global buffer with data.
fill_global_buf<<<1, 1>>>(global_buf);

// Define the parameters of the tensor map that will be created on device.
tensormap_params p{};
p.global_address    = global_buf;
p.rank              = 2;
p.box_dim[0]        = 128; // The box in shared memory has half the width of the full buffer
p.box_dim[1]        = 4;   // The box in shared memory has half the height of the full buffer
p.global_dim[0]     = 256; //
p.global_dim[1]     = 8;   //
p.global_stride[0]  = 256; //
p.element_stride[0] = 1;   //
p.element_stride[1] = 1;   //

// Encode global_tensor_map on device:
encode_tensor_map<<<1, 32>>>(template_tensor_map, p, global_tensor_map);

// Use it from another kernel:
consume_tensor_map<<<1, 1>>>(global_tensor_map);

// Check for errors:
CUDA_CHECK(cudaDeviceSynchronize());

以下各节介绍总体步骤。在所有示例中,下面的tensormap_params结构体都包含待更新字段的新值。在此列出该结构体,以供阅读示例时参考。

struct tensormap_params {
  void* global_address;
  int rank;
  uint32_t box_dim[5];
  uint64_t global_dim[5];
  size_t global_stride[4];
  uint32_t element_stride[5];
};

4.12.2.2.2. 在设备端编码和修改张量映射

在全局内存中编码张量映射的建议流程如下。

  1. 将现有的张量映射template_tensor_map传递给核函数。与在cp.async.bulk.tensor指令中使用张量映射的核函数不同,这里可以采用任何方式传递:指向全局内存的指针、核函数参数、__constant___变量等。

  2. 使用 template_tensor_map 的值对共享内存中的张量映射进行复制初始化。

  3. 使用cuda::ptx::tensormap_replace函数修改共享内存中的张量映射。这些函数封装了tensormap.replacePTX 指令,该指令可用于修改分块类型张量映射的任何字段,包括基地址、大小、步长等。

  4. 使用cuda::ptx::tensormap_copy_fenceproxy函数,将修改后的张量映射从共享内存复制到全局内存,并执行所有必要的栅栏操作。

以下代码包含一个遵循这些步骤的核函数。为完整起见,它修改了张量映射的所有字段。通常,核函数只会修改少数字段。

在此核函数中,template_tensor_map作为核函数参数传递。这是将template_tensor_map从主机移动到设备的首选方式。如果核函数旨在更新设备内存中现有的张量映射,它可以接收指向待修改的现有张量映射的指针。

注意

张量映射的格式可能会随时间推移而改变。因此,cuda::ptx::tensormap_replace函数和相应的tensormap.replace.tilePTX 指令被标记为 sm_90a 专用。要使用它们,请使用nvcc -arch sm_90a ....进行编译。

提示

在 sm_90a 上,也可以使用共享内存中以零初始化的缓冲区作为张量映射的初始值。这使得张量映射可以完全在设备上编码,而无需使用驱动程序 API 来编码template_tensor_map value。

注意

仅分块类型的张量映射支持在设备上修改;其他类型的张量映射无法在设备上修改。有关张量映射类型的更多信息,请参阅驱动程序 API 参考。

#include <cuda/ptx>

namespace ptx = cuda::ptx;

// launch with 1 warp.
__launch_bounds__(32)
__global__ void encode_tensor_map(const __grid_constant__ CUtensorMap template_tensor_map, tensormap_params p, CUtensorMap* out) {
   __shared__ alignas(128) CUtensorMap smem_tmap;
   if (threadIdx.x == 0) {
      // Copy template to shared memory:
      smem_tmap = template_tensor_map;

      const auto space_shared = ptx::space_shared;
      ptx::tensormap_replace_global_address(space_shared, &smem_tmap, p.global_address);
      // For field .rank, the operand new_val must be ones less than the desired
      // tensor rank as this field uses zero-based numbering.
      ptx::tensormap_replace_rank(space_shared, &smem_tmap, p.rank - 1);

      // Set box dimensions:
      if (0 < p.rank) { ptx::tensormap_replace_box_dim(space_shared, &smem_tmap, ptx::n32_t<0>{}, p.box_dim[0]); }
      if (1 < p.rank) { ptx::tensormap_replace_box_dim(space_shared, &smem_tmap, ptx::n32_t<1>{}, p.box_dim[1]); }
      if (2 < p.rank) { ptx::tensormap_replace_box_dim(space_shared, &smem_tmap, ptx::n32_t<2>{}, p.box_dim[2]); }
      if (3 < p.rank) { ptx::tensormap_replace_box_dim(space_shared, &smem_tmap, ptx::n32_t<3>{}, p.box_dim[3]); }
      if (4 < p.rank) { ptx::tensormap_replace_box_dim(space_shared, &smem_tmap, ptx::n32_t<4>{}, p.box_dim[4]); }
      // Set global dimensions:
      if (0 < p.rank) { ptx::tensormap_replace_global_dim(space_shared, &smem_tmap, ptx::n32_t<0>{}, (uint32_t) p.global_dim[0]); }
      if (1 < p.rank) { ptx::tensormap_replace_global_dim(space_shared, &smem_tmap, ptx::n32_t<1>{}, (uint32_t) p.global_dim[1]); }
      if (2 < p.rank) { ptx::tensormap_replace_global_dim(space_shared, &smem_tmap, ptx::n32_t<2>{}, (uint32_t) p.global_dim[2]); }
      if (3 < p.rank) { ptx::tensormap_replace_global_dim(space_shared, &smem_tmap, ptx::n32_t<3>{}, (uint32_t) p.global_dim[3]); }
      if (4 < p.rank) { ptx::tensormap_replace_global_dim(space_shared, &smem_tmap, ptx::n32_t<4>{}, (uint32_t) p.global_dim[4]); }
      // Set global stride:
      if (1 < p.rank) { ptx::tensormap_replace_global_stride(space_shared, &smem_tmap, ptx::n32_t<0>{}, p.global_stride[0]); }
      if (2 < p.rank) { ptx::tensormap_replace_global_stride(space_shared, &smem_tmap, ptx::n32_t<1>{}, p.global_stride[1]); }
      if (3 < p.rank) { ptx::tensormap_replace_global_stride(space_shared, &smem_tmap, ptx::n32_t<2>{}, p.global_stride[2]); }
      if (4 < p.rank) { ptx::tensormap_replace_global_stride(space_shared, &smem_tmap, ptx::n32_t<3>{}, p.global_stride[3]); }
      // Set element stride:
      if (0 < p.rank) { ptx::tensormap_replace_element_size(space_shared, &smem_tmap, ptx::n32_t<0>{}, p.element_stride[0]); }
      if (1 < p.rank) { ptx::tensormap_replace_element_size(space_shared, &smem_tmap, ptx::n32_t<1>{}, p.element_stride[1]); }
      if (2 < p.rank) { ptx::tensormap_replace_element_size(space_shared, &smem_tmap, ptx::n32_t<2>{}, p.element_stride[2]); }
      if (3 < p.rank) { ptx::tensormap_replace_element_size(space_shared, &smem_tmap, ptx::n32_t<3>{}, p.element_stride[3]); }
      if (4 < p.rank) { ptx::tensormap_replace_element_size(space_shared, &smem_tmap, ptx::n32_t<4>{}, p.element_stride[4]); }

      // These constants are documented in this table:
      // https://docs.nvidia.com/cuda/parallel-thread-execution/index.html#tensormap-new-val-validity
      auto u8_elem_type = ptx::n32_t<0>{};
      ptx::tensormap_replace_elemtype(space_shared, &smem_tmap, u8_elem_type);
      auto no_interleave = ptx::n32_t<0>{};
      ptx::tensormap_replace_interleave_layout(space_shared, &smem_tmap, no_interleave);
      auto no_swizzle = ptx::n32_t<0>{};
      ptx::tensormap_replace_swizzle_mode(space_shared, &smem_tmap, no_swizzle);
      auto zero_fill = ptx::n32_t<0>{};
      ptx::tensormap_replace_fill_mode(space_shared, &smem_tmap, zero_fill);
   }
   // Synchronize the modifications with other threads in warp
   __syncwarp();
   // Copy the tensor map to global memory collectively with threads in the warp.
   // In addition: make the updated tensor map visible to other threads on device that
   // for use with cp.async.bulk.
   ptx::n32_t<128> bytes_128;
   ptx::tensormap_cp_fenceproxy(ptx::sem_release, ptx::scope_gpu, out, &smem_tmap, bytes_128);
}

4.12.2.2.3. 使用修改后的张量映射

与使用通过const __grid_constant__核函数参数传入的张量映射不同,使用全局内存中的张量映射时,必须在修改张量映射的线程与使用它的线程之间,在张量映射代理中显式建立释放-获取模式。

上一节展示了该模式的释放部分。它通过cuda::ptx::tensormap.cp_fenceproxy函数实现。

获取部分通过cuda::ptx::fence_proxy_tensormap_generic函数实现,该函数封装了fence.proxy.tensormap::generic.acquire指令。如果参与释放-获取模式的两个线程位于同一设备上,使用.gpu作用域即可。如果这些线程位于不同设备上,则必须使用.sys作用域。一旦某个线程获取了张量映射,在经过充分同步后(例如使用__syncthreads()),线程块中的其他线程就可以使用它。使用张量映射的线程和执行栅栏的线程必须位于同一线程块中。也就是说,如果这些线程分别位于不同线程块中,例如位于同一簇、同一网格或不同核函数的两个不同线程块中,那么cooperative_groups::cluster或grid_group::sync()之类的同步 API 或流顺序同步不足以为张量映射更新建立顺序关系,也就是说,这些其他线程块中的线程在使用更新后的张量映射之前,仍需要在正确的作用域获取张量映射代理。如果中间没有发生修改,则不必在每条cp.async.bulk.tensor指令之前重复执行栅栏。

以下示例展示了fence以及张量映射的后续使用。

// Consumer of tensor map in global memory:
__global__ void consume_tensor_map(CUtensorMap* tensor_map) {
  // Fence acquire tensor map:
  ptx::n32_t<128> size_bytes;
  ptx::fence_proxy_tensormap_generic(ptx::sem_acquire, ptx::scope_sys, tensor_map, size_bytes);
  // Safe to use tensor_map after fence.

  __shared__ uint64_t bar;
  __shared__ alignas(128) char smem_buf[4][128];

  if (threadIdx.x == 0) {
    // Initialize barrier
    ptx::mbarrier_init(&bar, 1);
    // Issue TMA request
    ptx::cp_async_bulk_tensor(ptx::space_cluster, ptx::space_global, smem_buf, tensor_map, {0, 0}, &bar);
    // Arrive on barrier. Expect 4 * 128 bytes.
    ptx::mbarrier_arrive_expect_tx(ptx::sem_release, ptx::scope_cta, ptx::space_shared, &bar, sizeof(smem_buf));
  }
  const int parity = 0;
  // Wait for load to have completed
  while (!ptx::mbarrier_try_wait_parity(&bar, parity)) {}

  // print items:
  printf("Got:\n\n");
  for (int j = 0; j < 4; ++j) {
    for (int i = 0; i < 128; ++i) {
      printf("%3d ", smem_buf[j][i]);
      if (i % 32 == 31) { printf("\n"); };
    }
    printf("\n");
  }
}

4.12.2.2.4. 使用驱动程序 API 创建模板张量映射值

以下代码创建一个最小的分块类型张量映射,随后可以在设备上对其进行修改。

CUtensorMap make_tensormap_template() {
  CUtensorMap template_tensor_map{};
  auto cuTensorMapEncodeTiled = get_cuTensorMapEncodeTiled();

  uint32_t dims_32         = 16;
  uint64_t dims_strides_64 = 16;
  uint32_t elem_strides    = 1;

  // Create the tensor descriptor.
  CUresult res = cuTensorMapEncodeTiled(
    &template_tensor_map, // CUtensorMap *tensorMap,
    CUtensorMapDataType::CU_TENSOR_MAP_DATA_TYPE_UINT8,
    1,                // cuuint32_t tensorRank,
    nullptr,          // void *globalAddress,
    &dims_strides_64, // const cuuint64_t *globalDim,
    &dims_strides_64, // const cuuint64_t *globalStrides,
    &dims_32,         // const cuuint32_t *boxDim,
    &elem_strides,    // const cuuint32_t *elementStrides,
    CUtensorMapInterleave::CU_TENSOR_MAP_INTERLEAVE_NONE,
    CUtensorMapSwizzle::CU_TENSOR_MAP_SWIZZLE_NONE,
    CUtensorMapL2promotion::CU_TENSOR_MAP_L2_PROMOTION_NONE,
    CUtensorMapFloatOOBfill::CU_TENSOR_MAP_FLOAT_OOB_FILL_NONE);

  CU_CHECK(res);
  return template_tensor_map;
}

4.12.2.2.5. 共享内存存储体重排

默认情况下,TMA 引擎按照数据在全局内存中的布局顺序将数据加载到共享内存中。但是,对于某些共享内存访问模式,这种布局可能并非最优,因为它可能导致共享内存存储体冲突。为了提高性能并减少存储体冲突,我们可以应用“重排模式”来改变共享内存布局。

共享内存有 32 个存储体,其组织方式使连续的 32 位字映射到连续的存储体。每个存储体的带宽为每时钟周期 32 位。在加载和存储共享内存时,如果同一存储体在一个事务中被多次使用,就会发生存储体冲突,从而导致带宽降低。请参阅共享内存访问模式。

为确保共享内存中的数据布局能让用户代码避免共享内存存储体冲突,可以指示 TMA 引擎在将数据存储到共享内存之前对其进行“重排”,并在将数据从共享内存复制回全局内存时对其进行“逆重排”。张量映射编码了“重排模式”,用于指示采用哪一种重排方式。

示例:矩阵转置

一个例子是矩阵转置,其中数据从按行优先访问映射为按列优先访问。数据以行主序存储在全局内存中,但我们还希望在共享内存中按列访问它,这会导致存储体冲突。不过,使用 128 字节“重排”模式和新的共享内存索引可以消除这些冲突。

在此示例中,我们将一个类型为int4、以行主序存储在全局内存中的 8x8 矩阵加载到共享内存中。然后,每组八个线程从共享内存缓冲区中加载一行,并将其存储到单独的转置共享内存缓冲区中的一列。这会在存储时产生八路存储体冲突。最后,将转置缓冲区写回全局内存。

为避免存储体冲突,可以使用CU_TENSOR_MAP_SWIZZLE_128B布局。此布局与 128 字节的行长度相匹配,并改变共享内存布局,使按列访问和按行访问都不需要在每个事务中使用相同的存储体。

下面的两个表格,即图 51和图 52,展示了类型为int4的 8x8 矩阵及其转置矩阵的常规共享内存布局和重排后的共享内存布局。颜色表示矩阵元素映射到八个存储体组中的哪一组,每组包含四个存储体,边缘的行和列列出了全局内存的行索引和列索引。各表项显示了这些 16 字节矩阵元素的共享内存索引。

未进行重排的共享内存数据布局

图 51在未进行重排的共享内存数据布局中,共享内存索引与全局内存索引相同。每条加载指令读取一行,并将其存储到转置缓冲区的一列中。由于转置后该列中的所有矩阵元素都落在同一存储体中,因此存储必须串行进行,产生八个存储事务,使每个存储的列都发生八路存储体冲突。

采用 CU_TENSOR_MAP_SWIZZLE_128B 重排的共享内存数据布局。

图 52采用CU_TENSOR_MAP_SWIZZLE_128B重排的共享内存数据布局。一行被存储到一列中,对于行和列而言,每个矩阵元素都来自不同的存储体,因此不存在任何存储体冲突。

__global__ void kernel_tma(const __grid_constant__ CUtensorMap tensor_map) {
   // The destination shared memory buffer of a bulk tensor operation
   // with the 128-byte swizzle mode, it should be 1024 bytes aligned.
   __shared__ alignas(1024) int4 smem_buffer[8][8];
   __shared__ alignas(1024) int4 smem_buffer_tr[8][8];

   // Initialize shared memory barrier
   #pragma nv_diag_suppress static_var_with_dynamic_init
   __shared__ barrier bar;

   if (threadIdx.x == 0) {
     init(&bar, blockDim.x);
   }
   __syncthreads();

   barrier::arrival_token token;
   if (is_elected()) {
     // Initiate bulk tensor copy from global to shared memory,
     // in the same way as without swizzle.
     int32_t tensor_coords[2] = { 0, 0 };
     ptx::cp_async_bulk_tensor(
       ptx::space_shared, ptx::space_global,
       &smem_buffer, &tensor_map, tensor_coords,
       cuda::device::barrier_native_handle(bar));
     token = cuda::device::barrier_arrive_tx(bar, 1, sizeof(smem_buffer));
   } else {
     token = bar.arrive();
   }

   bar.wait(std::move(token));

   /* Matrix transpose
    *  When using the normal shared memory layout, there are eight
    *  8-way shared memory bank conflict when storing to the transpose.
    *  When enabling the 128-byte swizzle pattern and using the according access pattern,
    *  they are eliminated both for load and store. */
   for(int sidx_j =threadIdx.x; sidx_j < 8; sidx_j+= blockDim.x){
      for(int sidx_i = 0; sidx_i < 8; ++sidx_i){
         const int swiz_j_idx = (sidx_i % 8) ^ sidx_j;
         const int swiz_i_idx_tr = (sidx_j % 8) ^ sidx_i;
         smem_buffer_tr[sidx_j][swiz_i_idx_tr] = smem_buffer[sidx_i][swiz_j_idx];
      }
   }

   // Wait for shared memory writes to be visible to TMA engine.
   ptx::fence_proxy_async(ptx::space_shared);
   __syncthreads();

   /* Initiate TMA transfer to copy the transposed shared memory buffer back to global memory,
    * it will 'unswizzle' the data. */
   if (is_elected()) {
       int32_t tensor_coords[2] = { x, y };
       ptx::cp_async_bulk_tensor(
         ptx::space_global, ptx::space_shared,
         &tensor_map, tensor_coords, &smem_buffer_tr);
      ptx::cp_async_bulk_commit_group();
      ptx::cp_async_bulk_wait_group_read(ptx::n32_t<0>());
   }

   // Destroy barrier
   if (threadIdx.x == 0) {
     (&bar)->~barrier();
   }
}

// --------------------------------- main ----------------------------------------

int main(){

...
   void* tensor_ptr = d_data;

   CUtensorMap tensor_map{};
   // rank is the number of dimensions of the array.
   constexpr uint32_t rank = 2;
   // global memory size
   uint64_t size[rank] = {4*8, 8};
   // global memory stride, must be a multiple of 16.
   uint64_t stride[rank - 1] = {8 * sizeof(int4)};
   // The inner shared memory box dimension in bytes, equal to the swizzle span.
   uint32_t box_size[rank] = {4*8, 8};

   uint32_t elem_stride[rank] = {1, 1};

   // Create the tensor descriptor.
   CUresult res = cuTensorMapEncodeTiled(
       &tensor_map,                // CUtensorMap *tensorMap,
       CUtensorMapDataType::CU_TENSOR_MAP_DATA_TYPE_INT32,
       rank,                       // cuuint32_t tensorRank,
       tensor_ptr,                 // void *globalAddress,
       size,                       // const cuuint64_t *globalDim,
       stride,                     // const cuuint64_t *globalStrides,
       box_size,                   // const cuuint32_t *boxDim,
       elem_stride,                // const cuuint32_t *elementStrides,
       CUtensorMapInterleave::CU_TENSOR_MAP_INTERLEAVE_NONE,
       // Using a swizzle pattern of 128 bytes.
       CUtensorMapSwizzle::CU_TENSOR_MAP_SWIZZLE_128B,
       CUtensorMapL2promotion::CU_TENSOR_MAP_L2_PROMOTION_NONE,
       CUtensorMapFloatOOBfill::CU_TENSOR_MAP_FLOAT_OOB_FILL_NONE
   );

   kernel_tma<<<1, 8>>>(tensor_map);
 ...
}

备注。本示例旨在展示重排的用法,其“原样”实现的性能并不好,也无法扩展到超出给定维度的情况。

说明。在数据传输期间,TMA 引擎根据重排模式对数据进行洗牌,如下表所述。这些重排模式定义了沿重排宽度排列的 16 字节数据块到每组四个存储体的子组的映射。其类型为CUtensorMapSwizzle,有四个选项:无、32 字节、64 字节和 128 字节。注意,共享内存盒的内层维度必须小于或等于重排模式的跨度。

重排模式

如前所述,有四种重排模式。下表展示了不同的重排模式,包括新的共享内存索引之间的关系。这些表定义了沿 128 字节排列的 16 字节数据块到八个子组的映射,每个子组包含四个存储体。

TMA 重排模式概览

图 53 TMA 重排模式概览

图内文字中文对照
Row index y
行索引 y
Groups of 4 shared memory banks (16 bytes each)
每组 4 个共享内存存储体(每组 16 字节)

注意事项。应用 TMA 重排模式时,务必遵守特定的内存要求:

  • 全局内存对齐:全局内存必须按 128 字节对齐。

  • 共享内存对齐:为简单起见,共享内存应按重排模式重复周期的字节数对齐。当共享内存缓冲区未按重排模式自身重复周期的字节数对齐时,重排模式与共享内存之间会存在偏移。请参阅下文的说明。

  • 内层维度:共享内存块的内层维度必须满足表 25中指定的大小要求。如果不满足这些要求,该指令将被视为无效。此外,如果重排宽度超过内层维度,请确保分配的共享内存能够容纳完整的重排宽度。

  • 粒度:重排映射的粒度固定为 16 字节。这意味着数据以 16 字节的数据块为单位进行组织和访问,在规划内存布局和访问模式时必须考虑这一点。

重排模式指针偏移计算。这里介绍当共享内存缓冲区未按重排模式自身重复周期的字节数对齐时,如何确定重排模式与共享内存之间的偏移。使用 TMA 时,要求共享内存按 128 字节对齐。要确定共享内存缓冲区相对于重排模式偏移了多少个这样的单位,请应用相应的偏移公式。

表 24 重排模式指针偏移公式和索引关系

重排模式

偏移公式

索引关系

CU_TENSOR_MAP_SWIZZLE_128B

(reinterpret_cast <uintptr_t>(smem_ptr)/128)%8

smem[y][x] <-> smem[y][((y+offset)%8)^x]

CU_TENSOR_MAP_SWIZZLE_64B

(reinterpret_cast <uintptr_t>(smem_ptr)/128)%4

smem[y][x] <-> smem[y][((y+offset)%4)^x]

CU_TENSOR_MAP_SWIZZLE_32B

(reinterpret_cast <uintptr_t>(smem_ptr)/128)%2

smem[y][x] <-> smem[y][((y+offset)%2)^x]

在图 53中,此偏移表示初始行偏移,因此在计算重排索引时,会将其加到行索引y上。以下代码片段展示了如何在CU_TENSOR_MAP_SWIZZLE_128B模式下访问重排后的共享内存。

data_t* smem_ptr = &smem[0][0];
int offset = (reinterpret_cast<uintptr_t>(smem_ptr)/128)%8;
smem[y][((y+offset)%8)^x] = ...

总结。下面的表 25总结了计算能力 9 的不同重排模式的要求和属性。

表 25 计算能力 9 的不同重排模式的要求和属性

模式

重排宽度

共享内存盒的内层维度

重复周期

共享内存对齐

全局内存对齐

CU_TENSOR_MAP_SWIZZLE_128B

128 字节

<=128 字节

1024 字节

128 字节

128 字节

CU_TENSOR_MAP_SWIZZLE_64B

64 字节

<=64 字节

512 字节

128 字节

128 字节

CU_TENSOR_MAP_SWIZZLE_32B

32 字节

<=32 字节

256 字节

128 字节

128 字节

CU_TENSOR_MAP_SWIZZLE_NONE(默认)

128 字节

16 字节

4.12.3. 使用 STAS

使用线程块簇的 CUDA 应用程序可能需要在簇内的线程块之间移动小型数据元素。STAS 指令(计算能力 9.0 及以上,参见PTX 文档)支持直接从寄存器向分布式共享内存异步复制数据。STAS 仅通过libcu++库中提供的较低层cuda::ptx::st_asyncAPI 暴露。

维度。STAS 支持复制 4、8 或 16 字节。

源和目标。STAS 异步复制操作唯一支持的方向是从寄存器到分布式共享内存。根据所复制数据的大小,目标指针需要按 4、8 或 16 字节对齐。

异步性。使用 STAS 的数据传输是异步的,并被建模为异步线程操作(参见异步线程和异步代理)。这使发起线程能够在硬件异步复制数据的同时继续计算。数据传输在实践中是否异步发生取决于硬件实现,并且将来可能会改变。STAS 操作可用于发出完成信号的完成机制是共享内存屏障。

在以下示例中,我们展示如何使用 STAS 在线程块簇内实现生产者-消费者模式。此核函数创建一个环形通信流水线,其中 8 个线程块排列成环,每个线程块同时:

  • 为序列中的下一个线程块生产数据。

  • 消费来自序列中前一个线程块的数据。

要实现这种模式,每个线程块需要 2 个共享内存屏障,一个用于通知消费者线程块数据已复制到共享内存缓冲区(filled),另一个用于通知生产者线程块消费者上的缓冲区已准备好被填充(ready)。

#include <cooperative_groups.h>
#include <cuda/barrier>
#include <cuda/ptx>

__global__ __cluster_dims__(8, 1, 1) void producer_consumer_kernel() 
{
    using namespace cooperative_groups;
    using namespace cuda::device;
    using namespace cuda::ptx;
    using barrier_t = cuda::barrier<cuda::thread_scope_block>;

    auto cluster = this_cluster();

    #pragma nv_diag_suppress static_var_with_dynamic_init
    __shared__ int buffer[BLOCK_SIZE];
    __shared__ barrier_t filled;
    __shared__ barrier_t ready;
    
    // Initialize shared memory barriers.
    if (threadIdx.x == 0) {
        init(&filled, 1);
        init(&ready, BLOCK_SIZE);
    }
    
    // Sync cluster to ensure remote barriers are initialized.
    cluster.sync();
    
    // Define my own and my neighbor's ranks.
    int rk = cluster.block_rank();
    int rk_next = (rk + 1) % 8;
    int rk_prev = (rk + 7) % 8;
      
    // Get addresses of remote buffer we are writing to and remote barriers of previous and next blocks.
    auto buffer_next = cluster.map_shared_rank(buffer, rk_next);
    auto bar_next = cluster.map_shared_rank(barrier_native_handle(filled), rk_next);
    auto bar_prev = cluster.map_shared_rank(barrier_native_handle(ready), rk_prev);
    
    int phase = 0;
    for (int it = 0; it < 1000; ++it) {
        
        // As producers, send data to our right neighbor.
        st_async(&buffer_next[threadIdx.x], rk, bar_next);
        
        if (threadIdx.x == 0) {
            // Thread 0 arrives on local barrier and indicates it expects to receive a certain number of bytes.
            mbarrier_arrive_expect_tx(sem_release, scope_cluster, space_shared, barrier_native_handle(filled), sizeof(buffer));
        }

        // As consumers, wait on local barrier for data from left neighbor to arrive.
        while (!mbarrier_try_wait_parity(barrier_native_handle(filled), phase, 1000)) {}
        
        // At this point, the data has been copied to our local buffer.
        int r = buffer[threadIdx.x];
        
        // Use the data to do something.

        // As consumers, notify our left neighbor that we are done with the data.
        mbarrier_arrive(sem_release, scope_cluster, space_cluster, bar_prev);
        
        // As producers, wait on local barrier until the right neighbor is ready to receive new data.
        while (!mbarrier_try_wait_parity(barrier_native_handle(ready), phase, 1000)) {}
        phase ^= 1;
    }
}
  • 共享内存屏障由每个线程块的第一个线程初始化。屏障filled初始化为 1,屏障ready初始化为线程块中的线程数。

  • 执行簇范围的同步,以确保在任何线程开始通信之前,所有屏障都已初始化。

  • 每个线程确定其相邻线程块的秩,并使用它们来映射远程共享内存屏障和用于写入数据的远程共享内存缓冲区。

  • 在每次迭代中:

    1. 作为生产者,每个线程都向其右侧相邻线程块发送数据。

    2. 作为消费者,线程 0 到达本地filled屏障,并指示其预期接收一定数量的字节。

    3. 作为消费者,每个线程都在本地filled屏障上等待来自左侧相邻线程块的数据到达。

    4. 作为消费者,每个线程都使用这些数据执行某些操作。

    5. 作为消费者,每个线程都通知左侧相邻线程块,它已使用完这些数据。

    6. 作为生产者,每个线程都在本地ready屏障上等待,直到右侧相邻线程块准备好接收新数据。

注意,对于每个屏障,我们都需要使用正确的空间。对于映射后的远程屏障,需要使用space_cluster空间,而对于本地屏障,则需要使用space_shared空间。

4.13. 使用簇启动控制进行工作窃取

开发 CUDA 应用程序时,处理数据规模和计算规模可变的问题至关重要。传统上,CUDA 开发者使用两种主要方法来确定要启动的核函数线程块数量:每个线程块的工作量固定和线程块数量固定。这两种方法各有优缺点。

每个线程块的工作量固定:在这种方法中,线程块数量由问题规模决定,而每个线程块执行的工作量保持不变。

这种方法的主要优点:

  • SM 之间的负载均衡

    当线程块的运行时间存在差异,并且/或者线程块数量远大于 GPU 能够同时执行的数量(产生较小的尾部效应)时,这种方法允许 GPU 调度器在某些 SM 上运行比其他 SM 更多的线程块。

  • 抢占

    即使较高优先级的核函数是在较低优先级的核函数已经开始执行之后才启动的,GPU 调度器也可以在较低优先级核函数的线程块完成时调度较高优先级核函数的线程块,从而开始执行较高优先级的核函数。随后,一旦较高优先级的核函数执行完毕,就可以恢复执行较低优先级的核函数。

线程块数量固定:在这种方法中,线程块数量不依赖于问题规模,通常以块步长循环或网格步长循环实现。相反,每个线程块执行的工作量是问题规模的函数。通常,线程块数量取决于执行核函数的 GPU 上的 SM 数量以及期望的占用率。

这种方法的主要优点:

  • 降低线程块开销

    这种方法不仅降低了摊销后的线程块启动延迟,还尽量减少了与所有线程块共同执行的操作相关的计算开销。这些开销可能显著高于启动延迟开销。

    例如,在卷积核函数中,由于线程块数量固定,用于计算卷积系数且与线程块索引无关的前导计算可以执行更少的次数,从而减少冗余计算。

簇启动控制是 NVIDIA Blackwell GPU 架构(计算能力 10.0)引入的一项功能,旨在结合前述两种方法的优点。它允许开发者取消线程块或线程块簇,从而对线程块调度拥有更多控制权。这一机制支持工作窃取。工作窃取是并行计算中的一种动态负载均衡技术,空闲处理器主动从繁忙处理器的工作队列中“窃取”任务,而不是等待被分配工作。

簇启动控制流程

图 54 簇启动控制流程

图内文字中文对照
START
开始
Initialize Barrier
初始化屏障
Synchronize Threads
同步线程
Asynchronous Cancellation Request
异步取消请求
Kernel Computation
核函数计算
Synchronize Cancellation Request
同步取消请求
Cancelled?
是否已取消?
YES
是
NO
否
END
结束

借助簇启动控制,一个线程块会尝试取消另一个尚未开始执行的线程块的启动。如果取消请求成功,它就会使用另一个线程块的索引来执行任务,从而“窃取”其工作。如果没有更多可用的线程块索引,或者由于其他原因,例如正在调度较高优先级的核函数,取消操作就会失败。在后一种情况下,如果线程块在取消失败后退出,调度器就可以开始执行较高优先级的核函数,之后将继续调度当前核函数剩余的线程块执行。上图展示了这一过程的执行流程。

下表总结了这三种方法的优缺点:

每个线程块的工作量固定

线程块数量固定

簇启动控制

降低开销

X\textcolor{red}{\textbf{X}}

V\textcolor{lime}{\textbf{V}}

V\textcolor{lime}{\textbf{V}}

抢占

V\textcolor{lime}{\textbf{V}}

X\textcolor{red}{\textbf{X}}

V\textcolor{lime}{\textbf{V}}

负载均衡

V\textcolor{lime}{\textbf{V}}

X\textcolor{red}{\textbf{X}}

V\textcolor{lime}{\textbf{V}}

4.13.1. API 细节

通过簇启动控制 API 取消线程块是异步执行的,并使用共享内存屏障进行同步,遵循与异步数据复制类似的编程模式。

该 API 通过libcu++提供,包含:

  • 一条请求指令,用于将编码后的取消结果写入__shared__变量。

  • 用于提取成功/失败状态和被取消线程块索引的解码指令。

注意,簇启动控制操作被建模为异步代理操作(参见异步线程和异步代理)。

4.13.1.1. 线程块取消

使用簇启动控制的首选方式是由单个线程发起,即一次一个请求。

取消过程包含五个步骤:

  • 设置阶段(步骤 1-2):声明并初始化取消结果变量和同步变量。

  • 工作窃取循环(步骤 3-5):重复执行,以请求、同步并处理取消结果。

  1. 声明用于线程块取消的变量:

    __shared__ uint4 result; // Request result.
    __shared__ uint64_t bar; // Synchronization barrier.
    int phase = 0;           // Synchronization barrier phase.
    
  2. 将共享内存屏障的到达计数初始化为 1:

    if (cg::thread_block::thread_rank() == 0)
        ptx::mbarrier_init(&bar, 1);
    __syncthreads();
    
  3. 由单个线程提交异步取消请求,并设置事务计数:

    if (cg::thread_block::thread_rank() == 0) {
        cg::invoke_one(cg::coalesced_threads(), [&](){ptx::clusterlaunchcontrol_try_cancel(&result, &bar);});
        ptx::mbarrier_arrive_expect_tx(ptx::sem_relaxed, ptx::scope_cta, ptx::space_shared, &bar, sizeof(uint4));
    }
    

    注意

    由于线程块取消是一条一致指令,建议在invoke_one线程选择器内提交它。这使编译器能够优化掉剥离循环。

  4. 同步(完成)异步取消请求:

    while (!ptx::mbarrier_try_wait_parity(&bar, phase))
    {}
    phase ^= 1;
    
  5. 获取取消状态和被取消线程块的索引:

    bool success = ptx::clusterlaunchcontrol_query_cancel_is_canceled(result);
    if (success) {
        // Don't need all three for 1D/2D thread blocks:
        int bx = ptx::clusterlaunchcontrol_query_cancel_get_first_ctaid_x(result);
        int by = ptx::clusterlaunchcontrol_query_cancel_get_first_ctaid_y(result);
        int bz = ptx::clusterlaunchcontrol_query_cancel_get_first_ctaid_z(result);
    }
    
  6. 确保异步和通用代理之间共享内存操作的可见性,并防止工作窃取循环各次迭代之间发生数据竞争。

4.13.1.2. 线程块取消的约束

这些约束与失败的取消请求有关:

  • 在观察到先前的请求失败后再提交另一个取消请求属于未定义行为。

    在下面的两个代码示例中,假设第一个取消请求失败,则只有第一个示例表现出未定义行为。第二个示例是正确的,因为在两个取消请求之间没有进行观察:

    无效代码:

    // First request:
    ptx::clusterlaunchcontrol_try_cancel(&result0, &bar0);
    
    // First request query:
    [Synchronize bar0 code here.]
    bool success0 = ptx::clusterlaunchcontrol_query_cancel_is_canceled(result0);
    assert(!success0); // Observed failure; second cancellation will be invalid.
    
    // Second request - next line is Undefined Behavior:
    ptx::clusterlaunchcontrol_try_cancel(&result1, &bar1);
    

    有效代码:

    // First request:
    ptx::clusterlaunchcontrol_try_cancel(&result0, &bar0);
    
    // Second request:
    ptx::clusterlaunchcontrol_try_cancel(&result1, &bar1);
    
    // First request query:
    [Synchronize bar0 code here.]
    bool success0 = ptx::clusterlaunchcontrol_query_cancel_is_canceled(result0);
    assert(!success0); // Observed failure; second cancellation was valid.
    
  • 获取失败的取消请求所对应的线程块索引属于未定义行为。

  • 不建议由多个线程提交取消请求。这会导致多个线程块被取消,需要谨慎处理,例如:

    • 每个提交请求的线程都必须提供唯一的__shared__结果指针,以避免数据竞争。

    • 如果使用同一个屏障进行同步,则必须相应调整到达计数和事务计数。

4.13.2. 示例:向量与标量乘法

在以下小节中,我们通过一个向量与标量乘法核函数,演示如何通过簇启动控制进行工作窃取。我们展示同一问题的两种变体:一种使用线程块,另一种使用线程块簇。

4.13.2.1. 用例:线程块

下面的三个核函数演示了用于向量与标量乘法v‾:=αv‾\overline{v} := \alpha \overline{v}的每个线程块的工作量固定、线程块数量固定和簇启动控制方法。

  • 每个线程块的工作量固定:

    __global__
    void kernel_fixed_work (float* data, int n)
    {
        // Prologue:
        float alpha = compute_scalar();
    
        // Computation:
        int i = blockIdx.x * blockDim.x + threadIdx.x;
        if (i < n)
            data[i] *= alpha;
    }
    
    // Launch: kernel_fixed_work<<<(n + 1023) / 1024, 1024>>>(data, n);
    
  • 线程块数量固定:

    __global__
    void kernel_fixed_blocks (float* data, int n)
    {
        // Prologue:
        float alpha = compute_scalar();
    
        // Computation:
        int i = blockIdx.x * blockDim.x + threadIdx.x;
        while (i < n) {
            data[i] *= alpha;
            i += gridDim.x * blockDim.x;
        }
    }
    
    // Launch: kernel_fixed_blocks<<<SM_COUNT, 1024>>>(data, n);
    
  • 簇启动控制:

    #include <cooperative_groups.h>
    #include <cuda/ptx>
    
    namespace cg = cooperative_groups;
    namespace ptx = cuda::ptx;
    
    __global__
    void kernel_cluster_launch_control (float* data, int n)
    {
        // Cluster launch control initialization:
        __shared__ uint4 result;
        __shared__ uint64_t bar;
        int phase = 0;
    
        if (cg::thread_block::thread_rank() == 0)
            ptx::mbarrier_init(&bar, 1);
    
        // Prologue:
        float alpha = compute_scalar(); // Device function not shown in this code snippet.
    
        // Work-stealing loop:
        int bx = blockIdx.x; // Assuming 1D x-axis thread blocks.
    
        while (true) {
            // Protect result from overwrite in the next iteration,
            // (also ensure barrier initialization at 1st iteration):
            __syncthreads();
    
            // Cancellation request:
            if (cg::thread_block::thread_rank() == 0) {
                // Acquire write of result in the async proxy:
                ptx::fence_proxy_async_generic_sync_restrict(ptx::sem_acquire, ptx::space_cluster, ptx::scope_cluster);
    
                cg::invoke_one(cg::coalesced_threads(), [&](){ptx::clusterlaunchcontrol_try_cancel(&result, &bar);});
                ptx::mbarrier_arrive_expect_tx(ptx::sem_relaxed, ptx::scope_cta, ptx::space_shared, &bar, sizeof(uint4));
            }
    
            // Computation:
            int i = bx * blockDim.x + threadIdx.x;
            if (i < n)
                data[i] *= alpha;
    
            // Cancellation request synchronization:
            while (!ptx::mbarrier_try_wait_parity(ptx::sem_acquire, ptx::scope_cta, &bar, phase))
            {}
            phase ^= 1;
    
            // Cancellation request decoding:
            bool success = ptx::clusterlaunchcontrol_query_cancel_is_canceled(result);
            if (!success)
                break;
    
            bx = ptx::clusterlaunchcontrol_query_cancel_get_first_ctaid_x<int>(result);
    
            // Release read of result to the async proxy:
            ptx::fence_proxy_async_generic_sync_restrict(ptx::sem_release, ptx::space_shared, ptx::scope_cluster);
        }
    }
    
    // Launch: kernel_cluster_launch_control<<<(n + 1023) / 1024, 1024>>>(data, n);
    

4.13.2.2. 用例:线程块簇

对于线程块簇,线程块取消的步骤与非簇情形相同,仅需略作调整。与非簇情形一样,不建议由簇内多个线程提交取消请求,因为这将尝试取消多个簇。

  • 取消请求由簇中的单个线程提交。

  • 每个簇的各线程块的共享内存结果都会接收到相同的已取消线程块索引的(编码后的)值(即,结果值通过多播发送)。所有线程块接收到的结果都对应于簇内的局部块索引{0, 0, 0}。因此,簇内的线程块需要加上局部块索引。

  • 同步由每个簇的各线程块使用局部__shared__内存屏障来执行。屏障操作必须在ptx::scope_cluster作用域内执行。

  • 在簇的情况下,取消操作要求所有线程块都存在。用户可以通过使用sync API 中的cg::cluster_group::sync()来保证所有线程块都在运行。

下面的核函数演示了使用线程块簇的簇启动控制方法。

#include <cooperative_groups.h>
#include <cuda/ptx>

namespace cg = cooperative_groups;
namespace ptx = cuda::ptx;

__global__ __cluster_dims__(2, 1, 1)
void kernel_cluster_launch_control (float* data, int n)
{
    // Cluster launch control initialization:
    __shared__ uint4 result;
    __shared__ uint64_t bar;
    int phase = 0;

    if (cg::thread_block::thread_rank() == 0) {
        ptx::mbarrier_init(&bar, 1);
        ptx::fence_mbarrier_init(ptx::sem_release, ptx::scope_cluster); // CGA-level fence.
    }

    // Prologue:
    float alpha = compute_scalar(); // Device function not shown in this code snippet.

    // Work-stealing loop:
    int bx = blockIdx.x; // Assuming 1D x-axis thread blocks.

    while (true) {
        // Protect result from overwrite in the next iteration,
        // (also ensure all thread blocks have started at 1st iteration):
        cg::cluster_group::sync();

        // Cancellation request by a single cluster thread:
        if (cg::cluster_group::thread_rank() == 0) {
            // Acquire write of result in the async proxy:
            ptx::fence_proxy_async_generic_sync_restrict(ptx::sem_acquire, ptx::space_cluster, ptx::scope_cluster);

            cg::invoke_one(cg::coalesced_threads(), [&](){ptx::clusterlaunchcontrol_try_cancel_multicast(&result, &bar);});
        }

        // Cancellation completion tracked by each thread block:
        if (cg::thread_block::thread_rank() == 0)
            ptx::mbarrier_arrive_expect_tx(ptx::sem_relaxed, ptx::scope_cluster, ptx::space_shared, &bar, sizeof(uint4));

        // Computation:
        int i = bx * blockDim.x + threadIdx.x;
        if (i < n)
            data[i] *= alpha;

        // Cancellation request synchronization:
        while (!ptx::mbarrier_try_wait_parity(ptx::sem_acquire, ptx::scope_cluster, &bar, phase))
        {}
        phase ^= 1;

        // Cancellation request decoding:
        bool success = ptx::clusterlaunchcontrol_query_cancel_is_canceled(result);
        if (!success)
            break;

        bx = ptx::clusterlaunchcontrol_query_cancel_get_first_ctaid_x<int>(result);
        bx += cg::cluster_group::block_index().x; // Add local offset.

        // Release read of result to the async proxy:
        ptx::fence_proxy_async_generic_sync_restrict(ptx::sem_release, ptx::space_shared, ptx::scope_cluster);
    }
}

// Launch: kernel_cluster_launch_control<<<(n + 1023) / 1024, 1024>>>(data, n);

4.14. L2 缓存控制

当 CUDA 核函数反复访问全局内存中的某个数据区域时,此类数据访问可视为持久访问。另一方面,如果数据只被访问一次,则此类数据访问可视为流式访问。

计算能力为 8.0 及以上的设备能够影响数据在 L2 缓存中的持久性,从而有可能以更高的带宽和更低的延迟访问全局内存。

此功能通过两个主要 API 提供:

  • CUDA 运行时 API(从 CUDA 11.0 开始)提供了对 L2 缓存持久性的编程控制。

  • libcu++库中的cuda::annotated_ptr API(从 CUDA 11.5 开始)为 CUDA 核函数中的指针标注内存访问属性,以实现类似的效果..

以下各节重点介绍 CUDA 运行时 API。有关cuda::annotated_ptr方法的详细信息,请参阅libcu++ 文档。

4.14.1. 为持久访问预留 L2 缓存

可以预留一部分 L2 缓存,用于对全局内存的持久数据访问。持久访问优先使用这部分预留的 L2 缓存,而对全局内存的普通访问或流式访问只有在持久访问未使用这部分 L2 缓存时,才能使用它。

为持久访问预留的 L2 缓存大小可以在限制范围内调整:

cudaGetDeviceProperties(&prop, device_id);
size_t size = min(int(prop.l2CacheSize * 0.75), prop.persistingL2CacheMaxSize);
cudaDeviceSetLimit(cudaLimitPersistingL2CacheSize, size); /* set-aside 3/4 of L2 cache for persisting accesses or the max allowed*/

当 GPU 配置为多实例 GPU(MIG)模式时,L2 缓存预留功能会被禁用。

使用多进程服务(MPS)时,不能通过cudaDeviceSetLimit更改 L2 缓存的预留大小。预留大小只能在 MPS 服务器启动时通过环境变量CUDA_DEVICE_DEFAULT_PERSISTING_L2_CACHE_PERCENTAGE_LIMIT指定。

4.14.2. 持久访问的 L2 策略

访问策略窗口指定全局内存中的一个连续区域,以及对该区域内的访问在 L2 缓存中的持久性属性。

下面的代码示例展示了如何使用 CUDA 流设置 L2 持久访问窗口。

CUDA 流示例

cudaStreamAttrValue stream_attribute;                                         // Stream level attributes data structure
stream_attribute.accessPolicyWindow.base_ptr  = reinterpret_cast<void*>(ptr); // Global Memory data pointer
stream_attribute.accessPolicyWindow.num_bytes = num_bytes;                    // Number of bytes for persistence access.
                                                                              // (Must be less than cudaDeviceProp::accessPolicyMaxWindowSize)
stream_attribute.accessPolicyWindow.hitRatio  = 0.6;                          // Hint for cache hit ratio
stream_attribute.accessPolicyWindow.hitProp   = cudaAccessPropertyPersisting; // Type of access property on cache hit
stream_attribute.accessPolicyWindow.missProp  = cudaAccessPropertyStreaming;  // Type of access property on cache miss.

//Set the attributes to a CUDA stream of type cudaStream_t
cudaStreamSetAttribute(stream, cudaStreamAttributeAccessPolicyWindow, &stream_attribute);

当核函数随后在 CUDAstream中执行时,对全局内存范围[ptr..ptr+num_bytes)内的内存访问比对其他全局内存位置的访问更有可能持久保留在 L2 缓存中。

也可以为 CUDA 图核函数节点设置 L2 持久性,如下例所示:

CUDA GraphKernelNode 示例

cudaKernelNodeAttrValue node_attribute;                                     // Kernel level attributes data structure
node_attribute.accessPolicyWindow.base_ptr  = reinterpret_cast<void*>(ptr); // Global Memory data pointer
node_attribute.accessPolicyWindow.num_bytes = num_bytes;                    // Number of bytes for persistence access.
                                                                            // (Must be less than cudaDeviceProp::accessPolicyMaxWindowSize)
node_attribute.accessPolicyWindow.hitRatio  = 0.6;                          // Hint for cache hit ratio
node_attribute.accessPolicyWindow.hitProp   = cudaAccessPropertyPersisting; // Type of access property on cache hit
node_attribute.accessPolicyWindow.missProp  = cudaAccessPropertyStreaming;  // Type of access property on cache miss.

//Set the attributes to a CUDA Graph Kernel node of type cudaGraphNode_t
cudaGraphKernelNodeSetAttribute(node, cudaKernelNodeAttributeAccessPolicyWindow, &node_attribute);

可以使用hitRatio参数指定获得hitProp属性的访问所占的比例。在上述两个示例中,全局内存区域[ptr..ptr+num_bytes)中 60% 的内存访问具有持久属性,40% 的内存访问具有流式属性。哪些具体的内存访问被归类为持久访问(hitProp)是随机的,概率约为hitRatio;概率分布取决于硬件架构和内存范围。

例如,如果 L2 预留缓存大小为 16KB,且accessPolicyWindow中的num_bytes为 32KB:

  • 当hitRatio为 0.5 时,硬件会从 32KB 窗口中随机选取 16KB,将其指定为持久数据,并缓存在预留的 L2 缓存区域中。

  • 当hitRatio为 1.0 时,硬件会尝试将整个 32KB 窗口缓存在预留的 L2 缓存区域中。由于预留区域小于窗口,缓存行将被逐出,以便将 32KB 数据中最近使用的 16KB 保留在 L2 缓存的预留部分中。

因此,可以使用hitRatio来避免缓存行抖动,并从总体上减少移入和移出 L2 缓存的数据量。

可以使用小于 1.0 的hitRatio值,手动控制并发 CUDA 流的不同accessPolicyWindow能够在 L2 中缓存的数据量。例如,假设 L2 预留缓存大小为 16KB;两个不同 CUDA 流中的两个并发核函数各有一个 16KB 的accessPolicyWindow,且hitRatio值均为 1.0,它们在竞争共享的 L2 资源时,可能会逐出彼此的缓存行。但是,如果这两个accessPolicyWindows的 hitRatio 值均为 0.5,那么它们逐出自身或彼此的持久缓存行的可能性就会降低。

4.14.3. L2 访问属性

针对不同的全局内存数据访问,定义了三种访问属性:

  1. cudaAccessPropertyStreaming:具有流式属性的内存访问不太可能持久保留在 L2 缓存中,因为这些访问会被优先逐出。

  2. cudaAccessPropertyPersisting:具有持久属性的内存访问更有可能持久保留在 L2 缓存中,因为这些访问会被优先保留在 L2 缓存的预留部分中。

  3. cudaAccessPropertyNormal:此访问属性会将先前应用的持久访问属性强制重置为普通状态。先前 CUDA 核函数中具有持久属性的内存访问,可能在其预期用途结束后很长时间内仍保留在 L2 缓存中。这种使用后仍持久保留的行为,会减少后续不使用持久属性的核函数可用的 L2 缓存量。使用cudaAccessPropertyNormal属性重置访问属性窗口,会移除先前访问的持久(优先保留)状态,就像先前的访问未带有访问属性一样。

4.14.4. L2 持久性示例

以下示例展示了如何为持久访问预留 L2 缓存,如何在 CUDA 核函数中通过 CUDA 流使用预留的 L2 缓存,以及如何随后重置 L2 缓存。

cudaStream_t stream;
cudaStreamCreate(&stream);                                                                  // Create CUDA stream

cudaDeviceProp prop;                                                                        // CUDA device properties variable
cudaGetDeviceProperties( &prop, device_id);                                                 // Query GPU properties
size_t size = min( int(prop.l2CacheSize * 0.75) , prop.persistingL2CacheMaxSize );
cudaDeviceSetLimit( cudaLimitPersistingL2CacheSize, size);                                  // set-aside 3/4 of L2 cache for persisting accesses or the max allowed

size_t window_size = min(prop.accessPolicyMaxWindowSize, num_bytes);                        // Select minimum of user defined num_bytes and max window size.

cudaStreamAttrValue stream_attribute;                                                       // Stream level attributes data structure
stream_attribute.accessPolicyWindow.base_ptr  = reinterpret_cast<void*>(data1);               // Global Memory data pointer
stream_attribute.accessPolicyWindow.num_bytes = window_size;                                // Number of bytes for persistence access
stream_attribute.accessPolicyWindow.hitRatio  = 0.6;                                        // Hint for cache hit ratio
stream_attribute.accessPolicyWindow.hitProp   = cudaAccessPropertyPersisting;               // Persistence Property
stream_attribute.accessPolicyWindow.missProp  = cudaAccessPropertyStreaming;                // Type of access property on cache miss

cudaStreamSetAttribute(stream, cudaStreamAttributeAccessPolicyWindow, &stream_attribute);   // Set the attributes to a CUDA Stream

for(int i = 0; i < 10; i++) {
    cuda_kernelA<<<grid_size,block_size,0,stream>>>(data1);                                 // This data1 is used by a kernel multiple times
}                                                                                           // [data1 + num_bytes) benefits from L2 persistence
cuda_kernelB<<<grid_size,block_size,0,stream>>>(data1);                                     // A different kernel in the same stream can also benefit
                                                                                            // from the persistence of data1

stream_attribute.accessPolicyWindow.num_bytes = 0;                                          // Setting the window size to 0 disable it
cudaStreamSetAttribute(stream, cudaStreamAttributeAccessPolicyWindow, &stream_attribute);   // Overwrite the access policy attribute to a CUDA Stream
cudaCtxResetPersistingL2Cache();                                                            // Remove any persistent lines in L2

cuda_kernelC<<<grid_size,block_size,0,stream>>>(data2);                                     // data2 can now benefit from full L2 in normal mode

4.14.5. 将 L2 访问重置为普通状态

来自先前 CUDA 核函数的持久 L2 缓存行,可能在使用后很长时间内仍持久保留在 L2 中。因此,要让流式或普通内存访问以普通优先级使用 L2 缓存,将 L2 缓存重置为普通状态很重要。有三种方法可以将持久访问重置为普通状态。

  1. 使用访问属性cudaAccessPropertyNormal重置先前的持久内存区域。

  2. 通过调用cudaCtxResetPersistingL2Cache(),将所有持久 L2 缓存行重置为普通状态。

  3. 未被访问的缓存行最终会自动重置为普通状态。强烈不建议依赖自动重置,因为自动重置发生所需的时间长度是不确定的。

4.14.6. 管理 L2 预留缓存的利用情况

在不同 CUDA 流中并发执行的多个 CUDA 核函数,其所在的流可能各自分配了不同的访问策略窗口。但是,L2 预留缓存部分由所有这些并发 CUDA 核函数共享。因此,这部分预留缓存的净使用量是所有并发核函数各自使用量的总和。当持久访问的数据量超过 L2 预留缓存容量时,将内存访问指定为持久访问所带来的收益就会降低。

要管理 L2 缓存预留部分的利用情况,应用程序必须考虑以下因素:

  • L2 预留缓存的大小。

  • 可能并发执行的 CUDA 核函数。

  • 所有可能并发执行的 CUDA 核函数的访问策略窗口。

  • 何时以及如何重置 L2,才能使普通访问或流式访问以相同的优先级使用先前预留的 L2 缓存。

4.14.7. 查询 L2 缓存属性

与 L2 缓存相关的属性是cudaDeviceProp结构体的一部分,可以使用 CUDA 运行时 APIcudaGetDeviceProperties进行查询

CUDA 设备属性包括:

  • l2CacheSize:GPU 上可用的 L2 缓存量。

  • persistingL2CacheMaxSize:可为持久内存访问预留的最大 L2 缓存量。

  • accessPolicyMaxWindowSize:访问策略窗口的最大大小。

4.14.8. 控制为持久内存访问预留的 L2 缓存大小

为持久内存访问预留的 L2 缓存大小可使用 CUDA 运行时 APIcudaDeviceGetLimit查询,并使用 CUDA 运行时 APIcudaDeviceSetLimit将其作为cudaLimit进行设置。设置此限制时的最大值为cudaDeviceProp::persistingL2CacheMaxSize。

enum cudaLimit {
    /* other fields not shown */
    cudaLimitPersistingL2CacheSize
};

4.15. 内存同步域

4.15.1. 内存栅栏干扰

某些 CUDA 应用程序可能会出现性能下降,因为内存栅栏或刷新操作等待的事务多于 CUDA 内存一致性模型所要求的事务。

__managed__ int x = 0;
__device__  cuda::atomic<int, cuda::thread_scope_device> a(0);
__managed__ cuda::atomic<int, cuda::thread_scope_system> b(0);

线程 1(SM)

x = 1;
a = 1;

线程 2(SM)

while (a != 1) ;
assert(x == 1);
b = 1;

线程 3(CPU)

while (b != 1) ;
assert(x == 1);

考虑上面的示例。CUDA 内存一致性模型保证断言条件为真,因此,线程 1 对x的写入必须先于线程 2 对b的写入对线程 3 可见。

对a执行释放和获取所提供的内存顺序,仅足以使x对线程 2 可见,而不足以使其对线程 3 可见,因为这是设备作用域的操作。因此,对b执行释放和获取所提供的系统作用域顺序,不仅需要确保线程 2 自身发出的写入对线程 3 可见,还需要确保来自其他线程且对线程 2 可见的写入也对线程 3 可见。这称为累积性。由于 GPU 在执行时无法知道哪些写入在源代码层面已被保证可见,哪些写入只是由于偶然的时序而可见,因此它必须以保守方式涵盖较大范围内正在进行的内存操作。

这有时会导致干扰:由于 GPU 在等待源代码层面并不要求它等待的内存操作,栅栏或刷新操作所花费的时间可能会超过必要的时间。

请注意,栅栏可以像示例中那样,以内建函数或原子操作的形式显式出现在代码中,也可以隐式出现,以实现任务边界处的与之同步关系。

一个常见的示例是,某个核函数正在本地 GPU 内存中执行计算,而一个并行核函数(例如来自 NCCL 的核函数)正在与对等设备通信。完成时,本地核函数会隐式刷新其写入,以满足与下游工作之间的任何与之同步关系。这可能会不必要地全部或部分等待通信核函数发出的较慢的 nvlink 或 PCIe 写入。

4.15.2. 使用域隔离流量

从计算能力为 9.0(Hopper 架构)的 GPU 和 CUDA 12.0 开始,内存同步域功能提供了一种减轻此类干扰的方法。借助代码提供的显式协助,GPU 可以缩小栅栏操作所涵盖的范围。每次核函数启动都会被赋予一个域 ID。写入和栅栏都标记有该 ID,栅栏只会对与其域匹配的写入施加顺序约束。在上述计算与通信并发的示例中,可以将通信核函数放置在不同的域中。

使用域时,代码必须遵守以下规则:同一 GPU 上不同域之间的排序或同步需要系统作用域的栅栏操作。在一个域内,设备作用域的栅栏操作仍然足够。这对于累积性是必要的,因为一个核函数的写入不会被另一个域中的核函数发出的栅栏所涵盖。实质上,通过确保跨域流量提前刷新到系统作用域,即可满足累积性。

请注意,这会修改thread_scope_device的定义。但是,由于核函数将默认为域 0,如下文所述,因此仍保持向后兼容性。

4.15.3. 在 CUDA 中使用域

可以通过新的启动属性cudaLaunchAttributeMemSyncDomain和cudaLaunchAttributeMemSyncDomainMap访问域。前者在逻辑域cudaLaunchMemSyncDomainDefault和cudaLaunchMemSyncDomainRemote之间进行选择,后者提供从逻辑域到物理域的映射。远程域旨在用于执行远程内存访问的核函数,以将其内存流量与本地核函数的内存流量隔离。但是请注意,选择某个特定域并不影响核函数可以合法执行哪些内存访问。

可以通过设备属性cudaDevAttrMemSyncDomainCount查询域的数量。计算能力为 9.0(Hopper)的设备有 4 个域。为了便于编写可移植代码,所有设备上都可以使用域功能,对于计算能力低于 9.0 的设备,CUDA 将报告域的数量为 1。

逻辑域的存在便于应用程序组合。在软件栈较低层级进行的单次核函数启动(例如来自 NCCL 的启动)可以选择一个具有语义意义的逻辑域,而无需关心周围的应用程序架构。较高层级可以使用映射来调控逻辑域。如果未设置逻辑域,其默认值为默认域;默认映射将默认域映射到 0,将远程域映射到 1(在具有多于 1 个域的 GPU 上)。在 CUDA 12.0 及更高版本中,某些特定库可能会将启动标记为远程域;例如,NCCL 2.16 会这样做。这些机制共同为常见应用程序提供了一种开箱即用的有益使用模式,无需更改其他组件、框架或应用程序层面的代码。另一种使用模式可能是对并行流进行划分,例如在使用 NVSHMEM 或核函数类型没有明确区分的应用程序中。流 A 可以将两个逻辑域都映射到物理域 0,流 B 则映射到 1,依此类推。

// Example of launching a kernel with the remote logical domain
cudaLaunchAttribute domainAttr;
domainAttr.id = cudaLaunchAttrMemSyncDomain;
domainAttr.val = cudaLaunchMemSyncDomainRemote;
cudaLaunchConfig_t config;
// Fill out other config fields
config.attrs = &domainAttr;
config.numAttrs = 1;
cudaLaunchKernelEx(&config, myKernel, kernelArg1, kernelArg2...);
// Example of setting a mapping for a stream
// (This mapping is the default for streams starting on compute capability 9.0 (Hopper) or later if not
// explicitly set, and provided for illustration)
cudaLaunchAttributeValue mapAttr;
mapAttr.memSyncDomainMap.default_ = 0;
mapAttr.memSyncDomainMap.remote = 1;
cudaStreamSetAttribute(stream, cudaLaunchAttributeMemSyncDomainMap, &mapAttr);
// Example of mapping different streams to different physical domains, ignoring
// logical domain settings
cudaLaunchAttributeValue mapAttr;
mapAttr.memSyncDomainMap.default_ = 0;
mapAttr.memSyncDomainMap.remote = 0;
cudaStreamSetAttribute(streamA, cudaLaunchAttributeMemSyncDomainMap, &mapAttr);
mapAttr.memSyncDomainMap.default_ = 1;
mapAttr.memSyncDomainMap.remote = 1;
cudaStreamSetAttribute(streamB, cudaLaunchAttributeMemSyncDomainMap, &mapAttr);

与其他启动属性一样,这些属性以统一的方式提供给 CUDA 流、使用cudaLaunchKernelEx的单次启动以及 CUDA 图中的核函数节点。典型用法是在流级别设置映射,并在启动级别设置逻辑域(或在流的一段使用区间前后设置),如上所述。

在流捕获期间,这两个属性都会被复制到图节点。图从节点本身获取这两个属性,这实质上是间接指定物理域的一种方式。在图所启动到的流上设置的域相关属性,不会用于该图的执行。

4.16. 进程间通信

通过使用进程间通信(IPC)API 和可由 IPC 共享的内存缓冲区,可以支持由不同主机进程管理的多个 GPU 之间的通信,其方法是创建可跨进程使用的句柄,随后使用这些句柄获取指向对等 GPU 上设备内存的进程本地设备指针。

由主机线程创建的任何设备内存指针或事件句柄,都可以由同一进程中的任何其他线程直接引用。但是,设备指针或事件句柄在创建它们的进程之外无效,因此不能由属于不同进程的线程直接引用。要跨进程访问设备内存和 CUDA 事件,应用程序必须使用 CUDA 进程间通信(IPC)或虚拟内存管理 API,创建可跨进程使用的句柄,并通过标准主机操作系统 IPC 机制(例如进程间共享内存或文件)与其他进程共享这些句柄。在进程之间交换了可跨进程使用的句柄之后,必须使用 CUDA IPC 或 VMM API 从句柄获取进程局部的设备指针。随后,进程局部的设备指针就可以像在单个进程中一样使用。

在单个节点和单个操作系统实例中用于 IPC 的同类可移植句柄方法,也用于通过 NVLink 连接的多节点集群中 GPU 之间的对等通信。在多节点情况下,进行通信的 GPU 由集群中各节点上独立操作系统实例内运行的进程管理,因此需要在操作系统实例级别之上增加一层抽象。多节点对等通信的实现方式是:在多节点对等 GPU 之间创建和交换所谓的“互连网络(fabric)”句柄,然后在与多节点秩对应的参与进程和操作系统实例中获取进程局部的设备指针。

有关用于建立和交换可跨进程、跨节点和跨操作系统实例使用的句柄的具体 API,请参见下文(单节点 CUDA IPC)和 ref::virtual-memory-management;这些句柄用于获取 GPU 通信所需的进程局部设备指针。

注意

CUDA IPC API 和虚拟内存管理(VMM)API 用于 IPC 时,各有其优势和限制。

CUDA IPC API 目前仅在 Linux 平台上受支持。

CUDA 虚拟内存管理 API 允许在分配内存时,针对每次分配控制对等访问能力和共享,但要求使用 CUDA Driver API。

4.16.1. 使用旧版进程间通信 API 进行 IPC

要跨进程共享设备内存指针和事件,应用程序必须使用 CUDA 进程间通信 API,参考手册中对此 API 有详细说明。IPC API 允许应用程序使用cudaIpcGetMemHandle()获取给定设备内存指针的 IPC 句柄。CUDA IPC 句柄可以通过标准主机操作系统 IPC 机制(例如进程间共享内存或文件)传递给另一个进程。cudaIpcOpenMemHandle()使用 IPC 句柄获取可以在另一个进程内使用的有效设备指针。事件句柄可以使用类似的入口点进行共享。

使用 IPC API 的一个示例是,由单个主进程生成一批输入数据,使多个辅助进程能够使用这些数据,而无需重新生成或复制。

注意

IPC API 仅在 Linux 上受支持。

请注意,IPC API 不支持cudaMallocManaged分配的内存。

使用 CUDA IPC 相互通信的应用程序应使用相同的 CUDA 驱动程序和运行时进行编译、链接和运行。

出于性能原因,cudaMalloc()分配的内存可能是从较大的内存块中子分配而来的。在这种情况下,CUDA IPC API 会共享整个底层内存块,这可能导致其他子分配的内存也被共享,从而可能导致进程之间的信息泄露。为防止这种行为,建议只共享大小按 2MiB 对齐的内存分配。

在计算能力为 7.x 及更高版本的 L4T 和嵌入式 Linux Tegra 设备上,仅支持 IPC 事件共享 API。Tegra 平台不支持 IPC 内存共享 API。

4.16.2. 使用虚拟内存管理 API 进行 IPC

CUDA 虚拟内存管理 API 允许创建可通过 IPC 共享的内存分配,并借助特定于操作系统的 IPC 句柄数据结构来支持多种操作系统。

4.17. 虚拟内存管理

在 CUDA 编程模型中,内存分配调用(例如cudaMalloc())返回 GPU 内存中的一个内存地址。该地址可用于任何 CUDA API,也可用于设备核函数内部。开发者可以使用cudaEnablePeerAccess启用对等设备对这些内存分配的访问。这样,不同设备上的核函数就可以访问相同的数据。但是,所有过去和未来的用户分配也都会映射到目标对等设备。这可能导致用户无意中承担将所有cudaMalloc分配的内存映射到对等设备的运行时开销。在大多数情况下,应用程序只需与另一台设备共享少量内存分配即可进行通信。通常没有必要将所有内存分配映射到所有设备。此外,将这种方法扩展到多节点环境本身就存在困难。

CUDA 提供了虚拟内存管理(VMM)API,使开发者能够对这一过程进行显式的底层控制。

虚拟内存分配是由操作系统和内存管理单元(MMU)管理的复杂过程,分为两个关键阶段。首先,操作系统为程序保留一段连续的虚拟地址范围,而不分配任何物理内存。然后,当程序首次尝试使用该内存时,操作系统提交这些虚拟地址,根据需要为虚拟页分配物理存储。

CUDA 的 VMM API 允许开发者显式保留虚拟地址范围,然后再将其映射到物理 GPU 内存,从而将类似的概念引入 GPU 内存管理。借助 VMM,应用程序可以明确选择允许其他设备访问的某些内存分配。

VMM API 使复杂应用程序能够更高效地管理多个 GPU(以及 CPU 核心)上的内存。通过支持对内存保留、映射和访问权限进行手动控制,VMM API 可以实现细粒度数据共享、零拷贝传输和自定义内存分配器等高级技术。CUDA VMM API 向用户提供了细粒度控制能力,用于管理应用程序中的 GPU 内存。

开发者可以通过以下几个关键方面受益于 VMM API:

  • 对虚拟内存和物理内存管理进行细粒度控制,允许分配不连续的物理内存块,并将它们映射到连续的虚拟地址空间。这有助于减少 GPU 内存碎片并提高内存利用率,尤其适用于深度神经网络训练等大型工作负载。

  • 将虚拟地址空间的保留与物理内存分配分离,实现高效的内存分配和释放。开发者可以保留较大的虚拟内存区域,并按需映射物理内存,无需进行代价高昂的内存复制或重新分配,从而提高动态数据结构和可变大小内存分配的性能。

  • 能够动态扩展 GPU 内存分配,而无需复制所有数据并为其重新分配内存,类似于 CPU 内存管理中realloc或std::vector的工作方式。这支持更灵活、更高效的 GPU 内存使用模式。

  • 通过提供底层 API 来提高开发者的生产力和应用程序性能,这些 API 允许构建复杂的内存分配器和缓存管理系统,例如动态管理大型语言模型中的键值缓存,从而改善吞吐量和延迟。

  • CUDA VMM API 在分布式多 GPU 环境中具有很高的价值,因为它能够实现多个 GPU 之间高效的内存共享和访问。通过将虚拟地址与物理内存解耦,该 API 允许开发者创建统一的虚拟地址空间,其中的数据可以动态映射到不同的 GPU。这可以优化内存使用并减少数据传输开销。例如,NVIDIA 的 NCCL 和 NVSHMEM 等库就在积极使用 VMM。

总之,CUDA VMM API 为开发者提供了超越传统类 malloc 抽象的高级工具,以实现精细调节、高效、灵活且可扩展的 GPU 内存管理,这对于高性能和大内存应用程序至关重要

注意

本节所述的一组 API 要求系统支持 UVA。请参见虚拟内存管理 API。

4.17.1. 预备知识

4.17.1.1. 定义

互连网络内存:互连网络内存是指可以通过 NVIDIA 的 NVLink 和 NVSwitch 等高速互连网络(fabric)访问的内存。该互连网络在多个 GPU 或节点之间提供内存相干性和高带宽通信层,使它们能够高效地共享内存,就好像内存连接到统一的互连网络,而不是隔离在各个设备上一样。

CUDA 12.4 及更高版本具有 VMM 分配句柄类型CU_MEM_HANDLE_TYPE_FABRIC。在受支持的平台上,并且在 NVIDIA IMEX 守护进程正在运行的前提下,这种分配句柄类型不仅能够通过任意通信机制(例如 MPI)在节点内共享内存分配,还能够在节点间共享内存分配。这使多节点 NVLink 系统中的 GPU 能够映射属于同一 NVLink 互连网络的所有其他 GPU 的内存,即使它们位于不同节点上也是如此。

注意

此处描述的互连网络内存是以地址为中心的:对等设备的物理内存映射到本地虚拟地址空间中,并通过普通的加载和存储进行访问。计算互连传输在同一 NVLink 互连网络上提供了一种互补的以资源为中心的模型,它针对命名的逻辑端点而不是映射的地址,使用异步 put、get 和归约操作移动数据。它会为每个操作报告显式的完成状态,因此适用于需要关注互连网络错误恢复的大型互连网络。

内存句柄:在 VMM 中,句柄是代表物理内存分配的不透明标识符。这些句柄是底层 CUDA VMM API 中内存管理的核心。它们支持对可映射到虚拟地址空间的物理内存对象进行灵活控制。一个句柄唯一标识一项物理内存分配。句柄充当对内存资源的抽象引用,而不暴露直接指针。句柄允许执行跨进程或设备导出和导入内存等操作,从而促进内存共享和虚拟化。

IMEX 通道:IMEX 这一名称代表节点间内存交换,它是 NVIDIA 跨不同节点进行 GPU 到 GPU 通信的解决方案的一部分。IMEX 通道是一项 GPU 驱动程序功能,可在 IMEX 域内的多用户或多节点环境中提供基于用户的内存隔离。IMEX 通道充当一种安全与隔离机制。

IMEX 通道与互连网络句柄直接相关,必须在多节点 GPU 通信中启用。当一个 GPU 分配了内存,并希望另一节点上的 GPU 能够访问这块内存时,它首先需要导出该内存的句柄。在此导出过程中,IMEX 通道用于生成安全的互连网络句柄,只有具备正确通道访问权限的远程进程才能导入该句柄。

单播内存访问:在 VMM API 的上下文中,单播内存访问是指由特定设备或进程将物理内存受控地直接映射到唯一的虚拟地址范围并进行访问。单播内存访问并非将访问广播到多个设备,而是向某个特定 GPU 设备显式授予对某个保留虚拟地址范围的读写权限,该范围映射到一项物理内存分配。

多播内存访问:在 VMM API 的上下文中,多播内存访问是指使用多播机制,将单个物理内存分配或区域同时映射到多个设备的虚拟地址空间的能力。这使数据能够以一对多的方式在多个 GPU 之间高效共享,减少冗余数据传输并提高通信效率。NVIDIA 的 CUDA VMM API 支持创建多播对象,将来自多个设备的物理内存分配绑定在一起。

4.17.1.2. 查询支持情况

应用程序应在尝试使用功能之前查询是否支持这些功能,因为它们的可用性可能因 GPU 架构、驱动程序版本以及所使用的特定软件库而异。以下各节详细说明如何以编程方式检查所需的支持情况。

VMM 支持在尝试使用 VMM API 之前,应用程序必须确保要使用的设备支持 CUDA 虚拟内存管理。以下代码示例展示了如何查询 VMM 支持情况:

int deviceSupportsVmm;
CUresult result = cuDeviceGetAttribute(&deviceSupportsVmm, CU_DEVICE_ATTRIBUTE_VIRTUAL_MEMORY_MANAGEMENT_SUPPORTED, device);
if (deviceSupportsVmm != 0) {
    // `device` supports Virtual Memory Management
}

互连网络内存支持:在尝试使用互连网络内存之前,应用程序必须确保要使用的设备支持互连网络内存。以下代码示例展示了如何查询互连网络内存支持情况:

int deviceSupportsFabricMem;
CUresult result = cuDeviceGetAttribute(&deviceSupportsFabricMem, CU_DEVICE_ATTRIBUTE_HANDLE_TYPE_FABRIC_SUPPORTED, device);
if (deviceSupportsFabricMem != 0) {
    // `device` supports Fabric Memory
}

除了使用CU_MEM_HANDLE_TYPE_FABRIC作为句柄类型,以及无需使用操作系统原生进程间通信机制来交换可共享句柄之外,使用互连网络内存与使用其他分配句柄类型没有区别。

IMEX 通道支持在 IMEX 域内,IMEX 通道能够在多用户环境中实现安全的内存共享。NVIDIA 驱动程序通过创建字符设备nvidia-caps-imex-channels来实现这一点。要使用基于互连网络句柄的共享,用户应验证两件事:

  • 首先,应用程序必须验证/proc/devices下存在此设备:

# cat /proc/devices | grep nvidia
195 nvidia
195 nvidiactl
234 nvidia-caps-imex-channels
509 nvidia-nvswitch

The nvidia-caps-imex-channels device should have a major number (e.g., 234).
  • 其次,要让两个 CUDA 进程(一个导出方和一个导入方)共享内存,它们都必须有权访问同一个 IMEX 通道文件。这些文件(例如/dev/nvidia-caps-imex-channels/channel0)是代表各个 IMEX 通道的节点。系统管理员必须创建这些文件,例如使用mknod()命令。

# mknod /dev/nvidia-caps-imex-channels/channelN c <major_number> 0

This command creates channelN using the major number obtained from
/proc/devices.

注意

默认情况下,如果指定了NVreg_CreateImexChannel0模块参数,驱动程序可以创建 channel0。

多播对象支持:在尝试使用多播对象之前,应用程序必须确保要使用的设备支持这些对象。以下代码示例展示了如何查询多播对象支持情况:

int deviceSupportsMultiCast;
CUresult result = cuDeviceGetAttribute(&deviceSupportsMultiCast, CU_DEVICE_ATTRIBUTE_MULTICAST_SUPPORTED, device);
if (deviceSupportsMultiCast != 0) {
    // `device` supports Multicast Objects
}

4.17.2. API 概述

VMM API 为开发者提供了对虚拟内存管理的细粒度控制。VMM 是一种非常底层的 API,要求直接使用CUDA Driver API。这一用途广泛的 API 既可以用于单节点环境,也可以用于多节点环境。

要有效使用 VMM,开发者必须牢固掌握内存管理中的几个关键概念:

  • 了解操作系统虚拟内存的基础知识,包括它如何处理页和地址空间

  • 必须了解内存层次结构和硬件特性

  • 熟悉进程间通信(IPC)方法,例如套接字或消息传递,

  • 具备有关内存访问权限安全性的基础知识

VMM 使用概览图

图 55 VMM 使用概览。此图概述了使用 VMM 所需的一系列步骤。该过程首先评估环境配置。根据此评估,用户必须作出一个关键的初始决策:是使用互连网络(fabric)内存句柄,还是使用特定于操作系统的句柄。根据最初选择的句柄,必须执行不同的一系列后续步骤。但是,无论选择哪种类型的句柄,最后的内存管理操作都是相同的,具体包括映射、预留以及设置所分配内存的访问权限。

图内文字中文对照
Single-Node?
是否为单节点?
YES
是
NO
否
Use Fabric Handle?
是否使用互连网络句柄?
Imex Channels Available?
Imex 通道是否可用?
N/A
不适用
Allocate Physical Memory
分配物理内存
Export Handle: OS-Specific
导出句柄:操作系统专用
Share Handle: OS-Specific IPC
共享句柄:操作系统专用 IPC
Import Handle: OS-Specific
导入句柄:操作系统专用
Export Handle: Fabric-Specific
导出句柄:互连网络专用
Share Handle: Fabric IPC
共享句柄:互连网络 IPC
Import Handle: Fabric-Specific
导入句柄:互连网络专用
Map and Reserve Memory
映射并预留内存
Set Access Rights
设置访问权限

VMM API 的工作流程包含一系列内存管理步骤,重点是在不同设备或进程之间共享内存。首先,开发者必须在源设备上分配物理内存。为便于共享,VMM API 使用句柄向目标设备或进程传递必要的信息。用户必须导出一个用于共享的句柄,该句柄可以是特定于操作系统的句柄,也可以是特定于互连网络(fabric)的句柄。特定于操作系统的句柄仅限于单个节点上的进程间通信,而特定于互连网络(fabric)的句柄用途更加广泛,既可用于单节点环境,也可用于多节点环境。需要注意的是,使用特定于互连网络(fabric)的句柄需要启用 IMEX 通道。

句柄导出后,必须使用进程间通信协议将其共享给一个或多个接收进程,具体方法由开发者选择。然后,接收进程使用 VMM API 导入句柄。在句柄成功导出、共享和导入后,源进程和目标进程都必须预留虚拟地址空间,以便将所分配的物理内存映射到该空间。最后一步是为每个设备设置内存访问权限,确保建立适当的权限。附图进一步详细说明了整个过程,包括两种句柄方案。

4.17.3. 单播内存共享

GPU 内存共享可以在一台具有多个 GPU 的机器上进行,也可以跨联网的多台机器进行。该过程遵循以下步骤:

  • 分配和导出:一个 GPU 上的 CUDA 程序分配内存,并获取该内存的可共享句柄。

  • 共享和导入:随后使用 IPC、MPI 或 NCCL 等将句柄发送给节点上的其他程序。在接收端 GPU 中,CUDA 驱动程序导入句柄并创建所需的内存对象。

  • 预留和映射:驱动程序创建从程序的虚拟地址(VA)到 GPU 的物理地址(PA),再到其互连网络(fabric)地址(FA)的映射。

  • 访问权限:为分配设置访问权限。

  • 释放内存:在程序结束执行时释放所有分配。

单播内存共享示例

图 56 单播内存共享示例

图内文字中文对照
Node1, GPU 1
节点 1,GPU 1
Node1, GPU 2
节点 1,GPU 2
Virtual Address Space of a Process
进程的虚拟地址空间
Physical Memory
物理内存
Unified Address Space or Fabric Address Space
统一地址空间或互连网络地址空间

4.17.3.1. 分配和导出

分配物理内存使用虚拟内存管理 API 分配内存的第一步是创建一个物理内存块,为该分配提供后备存储。要分配物理内存,应用程序必须使用cuMemCreate API。此函数创建的分配没有任何设备或主机映射。函数参数CUmemGenericAllocationHandle描述了要分配的内存的属性,例如分配的位置、该分配是否将共享给另一个进程(或图形 API),或者要分配的内存的物理属性。用户必须确保请求的分配大小按适当的粒度对齐。可以使用cuMemGetAllocationGranularity查询有关分配粒度要求的信息。

CUmemGenericAllocationHandle allocatePhysicalMemory(int device, size_t size) {
    CUmemAllocationHandleType handleType = CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR;
    CUmemAllocationProp prop = {};
    prop.type = CU_MEM_ALLOCATION_TYPE_PINNED;
    prop.location.type = CU_MEM_LOCATION_TYPE_DEVICE;
    prop.location.id = device;
    prop.requestedHandleType = handleType;

    size_t granularity = 0;
    cuMemGetAllocationGranularity(&granularity, &prop, CU_MEM_ALLOC_GRANULARITY_MINIMUM);

    // Ensure size matches granularity requirements for the allocation
    size_t padded_size = ROUND_UP(size, granularity);

    // Allocate physical memory
    CUmemGenericAllocationHandle allocHandle;
    cuMemCreate(&allocHandle, padded_size, &prop, 0);

    return allocHandle;
}
CUmemGenericAllocationHandle allocatePhysicalMemory(int device, size_t size) {
    CUmemAllocationHandleType handleType = CU_MEM_HANDLE_TYPE_FABRIC;
    CUmemAllocationProp prop = {};
    prop.type = CU_MEM_ALLOCATION_TYPE_PINNED;
    prop.location.type = CU_MEM_LOCATION_TYPE_DEVICE;
    prop.location.id = device;
    prop.requestedHandleType = handleType;

    size_t granularity = 0;
    cuMemGetAllocationGranularity(&granularity, &prop, CU_MEM_ALLOC_GRANULARITY_MINIMUM);

    // Ensure size matches granularity requirements for the allocation
    size_t padded_size = ROUND_UP(size, granularity);

    // Allocate physical memory
    CUmemGenericAllocationHandle allocHandle;
    cuMemCreate(&allocHandle, padded_size, &prop, 0);

    return allocHandle;
}

注意

由cuMemCreate分配的内存通过其返回的CUmemGenericAllocationHandle引用。请注意,此引用不是指针,并且其内存尚不可访问。

注意

可以使用cuMemGetAllocationPropertiesFromHandle查询分配句柄的属性。

在局部性域中分配设备内存

要在特定局部性域中分配设备内存,请将CUmemAllocationProp::location.type设置为CU_MEM_LOCATION_TYPE_DEVICE_LOCALITY_DOMAIN。设置CUmemAllocationProp::location.localized的两个成员:deviceId标识 GPU,localityDomainId标识该 GPU 上的有效局部性域。使用cuDeviceGetAttribute和CU_DEVICE_ATTRIBUTE_LOCALITY_DOMAIN_COUNT查询有效局部性域的数量。

分配大小必须是针对局部化分配属性返回的最小粒度的倍数:

CUmemAllocationProp prop {};
prop.type = CU_MEM_ALLOCATION_TYPE_PINNED;
prop.location.type = CU_MEM_LOCATION_TYPE_DEVICE_LOCALITY_DOMAIN;
prop.location.localized.deviceId = device;
prop.location.localized.localityDomainId = localityDomainId;

size_t granularity = 0;
cuMemGetAllocationGranularity(&granularity, &prop, CU_MEM_ALLOC_GRANULARITY_MINIMUM);
size_t alignedSize = ((size + granularity - 1) / granularity) * granularity;

CUmemGenericAllocationHandle allocationHandle {};
cuMemCreate(&allocationHandle, alignedSize, &prop, 0);

返回的句柄与其他 VMM 分配使用相同的预留、映射、访问控制和释放 API。

在只有一个局部性域的设备上,请求局部性域 0 等效于使用CU_MEM_LOCATION_TYPE_DEVICE创建的全设备分配;所得到的分配将其位置类型报告为CU_MEM_LOCATION_TYPE_DEVICE。

有关整体编程模型,请参阅局部性域。

导出内存句柄CUDA 虚拟内存管理 API 提供了一种新的进程间通信机制,使用句柄来交换有关分配和物理地址空间的必要信息。可以导出用于特定于操作系统的 IPC 或特定于互连网络(fabric)的 IPC 的句柄。特定于操作系统的 IPC 句柄只能用于单节点配置。特定于互连网络(fabric)的句柄可用于单节点或多节点配置。

CUmemAllocationHandleType handleType = CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR;
CUmemGenericAllocationHandle handle = allocatePhysicalMemory(0, 1<<21);
int fd;
cuMemExportToShareableHandle(&fd, handle, handleType, 0);
CUmemAllocationHandleType handleType = CU_MEM_HANDLE_TYPE_FABRIC;
CUmemGenericAllocationHandle handle = allocatePhysicalMemory(0, 1<<21);
CUmemFabricHandle fh;
cuMemExportToShareableHandle(&fh, handle, handleType, 0);

注意

特定于操作系统的句柄要求所有进程都属于同一个操作系统。

注意

特定于互连网络(fabric)的句柄要求系统管理员启用 IMEX 通道。

memMapIpcDrv示例可作为对 VMM 分配使用 IPC 的示例。

4.17.3.2. 共享和导入

共享内存句柄句柄导出后,必须使用进程间通信协议将其共享给一个或多个接收进程。开发者可以自由使用任何方法来共享句柄。使用的具体 IPC 方法取决于应用程序的设计和环境。常用方法包括特定于操作系统的进程间套接字和分布式消息传递。使用特定于操作系统的 IPC 可提供高性能传输,但仅限于同一台机器上的进程,而且不具备可移植性。特定于互连网络(fabric)的 IPC 更简单,可移植性也更强。但是,特定于互连网络(fabric)的 IPC 需要系统级支持。所选方法必须将句柄数据安全可靠地传输到目标进程,以便用它导入内存并建立有效映射。选择 IPC 方法的灵活性使 VMM API 能够集成到各种系统架构中,从单节点应用程序到分布式多节点配置均可。在以下代码片段中,我们将提供分别使用套接字编程和 MPI 来共享和接收句柄的示例。

int ipcSendShareableHandle(int socket, int fd, pid_t process) {
    struct msghdr msg;
    struct iovec iov[1];

    union {
        struct cmsghdr cm;
        char* control;
    } control_un;

    size_t sizeof_control = CMSG_SPACE(sizeof(int)) * sizeof(char);
    control_un.control = (char*) malloc(sizeof_control);

    struct cmsghdr *cmptr;
    ssize_t readResult;
    struct sockaddr_un cliaddr;
    socklen_t len = sizeof(cliaddr);

    // Construct client address to send this SHareable handle to
    memset(&cliaddr, 0, sizeof(cliaddr));
    cliaddr.sun_family = AF_UNIX;
    char temp[20];
    sprintf(temp, "%s%u", "/tmp/", process);
    strcpy(cliaddr.sun_path, temp);
    len = sizeof(cliaddr);

    // Send corresponding shareable handle to the client
    int sendfd = fd;

    msg.msg_control = control_un.control;
    msg.msg_controllen = sizeof_control;

    cmptr = CMSG_FIRSTHDR(&msg);
    cmptr->cmsg_len = CMSG_LEN(sizeof(int));
    cmptr->cmsg_level = SOL_SOCKET;
    cmptr->cmsg_type = SCM_RIGHTS;

    memmove(CMSG_DATA(cmptr), &sendfd, sizeof(sendfd));

    msg.msg_name = (void *)&cliaddr;
    msg.msg_namelen = sizeof(struct sockaddr_un);

    iov[0].iov_base = (void *)"";
    iov[0].iov_len = 1;
    msg.msg_iov = iov;
    msg.msg_iovlen = 1;

    ssize_t sendResult = sendmsg(socket, &msg, 0);
    if (sendResult <= 0) {
        perror("IPC failure: Sending data over socket failed");
        free(control_un.control);
        return -1;
    }

    free(control_un.control);
    return 0;
}
int ipcSendShareableHandle(HANDLE *handle, HANDLE &shareableHandle, PROCESS_INFORMATION process) {
    HANDLE hProcess = OpenProcess(PROCESS_DUP_HANDLE, FALSE, process.dwProcessId);
    HANDLE hDup = INVALID_HANDLE_VALUE;
    DuplicateHandle(GetCurrentProcess(), shareableHandle, hProcess, &hDup, 0, FALSE, DUPLICATE_SAME_ACCESS);
    DWORD cbWritten;
    WriteFile(handle->hMailslot[i], &hDup, (DWORD)sizeof(hDup), &cbWritten, (LPOVERLAPPED)NULL);
    CloseHandle(hProcess);
    return 0;
}
MPI_Send(&fh, sizeof(CUmemFabricHandle), MPI_BYTE, 1, 0, MPI_COMM_WORLD);
int ipcRecvShareableHandle(int socket, int* fd) {
    struct msghdr msg = {0};
    struct iovec iov[1];
    struct cmsghdr cm;

    // Union to guarantee alignment requirements for control array
    union {
        struct cmsghdr cm;
        // This will not work on QNX as QNX CMSG_SPACE calls __cmsg_alignbytes
        // And __cmsg_alignbytes is a runtime function instead of compile-time macros
        // char control[CMSG_SPACE(sizeof(int))]
        char* control;
    } control_un;

    size_t sizeof_control = CMSG_SPACE(sizeof(int)) * sizeof(char);
    control_un.control = (char*) malloc(sizeof_control);
    struct cmsghdr *cmptr;
    ssize_t n;
    int receivedfd;
    char dummy_buffer[1];
    ssize_t sendResult;
    msg.msg_control = control_un.control;
    msg.msg_controllen = sizeof_control;

    iov[0].iov_base = (void *)dummy_buffer;
    iov[0].iov_len = sizeof(dummy_buffer);

    msg.msg_iov = iov;
    msg.msg_iovlen = 1;
    if ((n = recvmsg(socket, &msg, 0)) <= 0) {
        perror("IPC failure: Receiving data over socket failed");
        free(control_un.control);
        return -1;
    }

    if (((cmptr = CMSG_FIRSTHDR(&msg)) != NULL) &&
        (cmptr->cmsg_len == CMSG_LEN(sizeof(int)))) {
        if ((cmptr->cmsg_level != SOL_SOCKET) || (cmptr->cmsg_type != SCM_RIGHTS)) {
        free(control_un.control);
        return -1;
        }

        memmove(&receivedfd, CMSG_DATA(cmptr), sizeof(receivedfd));
        *fd = receivedfd;
    } else {
        free(control_un.control);
        return -1;
    }

    free(control_un.control);
    return 0;
}
int ipcRecvShareableHandle(HANDLE &handle, HANDLE *shareableHandle) {
    DWORD cbRead;
    ReadFile(handle, shareableHandle, (DWORD)sizeof(*shareableHandles), &cbRead, NULL);
    return 0;
}
MPI_Recv(&fh, sizeof(CUmemFabricHandle), MPI_BYTE, 1, 0, MPI_COMM_WORLD);

导入内存句柄同样,用户可以导入用于特定于操作系统的 IPC 或特定于互连网络(fabric)的 IPC 的句柄。特定于操作系统的 IPC 句柄只能用于单个节点。特定于互连网络(fabric)的句柄可用于单节点或多节点。

CUmemAllocationHandleType handleType = CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR;
cuMemImportFromShareableHandle(handle, (void*) &fd, handleType);
CUmemAllocationHandleType handleType = CU_MEM_HANDLE_TYPE_FABRIC;
cuMemImportFromShareableHandle(handle, (void*) &fh, handleType);

4.17.3.3. 预留和映射

预留虚拟地址范围

由于 VMM 中地址和内存是不同的概念,应用程序必须划出一个地址范围,以容纳由cuMemCreate进行的内存分配。预留的地址范围必须至少与用户计划放入其中的所有物理内存分配的大小之和一样大。

应用程序可以通过向cuMemAddressReserve传递适当的参数来预留虚拟地址范围。获得的地址范围不会与任何设备或主机物理内存关联。预留的虚拟地址范围可以映射到属于系统中任何设备的内存块,从而为应用程序提供一个连续的 VA 范围,该范围由属于不同设备的内存提供后备存储并与其建立映射。应用程序应使用cuMemAddressFree将虚拟地址范围归还给 CUDA。用户必须确保在调用cuMemAddressFree之前已解除整个 VA 范围的映射。这些函数在概念上类似于 Linux 上的mmap和munmap,或 Windows 上的VirtualAlloc和VirtualFree。以下代码片段展示了该函数的用法:

CUdeviceptr ptr;
// `ptr` holds the returned start of virtual address range reserved.
CUresult result = cuMemAddressReserve(&ptr, size, 0, 0, 0); // alignment = 0 for default alignment

映射内存

前两节中分配的物理内存和划出的虚拟地址空间体现了 VMM API 引入的内存与地址之间的区别。要使分配的内存可用,用户必须将内存映射到地址空间。必须使用cuMemMap将从cuMemAddressReserve获得的地址范围与从cuMemCreate或cuMemImportFromShareableHandle获得的物理分配相互关联。

只要划出了足够的地址空间,用户就可以将来自多个设备的分配关联到连续的虚拟地址范围中。要解除物理分配与地址范围之间的关联,用户必须使用cuMemUnmap解除该映射地址的映射。用户可以根据需要,将内存映射到同一个地址范围并解除映射任意多次,只要确保不尝试在已映射的预留 VA 范围上创建映射即可。以下代码片段展示了该函数的用法:

CUdeviceptr ptr;
// `ptr`: address in the address range previously reserved by cuMemAddressReserve.
// `allocHandle`: CUmemGenericAllocationHandle obtained by a previous call to cuMemCreate.
CUresult result = cuMemMap(ptr, size, 0, allocHandle, 0);

4.17.3.4. 访问权限

CUDA 的虚拟内存管理 API 使应用程序能够通过访问控制机制显式保护其 VA 范围。使用cuMemMap将分配映射到地址范围中的某个区域并不会使该地址变得可访问,如果 CUDA 核函数访问该地址,则会导致程序崩溃。用户必须在源设备和执行访问的设备上使用cuMemSetAccess函数专门选择访问控制。这会允许或限制特定设备对已映射地址范围的访问。以下代码片段展示了该函数的用法:

void setAccessOnDevice(int device, CUdeviceptr ptr, size_t size) {
    CUmemAccessDesc accessDesc = {};
    accessDesc.location.type = CU_MEM_LOCATION_TYPE_DEVICE;
    accessDesc.location.id = device;
    accessDesc.flags = CU_MEM_ACCESS_FLAGS_PROT_READWRITE;

    // Make the address accessible
    cuMemSetAccess(ptr, size, &accessDesc, 1);
}

VMM 提供的访问控制机制允许用户明确指定要与系统上其他对等设备共享哪些分配。如前所述,cudaEnablePeerAccess会强制将之前和之后使用cudaMalloc进行的所有分配映射到目标对等设备。在许多情况下,这很方便,因为用户不必担心跟踪每个分配到系统中每个设备的映射状态。但是,这种方法会影响性能。通过以分配为粒度的访问控制,VMM 允许以最小的开销建立对等映射。

vectorAddMMAP示例可作为使用虚拟内存管理 API 的示例。

4.17.3.5. 释放内存

要释放分配的内存和地址空间,源进程和目标进程都应依次使用cuMemUnmap、cuMemRelease和cuMemAddressFree函数。cuMemUnmap函数从地址范围中解除先前映射的内存区域的映射,从而有效地将物理内存与预留的虚拟地址空间分离。接下来,cuMemRelease释放先前创建的物理内存,将其归还给系统。最后,cuMemAddressFree释放先前预留的虚拟地址范围,使其可供将来使用。这一特定顺序可确保物理内存和虚拟地址空间均被彻底、完整地释放。

cuMemUnmap(ptr, size);
cuMemRelease(handle);
cuMemAddressFree(ptr, size);

注意

在特定于操作系统的情况下,必须使用fclose关闭导出的句柄。此步骤不适用于基于互连网络(fabric)的情况。

4.17.4. 多播内存共享

多播对象管理 API为应用程序提供了创建多播对象的方法,并结合上述虚拟内存管理 API,使应用程序能够在支持此功能、通过 NVSwitch 连接的 NVLink 互连 GPU 上利用 NVLink SHARP。NVLink SHARP 允许 CUDA 应用程序利用互连网络(fabric)内计算,加速通过 NVSwitch 连接的 GPU 之间的广播和归约等操作。为实现这一点,多个通过 NVLink 连接的 GPU 组成一个多播团队,团队中的每个 GPU 都用物理内存为一个多播对象提供后备存储。因此,由 N 个 GPU 组成的多播团队拥有一个多播对象的 N 个物理副本,每个副本都位于某个参与 GPU 的本地。使用多播对象映射的multimem PTX 指令会对该多播对象的所有副本进行操作。

要使用多播对象,应用程序需要

  • 查询多播支持情况。

  • 使用cuMulticastCreate创建多播句柄。

  • 将多播句柄共享给所有控制着应参与多播团队的 GPU 的进程。如上所述,这可通过cuMemExportToShareableHandle实现。

  • 使用cuMulticastAddDevice添加所有应参与多播团队的 GPU。

  • 对于每个参与的 GPU,将如上所述使用cuMemCreate分配的物理内存绑定到多播句柄。在任何设备上绑定内存之前,需要先将所有设备添加到多播团队。

  • 按照上文针对常规单播映射的说明,预留地址范围、映射多播句柄并设置访问权限。可以对同一物理内存建立单播和多播映射。有关如何确保同一物理内存的多个映射之间的一致性,请参阅虚拟别名支持一节。

  • 对多播映射使用multimem PTX 指令。

多 GPU 编程模型 GitHub 仓库中的multi_node_p2p示例包含一个完整示例,使用互连网络(fabric)内存(包括多播对象)来利用 NVLink SHARP。请注意,此示例面向 NCCL 或 NVSHMEM 等库的开发者。它展示了 NVSHMEM 等较高层次的编程模型在一个(多节点)NVLink 域内的内部工作原理。应用程序开发者通常应使用较高层次的 MPI、NCCL 或 NVSHMEM 接口,而不是此 API。

注意

此处描述的多播共享将一个多播对象映射到每个设备的虚拟地址空间,并使用multimem指令对其进行操作,这就是以地址为中心的模型。计算互连传输提供了一个以资源为中心的对应方案,即多播逻辑端点,它通过 NVLink 互连网络(fabric)为一个多播团队提供服务,而不将对象映射到本地地址空间。它会报告每次操作的显式完成状态,因此适用于需要关注互连网络(fabric)错误恢复的大型互连网络(fabric)。

4.17.4.1. 分配多播对象

可以使用cuMulticastCreate创建多播对象:

CUmemGenericAllocationHandle createMCHandle(int numDevices, size_t size) {
    CUmemAllocationProp mcProp = {};
    mcProp.numDevices = numDevices;
    mcProp.handleTypes = CU_MEM_HANDLE_TYPE_FABRIC; // or on single node CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR

    size_t granularity = 0;
    cuMulticastGetGranularity(&granularity, &mcProp, CU_MEM_ALLOC_GRANULARITY_MINIMUM);

    // Ensure size matches granularity requirements for the allocation
    size_t padded_size = ROUND_UP(size, granularity);

    mcProp.size = padded_size;

    // Create Multicast Object this has no devices and no physical memory associated yet
    CUmemGenericAllocationHandle mcHandle;
    cuMulticastCreate(&mcHandle, &mcProp);

    return mcHandle;
}

4.17.4.2. 向多播对象添加设备

可以使用cuMulticastAddDevice将设备添加到多播团队中:

cuMulticastAddDevice(&mcHandle, device);

在将任何设备上的内存绑定到多播对象之前,所有控制多播团队参与设备的进程都需要完成此步骤。

4.17.4.3. 将内存绑定到多播对象

创建多播对象并将所有参与设备添加到多播对象后,需要以使用cuMemCreate为每个设备分配的物理内存作为其后备存储:

cuMulticastBindMem(mcHandle, mcOffset, memHandle, memOffset, size, 0 /*flags*/);

4.17.4.4. 使用多播映射

要在 CUDA C++ 中使用多播映射,必须通过内联 PTX 使用multimem PTX 指令:

__global__ void all_reduce_norm_barrier_kernel(float* l2_norm,
                                               float* partial_l2_norm_mc,
                                               unsigned int* arrival_counter_uc, unsigned int* arrival_counter_mc,
                                               const unsigned int expected_count) {
    assert( 1 == blockDim.x * blockDim.y * blockDim.z * gridDim.x * gridDim.y * gridDim.z );
    float l2_norm_sum = 0.0;
#if __CUDA_ARCH__ >= 900

    // atomic reduction to all replicas
    // this can be conceptually thought of as __threadfence_system(); atomicAdd_system(arrival_counter_mc, 1);
    cuda::ptx::multimem_red(cuda::ptx::release_t, cuda::ptx::scope_sys_t, cuda::ptx::op_add_t, arrival_counter_mc, n);

    // Need a fence between Multicast (mc) and Unicast (uc) access to the same memory `arrival_counter_uc` and `arrival_counter_mc`:
    // - fence.proxy instructions establish an ordering between memory accesses that may happen through different proxies
    // - Value .alias of the .proxykind qualifier refers to memory accesses performed using virtually aliased addresses to the same memory location.
    // from https://docs.nvidia.com/cuda/parallel-thread-execution/#parallel-synchronization-and-communication-instructions-membar
    cuda::ptx::fence_proxy_alias();

    // spin wait with acquire ordering on UC mapping till all peers have arrived in this iteration
    // Note: all ranks need to reach another barrier after this kernel, such that it is not possible for the barrier to be unblocked by an
    // arrival of a rank for the next iteration if some other rank is slow.
    cuda::atomic_ref<unsigned int,cuda::thread_scope_system> ac(arrival_counter_uc);
    while (expected_count > ac.load(cuda::memory_order_acquire));

    // Atomic load reduction from all replicas. It does not provide ordering so it can be relaxed.
    asm volatile ("multimem.ld_reduce.relaxed.sys.global.add.f32 %0, [%1];" : "=f"(l2_norm_sum) : "l"(partial_l2_norm_mc) : "memory");

#else
    #error "ERROR: multimem instructions require compute capability 9.0 or larger."
#endif

    *l2_norm = std::sqrt(l2_norm_sum);
}

4.17.5. 高级配置

4.17.5.1. 内存类型

VMM 还提供了一种机制,使应用程序能够分配某些设备可能支持的特殊类型的内存。通过cuMemCreate,应用程序可以使用CUmemAllocationProp::allocFlags指定内存类型要求,以选择启用特定的内存功能。应用程序必须确保设备支持所请求的内存类型。

4.17.5.2. 可压缩内存

可压缩内存可用于加速对具有非结构化稀疏性的数据以及具有其他可压缩模式的数据的访问。根据数据的不同,压缩可以节省 DRAM 带宽、L2 读取带宽以及 L2 容量。希望在支持计算数据压缩的设备上分配可压缩内存的应用程序,可以通过将CUmemAllocationProp::allocFlags::compressionType设置为CU_MEM_ALLOCATION_COMP_GENERIC来实现。用户必须使用CU_DEVICE_ATTRIBUTE_GENERIC_COMPRESSION_SUPPORTED查询设备是否支持计算数据压缩。以下代码片段演示了如何使用cuDeviceGetAttribute查询对可压缩内存的支持。

int compressionSupported = 0;
cuDeviceGetAttribute(&compressionSupported, CU_DEVICE_ATTRIBUTE_GENERIC_COMPRESSION_SUPPORTED, device);

在支持计算数据压缩的设备上,用户必须在分配时选择启用此功能,如下所示:

prop.allocFlags.compressionType = CU_MEM_ALLOCATION_COMP_GENERIC;

由于硬件资源有限等各种原因,分配的内存可能不具有压缩属性。要验证这些标志是否生效,用户应使用cuMemGetAllocationPropertiesFromHandle查询所分配内存的属性。

CUmemAllocationProp allocationProp = {};
cuMemGetAllocationPropertiesFromHandle(&allocationProp, allocationHandle);

if (allocationProp.allocFlags.compressionType == CU_MEM_ALLOCATION_COMP_GENERIC)
{
    // Obtained compressible memory allocation
}

4.17.5.3. 虚拟别名支持

虚拟内存管理 API 提供了一种方法,通过使用不同的虚拟地址多次调用cuMemMap,为同一内存分配创建多个虚拟内存映射或“代理”。这称为虚拟别名。除非 PTX ISA 中另有说明,否则在执行写入的设备操作(网格启动、memcpy、memset 等)完成之前,对该内存分配的一个代理的写入被视为与同一内存的任何其他代理不一致且不相干。对于在执行写入的设备操作之前已存在于 GPU 上、但在该写入设备操作完成后才进行读取的网格,其代理也被视为不一致且不相干。

例如,假设设备指针 A 和 B 是同一内存分配的虚拟别名,则以下代码片段被视为具有未定义行为:

__global__ void foo(char *A, char *B) {
  *A = 0x1;
  printf("%d\n", *B);    // Undefined behavior!  *B can take on either
// the previous value or some value in-between.
}

假设这两个核函数(通过流或事件)按单调顺序执行,以下行为是已定义的。

__global__ void foo1(char *A) {
  *A = 0x1;
}

__global__ void foo2(char *B) {
  printf("%d\n", *B);    // *B == *A == 0x1 assuming foo2 waits for foo1
// to complete before launching
}

cudaMemcpyAsync(B, input, size, stream1);    // Aliases are allowed at
// operation boundaries
foo1<<<1,1,0,stream1>>>(A);                  // allowing foo1 to access A.
cudaEventRecord(event, stream1);
cudaStreamWaitEvent(stream2, event);
foo2<<<1,1,0,stream2>>>(B);
cudaStreamWaitEvent(stream3, event);
cudaMemcpyAsync(output, B, size, stream3);  // Both launches of foo2 and
                                            // cudaMemcpy (which both
                                            // read) wait for foo1 (which writes)
                                            // to complete before proceeding

如果需要在同一个核函数中通过不同的“代理”访问同一内存分配,可以在两次访问之间使用fence.proxy.alias。因此,可以通过内联 PTX 汇编使上述示例变为合法:

__global__ void foo(char *A, char *B) {
  *A = 0x1;
  cuda::ptx::fence_proxy_alias();
  printf("%d\n", *B);    // *B == *A == 0x1
}

4.17.5.4. 用于 IPC 的操作系统特定句柄详情

通过cuMemCreate,用户可以在分配时表明,已将某个特定的内存分配指定用于进程间通信或图形互操作。应用程序可以通过将CUmemAllocationProp::requestedHandleTypes设置为平台特定的字段来实现这一点。在 Windows 上,当CUmemAllocationProp::requestedHandleTypes设置为CU_MEM_HANDLE_TYPE_WIN32时,应用程序还必须在CUmemAllocationProp::win32HandleMetaData中指定一个 LPSECURITYATTRIBUTES 属性。此安全属性定义了哪些已导出的内存分配可以传递给其他进程。

在尝试导出使用cuMemCreate分配的内存之前,用户必须确保已查询是否支持所请求的句柄类型。以下代码片段演示了如何以平台特定的方式查询对句柄类型的支持。

int deviceSupportsIpcHandle;
#if defined(__linux__)
    cuDeviceGetAttribute(&deviceSupportsIpcHandle, CU_DEVICE_ATTRIBUTE_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR_SUPPORTED, device));
#else
    cuDeviceGetAttribute(&deviceSupportsIpcHandle, CU_DEVICE_ATTRIBUTE_HANDLE_TYPE_WIN32_HANDLE_SUPPORTED, device));
#endif

用户应适当地设置CUmemAllocationProp::requestedHandleTypes,如下所示:

#if defined(__linux__)
    prop.requestedHandleTypes = CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR;
#else
    prop.requestedHandleTypes = CU_MEM_HANDLE_TYPE_WIN32;
    prop.win32HandleMetaData = // Windows specific LPSECURITYATTRIBUTES attribute.
#endif

4.18. 计算互连传输

计算互连传输通过NCCL 设备 API和NVSHMEM,为 NCCL 和 NVSHMEM 等通信库提供经由 NVLink 互连网络(fabric)的大规模 GPU 间通信。当这些库与所需的通信模式相匹配时,大多数开发者应优先使用它们。计算互连传输主要面向编写此类通信库的开发者,或面向在这些库无法很好地涵盖其通信模式时需要精细控制的应用程序。自定义通信核函数的作者在需要比这些设备 API 单独提供的访问更加直接的访问时,也可以将互连网络(fabric)操作与 NCCL 和 NVSHMEM 设备 API 结合使用。

单播和多播内存共享 API 通过将远程物理内存映射到本地虚拟地址空间,使应用程序能够跨 NVLink 互连网络(fabric)访问 GPU 内存。一旦对等方的内存分配已映射并被授予访问权限,核函数就可以像访问本地内存一样,使用加载和存储来访问它(对于多播映射,还需使用multimem指令)。该模型既是以地址为中心的,也是以内存为中心的:共享的单位是虚拟地址到物理地址的映射,核函数在对等方上访问的每个字节,都必须能通过发起访问的进程已预留并映射的地址来访问。

在大型 NVLink 互连网络(fabric)上,此模型有两个限制,而且两者都会影响应用程序的编写方式。

虚拟地址不提供错误传播通道。当核函数向对等方地址发出加载或存储时,硬件要么交付数据,要么产生内存故障,不存在供发起操作的线程检查、以检测故障并以编程方式处理故障的中间结果。在大型互连网络(fabric)系统上,可能发生丢包或链路重置等瞬态错误;在规模足够大时,这些错误会成为设计具有故障恢复能力的应用程序时需要考虑的因素。在远程访问期间遇到的互连网络(fabric)错误通常会使该访问发生故障或使进程终止,而 GPU 核函数无法响应此类故障:既无法重试,也无法重新路由。

虚拟地址也只能标识内存。映射是根据物理内存定义的,因此互连网络(fabric)上不是内存的资源根本无法通过映射来访问。

计算互连传输为通过 NVLink 互连网络(fabric)连接的 GPU 提供了一种互补的、以资源为中心的模型。应用程序不再将对等方的内存导入本地地址空间,而是创建一个逻辑端点:一个具名的传输对象,表示可通过互连网络(fabric)访问的资源。逻辑端点由一个 32 位整数,即逻辑端点 ID标识;端点内的目标由该 ID 与一个 64 位偏移量共同标识,而不是由虚拟地址标识。由于该 ID 标识的是资源本身,因此该模型并不局限于内存,尽管内存目前是逻辑端点唯一暴露的资源类型。

当所有者更改端点的后备内存时,已导入的端点也仍然有效。对等方导入端点,将其与自己预留的某个端点 ID 关联,而不会导入所有者的内存分配。因此,所有者可以通过在本地重新绑定来扩大、缩小或替换端点背后的缓冲区,而对等方可以继续使用其本地端点 ID,无需重新导入端点。所有者及其对等方仍必须在重新绑定前后进行同步,以便在所有者将端点重新绑定到新内存期间,对等方不会访问该端点。此外,发出的操作若落在当前绑定的范围之外,则属于未定义行为。相比之下,VMM IPC 导入会映射一个特定的内存分配,因此在所有者一侧调整该内存分配的大小时,需要对等方导入并映射新的内存分配。

一旦端点已创建、已与需要它的对等方共享,并且已由资源提供后备支持,就可以针对对等方的逻辑端点 ID 和偏移量发出异步互连网络(fabric)操作,包括 put、get、原子操作和归约。与对已映射的对等方地址执行加载或存储不同,互连网络(fabric)操作会报告明确的完成状态,发起操作的代码可以检查该状态。发生故障时,应用程序可以重试、重新路由或采取其他恢复措施,而不是使进程发生故障。

4.18.1. 先决条件与范围

使用逻辑端点有以下要求:

  • CUDA 驱动程序 API。逻辑端点 API 是底层CUDA 驱动程序 API的一部分。

  • NVLink 互连网络(fabric)连通性与设备支持。参与的 GPU 必须能够通过 NVLink 互连网络(fabric)相互访问。设备是否支持逻辑端点取决于其架构、驱动程序和系统配置,因此应用程序应通过查询第 4.18.2.2 节中描述的设备属性来确定是否支持,而不是根据现有硬件进行推断。

  • IMEX 守护进程与通道。在进程间共享端点使用互连网络(fabric)IPC 句柄,这要求 NVIDIA IMEX 守护进程正在运行,并且已配置 IMEX 通道。

端点可以与同一 NVLink 互连网络(fabric)内同一节点或不同节点上的进程共享。互连网络(fabric)IPC 句柄机制与 VMM API 使用的机制类似。有关互连网络(fabric)句柄和 IMEX 通道的详细信息,请参阅虚拟内存管理。

4.18.2. 预备知识

以下定义确立了本节后续内容中使用的关键对象和操作。

4.18.2.1. 定义

逻辑端点 ID:逻辑端点 ID(CUlogicalEndpointId)是一个 32 位值,用于标识进程内的逻辑端点。端点 ID 是进程局部的:每个进程使用cuLogicalEndpointIdReserve预留自己的范围,并选择将哪个预留 ID 与给定端点关联。互连网络(fabric)操作通过此 ID 和偏移量来寻址端点。单个端点 ID 标识一个端点,但多个 ID 可以标识同一端点。端点 ID 通过cuLogicalEndpointCreate或cuLogicalEndpointImport与端点关联。在使用cuLogicalEndpointIdRelease释放端点 ID 之前,必须使用cuLogicalEndpointDestroy移除此关联。在端点 ID 仍与端点关联时释放它属于未定义行为。后续预留可能会重用已释放的 ID。第 4.18.6 节介绍了逻辑端点 ID 的生命周期。

逻辑端点:逻辑端点是一个传输对象,表示可通过 NVLink 互连网络(fabric)访问的资源,并以有界偏移空间的形式暴露。创建端点会分配用于路由以该端点为目标的互连网络(fabric)操作的资源。随后,应用程序必须将目标资源显式绑定到端点偏移空间的一个或多个范围。互连网络(fabric)操作使用已关联的端点 ID 以及该空间内的偏移量来标识其目标。当进程使用cuLogicalEndpointCreate创建端点,或使用cuLogicalEndpointImport导入对等方端点时,端点会与预留的 ID 关联。在显式解除端点资源的绑定之前销毁端点关联,会解除这些资源的绑定,但不会销毁资源本身。在将资源与端点解除绑定之前销毁资源属于未定义行为。第 4.18.3 节概述了端点的生命周期。

单播逻辑端点:单播逻辑端点表示点对点传输目标。它具有一个在创建端点时指定的所有者设备,端点所绑定的内存位于该设备上。对等设备针对端点 ID 发出 put 或 get 操作,以将数据移入或移出所有者的已绑定内存。

多播逻辑端点:多播逻辑端点表示在每个偏移量处暴露多个资源的传输目标。在绑定其资源之前,每个参与进程都必须将该资源添加到多播端点。随后,该进程将资源绑定到端点。这些绑定共同为每个团队成员创建一个副本。以多播端点为目标的互连网络(fabric)操作会访问每个副本中的对应偏移量。多播逻辑端点是计算互连传输中与多播内存共享中描述的 VMM 多播对象相对应的对象。

计算互连传输句柄:计算互连传输句柄是传统指针的一种以资源为中心的替代方案。它由逻辑端点 ID 和偏移量组成一对:逻辑端点 ID 选择 NVLink 互连网络(fabric)上的目标位置,即单播端点的所有者设备或一个多播团队,而偏移量选择该目标位置的已绑定内存中的某个位置。互连网络(fabric)操作使用此句柄代替对等方虚拟地址。

逻辑端点互连组:逻辑端点互连组是由 GPU 构成的动态组,这些 GPU 能够以所要求的能力级别通过计算互连传输操作相互访问。互连组类型描述所需的能力级别。逻辑端点互连组有两种类型:CU_CLIQUE_TYPE_UNICAST_LOGICAL_ENDPOINT和CU_CLIQUE_TYPE_MULTICAST_LOGICAL_ENDPOINT。互连组 ID是一个 32 位值,用于标识该 GPU 动态组。尝试将逻辑端点导入到与导出设备不属于同一互连组的设备上将会失败。尝试通过添加不属于同一互连组的设备来创建多播逻辑端点将会失败。单个设备可以是多个互连组的成员。程序可以使用cuDeviceGetCliqueCount和cuDeviceGetCliqueInfo查询设备所属的互连组。

端点绑定:端点绑定是逻辑端点偏移空间中的某个范围与应用程序提供的物理内存分配之间的关联。应用程序可以使用cuLogicalEndpointBindAddr通过已映射的虚拟地址来绑定内存,也可以使用cuLogicalEndpointBindMem通过内存分配句柄来绑定内存。互连网络(fabric)操作仅对已绑定的端点范围有效,并且绑定必须满足端点的对齐和大小限制。请参阅第 4.18.8 节和第 4.18.6.1 节。

所有互连网络(fabric)操作都是异步的:发出一个操作仅仅会启动传输。它们的完成情况和状态通过每个操作提供的完成机制显式报告(请参阅第 4.18.10 节)。

put 和 get 操作:put 和 get 是计算互连传输提供的数据移动原语。put 操作将数据从发起操作的 GPU 移动到目标逻辑端点。其 multimem 变体cuda::ptx::fabric_try_put_multimem向多播逻辑端点执行广播,将源数据写入每个副本中相应的偏移位置。get 操作将数据从目标逻辑端点移动到发起操作的 GPU。在两个方向上,远端都通过逻辑端点 id 和偏移量来标识,而不是通过对等端虚拟地址来标识,并且远端分配的内存无需映射到发起操作的进程的虚拟地址空间中。

归约和拉取归约操作:归约操作(red)使用归约运算符将来自发起操作的 GPU 的数据合并到目标逻辑端点中。拉取归约操作(pull_red)从目标多播逻辑端点读取数据,使用归约运算符合并多播团队各副本中的值,并将结果返回给发起操作的 GPU。它是multimem.ld_reduce在互连网络(fabric)中的对应操作。

原子操作:原子操作(atom)对目标逻辑端点执行原子的读-修改-写操作。它读取原始值,用新值覆盖原始值,并返回原始值。相比之下,归约操作不返回原始值。用于计算新值的运算符包括add、min、max以及按位and、or和xor。交换操作存储用户提供的值,而比较并交换操作仅在目标保存着预期的比较值时才存储新值。

计数操作:计数操作是一种互连网络操作,它在数据到达时,按写入目标的字节数递增目标计数器。接收方等待计数器达到预期字节数后,才使用这些数据。在达到这一预期字节数之前,接收方不知道哪些字节已被写入。一旦计数器达到预期字节数,接收方就可以安全地使用这些数据。计数器必须在端点内存中对齐到 256 字节边界。创建端点时必须启用计数操作支持(CU_LOGICAL_ENDPOINT_FLAG_COUNTED_OPS),并且应用程序在使用此功能之前必须查询设备支持情况(CU_DEVICE_ATTRIBUTE_LOGICAL_ENDPOINT_COUNTED_OPS_SUPPORTED)。

完成状态与错误报告:互连网络操作由一个完成对象跟踪,该对象在操作完成时发出信号,并记录操作的状态。等待该对象可以得知所跟踪的操作是成功完成了,还是发生了互连网络错误。报告失败时,应用程序可以查询每个操作的详细错误状态,然后重试、重新路由或以其他方式恢复,而不是使进程发生故障。

由于互连网络操作的更新是乱序送达的,因此观察到某个目标位置已被写入,例如目标区域的最后一个字节已被写入,并不意味着传输的其余部分已经到达。观察到每个目标位置都已被写入,也并不表示操作已成功完成且目标资源可以重用。例如,如果响应被丢弃,生产者可能会收到错误状态,从而导致其重试该操作。因此,应用程序必须等待完成并检查所报告的状态,之后才能将该操作视为已完成。请参阅第 4.18.10 节。

4.18.2.2. 查询支持情况

应用程序在使用逻辑端点 API 之前应查询支持情况,因为其可用性取决于 GPU 架构、驱动程序以及系统配置。以下设备属性描述了相关能力。这些功能彼此独立,因此应用程序应仅查询它打算使用的那些功能。

单播逻辑端点支持

查询设备是否支持单播逻辑端点:

int unicastSupported = 0;
cuDeviceGetAttribute(&unicastSupported,
                     CU_DEVICE_ATTRIBUTE_LOGICAL_ENDPOINT_UNICAST_SUPPORTED,
                     device);
if (unicastSupported != 0) {
    // `device` supports unicast logical endpoints
}

多播逻辑端点支持

查询设备是否支持多播逻辑端点:

int multicastSupported = 0;
cuDeviceGetAttribute(&multicastSupported,
                     CU_DEVICE_ATTRIBUTE_LOGICAL_ENDPOINT_MULTICAST_SUPPORTED,
                     device);
if (multicastSupported != 0) {
    // `device` supports multicast logical endpoints
}

逻辑端点 IPC 句柄支持

在导出或导入端点之前,请查询设备对逻辑端点支持的 IPC 句柄类型。该属性返回由CUlogicalEndpointIpcHandleType值组成的位掩码。以下代码检查是否支持逻辑端点的互连网络 IPC 句柄类型:

int supportedHandleTypes = 0;
cuDeviceGetAttribute(
    &supportedHandleTypes,
    CU_DEVICE_ATTRIBUTE_LOGICAL_ENDPOINT_SUPPORTED_HANDLE_TYPES,
    device);
if ((supportedHandleTypes &
     CU_LOGICAL_ENDPOINT_IPC_HANDLE_TYPE_FABRIC) != 0) {
    // `device` supports fabric IPC handles for logical endpoints
}

计数操作支持

计数操作是一项可选能力。在端点上请求CU_LOGICAL_ENDPOINT_FLAG_COUNTED_OPS之前,请查询设备是否支持计数操作:

int countedOpsSupported = 0;
cuDeviceGetAttribute(&countedOpsSupported,
                     CU_DEVICE_ATTRIBUTE_LOGICAL_ENDPOINT_COUNTED_OPS_SUPPORTED,
                     device);
if (countedOpsSupported != 0) {
    // `device` supports counted operations via logical endpoints
}

来自所有者设备的单播访问

单播端点的所有者设备在传递给cuLogicalEndpointCreate的端点属性中指定。CU_DEVICE_ATTRIBUTE_LOGICAL_ENDPOINT_UNICAST_ACCESS_ON_OWNER_DEVICE_SUPPORTED设备属性指示该设备能否访问它所拥有的单播逻辑端点。如果不支持这种访问,设备可以通过指针访问所绑定的内存,这目前为所有者设备提供了最佳性能。所有者以外的设备可以通过与单播端点关联的逻辑端点 id 来访问该端点。

int ownerAccessSupported = 0;
cuDeviceGetAttribute(&ownerAccessSupported,
                     CU_DEVICE_ATTRIBUTE_LOGICAL_ENDPOINT_UNICAST_ACCESS_ON_OWNER_DEVICE_SUPPORTED,
                     device);
if (ownerAccessSupported != 0) {
    // `device` supports unicast logical endpoint access on the owner device
}

4.18.3. API 概述

逻辑端点 API 是底层CUDA Driver API的一部分,使用逻辑端点遵循明确定义的生命周期。下面的步骤概述了工作流程,后续各节将详细介绍每一步。这些步骤分为以下阶段:

  • 设置阶段(步骤 1-11):验证参与设备是同一互连组的成员,预留一个 id,描述并创建端点,与对等端共享端点,确认端点已就绪,然后在开始数据移动之前分配并绑定其后备内存。

  • 使用阶段(步骤 12):从设备发起针对该端点的互连网络操作。

  • 清理阶段(步骤 13):在所有使用均已完成后,解除端点的绑定并销毁端点,释放其 id,并释放后备内存。

  1. 验证互连组成员资格。按照第 4.18.4 节所述,使用cuDeviceGetCliqueCount和cuDeviceGetCliqueInfo验证参与设备属于同一互连组。

  2. 预留 id。使用cuLogicalEndpointIdReserve预留一个逻辑端点 id 范围。预留以进程为单位。id 的生命周期以及重用和释放规则在第 4.18.6 节中介绍。

  3. 描述端点。在CUlogicalEndpointProp中填入端点类型(单播或多播)、单播的所有者设备或多播的组大小、大小、请求的 IPC 句柄类型(ipcHandleTypes),以及任何标志,例如使用CU_LOGICAL_ENDPOINT_FLAG_COUNTED_OPS请求计数操作支持(请参阅计数操作)。

  4. 检查对齐和大小限制。针对拟使用的属性调用cuLogicalEndpointGetLimits,以获取所需的bindAlignment和maxSize。maxSize是该配置允许的最大端点大小。端点大小和所有绑定都必须满足这些限制。

  5. 创建端点。调用cuLogicalEndpointCreate,将其中一个预留 id 与新创建的端点关联起来,该端点由上面填充的CUlogicalEndpointProp描述。

  6. 共享端点。使用cuLogicalEndpointExport将端点导出为互连网络 IPC 句柄,通过任意 IPC 机制将该句柄传递给需要它的对等进程,并让每个对等进程使用cuLogicalEndpointImport将其导入。导出和导入端点并不要求端点已绑定内存。

  7. 添加设备(仅限多播)。对于多播端点,必须使用cuLogicalEndpointAddDevice将每个参与设备添加到组中。每个进程在本地持有该端点之后添加自己的设备,因此对于多播,先共享端点,再添加设备。

  8. 确认就绪。cuLogicalEndpointCreate和cuLogicalEndpointImport都是非阻塞的。使用前,请使用cuLogicalEndpointQuery确认就绪状态。对于本地创建的端点,应在绑定其内存之前确认;对于导入的端点,应在核函数发起针对它的操作之前确认。

  9. 分配后备内存。使用虚拟内存管理 API(cuMemCreate)或通过cudaMallocAsync分配后备内存。如果端点将与另一个进程共享,所绑定的内存必须能够对外共享。通过导入的多播端点绑定内存时,也适用相同的要求。创建内存分配时,通过请求互连网络句柄类型(CU_MEM_HANDLE_TYPE_FABRIC)来将其配置为可对外共享,并设置分配大小,使其同时满足bindAlignment和分配粒度的要求。此步骤与端点创建相互独立,但两者都必须在绑定之前完成。

  10. 绑定内存。使用cuLogicalEndpointBindMem(通过分配句柄)或cuLogicalEndpointBindAddr(通过映射地址)将物理内存与端点关联起来。不能将内存绑定到导入的单播端点,但可以将内存绑定到导入的多播端点。互连网络操作仅对已绑定的范围有效。

  11. 绑定后同步。在任何进程发起互连网络操作之前,参与进程必须在绑定后进行同步,如第 4.18.8 节所述。

  12. 使用端点。启动核函数,由核函数发起针对对等端点 id 的互连网络操作。

  13. 清理。解除内存绑定,销毁端点,释放 id 范围,并释放后备内存。

4.18.4. 验证互连组成员资格

在发起任何计算互连传输操作之前,我们需要确保所有参与设备都是支持所需计算互连传输操作集的互连组的成员,并且对于特定的互连组类型,它们都是同一互连组的成员。否则,设备无法通过网络使用该特定类型的操作相互访问。

首先,我们查询该设备是几个互连组的成员:

  size_t cliqueCount = 0; // not modified if there are errors
  cuDeviceGetCliqueCount(&cliqueCount, cuDevice);

然后,我们查询该设备所属的所有互连组的信息,并找到将要使用的互连组的互连组 id。在此示例中,我们将使用类型为CU_CLIQUE_TYPE_UNICAST_LOGICAL_ENDPOINT的互连组。

  std::vector<CUcliqueInfo> cliqueInfos(cliqueCount);
  cuDeviceGetCliqueInfo(cliqueInfos.data(), &cliqueCount, cuDevice);
  unsigned int localCliqueId = 0;
  bool found = false;
  for (size_t idx = 0; idx < cliqueCount; ++idx) {
    if (cliqueInfos[idx].type == CU_CLIQUE_TYPE_UNICAST_LOGICAL_ENDPOINT) {
      localCliqueId = cliqueInfos[idx].id;
      found = true;
      break;
    }
  }

验证所有参与设备对于该互连组类型都具有相同的互连组 id。

  std::vector<unsigned int> allCliqueIds(numRanks);
  MPI_Allgather(&localCliqueId, sizeof(unsigned int), MPI_BYTE,
                          allCliqueIds.data(), sizeof(unsigned int), MPI_BYTE, MPI_COMM_WORLD);
  for (int i = 0; i < numRanks; i++) {
    if (allCliqueIds[i] != localCliqueId) {
      fprintf(stderr, "Rank %d: Clique id %u does not match expected %u\n", myRank, allCliqueIds[i], localCliqueId);
      return EXIT_FAILURE;
    }
  }

4.18.5. 创建端点

创建和导入端点首先要确定进程需要多少个逻辑端点 id。然后使用cuLogicalEndpointIdReserve预留一个 id 范围,该函数返回该范围的基准 id。选择足够大的数量,以满足进程将创建或导入的每个端点的需要。与该范围关联的端点可以具有不同的属性。例如,应用程序可以为每个 GPU 预留一个 id,再为一个多播端点额外预留一个 id。此示例为每个秩预留一个 id:

  CUlogicalEndpointId leId = 0;
  cuLogicalEndpointIdReserve(&leId, (uint32_t)numRanks);

预留范围之后,在创建或导入端点时关联其中的 id。对于本地创建的端点,使用cuLogicalEndpointCreate;当远端端点的句柄可用时,使用cuLogicalEndpointImport。对于进程创建的每个端点,在CUlogicalEndpointProp中描述其属性。单播端点指定将持有其所绑定内存的唯一所有者设备。设置端点类型、请求的 IPC 句柄类型(ipcHandleTypes)以及任何标志,仅在有请求时才启用计数操作:

  CUlogicalEndpointProp endpointProp {};
  endpointProp.type = CU_LOGICAL_ENDPOINT_TYPE_UNICAST;
  endpointProp.unicast = {.device = cuDevice};
  endpointProp.ipcHandleTypes = CU_LOGICAL_ENDPOINT_IPC_HANDLE_TYPE_FABRIC;
  endpointProp.flags = useCounted ? CU_LOGICAL_ENDPOINT_FLAG_COUNTED_OPS : CU_LOGICAL_ENDPOINT_FLAG_NONE;

端点大小在得知其对齐和最大大小限制后单独设置。有关如何查询这些限制以及如何选择大小,请参阅第 4.18.6.1 节。

通过调用cuLogicalEndpointCreate,将一个预留 id 与所描述的属性关联起来。创建本地端点并导入对等端点的进程通常为每个参与者预留一个 id,以便通过基准 id 加上对等端的秩来寻址该对等端。每个秩在leId + myRank处创建自己的端点:

  cuLogicalEndpointCreate(leId + myRank, &endpointProp);

cuLogicalEndpointCreate是非阻塞的,因此它返回时端点并不能立即使用。在将内存绑定到端点之前,使用cuLogicalEndpointQuery确认端点已完全构建,如第 4.18.7.1 节所述。

多播端点。多播端点遵循相同的步骤,区别在于其描述方式,以及它需要添加一组设备。描述时使用type = CU_LOGICAL_ENDPOINT_TYPE_MULTICAST,并将multicast.numDevices设置为多播组中的设备数量。创建并共享端点后,每个参与设备都必须使用cuLogicalEndpointAddDevice加入该组。进程只有在本地持有端点后才添加自己的设备,创建者在cuLogicalEndpointCreate之后添加,各对等端在cuLogicalEndpointImport之后添加。在多播组完整之前,端点尚未就绪,无法使用。在端点的整个生命周期内,成员资格是永久性的。

4.18.6. 逻辑端点 id

逻辑端点 id 是进程局部的名称,其生命周期独立于它所标识的端点的生命周期。端点 id 始终处于以下状态之一:

  1. 未预留。该 id 不归该进程所有,不能与端点关联。cuLogicalEndpointIdReserve会将其预留。

  2. 已预留。该 id 归该进程所有,但尚未指代任何对象。使用已预留的 id 调用cuLogicalEndpointCreate或cuLogicalEndpointImport会将其与一个端点关联,并使其进入已关联状态。cuLogicalEndpointIdRelease会使其回到未预留状态。

  3. 已关联。该 id 指代一个端点,使用该 id 的互连网络操作以该端点为目标。cuLogicalEndpointDestroy会移除该关联,并使该 id 回到已预留状态。

cuLogicalEndpointIdReserve预留一个包含count个端点 id 的范围,并返回其基准baseLeId,因此预留的范围为[baseLeId, baseLeId + count)。基准是一个输出值:调用者不能选择获得哪些 id,并且如果没有包含count个 id 的可用范围,预留就会失败,例如,进程中的另一个库持有预留范围时就可能出现这种情况。

一个端点可以同时与多个 id 关联。例如,它可以通过cuLogicalEndpointCreate与所有者的 id 关联,并通过cuLogicalEndpointImport与其他 id 关联。这些 id 是同一端点的别名,针对其中任意一个 id 发起的操作都会到达同一目标。

cuLogicalEndpointDestroy移除一个 id 的关联。此后,通过后续的创建或导入操作,该 id 可以与另一个端点关联,无需再次预留。销毁操作还会解除该进程通过此 id 绑定的所有内存的绑定。只有在端点的最后一个别名被销毁时,端点本身才会被释放。

cuLogicalEndpointIdRelease(baseLeId, count)释放范围[baseLeId, baseLeId + count)中最多count个 id。该范围中的每个 id 都必须已经预留,并且与该范围中的 id 关联的每个端点都必须先被销毁。

由于预留以进程为单位,因此在每个导入进程中导入端点时,不必使用相同的 id。一种方便的约定是,每个进程预留一个大小相同的范围,为每个参与者预留一个 id,并在本地以baseLeId + peer来寻址给定对等端的端点。每个进程都有自己预留的baseLeId,它在各进程之间不必相同。如果在不同进程中使用不同 id 导入同一个对等端点,CUDA 不会检测或报告这种不匹配。应用程序有责任维护正确的本地对等端到端点 id 的映射。

4.18.6.1. 限制与对齐

端点的对齐要求和最大大小要求取决于其属性,因此必须针对正在创建的具体配置查询这些要求,而不能作出假设。cuLogicalEndpointGetLimits会返回给定CUlogicalEndpointProp的这两个值:

cuuint64_t bindAlignment = 0;
cuuint64_t maxSize = 0;
cuLogicalEndpointGetLimits(&bindAlignment, &maxSize, &prop);

端点大小和每个绑定偏移量都必须是返回的bindAlignment的整数倍。maxSize是逻辑端点的最大大小。如果maxSize小于CUlogicalEndpointProp::size,应用程序必须将请求调整为这个较小的值。要暴露整个请求范围,应用程序可以将其划分到多个端点上,每个端点都不大于maxSize。

用单个分配覆盖整个端点是最简单的安排,但这并非要求。端点的偏移空间同样可以由多个绑定提供支撑,这些绑定映射到不同的、可能不连续的物理分配,只要每个绑定范围都是bindAlignment的整数倍即可。

4.18.7. 共享端点

与对等方共享端点遵循导出、传输、导入的交换过程:所有者将端点导出为互连网络(fabric)IPC 句柄,将该句柄传输给需要它的对等方,然后每个对等方导入端点,并将其与自身预留的某个尚未关联端点的逻辑端点 ID 关联。共享使用互连网络(fabric)IPC 句柄,因此端点必须绑定到可对外共享的内存,并且环境必须满足第 4.18.1 节中描述的互连网络(fabric)IPC 要求。

使用cuLogicalEndpointExport导出端点,请求与描述该端点时所用类型相同的 IPC 句柄类型(ipcHandleTypes)。这会生成一个CUlogicalEndpointFabricHandle,即代表该逻辑端点的互连网络(fabric)IPC 句柄。使用应用程序自行选择的进程间通信机制将其传输给对等方,然后让每个对等方使用cuLogicalEndpointImport导入它。导入 ID 在本地选择,使用cuLogicalEndpointImport导入它。导入方可以选择用于导入端点的逻辑端点 ID。此 ID 必须已成功预留,且不得与任何其他逻辑端点关联,但不必与所有者用于创建端点的 ID 相同。每个设备都可以在不同的 ID 处导入同一端点,甚至可以在多个不同的 ID 处导入它(即允许多次导入同一端点)。

下面的单播点对点示例在环上共享端点。每个秩导出自己的端点,并通过一次MPI_Sendrecv将句柄发送给接收邻居,同时收集发送邻居的句柄。它将该句柄导入为leId + sendRank,随后使用此 ID 针对所导入端点的绑定内存发出互连网络(fabric)操作:

  // Export our own endpoint to a fabric IPC handle, hand it to the peers that
  // need it, and import each peer's handle to a locally reserved id.
  CUlogicalEndpointFabricHandle localHandle;
  CUlogicalEndpointFabricHandle sendRankHandle;
  cuLogicalEndpointExport(&localHandle, leId + myRank, CU_LOGICAL_ENDPOINT_IPC_HANDLE_TYPE_FABRIC);
  MPI_Sendrecv(
      &localHandle, sizeof(CUlogicalEndpointFabricHandle), MPI_BYTE, recvRank, 0,
      &sendRankHandle, sizeof(CUlogicalEndpointFabricHandle), MPI_BYTE, sendRank, 0,
      MPI_COMM_WORLD, MPI_STATUS_IGNORE);
  cuLogicalEndpointImport(leId + sendRank, &sendRankHandle, CU_LOGICAL_ENDPOINT_IPC_HANDLE_TYPE_FABRIC);

4.18.7.1. 确认就绪状态

cuLogicalEndpointCreate和cuLogicalEndpointImport在请求被接受后立即返回。在cuLogicalEndpointQuery报告就绪之前,将逻辑端点 ID 用于要求端点已完全构造的 API,会产生未定义行为。cuLogicalEndpointQuery本身是非阻塞的:如果所查询范围内的任何 ID 尚未就绪,它就返回 0;一旦给定范围内的所有 ID 都已就绪,它就返回非零值,因此通常会在轮询循环中调用它。

在查询就绪状态之前,请预留逻辑端点 ID,并通过cuLogicalEndpointCreate或cuLogicalEndpointImport将其与端点关联。多播端点只有在使用cuLogicalEndpointAddDevice添加了所有设备后才会就绪,因此多进程程序必须先导出它们(cuLogicalEndpointExport)并共享它们,然后才能阻塞等待它们就绪。

在对逻辑端点执行以下任何操作之前,请调用cuLogicalEndpointQuery并等待,直到它报告该逻辑端点已完全构造:

  • 使用cuLogicalEndpointBindAddr或cuLogicalEndpointBindMem绑定内存。

  • 使用cuLogicalEndpointUnbind解除内存绑定。

  • 发出任何cuda::ptx::fabric_try_*操作。

以下 API 不要求cuLogicalEndpointQuery已报告就绪:

  • cuLogicalEndpointExport

  • cuLogicalEndpointDestroy

  • cuLogicalEndpointIdRelease

这些 API 不要求就绪,但它们的其他生命周期前置条件仍然适用。

4.18.8. 绑定内存

端点不拥有任何内存。绑定将端点偏移空间中的一个范围与物理分配关联,互连网络(fabric)操作仅对已绑定的范围有效。绑定按设备建立,因此单播绑定适用于所有者设备,而多播绑定适用于多播团队中的单个设备。

根据应用程序持有分配的方式,可以使用以下两种方式之一绑定内存:

  • cuLogicalEndpointBindMem按分配句柄进行绑定,使用的是cuMemCreate返回的CUmemGenericAllocationHandle。

  • cuLogicalEndpointBindAddr按映射的虚拟地址进行绑定。示例包括cudaMallocAsync返回的指针,以及使用cuMemMap映射的范围内的地址。有关当前支持的分配类型,请参阅 CUDA 内存管理 API。

注意

并非每个 CUDA 可访问的分配都能绑定到逻辑端点。有关绑定 API 当前支持的分配类型,请参阅 CUDA Driver API 文档。

两者都接受端点 ID、绑定适用的设备、端点偏移空间内的偏移量、要绑定的分配以及绑定的大小。cuLogicalEndpointBindMem还接受分配内的memOffset,从而允许绑定该句柄的一个子范围。端点偏移量、大小以及(对于cuLogicalEndpointBindMem)memOffset都必须是bindAlignment的整数倍,并且绑定范围必须位于端点大小所限定的范围内(请参阅第 4.18.6.1 节)。

  // Bind the whole backing allocation at endpoint offset 0, by allocation handle.
  cuLogicalEndpointBindMem(leId + myRank, cuDevice, 0, exportHandle, 0, exportSize, 0);
  // No rank may issue an operation until every destination has been bound.
  MPI_CHECK(MPI_Barrier(MPI_COMM_WORLD));

建立初始绑定后,参与的进程必须先同步,然后任何进程才能发出互连网络(fabric)操作。对于单播端点,每个导入方都必须在所有者绑定其支撑内存后与导出进程同步。对于多播端点,所有参与的进程都必须等待,直到每个参与方都已绑定其支撑内存。应用程序可以使用任何能够建立这种顺序的进程间同步机制。

由于对等方导入的是端点,而不是所有者的分配,因此所有者可以替换端点背后的内存,而无需任何对等方重新导入端点。使用cuLogicalEndpointUnbind解除当前范围的绑定,并通过同一个端点 ID 将新的分配绑定到端点偏移空间中的同一范围。所有者及其对等方必须在重新绑定前后进行同步,以确保在端点未绑定期间,没有对等方针对该端点发出操作。发出落在当前绑定范围之外的操作会产生未定义行为。

4.18.9. 互连网络(fabric)操作

线程通过执行接受计算互连传输句柄的互连网络(fabric)操作来访问端点资源,该句柄是一个逻辑端点 ID 与偏移量的组合。互连网络(fabric)操作是异步的,也就是说,程序必须显式等待它们完成,才能观察到它们的效果。互连网络(fabric)操作带有try_前缀,以指示它们可能失败。也就是说,在等待完成之后,线程必须检查该操作的完成机制(请参阅第 4.18.10 节),以确定操作是否成功。这使互连网络(fabric)操作有别于普通指针操作:基于指针的访问所发生的故障,是通过销毁上下文或整个应用程序来向应用程序报告的。想要处理这些错误的应用程序,例如要从指针故障中恢复的应用程序,只能在上下文或进程级别这样做。互连网络(fabric)操作允许应用程序在程序线程级别处理错误。

设备代码通过以下cuda::ptx指令发出互连网络(fabric)操作:

指令

操作

cuda::ptx::fabric_try_put

写入计算互连传输句柄。

cuda::ptx::fabric_try_get

从计算互连传输句柄读取。

cuda::ptx::fabric_try_red

归约到计算互连传输句柄中。

cuda::ptx::fabric_try_pullred

从计算互连传输句柄进行拉取归约。

cuda::ptx::fabric_try_atom

对计算互连传输句柄进行原子读改写。

上述操作(try_atom除外)以 16 字节为单位移动数据,因此传输的两端都必须按 16 字节对齐:源指针和端点内的目标偏移量都必须是 16 字节的整数倍。

该示例发出互连网络(fabric)put 操作。put 从共享内存缓冲区读取数据,并将其写入目标句柄。多个线程将输入数据写入共享内存,然后使用__syncthreads与发出 put 的线程同步。put 通过异步代理读取共享内存,因此发出操作的线程调用cuda::ptx::fence_proxy_async(cuda::ptx::space_shared),以将通用代理的共享内存写入同步到异步代理。put 接受目标句柄、源缓冲区基址、字节数以及用于跟踪完成情况的mbarrier:

    // Make the staged shared-memory source visible to the async proxy before the put reads it.
    cuda::ptx::fence_proxy_async(cuda::ptx::space_shared);
    cuda::ptx::fabric_try_put(cuda::ptx::space_shared, cuda::ptx::sem_relaxed,
                              cuda::ptx::scope_sys, putLeId, offset, smemBuf,
                              FABRIC_CHUNK_SIZE, &putBar);

该调用设置了若干指令限定符,其完整集合由 PTX ISA 规定,包括每条指令接受的访问大小、内存排序模式和作用域(请参阅互连网络(fabric)指令)。这里值得指出该操作的两个方面,因为它们将该操作与程序的其余部分联系起来:如何跟踪其完成情况,以及如何指定内存排序。

事务计数。使用mbarrier来指示完成的互连网络(fabric)操作遵循与使用 TMA 传输数据中描述的指针寻址cuda::ptx::cp_async_bulk复制相同的mbarrier事务记账模型。随着 put 完成,每交付 16 字节,它就将其mbarrier的事务计数减 1。在所有预期的到达都已发生且事务计数达到零后,屏障阶段完成。put 不会设置预期事务计数。在提交该操作之后发出的单独一个cuda::ptx::mbarrier_arrive_expect_tx会记录一次到达,并将事务计数增加字节数除以 16 的值,因为这就是 put 将减少的计数值。取回操作则使用基于字节的事务记账。cuda::ptx::fabric_try_get和cuda::ptx::fabric_try_pullred每交付一个字节就将事务计数减 1,因此cuda::ptx::mbarrier_arrive_expect_tx会加上完整的字节数。第 4.18.10 节介绍了 mbarrier 的布局要求,以及如何读回互连网络(fabric)状态。

带计数的 put 还会在其数据到达时递增目标端的字节计数器,因此接收方可以在访问本地数据之前轮询该计数器,而不必交换单独的完成消息。下面的代码使用cuda::ptx::fabric_try_put_counted(普通 put 的.counted::bytes变体)来实现这一点,并传入第二个目标偏移量,即计数器的端点偏移量:

    // Make the staged shared-memory source visible to the async proxy before the put reads it.
    cuda::ptx::fence_proxy_async(cuda::ptx::space_shared);
    cuda::ptx::fabric_try_put_counted(cuda::ptx::space_shared, cuda::ptx::sem_relaxed,
                                      cuda::ptx::scope_sys, putLeId, offset, counterOffset,
                                      smemBuf, FABRIC_CHUNK_SIZE, &putBar);

带计数的操作要求端点在创建时支持带计数操作(CU_LOGICAL_ENDPOINT_FLAG_COUNTED_OPS,请参阅第 4.18.2.2 节),并且目标计数器必须在端点内存中按 256 字节边界对齐。应用程序在布局端点的支撑内存时,通过在适当对齐的区域中为计数器预留空间来满足这一要求。

尽管该示例仅发出 put,但 get、归约和拉取归约操作使用第 4.18.10 节中描述的相同提交、状态报告和等待序列。如上所述,它们的事务计数单位取决于具体操作。

内存排序。该示例以sys作用域的relaxed排序发出其互连网络(fabric)put 操作。一次互连网络(fabric)put 会跨越多个代理:它通过互连网络(fabric)代理访问远程数据(对于带计数的 put,还会访问计数器),通过异步代理读取其.shared::cta源,并通过通用代理更新用于指示完成的mbarrier。由于 mbarrier 通过通用代理更新,因此只需使用线程块作用域的操作在屏障处等待,就能观察到完成。然而,与cuda::ptx::cp_async_bulk操作不同,观察到互连网络(fabric)操作完成,并不会使互连网络(fabric)代理的访问相对于通用代理有序。在使用指针通过通用代理访问数据之前,必须使用cuda::ptx::fence_proxy_generic_fabric_alias将互连网络(fabric)代理与通用代理同步。

仅有端点 ID 并不能提供对远程目标的指针访问。导入端点不会将其绑定的资源映射到导入方的虚拟地址空间。因此,下面的示例使用由发出操作的线程所在设备拥有的单播端点。leId是端点 ID,而绑定在dataOffset处的内存也映射到了该设备的虚拟地址空间中。dataPtr指向同一位置。

发出 put 后,线程等待其完成。在使用dataPtr通过通用代理读取目标之前,线程必须先使用cuda::ptx::sem_release发出cuda::ptx::fence_proxy_generic_fabric_alias,然后再使用cuda::ptx::sem_acquire发出。仅等待完成,并不能使互连网络(fabric)代理的写入排序在通用代理的指针加载之前。waitForSuccessfulCompletion代表第 4.18.10 节中描述的mbarrier等待和状态检查。目标的初始值为 0,smemSrcData是一个 16 字节的共享内存源,其第一个uint32_t元素保存着 42。

由同一线程(线程 0)发出并加载

namespace ptx = cuda::ptx;

ptx::fabric_try_put(
    ptx::space_shared,
    ptx::sem_relaxed,
    ptx::scope_sys,
    leId, dataOffset,
    smemSrcData, 16, &mBar);
ptx::fabric_submit();
ptx::mbarrier_arrive_expect_tx(
    ptx::sem_acquire,
    ptx::scope_cta,
    ptx::space_shared,
    &mBar, 1);
waitForSuccessfulCompletion(&mBar);

// Order the subsequent generic-proxy load after the fabric put.
ptx::fence_proxy_generic_fabric_alias(ptx::sem_release);
ptx::fence_proxy_generic_fabric_alias(ptx::sem_acquire);

assert(*dataPtr == 42);

mbarrier 等待返回后,put 已完成,但互连网络(fabric)代理的写入相对于通用代理的加载仍然是无序的。释放栅栏和获取栅栏建立了这种顺序,因此指针加载会观察到值 42。

当一个线程使用互连网络(fabric)操作发布数据,而端点所有者设备上的另一个线程通过指针使用绑定目标中的数据时,也需要相同的释放/获取代理栅栏。在下面的消息传递示意示例中,发送方通过互连网络(fabric)代理使用互连网络(fabric)操作写入数据和信号标志。接收方使用通用代理通过指针轮询标志并读取数据。dataPtr和flagPtr对绑定到接收方所拥有端点的目标内存进行寻址。初始时,目标数据和标志均为 0。smemSrcData和smemSrcFlag是由四个uint32_t元素组成的 16 字节共享内存源。smemSrcData的第一个元素保存着 42,smemSrcFlag的第一个元素保存着 1。

发送方(线程 0)

接收方(线程 1)

namespace ptx = cuda::ptx;

ptx::fabric_try_put(
    ptx::space_shared,
    ptx::sem_relaxed,
    ptx::scope_sys,
    leId, dataOffset,
    smemSrcData, 16, &mBar);
ptx::fabric_submit();
ptx::mbarrier_arrive_expect_tx(
    ptx::sem_acquire,
    ptx::scope_cta,
    ptx::space_shared,
    &mBar, 1);
waitForSuccessfulCompletion(&mBar);

ptx::fence_proxy_generic_fabric_alias(
    ptx::sem_release);

ptx::fabric_try_put(
    ptx::space_shared,
    ptx::sem_relaxed,
    ptx::scope_sys,
    leId, flagOffset,
    smemSrcFlag, 16, &mBar);
ptx::fabric_submit();
ptx::mbarrier_arrive_expect_tx(
    ptx::sem_acquire,
    ptx::scope_cta,
    ptx::space_shared,
    &mBar, 1);
waitForSuccessfulCompletion(&mBar);
namespace ptx = cuda::ptx;

cuda::atomic_ref<uint32_t,
cuda::thread_scope_system> flag(*flagPtr);

while (flag.load(cuda::memory_order_relaxed) != 1);

// Order the data load after the observed flag update.
ptx::fence_proxy_generic_fabric_alias(
    ptx::sem_acquire);

assert(*dataPtr == 42);

数据 put 完成后,发送方先发出cuda::ptx::fence_proxy_generic_fabric_alias(cuda::ptx::sem_release),然后再发出标志 put。当接收方的标志加载观察到该 put 写入的值 1 时,接收方就知道发送方已发布数据。在使用数据之前,接收方必须发出cuda::ptx::fence_proxy_generic_fabric_alias(cuda::ptx::sem_acquire)。此获取栅栏防止后续对数据的指针加载被排序在所观察到的标志更新之前。发送方的释放栅栏与接收方的获取栅栏共同使发送方的数据 put 排序在接收方的数据加载之前。因此,在没有任何其他数据写入的情况下,该加载会观察到值 42。

4.18.9.1. 逐线程操作与线程束集体操作

每条互连网络(fabric)指令都定义了自身的执行粒度。fabric.try_get、fabric.try_get.tensor、fabric.try_put、fabric.try_put.tensor和fabric.try_red是逐线程的:每次执行都会为发出指令的线程启动一个独立操作。fabric.try_pullred是线程束集体操作:一个线程束的全部 32 个通道都必须一起执行该指令。PTX ISA 规定了每条指令所要求的参与方式。

执行粒度仅描述线程如何发出互连网络(fabric)操作。完成跟踪与该粒度无关;请参阅第 4.18.10.1 节。

4.18.10. 完成状态与错误报告

互连网络(fabric)操作通过其完成对象报告状态。此处描述的操作使用共享内存中的mbarrier来表示完成,因此同一个mbarrier既发出完成信号,也记录状态。通过使用cuda::ptx::layout_v1调用cuda::ptx::mbarrier_init来初始化此mbarrier。这种布局为该对象扩展了一个保存互连网络(fabric)操作状态的字段。默认的cuda::ptx::layout_v0只跟踪到达计数和事务计数,无法承载该状态,因此不能用于此报告机制:

    cuda::ptx::mbarrier_init(cuda::ptx::layout_v1, &putBar, 1);

cuda::ptx::fabric_try_put接受跟踪该操作的 mbarrier。随着操作的数据送达,互连网络(fabric)引擎会在屏障上完成相应的事务,并在那里记录操作的状态。因此,传输已送达的信号来自 put 操作本身,而不是发出操作的线程。

put 操作会送达其字节,但不会告知屏障应预期多少字节。对于此处所示的逐线程 put 操作,发出操作的线程会在单独的步骤中提供该计数。它首先使用fabric_submit提交操作,以便互连网络(fabric)引擎最终开始处理该操作,然后调用cuda::ptx::mbarrier_arrive_expect_tx记录一次到达并增加预期事务计数,即该操作将送达的字节数,以 16 字节为单位计数。每个互连网络(fabric)操作都必须在跟踪其完成情况的屏障阶段推进之前提交。提交操作后,发出操作的线程要么执行屏障阶段推进所需的屏障操作(例如cuda::ptx::mbarrier_arrive_expect_tx或cuda::ptx::mbarrier_expect_tx),要么与执行该操作的另一个线程同步。发出操作的线程不必亲自在屏障上等待。另一个线程可以执行该等待。一旦所有预期的到达均已发生,且事务计数降为零,屏障阶段就会推进,等待随之返回。程序必须在网格退出之前等待每个互连网络(fabric)操作完成。网格退出时仍有操作未完成属于未定义行为。

以下序列提交该 put 操作并等待其完成:

    cuda::ptx::fabric_submit();
    cuda::ptx::mbarrier_arrive_expect_tx(cuda::ptx::sem_relaxed, cuda::ptx::scope_cta,
                                         cuda::ptx::space_shared, &putBar,
                                         (FABRIC_CHUNK_SIZE / FABRIC_TX_GRANULARITY_BYTES));
    // Spin while the barrier's phase hasn't flipped yet. The report outputs are
    // only valid once the barrier completes, so inspect them after.
    bool isReportSeen = false;
    uint8_t reportValue = 0;
    bool complete = waitWithDebugTimeout(
        [&]() {
          return !cuda::ptx::mbarrier_try_wait_parity(cuda::ptx::mbarrier_phase_primary, cuda::ptx::sem_relaxed,
                                                      cuda::ptx::scope_cta, isReportSeen, reportValue, &putBar,
                                                      /*phaseParity=*/0);
        },
        "cftTryPutKernel: mbarrier try_wait parity");
    if (!complete) { __trap(); }
    if (isReportSeen) {
      reportFabricError(&reportValue, offset);
    }

cuda::ptx::mbarrier_try_wait_parity的phase_type::primary形式除了具有完成谓词之外,还具有报告谓词和报告值目标操作数。如果完成谓词为假,则报告谓词和报告值包含未指定的值。当try_wait观察到阶段完成时,即完成谓词为真时,报告谓词和报告值共同包含有关该屏障阶段所跟踪操作的附加信息。如果该屏障阶段跟踪的所有操作都是互连网络(fabric)操作,则为假的报告谓词表示所有互连网络(fabric)操作均已成功。否则,如果报告谓词为真,则报告值可能包含有关失败的更多信息,程序可以使用cudaFabricOpErrorStatusCount和cudaFabricOpErrorStatusGet检查这些信息。

inline __device__ void reportFabricError(uint8_t *reportValue, uint64_t offset) {
  const unsigned long long off = offset;
  unsigned int errCount = 0;
  if (cudaFabricOpErrorStatusCount(reportValue, cudaFabricOpStatusSourceMbarrierV1, &errCount) != cudaSuccess) {
    printf("cudaFabricOpErrorStatusCount failed at offset %llu\n", off);
    return;
  }
  for (unsigned int i = 0; i < errCount; ++i) {
    cudaFabricOpStatusInfo info = cudaFabricOpStatusInfoSuccess;
    if (cudaFabricOpErrorStatusGet(reportValue, cudaFabricOpStatusSourceMbarrierV1, i, &info) == cudaSuccess) {
      printf("fabric put error[%u/%u] at offset %llu: status=%d\n", i, errCount, off, (int)info);
    } else {
      printf("cudaFabricOpErrorStatusGet failed for error[%u/%u] at offset %llu\n", i, errCount, off);
    }
  }
}

解码后的状态指出操作失败的原因,使应用程序能够恢复,而不是导致进程发生故障。对于诸如数据包丢失或链路重置之类的瞬时互连网络(fabric)错误,可以通过重试、重新路由或应用程序专用的回退方式来处理。该示例打印解码后的错误,而通信库通常会针对这些错误采取行动,以保持向前进展。

4.18.10.1. 配置屏障到达计数与事务计数

上述完成序列记录了发出 put 操作的线程所贡献的到达计数和预期事务计数。哪个线程可以记录这些计数,取决于操作的执行粒度。对于逐线程操作,发出操作的线程必须为自己的操作记录这些计数。对于线程束集体执行的fabric.try_pullred,可以由参与线程束中的单个通道代表整个线程束记录这些计数,也可以由每个通道记录自己的到达。cuda::ptx::mbarrier_arrive_expect_tx是一个单独的融合操作:它在同一步骤中计入一次到达并增加预期事务计数,因此必须由一个线程执行这两项操作。它必须在使用fabric.submit提交操作之后、等待观察到完成之前运行。记录到达计数和预期事务计数的线程不必是在屏障上等待的线程。

多个独立操作可以共享一个屏障。当线程束集体执行的fabric.try_pullred使用一次到达时,由任意一个通道增加预期事务计数,增加值等于该线程束送达的字节数。如果改为由每个通道记录一次到达,则其他 31 个通道贡献的事务数为零,因此屏障的初始到达计数必须等于完整的线程束宽度。

当许多操作共享一个屏障时,为每个线程记录一次到达会增大该屏障的到达计数。layout::v1 mbarrier 使用一个 9 位字段保存其到达计数,因此该计数的上限为 511;超过此值会使屏障溢出。一个含有 512 个线程的线程块,若每个线程都在同一个共享屏障上到达,就已经会超过这一上限。在执行粒度允许时,优先采用每组一次到达的方式:同步线程束并选出单个通道,或者同步线程块并在整个线程块共享的屏障上记录一次到达。

4.18.11. 清理

清理需要在可能向某个端点发出操作的任何进程与已向该端点绑定资源的任何进程之间进行同步。对于单播端点,这意味着,只要导入方仍有可能向该端点发出操作,所有者就不得解除绑定或销毁该端点,也不得释放其后备资源。对于多播端点,每个参与进程都拥有其绑定的副本,因此,只要另一个进程仍有可能向该端点发出操作,任何参与者就都不得解除绑定或释放自己的副本。重要的是顺序,而不是强制执行该顺序的机制:只要进程间同步能在移除绑定之前建立这一顺序,就足够了。

  // ---- Clean up endpoints and free the backing allocation. ----
  // Once every rank's stream has synchronized, all fabric puts
  // have completed and no rank will issue another operation. Making that global
  // with a barrier ensures no peer still targets an endpoint we clean up below.
  MPI_Barrier(MPI_COMM_WORLD);

  // Destroy the imported endpoint (the peer target our puts addressed). An import
  // is only a local reference to the peer's endpoint id and maps no peer memory,
  // so once our puts have drained this just drops that reference.
  cuLogicalEndpointDestroy(leId + sendRank);

  // Unbind and destroy our owned endpoint, then release the reserved id range.
  cuLogicalEndpointUnbind(leId + myRank, cuDevice, 0, exportSize);
  cuLogicalEndpointDestroy(leId + myRank);
  cuLogicalEndpointIdRelease(leId, numRanks);

  // Free the backing allocation in VMM cleanup order: unmap the range, release
  // the physical handle, then free the reserved virtual address range.
  cuMemUnmap(exportPtr, exportSize);
  cuMemRelease(exportHandle);
  cuMemAddressFree(exportPtr, exportSize);

等待互连网络(fabric)操作和所有发出方停止。在释放跟踪互连网络(fabric)操作的完成对象之前,必须在该对象上等待。对于网格发出的互连网络(fabric)操作,网格中的一个线程必须在网格退出之前等待完成。未执行此等待就退出属于未定义行为。在该示例中,每个核函数都在返回之前等待完成,而流同步则确认这些核函数及其等待均已完成。

另一个进程发出的、以绑定到同一逻辑端点的资源为目标的互连网络(fabric)操作,可能仍在进行中。因此,在移除单播绑定之前,所有者必须等到每个导入方都已停止发出操作。在移除任何多播绑定之前,每个拥有已绑定副本的参与者都必须知道,所有进程均已停止发出操作。该示例在所有 rank 同步各自的 CUDA 流之后,使用一个屏障实现这一点。如果能够保持所要求的顺序,应用程序可以采用范围更小的同步方案。

销毁导入的端点 ID。每个导入进程通过调用cuLogicalEndpointDestroy销毁与其导入端点关联的逻辑端点 ID。这只会移除该本地别名,不会解除所有者上的任何绑定,因此销毁操作本身不需要跨进程协调。

销毁所有者的端点 ID。销毁所有者的逻辑端点 ID 也会解除此进程通过该 ID 绑定的任何内存的绑定,因此先调用cuLogicalEndpointUnbind是可选的,但建议这样做。一旦所有别名,即所有者的 ID 和任何导入的别名,都已销毁,端点的资源就会被释放。

单播端点和多播端点适用相同的处理流程。由于多播成员关系是永久性的,因此更改多播组需要销毁端点并创建一个新端点。只要端点仍然存在,设备就无法离开该组。

重用或释放端点 ID。使用cuLogicalEndpointDestroy销毁端点关联后,其 ID 仍处于保留状态,但不再有关联,可以立即重用。要将 ID 归还系统,请对之前使用cuLogicalEndpointIdReserve保留的范围调用cuLogicalEndpointIdRelease。

释放后备内存分配。端点并不拥有其后备内存,因此该内存分配需要单独释放。应使用与内存分配方式相对应的释放机制。对于 VMM 分配,通常的顺序是先使用cuMemUnmap移除映射,再使用cuMemRelease释放物理句柄,最后使用cuMemAddressFree归还保留的虚拟地址范围。程序不得释放仍绑定到端点的内存分配。

4.19. 扩展 GPU 内存

扩展 GPU 内存(EGM)功能利用高带宽 NVLink-C2C,使 GPU 能够在单节点和多节点系统中高效访问全部系统内存。EGM 适用于集成 CPU-GPU 的 NVIDIA 系统,允许分配可由该配置中任意 GPU 线程访问的物理内存。EGM 确保所有 GPU 都能以 GPU-GPU NVLink 或 NVLink-C2C 的速度访问其资源。

EGM
图内文字中文对照
Local CPU ← GPU
本地 CPU ← GPU
GPU → Peer GPU
GPU → 对等 GPU
GPU → Peer CPU
GPU → 对等 CPU

在此配置中,内存访问通过本地高带宽 NVLink-C2C 进行。远程内存访问使用 GPU NVLink,在某些情况下还会使用 NVLink-C2C。借助 EGM,GPU 线程能够通过 NVSwitch 互连网络(fabric)访问所有可用的内存资源,包括连接到 CPU 的内存和 HBM3。

4.19.1. 预备知识

在深入介绍用于 EGM 功能的 API 更改之前,我们将先介绍当前支持的拓扑、标识符分配、虚拟内存管理的先决条件,以及用于 EGM 的 CUDA 类型。

4.19.1.1. EGM 平台:系统拓扑

目前,可以在多种平台上启用 EGM:(1)单节点、单 GPU:由基于 Arm 的 CPU、连接到 CPU 的内存和一个 GPU 组成。CPU 与 GPU 之间具有高带宽 C2C(芯片到芯片)互连。(2)单节点、多 GPU:由基于 ARM 的多个 CPU 和通过基于 NVLink 的网络连接的多个 GPU 组成,每个 CPU 都带有相连的内存。(3)多节点、多 GPU:由两个或更多通过基于 NVLink 的网络连接的单节点系统组成,每个系统均如上面的(1)或(2)所述。

注意

使用cgroups限制可用设备会阻断通过 EGM 的路由,并导致性能问题。请改用CUDA_VISIBLE_DEVICES。

4.19.1.2. 插槽标识符:它们是什么?如何访问它们?

NUMA(非统一内存访问)是一种用于多处理器计算机系统的内存架构,其中内存被划分为多个节点。每个节点都有自己的处理器和内存。在这样的系统中,NUMA 将系统划分为节点,并为每个节点分配一个唯一的标识符(numaID)。

EGM 使用操作系统分配的 NUMA 节点标识符。注意,此标识符不同于设备的序号,它与最近的主机节点相关联。除了现有方法外,用户还可以使用CU_DEVICE_ATTRIBUTE_HOST_NUMA_ID属性类型调用cuDeviceGetAttribute,获取主机节点的标识符(numaID),如下所示:

int numaId;
cuDeviceGetAttribute(&numaId, CU_DEVICE_ATTRIBUTE_HOST_NUMA_ID, deviceOrdinal);

4.19.1.3. 分配器与 EGM 支持

将系统内存映射为 EGM 不会导致任何性能问题。事实上,访问映射为 EGM 的远程插槽的系统内存会更快。这是因为 EGM 保证流量通过 NVLink 路由。目前,支持使用适当的位置类型和 NUMA 标识符的cuMemCreate和cudaMemPoolCreate分配器。

4.19.1.4. 对现有 API 的内存管理扩展

目前,可以使用虚拟内存(cuMemCreate)或流有序内存(cudaMemPoolCreate)分配器映射 EGM 内存。用户负责分配物理内存,并在所有插槽上将其映射到虚拟内存地址空间。

注意

多节点、多 GPU 平台需要进程间通信。因此,我们建议读者参阅第 4.16 章。

注意

我们建议读者阅读《CUDA 编程指南》的第 4.17 章和第 4.3 章,以便更好地理解。

API 中新增了 CUDA 属性类型,使这些方法能够通过类似 NUMA 的节点标识符了解分配位置:

CUDA 类型

配合使用

CU_MEM_LOCATION_TYPE_HOST_NUMA

用于cuMemCreate的CUmemAllocationProp

cudaMemLocationTypeHostNuma

用于cudaMemPoolCreate的cudaMemPoolProps

注意

请参阅CUDA 驱动程序 API和CUDA 运行时数据类型,了解有关 NUMA 专用 CUDA 类型的更多信息。

4.19.2. 使用 EGM 接口

4.19.2.1. 单节点、单 GPU

任何现有的 CUDA 主机分配器以及系统分配的内存都可以用来受益于高带宽 C2C。对用户而言,本地访问与目前的主机内存分配相同。

注意

有关内存分配器和页大小的更多信息,请参阅调优指南。

4.19.2.2. 单节点、多 GPU

在多 GPU 系统中,用户必须提供用于确定放置位置的主机信息。如前所述,表达这些信息的一种自然方式是使用 NUMA 节点 ID,EGM 也采用这种方式。因此,用户应该能够使用cuDeviceGetAttribute函数获知最近的 NUMA 节点 ID。(参阅插槽标识符:它们是什么?如何访问它们?。)然后,用户可以使用 VMM(虚拟内存管理)API 或 CUDA 内存池来分配和管理 EGM 内存。

4.19.2.2.1. 使用 VMM API

使用虚拟内存管理 API 分配内存的第一步,是创建一个物理内存块,作为该分配的后备存储。更多详细信息,请参阅《CUDA 编程指南》的虚拟内存管理一节。在 EGM 分配中,用户必须显式提供CU_MEM_LOCATION_TYPE_HOST_NUMA作为位置类型,并提供numaID作为位置标识符。此外,在 EGM 中,分配必须按平台适当的粒度对齐。以下代码片段展示了使用cuMemCreate分配物理内存:

CUmemAllocationProp prop{};
prop.type = CU_MEM_ALLOCATION_TYPE_PINNED;
prop.location.type = CU_MEM_LOCATION_TYPE_HOST_NUMA;
prop.location.id = numaId;
size_t granularity = 0;
cuMemGetAllocationGranularity(&granularity, &prop, MEM_ALLOC_GRANULARITY_MINIMUM);
size_t padded_size = ROUND_UP(size, granularity);
CUmemGenericAllocationHandle allocHandle;
cuMemCreate(&allocHandle, padded_size, &prop, 0);

分配物理内存后,我们必须保留一个地址空间,并将其映射到一个指针。这些过程没有针对 EGM 的特定更改:

CUdeviceptr dptr;
cuMemAddressReserve(&dptr, padded_size, 0, 0, 0);
cuMemMap(dptr, padded_size, 0, allocHandle, 0);

最后,用户必须显式保护已映射的虚拟地址范围。否则,访问已映射的空间将导致崩溃。与内存分配类似,用户必须提供CU_MEM_LOCATION_TYPE_HOST_NUMA作为位置类型,并提供numaId作为位置标识符。以下代码片段为主机节点和 GPU 创建访问描述符,以便授予二者对已映射内存的读写访问权限:

CUmemAccessDesc accessDesc[2]{{}};
accessDesc[0].location.type = CU_MEM_LOCATION_TYPE_HOST_NUMA;
accessDesc[0].location.id = numaId;
accessDesc[0].flags = CU_MEM_ACCESS_FLAGS_PROT_READWRITE;
accessDesc[1].location.type = CU_MEM_LOCATION_TYPE_DEVICE;
accessDesc[1].location.id = currentDev;
accessDesc[1].flags = CU_MEM_ACCESS_FLAGS_PROT_READWRITE;
cuMemSetAccess(dptr, size, accessDesc, 2);

4.19.2.2.2. 使用 CUDA 内存池

要定义 EGM,用户可以在一个节点上创建内存池,并向对等方授予访问权限。在这种情况下,用户必须显式定义cudaMemLocationTypeHostNuma为位置类型,并定义numaId为位置标识符。以下代码片段展示了使用cudaMemPoolCreate创建内存池:

cudaSetDevice(homeDevice);
cudaMemPoolProps props{};
props.allocType = cudaMemAllocationTypePinned;
props.location.type = cudaMemLocationTypeHostNuma;
props.location.id = numaId;
cudaMemPoolCreate(&memPool, &props);

此外,对于直接连接的对等访问,也可以使用现有的对等访问 APIcudaMemPoolSetAccess。以下代码片段展示了一个accessingDevice的示例:

cudaMemAccessDesc desc{};
desc.flags = cudaMemAccessFlagsProtReadWrite;
desc.location.type = cudaMemLocationTypeDevice;
desc.location.id = accessingDevice;
cudaMemPoolSetAccess(memPool, &desc, 1);

创建内存池并授予访问权限后,用户可以将所创建的内存池设置给residentDevice,然后开始使用cudaMallocAsync分配内存:

cudaDeviceSetMemPool(residentDevice, memPool);
cudaMallocAsync(&ptr, size, memPool, stream);

注意

EGM 使用 2MB 的页进行映射。因此,用户在访问非常大的内存分配时,可能会遇到更多 TLB 未命中。

4.19.2.3. 多节点、多 GPU

除内存分配之外,远程对等访问没有针对 EGM 的特定修改,并且遵循 CUDA 进程间(IPC)协议。有关 IPC 的更多详细信息,请参阅CUDA 编程指南。

用户应使用cuMemCreate分配内存,并且同样必须显式提供CU_MEM_LOCATION_TYPE_HOST_NUMA作为位置类型,提供numaID作为位置标识符。此外,应将CU_MEM_HANDLE_TYPE_FABRIC定义为所请求的句柄类型。以下代码片段展示了在节点 A 上分配物理内存的过程:

CUmemAllocationProp prop{};
prop.type = CU_MEM_ALLOCATION_TYPE_PINNED;
prop.requestedHandleTypes = CU_MEM_HANDLE_TYPE_FABRIC;
prop.location.type = CU_MEM_LOCATION_TYPE_HOST_NUMA;
prop.location.id = numaId;
size_t granularity = 0;
cuMemGetAllocationGranularity(&granularity, &prop,
                              MEM_ALLOC_GRANULARITY_MINIMUM);
size_t padded_size = ROUND_UP(size, granularity);
size_t page_size = ...;
assert(padded_size % page_size == 0);
CUmemGenericAllocationHandle allocHandle;
cuMemCreate(&allocHandle, padded_size, &prop, 0);

使用cuMemCreate创建分配句柄后,用户可以调用cuMemExportToShareableHandle将该句柄导出到另一个节点,即节点 B:

cuMemExportToShareableHandle(&fabricHandle, allocHandle,
                             CU_MEM_HANDLE_TYPE_FABRIC, 0);
// At this point, fabricHandle should be sent to Node B via TCP/IP.

在节点 B 上,可以使用cuMemImportFromShareableHandle导入该句柄,并将其视为任何其他互连网络(fabric)句柄

// At this point, fabricHandle should be received from Node A via TCP/IP.
CUmemGenericAllocationHandle allocHandle;
cuMemImportFromShareableHandle(&allocHandle, &fabricHandle,
                               CU_MEM_HANDLE_TYPE_FABRIC);

在节点 B 上导入句柄后,用户就可以保留一个地址空间,并以常规方式在本地进行映射:

size_t granularity = 0;
cuMemGetAllocationGranularity(&granularity, &prop,
                              MEM_ALLOC_GRANULARITY_MINIMUM);
size_t padded_size = ROUND_UP(size, granularity);
size_t page_size = ...;
assert(padded_size % page_size == 0);
CUdeviceptr dptr;
cuMemAddressReserve(&dptr, padded_size, 0, 0, 0);
cuMemMap(dptr, padded_size, 0, allocHandle, 0);

最后一步,用户应为节点 B 上的每个本地 GPU 授予适当的访问权限。以下代码片段示例为八个本地 GPU 授予读写访问权限:

// Give all 8 local  GPUS access to exported EGM memory located on Node A.                                                               |
CUmemAccessDesc accessDesc[8];
for (int i = 0; i < 8; i++) {
   accessDesc[i].location.type = CU_MEM_LOCATION_TYPE_DEVICE;
   accessDesc[i].location.id = i;
   accessDesc[i].flags = CU_MEM_ACCESS_FLAGS_PROT_READWRITE;
}
cuMemSetAccess(dptr, size, accessDesc, 8);

注意

此处展示的跨节点共享是以地址为中心的:导入的内存分配被映射到本地虚拟地址空间中,并通过普通的加载和存储进行访问。对于通过 NVLink 互连网络(fabric)连接的 GPU,计算互连传输提供了一种以资源为中心的替代方案,它针对一个命名的逻辑端点而非映射的地址,通过异步互连网络操作移动数据。它会报告每个操作的显式完成状态,因此适用于需要考虑互连网络错误恢复的大型互连网络。

4.20. CUDA 动态并行

4.20.1. 简介

4.20.1.1. 概述

CUDA 动态并行(通常缩写为 CDP)是 CUDA 编程模型的一项特性,它使 GPU 上运行的代码能够创建新的 GPU 工作。也就是说,已经在 GPU 上运行的设备代码可以通过额外的核函数启动向 GPU 添加新工作。该特性可以减少在主机和设备之间传递执行控制权和数据的需要,因为在设备上执行的线程可以在运行时做出启动配置决策。

核函数可以在运行时生成依赖于数据的并行工作。在 CUDA 中加入 CDP 之前,某些算法和编程模式需要进行修改,以消除递归、不规则循环结构或其他不适合扁平的单层并行的构造。使用 CUDA 动态并行可以更自然地表达这些程序结构。

注意

本节介绍 CUDA 动态并行的更新版本,有时称为 CDP2,它是 CUDA 12.0 及更高版本中的默认版本。CDP2 是计算能力(CC)9.0 及更高的设备上唯一可用的 CUDA 动态并行版本。对于 CC 低于 9.0 的设备,开发者仍可通过编译器参数-DCUDA_FORCE_CDP1_IF_SUPPORTED选择使用旧版 CUDA 动态并行 CDP1。CDP1 文档可在旧版 CUDA 编程指南中找到。计划在未来的 CUDA 版本中移除 CDP1。

4.20.2. 执行环境

CUDA 中的动态并行允许 GPU 线程配置、启动新网格,并对其进行隐式同步。网格是核函数启动的一个实例,其中包括线程块的具体形状以及由线程块组成的网格的具体形状。在以下各节中,需要注意核函数本身与该核函数的特定调用(即网格)之间的区别。

4.20.2.1. 父网格与子网格

配置并启动新网格的设备线程属于父网格。由该调用创建的新网格称为子网格。

子网格的调用和完成具有正确的嵌套关系,这意味着,只有当父网格的线程所创建的所有子网格都完成后,才认为父网格完成,并且运行时保证父网格与子网格之间存在隐式同步。

父子启动嵌套

图 57 父子启动嵌套

图内文字中文对照
Time
时间
CPU Thread
CPU 线程
Grid A Launch
网格 A 启动
Grid A Complete
网格 A 完成
Grid A Threads
网格 A 的线程
Grid A - Parent
网格 A(父网格)
Grid B Launch
网格 B 启动
Grid B Complete
网格 B 完成
Grid B - Child
网格 B(子网格)
Grid B Threads
网格 B 的线程

4.20.2.2. CUDA 原语的作用域

CUDA 动态并行依赖于CUDA 设备运行时,它支持调用一组有限的 API,这些 API 在语法上与 CUDA 运行时 API 类似,但可在设备代码中使用。设备运行时 API 的行为与其对应的主机端 API 类似,但存在一些差异。这些差异列于API 参考一节中的表格中。

在主机和设备上,CUDA 运行时都提供了用于启动核函数以及通过流和事件跟踪启动之间依赖关系的 API。在设备上,已启动的核函数和 CUDA 对象对发起调用的网格中的所有线程都可见。例如,这意味着一个流可以由一个线程创建,并由同一网格中的任何其他线程使用。但是,通过设备 API 调用创建的流和事件等 CUDA 对象,仅在创建它们的网格内有效。

4.20.2.3. 流和事件

CUDA 的流和事件允许控制核函数启动之间的依赖关系:启动到同一流中的核函数按顺序执行,而事件可用于在流之间创建依赖关系。在设备上创建的流和事件具有完全相同的用途。

在网格内创建的流和事件存在于该网格的作用域内,但在创建它们的网格之外使用时,其行为未定义。如上所述,网格退出时会对其启动的所有工作进行隐式同步;这也包括启动到流中的工作,所有依赖关系都会得到适当处理。对在网格作用域之外被修改的流执行操作,其行为未定义。

在主机上创建的流和事件在任何核函数中使用时,其行为未定义,正如父网格创建的流和事件在子网格中使用时,其行为未定义一样。

4.20.2.4. 顺序与并发

设备运行时发起的核函数启动的顺序遵循 CUDA 流的顺序语义。在网格内,启动到同一流中的所有核函数(即发即弃流除外)都按顺序执行。当同一网格中的多个线程向同一流发起启动时,流内的顺序取决于网格内的线程调度,而线程调度可以通过__syncthreads()等同步原语来控制。

请注意,虽然命名流由网格内的所有线程共享,但隐式NULL流仅由一个线程块内的所有线程共享。如果一个线程块中的多个线程向隐式流发起启动,则这些启动将按顺序执行。如果不同线程块中的线程向隐式流发起启动,则这些启动可能并发执行。如果希望一个线程块内多个线程发起的启动能够并发执行,应使用显式命名流。

设备运行时并未在 CUDA 执行模型中引入新的并发保证。也就是说,不保证设备上任意数量的不同线程块之间会并发执行。

父网格及其子网格之间同样没有并发保证。当父网格启动子网格时,只要流依赖关系得到满足且硬件资源可用,子网格就可能开始执行,但在父网格到达隐式同步点之前,不保证子网格会开始执行。

并发性可能随设备配置、应用程序工作负载以及运行时调度而变化。因此,依赖不同线程块之间的任何并发性都是不安全的。

4.20.3. 内存相干性与一致性

父网格和子网格共享相同的全局内存和常量内存存储,但各自拥有不同的局部内存和共享内存。下表列出了哪些内存空间允许父网格和子网格通过相同的指针访问。子网格绝不能访问父网格的局部内存或共享内存,父网格也不能访问子网格的局部内存或共享内存。

表 26 动态并行:父网格与子网格之间的内存作用域可访问性

内存空间

父网格/子网格是否使用相同的指针?

全局内存

是

映射内存

是

局部内存

否

共享内存

否

纹理内存

是(只读)

4.20.3.1. 全局内存

父网格和子网格对全局内存的访问具有相干性,而子网格与父网格之间只有弱一致性保证。在子网格的执行过程中,只有一个时间点,其内存视图与父线程完全一致:即父线程调用子网格的时刻。

父线程在调用子网格之前执行的所有全局内存操作对子网格都是可见的。随着cudaDeviceSynchronize()被移除,已无法从父网格访问子网格中线程所做的修改。在父网格退出之前,访问子网格中线程所做修改的唯一方法是通过启动到cudaStreamTailLaunch流中的核函数。

在以下示例中,执行child_launch的子网格仅保证能够看到在子网格启动之前对data所做的修改。由于启动由父网格的线程 0 执行,子网格的内存视图将与父网格的线程 0 所见的内存一致。由于第一次__syncthreads()调用,子网格将看到data[0]=0、data[1]=1、…、data[255]=255(如果没有__syncthreads()调用,则仅保证子网格能够看到data[0]=0)。仅保证子网格在隐式同步时返回。这意味着,永远不保证子网格中线程所做的修改可供父网格访问。为了访问child_launch所做的修改,会将一个tail_launch核函数启动到cudaStreamTailLaunch流中。

__global__ void tail_launch(int *data) {
   data[threadIdx.x] = data[threadIdx.x]+1;
}

__global__ void child_launch(int *data) {
   data[threadIdx.x] = data[threadIdx.x]+1;
}

__global__ void parent_launch(int *data) {
   data[threadIdx.x] = threadIdx.x;

   __syncthreads();

   if (threadIdx.x == 0) {
       child_launch<<< 1, 256 >>>(data);
       tail_launch<<< 1, 256, 0, cudaStreamTailLaunch >>>(data);
   }
}

void host_launch(int *data) {
    parent_launch<<< 1, 256 >>>(data);
}

4.20.3.2. 映射内存

映射的系统内存具有与全局内存完全相同的相干性和一致性保证,并遵循上文详述的语义。核函数不能分配或释放映射内存,但可以使用从主机程序传入的指向映射内存的指针。

4.20.3.3. 共享内存与局部内存

共享内存和局部内存分别是线程块和线程的私有内存,在父网格和子网格之间不可见,也不具有相干性。当这两类位置之一中的对象在其所属作用域之外被引用时,其行为未定义,并且可能导致错误。

如果 NVIDIA 编译器能够检测到指向局部内存或共享内存的指针被作为参数传递给核函数启动,它会尝试发出警告。在运行时,程序员可以使用__isGlobal()内建函数来确定一个指针是否引用全局内存,从而确定它是否可以安全地传递给子网格启动。

为了保持流语义,对cudaMemcpy*Async()或cudaMemset*Async()的调用可能会在设备上调用新的子核函数。因此,向这些 API 传递共享内存或局部内存指针是非法的,并且会返回错误。

4.20.3.4. 局部内存

局部内存是执行中线程的私有存储,在该线程之外不可见。启动子核函数时,将指向局部内存的指针作为启动参数传递是非法的。从子网格中解引用这样的局部内存指针,其结果未定义。

例如,以下代码是非法的,如果child_launch访问x_array,则行为未定义:

int x_array[10];       // Creates x_array in parent's local memory
child_launch<<< 1, 1 >>>(x_array);

程序员有时很难知道编译器何时将变量放入局部内存。一般而言,传递给子核函数的所有存储都应从全局内存堆中显式分配,方法是使用cudaMalloc()、new(),或者在全局作用域中声明__device__存储。例如:

// Correct - "value" is global storage
__device__ int value;
__device__ void x() {
    value = 5;
    child<<< 1, 1 >>>(&value);
}
// Invalid - "value" is local storage
__device__ void y() {
    int value = 5;
    child<<< 1, 1 >>>(&value);
}

4.20.3.4.1. 纹理内存

对纹理所映射到的全局内存区域的写入,相对于纹理访问而言不具有相干性。在调用子网格时以及子网格完成时,会强制保证纹理内存的相干性。这意味着,子核函数启动之前对内存的写入会反映在子核函数的纹理内存访问中。与上文的全局内存类似,永远不保证子网格对内存的写入会反映在父网格的纹理内存访问中。在父网格退出之前,访问子网格中线程所做修改的唯一方法是通过启动到cudaStreamTailLaunch流中的核函数。父网格和子网格的并发访问可能导致数据不一致。

4.20.4. 编程接口

4.20.4.1. 基础

以下示例展示了一个使用动态并行的简单你好,世界程序:

#include <stdio.h>

__global__ void childKernel()
{
    printf("Hello ");
}

__global__ void tailKernel()
{
    printf("World!\n");
}

__global__ void parentKernel()
{
    // launch child
    childKernel<<<1,1>>>();
    if (cudaSuccess != cudaGetLastError()) {
        return;
    }

    // launch tail into cudaStreamTailLaunch stream
    // implicitly synchronizes: waits for child to complete
    tailKernel<<<1,1,0,cudaStreamTailLaunch>>>();

}

int main(int argc, char *argv[])
{
    // launch parent
    parentKernel<<<1,1>>>();
    if (cudaSuccess != cudaGetLastError()) {
        return 1;
    }

    // wait for parent to complete
    if (cudaSuccess != cudaDeviceSynchronize()) {
        return 2;
    }

    return 0;
}

可以通过命令行一步构建此程序,如下所示:

$ nvcc -arch=sm_75 -rdc=true hello_world.cu -o hello -lcudadevrt

4.20.4.2. CDP 的 C++ 语言接口

使用 CUDA C++ 实现动态并行的 CUDA 核函数所能使用的语言接口和 API 称为CUDA 设备运行时。

为便于复用可能运行于主机环境或设备环境中的例程的代码,尽可能保留了 CUDA 运行时 API 的语法和语义。

与 CUDA C++ 中的所有代码一样,此处概述的 API 和代码都是逐线程代码。这使每个线程能够独立地动态决定接下来执行哪个核函数或操作。执行所提供的任何设备运行时 API 时,都不要求线程块内的线程之间进行同步,因此可以在具有任意分歧的核函数代码中调用设备运行时 API 函数,而不会发生死锁。

4.20.4.2.1. 设备端核函数启动

可以使用标准 CUDA <<< >>> 语法从设备端启动核函数:

kernel_name<<< Dg, Db, Ns, S >>>([kernel arguments]);
  • Dg的类型为dim3,用于指定网格的维度和大小

  • Db的类型为dim3,用于指定每个线程块的维度和大小

  • Ns的类型为size_t,用于指定在此次调用中,除静态分配的内存之外,为每个线程块动态分配的共享内存字节数。Ns是可选参数,默认值为 0。

  • S的类型为cudaStream_t,用于指定与此次调用关联的流。该流必须在发起此次调用的同一网格中分配。S是可选参数,默认为 NULL 流。

4.20.4.2.1.1. 启动是异步的

与主机端启动相同,所有设备端核函数启动相对于发起启动的线程都是异步的。也就是说,<<<>>>启动命令会立即返回,发起启动的线程会继续执行,直到到达隐式启动同步点,例如启动到cudaStreamTailLaunch流中的核函数处(尾部启动流)。子网格可能在启动后的任何时刻开始执行,但在发起启动的线程到达隐式启动同步点之前,不能保证子网格会开始执行。

与主机端启动类似,启动到不同流中的工作可能并发运行,但不保证实际发生并发。CUDA 编程模型不支持依赖子核函数之间并发执行的程序,此类程序将具有未定义的行为。

4.20.4.2.1.2. 启动环境配置

所有全局设备配置设置(例如,cudaDeviceGetCacheConfig()返回的共享内存和 L1 缓存大小,以及cudaDeviceGetLimit()返回的设备限制)都将从父级继承。同样,栈大小等设备限制也将保持配置的值。

对于由主机启动的核函数,从主机设置的逐核函数配置将优先于全局设置。从设备启动核函数时,也将使用这些配置。无法从设备重新配置核函数的环境。

4.20.4.2.2. 事件

仅支持 CUDA 事件的流间同步功能。这意味着支持cudaStreamWaitEvent(),但不支持cudaEventSynchronize()、cudaEventElapsedTime()和cudaEventQuery()。由于不支持cudaEventElapsedTime(),必须通过cudaEventCreateWithFlags()创建 cudaEvents,并传入cudaEventDisableTiming标志。

与流一样,事件对象可以在创建它们的网格内的所有线程之间共享,但它们仅限于该网格,不能传递给其他核函数。不保证事件句柄在不同网格之间是唯一的,因此,在未创建某个事件句柄的网格内使用该句柄将导致未定义的行为。

4.20.4.2.3. 同步

如果调用线程打算与其他线程调用的子网格同步,则程序应自行执行充分的线程间同步,例如通过 CUDA 事件。

由于无法从父线程显式同步子工作,因此无法保证子网格中发生的更改对父网格内的线程可见。

4.20.4.2.4. 设备管理

核函数只能控制其正在运行的设备。这意味着设备运行时不支持cudaSetDevice()等设备 API。从 GPU 看到的活动设备(由cudaGetDevice()返回)的设备编号与从主机系统看到的相同。cudaDeviceGetAttribute()调用可以请求有关其他设备的信息,因为此 API 允许将设备 ID 指定为调用的参数。注意,设备运行时不提供可获取所有属性的cudaGetDeviceProperties()API,必须逐个查询属性。

4.20.5. 编程指南

4.20.5.1. 性能

4.20.5.1.1. 启用动态并行的核函数开销

控制动态启动时处于活动状态的系统软件可能会给当时正在运行的任何核函数带来开销,无论该核函数自身是否发起核函数启动。此开销来自设备运行时的执行跟踪和管理软件,并且可能导致性能下降。一般而言,链接设备运行时库的应用程序会产生此开销。

4.20.5.2. 实现的约束与限制

动态并行保证本文档中描述的所有语义,但是,某些硬件和软件资源取决于实现,并会限制使用设备运行时的程序的规模、性能和其他特性。

4.20.5.2.1. 运行时

4.20.5.2.1.1. 内存占用

设备运行时系统软件会为各种管理用途预留内存,尤其会为跟踪待处理的网格启动预留内存。可以通过配置控制来减小这部分预留内存的大小,代价是受到某些启动限制。有关详细信息,请参阅下文的配置选项。

4.20.5.2.1.2. 待处理的核函数启动

启动核函数时,会跟踪所有关联的配置和参数数据,直到核函数完成。这些数据存储在系统管理的启动池中。

可以通过从主机调用cudaDeviceSetLimit()并指定cudaLimitDevRuntimePendingLaunchCount,来配置固定大小启动池的大小。

4.20.5.3. 兼容性与互操作性

默认使用 CDP2。可以使用-DCUDA_FORCE_CDP1_IF_SUPPORTED编译函数,以便在计算能力低于 9.0 的设备上选择不使用 CDP2。

使用 CUDA 12.0 及更新版本编译的函数(默认)

使用 CUDA 12.0 之前的版本编译的函数,或使用 CUDA 12.0 及更新版本并指定-DCUDA_FORCE_CDP1_IF_SUPPORTED编译的函数

编译

如果设备代码引用cudaDeviceSynchronize,则会出现编译错误。

如果代码引用cudaStreamTailLaunch或cudaStreamFireAndForget,则会出现编译错误。如果设备代码引用cudaDeviceSynchronize,并且代码是针对 sm_90 或更新架构编译的,则会出现编译错误。

计算能力 < 9.0

使用新接口。

使用旧接口。

计算能力为 9.0 及更高

使用新接口。

使用新接口。如果函数在设备代码中引用cudaDeviceSynchronize,则函数加载返回cudaErrorSymbolNotFound(如果代码是针对计算能力低于 9.0 的设备编译的,但使用 JIT 在计算能力为 9.0 或更高的设备上运行,就可能发生这种情况)。

使用 CDP1 和 CDP2 的函数可以在同一上下文中同时加载和运行。CDP1 函数能够使用 CDP1 特有的功能(例如cudaDeviceSynchronize),而 CDP2 函数能够使用 CDP2 特有的功能(例如尾部启动和即发即弃启动)。

使用 CDP1 的函数不能启动使用 CDP2 的函数,反之亦然。如果一个将使用 CDP1 的函数的调用图中包含将使用 CDP2 的函数,或者情况相反,则函数加载期间会产生cudaErrorCdpVersionMismatch。

本文档不包含旧版 CDP1 的行为。有关 CDP1 的信息,请参阅旧版 CUDA 编程指南

4.20.6. 从 PTX 进行设备端启动

前面几节讨论了使用CUDA 设备运行时实现动态并行。也可以从 PTX 执行动态并行。对于以并行线程执行(PTX)为目标并计划在其语言中支持动态并行的编程语言和编译器实现者,本节提供了在 PTX 层面支持核函数启动的相关底层细节。

4.20.6.1. 核函数启动 API

可以使用以下两个可从 PTX 访问的 API 实现设备端核函数启动:cudaLaunchDevice()和cudaGetParameterBuffer()。cudaLaunchDevice()使用参数缓冲区启动指定的核函数,该缓冲区通过调用cudaGetParameterBuffer()获得,并填入传递给所启动核函数的参数。如果所启动的核函数不接受任何参数,则参数缓冲区可以为 NULL,即无需调用cudaGetParameterBuffer()。

4.20.6.1.1. cudaLaunchDevice

在 PTX 层面,使用cudaLaunchDevice()之前,需要以下面所示的两种形式之一声明它。

// PTX-level Declaration of cudaLaunchDevice() when .address_size is 64
.extern .func(.param .b32 func_retval0) cudaLaunchDevice
(
  .param .b64 func,
  .param .b64 parameterBuffer,
  .param .align 4 .b8 gridDimension[12],
  .param .align 4 .b8 blockDimension[12],
  .param .b32 sharedMemSize,
  .param .b64 stream
)
;

下面的 CUDA 层面声明映射到前述 PTX 层面声明之一,可以在系统头文件cuda_device_runtime_api.h中找到它。该函数定义在cudadevrt系统库中,程序必须链接此库才能使用设备端核函数启动功能。

// CUDA-level declaration of cudaLaunchDevice()
extern "C" __device__
cudaError_t cudaLaunchDevice(void *func, void *parameterBuffer,
                             dim3 gridDimension, dim3 blockDimension,
                             unsigned int sharedMemSize,
                             cudaStream_t stream);

第一个参数是指向要启动的核函数的指针,第二个参数是参数缓冲区,其中保存传递给所启动核函数的实际参数。参数缓冲区的布局在下文的参数缓冲区布局中进行说明。其他参数指定启动配置,即网格维度、线程块维度、共享内存大小以及与启动关联的流(有关启动配置的详细说明,请参阅核函数配置。

4.20.6.1.2. cudaGetParameterBuffer

使用cudaGetParameterBuffer()之前,需要在 PTX 层面声明它。根据地址大小,PTX 层面的声明必须采用下面给出的两种形式之一:

// PTX-level Declaration of cudaGetParameterBuffer() when .address_size is 64
.extern .func(.param .b64 func_retval0) cudaGetParameterBuffer
(
  .param .b64 alignment,
  .param .b64 size
)
;

以下cudaGetParameterBuffer()的 CUDA 层面声明映射到前述 PTX 层面声明:

// CUDA-level Declaration of cudaGetParameterBuffer()
extern "C" __device__
void *cudaGetParameterBuffer(size_t alignment, size_t size);

第一个参数指定参数缓冲区的对齐要求,第二个参数指定大小要求,单位为字节。在当前实现中,始终保证cudaGetParameterBuffer()返回的参数缓冲区按 64 字节对齐,并且会忽略对齐要求参数。不过,建议向cudaGetParameterBuffer()传递正确的对齐要求值,即要放入参数缓冲区的所有参数中最大的对齐值,以确保将来的可移植性。

4.20.6.2. 参数缓冲区布局

禁止对参数缓冲区中的参数重新排序,并且放入参数缓冲区的每个参数都必须对齐。也就是说,每个参数必须放在参数缓冲区中的第n个字节处,其中n是参数大小的倍数中大于前一个参数所占用的最后一个字节偏移量的最小者。参数缓冲区的最大大小为 4KB。

有关 CUDA 编译器生成的 PTX 代码的更详细说明,请参阅 PTX-3.5 规范。

4.21. CUDA 与 API 的互操作性

在 CUDA 中直接访问来自 API 的 GPU 数据,可以使用 CUDA 核函数读写这些数据,从而在其他 API 中使用这些数据时提供 CUDA 功能。主要有两个概念:一种是直接方式,即与 OpenGL 和 Direct3D[9-11] 进行图形互操作,它能够将 OpenGL 和 Direct3D 的资源映射到 CUDA 地址空间;另一种是更灵活的外部资源互操作,它允许通过操作系统级句柄进行导入和导出,以访问内存和同步对象。以下 API 支持这种方式:Direct3D[11-12]、Vulkan 和 NVIDIA 软件通信接口互操作。

4.21.1. 图形互操作性

在使用 CUDA 访问 Direct3D 或 OpenGL 资源(例如 VBO,即顶点缓冲区对象)之前,必须先注册和映射该资源。使用相应的 CUDA 函数进行注册(参见下面的示例),会返回一个类型为struct cudaGraphicsResource的 CUDA 图形资源,它持有 CUDA 设备指针或数组。要在核函数中访问设备数据,必须映射该资源。在资源处于已注册状态时,可以根据需要多次映射和取消映射它。核函数使用返回的设备内存地址来访问已映射的资源:对于缓冲区,该地址由cudaGraphicsResourceGetMappedPointer()返回;对于 CUDA 数组,则由cudaGraphicsSubResourceGetMappedArray()返回。一旦 CUDA 不再需要该资源,就可以注销它。

主要步骤如下:

  1. 向 CUDA 注册图形缓冲区

  2. 映射资源

  3. 访问已映射资源的设备指针或数组

  4. 在 CUDA 核函数中使用设备指针或数组

  5. 取消资源映射

  6. 注销资源

注意,注册资源的开销很大,因此理想情况下,每个资源只调用一次注册操作,但是,对于每个打算使用该资源的 CUDA 上下文,都必须单独注册该资源。可以调用cudaGraphicsResourceSetMapFlags()来指定使用提示(只写、只读),CUDA 驱动程序可以利用这些提示优化资源管理。还需注意,在资源处于已映射状态时,通过 OpenGL、Direct3D 或其他 CUDA 上下文访问该资源会产生未定义的结果。

4.21.1.1. OpenGL 互操作性

可以映射到 CUDA 地址空间的 OpenGL 资源包括 OpenGL 缓冲区、纹理和渲染缓冲区对象。缓冲区对象使用cudaGraphicsGLRegisterBuffer()注册,在 CUDA 中表现为普通的设备指针。纹理或渲染缓冲区对象使用cudaGraphicsGLRegisterImage()注册,在 CUDA 中表现为 CUDA 数组。

如果纹理或渲染缓冲区对象已使用cudaGraphicsRegisterFlagsSurfaceLoadStore标志注册,则可以向其写入。cudaGraphicsGLRegisterImage()支持所有具有 1、2 或 4 个分量且内部类型为 float(例如GL_RGBA_FLOAT32)、归一化整数(例如GL_RGBA8, GL_INTENSITY16)和非归一化整数(例如GL_RGBA8UI)的纹理格式。

示例:simpleGL 互操作

以下代码示例使用核函数动态修改存储在顶点缓冲区对象(VBO)中的二维widthxheight顶点网格,并依次执行以下主要步骤:

  1. 向 CUDA 注册 VBO

  2. 循环:映射 VBO,以便从 CUDA 写入

  3. 循环:运行 CUDA 核函数以修改顶点位置

  4. 循环:取消 VBO 映射

  5. 循环:使用 OpenGL 渲染结果

  6. 注销并删除 VBO

本节的完整示例 simpleGL 可以在此处找到:NVIDIA/cuda-samples。

__global__ void simple_vbo_kernel(float4 *pos, unsigned int width, unsigned int height, float time)
{
    unsigned int x = blockIdx.x * blockDim.x + threadIdx.x;
    unsigned int y = blockIdx.y * blockDim.y + threadIdx.y;

    // calculate uv coordinates
    float u = x / (float)width;
    float v = y / (float)height;
    u = u * 2.0f - 1.0f;
    v = v * 2.0f - 1.0f;

    // calculate simple sine wave pattern
    float freq = 4.0f;
    float w = sinf(u * freq + time) * cosf(v * freq + time) * 0.5f;

    // write output vertex
    pos[y * width + x] = make_float4(u, w, v, 1.0f);
}

int main(int argc, char **argv)
{
    char *ref_file = NULL;

    pArgc = &argc;
    pArgv = argv;

#if defined(__linux__)
    setenv("DISPLAY", ":0", 0);
#endif

    printf("%s starting...\n", sSDKsample);

    if (argc > 1) {
        if (checkCmdLineFlag(argc, (const char **)argv, "file")) {
            // In this mode, we are running non-OpenGL and doing a compare of the VBO was generated correctly
            getCmdLineArgumentString(argc, (const char **)argv, "file", (char **)&ref_file);
        }
    }

    printf("\n");

    // First initialize OpenGL context
    if (false == initGL(&argc, argv)) {
        return false;
    }

    // register callbacks
    glutDisplayFunc(display);
    glutKeyboardFunc(keyboard);
    glutMouseFunc(mouse);
    glutMotionFunc(motion);
    glutCloseFunc(cleanup);

    // Create an empty vertex buffer object (VBO)
    // 1. Register the VBO with CUDA
    createVBO(&vbo, &cuda_vbo_resource, cudaGraphicsMapFlagsWriteDiscard);

    // start rendering mainloop
    //  5. Render the results using OpenGL
    glutMainLoop();

    printf("%s completed, returned %s\n", sSDKsample, (g_TotalErrors == 0) ? "OK" : "ERROR!");
    exit(g_TotalErrors == 0 ? EXIT_SUCCESS : EXIT_FAILURE);
    
}

void createVBO(GLuint *vbo, struct cudaGraphicsResource **vbo_res, unsigned int vbo_res_flags)
{
    assert(vbo);

    // create buffer object
    glGenBuffers(1, vbo);
    glBindBuffer(GL_ARRAY_BUFFER, *vbo);

    // initialize buffer object
    unsigned int size = mesh_width * mesh_height * 4 * sizeof(float);
    glBufferData(GL_ARRAY_BUFFER, size, 0, GL_DYNAMIC_DRAW);

    glBindBuffer(GL_ARRAY_BUFFER, 0);

    // register this buffer object with CUDA
    checkCudaErrors(cudaGraphicsGLRegisterBuffer(vbo_res, *vbo, vbo_res_flags));

    SDK_CHECK_ERROR_GL();
}

void display()
{
    float4 *dptr;
    // 2. Map the VBO for writing from CUDA
    checkCudaErrors(cudaGraphicsMapResources(1, &cuda_vbo_resource, 0));
    size_t num_bytes;
    checkCudaErrors(cudaGraphicsResourceGetMappedPointer((void **)&dptr, &num_bytes, cuda_vbo_resource));

    // 3. Run CUDA kernel to modify the vertex positions
    //call the CUDA kernel
    dim3 block(8, 8, 1);
    dim3 grid(mesh_width / block.x, mesh_height / block.y, 1);
    simple_vbo_kernel<<<grid, block>>>(dptr, mesh_width, mesh_height, g_fAnim);

    //  4. Unmap the VBO    
    checkCudaErrors(cudaGraphicsUnmapResources(1, &cuda_vbo_resource, 0));

    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

    // set view matrix
    glMatrixMode(GL_MODELVIEW);
    glLoadIdentity();
    glTranslatef(0.0, 0.0, translate_z);
    glRotatef(rotate_x, 1.0, 0.0, 0.0);
    glRotatef(rotate_y, 0.0, 1.0, 0.0);

    // 5. Render the updated  using OpenGL
    glBindBuffer(GL_ARRAY_BUFFER, vbo);
    glVertexPointer(4, GL_FLOAT, 0, 0);

    glEnableClientState(GL_VERTEX_ARRAY);
    glColor3f(1.0, 0.0, 0.0);
    glDrawArrays(GL_POINTS, 0, mesh_width * mesh_height);
    glDisableClientState(GL_VERTEX_ARRAY);

    glutSwapBuffers();

    g_fAnim += 0.01f;

}

void deleteVBO(GLuint *vbo, struct cudaGraphicsResource *vbo_res)
{
    // 6. Unregister and delete VBO
    checkCudaErrors(cudaGraphicsUnregisterResource(vbo_res));

    glBindBuffer(1, *vbo);
    glDeleteBuffers(1, vbo);

    *vbo = 0;
}

void cleanup()
{

    if (vbo) {
        deleteVBO(&vbo, cuda_vbo_resource);
    }
}

限制与注意事项。

  • 对于进行任何 OpenGL 互操作 API 调用的主机线程,其当前 OpenGL 上下文必须是资源正在被共享的那个上下文。

  • 当 OpenGL 纹理变为无绑定纹理时(例如,使用glGetTextureHandle或glGetImageHandleAPI 请求图像或纹理句柄),便无法向 CUDA 注册该纹理。应用程序需要在请求图像或纹理句柄之前注册该纹理以用于互操作。

4.21.1.2. Direct3D 互操作性

Direct3D 互操作支持 Direct3D9、Direct3D10 和 Direct3D11,但不支持 Direct3D12。这里重点介绍 Direct3D11,有关 Direct3D9 和 Direct3D10,请参阅 CUDA 编程指南 12.9。可以映射到 CUDA 地址空间的 Direct3D 资源包括 Direct3D 缓冲区、纹理和表面。这些资源使用cudaGraphicsD3D11RegisterResource()注册。

CUDA 上下文只能与创建时将DriverType设置为D3D_DRIVER_TYPE_HARDWARE的 Direct3D11 设备互操作。

示例:二维纹理 Direct3D11 互操作

以下代码片段来自NVIDIA/cuda-samples中的 simpleD3D11Texture 示例。完整示例包含大量 DX11 样板代码,这里重点介绍 CUDA 端。

CUDA 核函数cuda_kernel_texture_2d在闪烁的蓝色背景上,以移动的红色/绿色阴影线图案绘制二维纹理,它依赖于先前的纹理值。底层数据是二维 CUDA 数组,其中行偏移量由行距定义。

/*
 * Paint a 2D texture with a moving red/green hatch pattern on a
 * strobing blue background.  Note that this kernel reads to and
 * writes from the texture, hence why this texture was not mapped
 * as WriteDiscard.
 */
__global__ void cuda_kernel_texture_2d(unsigned char *surface, int width,
                                       int height, size_t pitch, float t) {
  int x = blockIdx.x * blockDim.x + threadIdx.x;
  int y = blockIdx.y * blockDim.y + threadIdx.y;
  float *pixel;

  // in the case where, due to quantization into grids, we have
  // more threads than pixels, skip the threads which don't
  // correspond to valid pixels
  if (x >= width || y >= height) return;

  // get a pointer to the pixel at (x,y)
  pixel = (float *)(surface + y * pitch) + 4 * x;

  // populate it
  float value_x = 0.5f + 0.5f * cos(t + 10.0f * ((2.0f * x) / width - 1.0f));
  float value_y = 0.5f + 0.5f * cos(t + 10.0f * ((2.0f * y) / height - 1.0f));
  pixel[0] = 0.5 * pixel[0] + 0.5 * pow(value_x, 3.0f);  // red
  pixel[1] = 0.5 * pixel[1] + 0.5 * pow(value_y, 3.0f);  // green
  pixel[2] = 0.5f + 0.5f * cos(t);                       // blue
  pixel[3] = 1;                                          // alpha
}

extern "C" void cuda_texture_2d(void *surface, int width, int height,
                                size_t pitch, float t) {
  cudaError_t error = cudaSuccess;

  dim3 Db = dim3(16, 16);  // block dimensions are fixed to be 256 threads
  dim3 Dg = dim3((width + Db.x - 1) / Db.x, (height + Db.y - 1) / Db.y);

  cuda_kernel_texture_2d<<<Dg, Db>>>((unsigned char *)surface, width, height,
                                     pitch, t);

  error = cudaGetLastError();

  if (error != cudaSuccess) {
    printf("cuda_kernel_texture_2d() failed to launch error = %d\n", error);
  }
}

为使指针与数据缓冲区保持对应,使用以下数据结构:

// Data structure for 2D texture shared between DX11 and CUDA
struct {
  ID3D11Texture2D *pTexture;
  ID3D11ShaderResourceView *pSRView;
  cudaGraphicsResource *cudaResource;
  void *cudaLinearMemory;
  size_t pitch;
  int width;
  int height;
  int offsetInShader;
} g_texture_2d;

初始化 Direct3D 设备和纹理后,向 CUDA 注册这些资源一次。为匹配 Direct3D 像素格式,分配 CUDA 数组时采用相同的宽度和高度,以及与 Direct3D 纹理行距匹配的行距。

    // register the Direct3D resources that are used in the CUDA kernel
    // we'll read to and write from g_texture_2d, so don't set any special map flags for it
    cudaGraphicsD3D11RegisterResource(&g_texture_2d.cudaResource,
                                      g_texture_2d.pTexture,
                                      cudaGraphicsRegisterFlagsNone);
    getLastCudaError("cudaGraphicsD3D11RegisterResource (g_texture_2d) failed");
    // CUDA cannot write into the texture directly : the texture is seen as a
    // cudaArray and can only be mapped as a texture
    // Create a buffer so that CUDA can write into it
    // the pixel fmt is DXGI_FORMAT_R32G32B32A32_FLOAT
    cudaMallocPitch(&g_texture_2d.cudaLinearMemory, &g_texture_2d.pitch,
                    g_texture_2d.width * sizeof(float) * 4,
                    g_texture_2d.height);
    getLastCudaError("cudaMallocPitch (g_texture_2d) failed");
    cudaMemset(g_texture_2d.cudaLinearMemory, 1,
               g_texture_2d.pitch * g_texture_2d.height);

在渲染循环中,先映射资源,再启动 CUDA 核函数以更新纹理数据,然后取消资源映射。完成此步骤后,使用 Direct3D 设备在屏幕上绘制更新后的纹理。

    cudaStream_t stream = 0;
    const int nbResources = 3;
    cudaGraphicsResource *ppResources[nbResources] = {
        g_texture_2d.cudaResource, g_texture_3d.cudaResource,
        g_texture_cube.cudaResource,
    };
    cudaGraphicsMapResources(nbResources, ppResources, stream);
    getLastCudaError("cudaGraphicsMapResources(3) failed");

    // run kernels which will populate the contents of those textures
    RunKernels();

    // unmap the resources
    cudaGraphicsUnmapResources(nbResources, ppResources, stream);
    getLastCudaError("cudaGraphicsUnmapResources(3) failed");

最后,当 CUDA 不再需要这些资源时,将注销它们并释放设备数组。

  // unregister the Cuda resources
  cudaGraphicsUnregisterResource(g_texture_2d.cudaResource);
  getLastCudaError("cudaGraphicsUnregisterResource (g_texture_2d) failed");
  cudaFree(g_texture_2d.cudaLinearMemory);
  getLastCudaError("cudaFree (g_texture_2d) failed");

4.21.2. 外部资源互操作性

外部资源互操作性允许 CUDA 导入由 API 显式导出的某些资源。这些对象通常使用操作系统的原生句柄导出,例如 Linux 上的文件描述符或 Windows 上的 NT 句柄。这样可以在其他 API 与 CUDA 之间高效地共享资源,而无须在两者之间进行复制或创建副本。以下 API 支持此功能:Direct3D[11-12]、Vulkan 和 NVIDIA 软件通信接口互操作性。可以导入两种类型的资源:

  • 内存对象

    可以使用cudaImportExternalMemory()导入 CUDA。然后,可以在核函数内使用通过cudaExternalMemoryGetMappedBuffer()映射到该内存对象的设备指针,或通过cudaExternalMemoryGetMappedMipmappedArray()映射的 CUDA mipmap 数组来访问已导入的内存对象。根据内存对象的类型,可能可以在单个内存对象上建立多个映射。这些映射必须与导出方 API 建立的映射相匹配。任何不匹配的映射都会导致未定义行为。已导入的内存对象必须使用cudaDestroyExternalMemory()释放。释放内存对象不会释放到该对象的任何映射。因此,映射到该对象的任何设备指针都必须使用cudaFree()显式释放,映射到该对象的任何 CUDA mipmap 数组都必须使用cudaFreeMipmappedArray()显式释放。在对象被销毁后访问到该对象的映射是非法的。

  • 同步对象

    可以使用cudaImportExternalSemaphore()导入 CUDA。然后,可以使用cudaSignalExternalSemaphoresAsync()向已导入的同步对象发出信号,并使用cudaWaitExternalSemaphoresAsync()等待该对象。在相应的信号发出之前发起等待是非法的。此外,根据所导入的同步对象的类型,可能还会对向其发出信号和等待它的方式施加额外的约束,如后续各节所述。已导入的信号量对象必须使用cudaDestroyExternalSemaphore()释放。在销毁信号量对象之前,所有尚未完成的信号和等待操作都必须已完成。

4.21.2.1. Vulkan 互操作性

在同一硬件上耦合执行 Vulkan 图形和计算工作负载,可以最大限度地提高 GPU 利用率并避免不必要的复制。注意,这不是 Vulkan 指南,我们仅关注与 CUDA 的互操作性,有关 Vulkan 指南,请参阅https://www.vulkan.org/learn#vulkan-tutorials。

实现 Vulkan-CUDA 互操作性所需的主要步骤包括:

  1. 初始化 Vulkan,创建并导出外部缓冲区和/或同步对象

  2. 通过匹配设备 UUID,将 CUDA 设备设置为 Vulkan 正在运行的设备

  3. 获取内存和/或同步句柄

  4. 使用这些句柄在 CUDA 中导入内存和/或同步对象

  5. 将设备指针或 mipmap 数组映射到内存对象

  6. 通过向同步对象发出信号和等待同步对象来定义执行顺序,从而在 CUDA 和 Vulkan 中交替使用已导入的内存对象。

本节借助NVIDIA/cuda-samples中的simpleVulkan示例说明上述步骤。我们逐步讲解该示例,重点介绍实现 CUDA 互操作性所需的部分。对于一些变体,则使用独立的代码片段加以说明。

注意

本节使用的代码示例采用直接内存分配和资源创建方式。由于多种原因,包括可创建的实例数量受到限制,这并不是当前最先进的方式。但是,要理解互操作性,就需要了解底层 Vulkan 代码以及特定的标志。有关使用VulkanMemoryAllocator的更先进的示例,请参阅NVProSamples仓库中的sample_cuda_interop。

整个示例中都使用以下数据结构:

class VulkanCudaSineWave : public VulkanBaseApp {
  typedef struct UniformBufferObject_st {
    mat4x4 modelViewProj;
  } UniformBufferObject;

  VkBuffer m_heightBuffer, m_xyBuffer, m_indexBuffer;
  VkDeviceMemory m_heightMemory, m_xyMemory, m_indexMemory;
  UniformBufferObject m_ubo;
  VkSemaphore m_vkWaitSemaphore, m_vkSignalSemaphore;
  SineWaveSimulation m_sim;
  cudaStream_t m_stream;
  cudaExternalSemaphore_t m_cudaWaitSemaphore, m_cudaSignalSemaphore, m_cudaTimelineSemaphore;
  cudaExternalMemory_t m_cudaVertMem;
  float *m_cudaHeightMap;
  // ...

4.21.2.1.1. 设置 Vulkan 设备

为了导出内存对象,创建 Vulkan 实例时必须启用VK_KHR_external_memory_capabilities扩展,创建设备时必须启用VK_KHR_external_memory。此外,还必须启用平台特定的句柄类型,在 Windows 上为VK_KHR_external_memory_win32,在基于 UNIX 的系统上为VK_KHR_external_memory_fd。

类似地,为了导出同步对象,需要在设备级别启用VK_KHR_external_semaphore_capabilities,在实例级别启用VK_KHR_external_semaphore。还需要启用平台特定的句柄扩展,即 Windows 上的VK_KHR_external_semaphore_win32和基于 Unix 的系统上的VK_KHR_external_semaphore_fd。

在simpleVulkan示例中,使用以下枚举启用这些扩展。

  std::vector<const char *> getRequiredExtensions() const {
    std::vector<const char *> extensions;
    extensions.push_back(VK_KHR_EXTERNAL_MEMORY_CAPABILITIES_EXTENSION_NAME);
    extensions.push_back(VK_KHR_EXTERNAL_SEMAPHORE_CAPABILITIES_EXTENSION_NAME);
    return extensions;
  }

  std::vector<const char *> getRequiredDeviceExtensions() const {
    std::vector<const char *> extensions;
    extensions.push_back(VK_KHR_EXTERNAL_MEMORY_EXTENSION_NAME);
    extensions.push_back(VK_KHR_EXTERNAL_SEMAPHORE_EXTENSION_NAME);
    extensions.push_back(VK_KHR_TIMELINE_SEMAPHORE_EXTENSION_NAME);
#ifdef _WIN64
    extensions.push_back(VK_KHR_EXTERNAL_MEMORY_WIN32_EXTENSION_NAME);
    extensions.push_back(VK_KHR_EXTERNAL_SEMAPHORE_WIN32_EXTENSION_NAME);
#else
    extensions.push_back(VK_KHR_EXTERNAL_MEMORY_FD_EXTENSION_NAME);
    extensions.push_back(VK_KHR_EXTERNAL_SEMAPHORE_FD_EXTENSION_NAME);
#endif /* _WIN64 */
    return extensions;
  }

随后,将这些扩展添加到 Vulkan 实例和设备创建信息中,详情请参阅simpleVulkan示例。

4.21.2.1.2. 使用匹配的设备 UUID 初始化 CUDA

导入由 Vulkan 导出的内存和同步对象时,必须在创建这些对象的同一设备上导入并映射它们。通过比较 CUDA 设备的 UUID 与 Vulkan 物理设备的 UUID,可以确定与创建这些对象的 Vulkan 物理设备相对应的 CUDA 设备,如以下来自 simpleVulkan 示例的代码片段所示。其中,vkDeviceUUID是 Vulkan API 结构体vkPhysicalDeviceIDProperties.deviceUUID的成员,定义了当前 Vulkan 实例的物理设备 ID。

// from the CUDA example `simpleVulkan`
int SineWaveSimulation::initCuda(uint8_t *vkDeviceUUID, size_t UUID_SIZE) {
  int current_device = 0;
  int device_count = 0;
  int devices_prohibited = 0;

  cudaDeviceProp deviceProp;
  checkCudaErrors(cudaGetDeviceCount(&device_count));

  if (device_count == 0) {
    fprintf(stderr, "CUDA error: no devices supporting CUDA.\n");
    exit(EXIT_FAILURE);
  }

  // Find the GPU which is selected by Vulkan
  while (current_device < device_count) {
    cudaGetDeviceProperties(&deviceProp, current_device);

    if ((deviceProp.computeMode != cudaComputeModeProhibited)) {
      // Compare the cuda device UUID with vulkan UUID
      int ret = memcmp((void *)&deviceProp.uuid, vkDeviceUUID, UUID_SIZE);
      if (ret == 0) {
        checkCudaErrors(cudaSetDevice(current_device));
        checkCudaErrors(cudaGetDeviceProperties(&deviceProp, current_device));
        printf("GPU Device %d: \"%s\" with compute capability %d.%d\n\n",
               current_device, deviceProp.name, deviceProp.major,
               deviceProp.minor);

        return current_device;
      }

    } else {
      devices_prohibited++;
    }

    current_device++;
  }

  if (devices_prohibited == device_count) {
    fprintf(stderr,
            "CUDA error:"
            " No Vulkan-CUDA Interop capable GPU found.\n");
    exit(EXIT_FAILURE);
  }

  return -1;
}

注意,该 Vulkan 物理设备不应属于包含多个 Vulkan 物理设备的设备组。也就是说,由vkEnumeratePhysicalDeviceGroups返回的、包含给定 Vulkan 物理设备的设备组,其物理设备数量必须为 1。

4.21.2.1.3. 导出 Vulkan 内存对象

为了导出 Vulkan 内存对象,必须创建带有相应导出标志的缓冲区。注意,句柄类型的枚举是平台特定的。

void VulkanBaseApp::createExternalBuffer(
    VkDeviceSize size, VkBufferUsageFlags usage,
    VkMemoryPropertyFlags properties,
    VkExternalMemoryHandleTypeFlagsKHR extMemHandleType, VkBuffer &buffer,
    VkDeviceMemory &bufferMemory) {
  VkBufferCreateInfo bufferInfo = {};
  bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
  bufferInfo.size = size;
  bufferInfo.usage = usage;
  bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;

  VkExternalMemoryBufferCreateInfo externalMemoryBufferInfo = {};
  externalMemoryBufferInfo.sType =
      VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_BUFFER_CREATE_INFO;
  externalMemoryBufferInfo.handleTypes = extMemHandleType;
  bufferInfo.pNext = &externalMemoryBufferInfo;

  if (vkCreateBuffer(m_device, &bufferInfo, nullptr, &buffer) != VK_SUCCESS) {
    throw std::runtime_error("failed to create buffer!");
  }

  VkMemoryRequirements memRequirements;
  vkGetBufferMemoryRequirements(m_device, buffer, &memRequirements);

#ifdef _WIN64
  WindowsSecurityAttributes winSecurityAttributes;

  VkExportMemoryWin32HandleInfoKHR vulkanExportMemoryWin32HandleInfoKHR = {};
  vulkanExportMemoryWin32HandleInfoKHR.sType =
      VK_STRUCTURE_TYPE_EXPORT_MEMORY_WIN32_HANDLE_INFO_KHR;
  vulkanExportMemoryWin32HandleInfoKHR.pNext = NULL;
  vulkanExportMemoryWin32HandleInfoKHR.pAttributes = &winSecurityAttributes;
  vulkanExportMemoryWin32HandleInfoKHR.dwAccess =
      DXGI_SHARED_RESOURCE_READ | DXGI_SHARED_RESOURCE_WRITE;
  vulkanExportMemoryWin32HandleInfoKHR.name = (LPCWSTR)NULL;
#endif /* _WIN64 */
  VkExportMemoryAllocateInfoKHR vulkanExportMemoryAllocateInfoKHR = {};
  vulkanExportMemoryAllocateInfoKHR.sType =
      VK_STRUCTURE_TYPE_EXPORT_MEMORY_ALLOCATE_INFO_KHR;
#ifdef _WIN64
  vulkanExportMemoryAllocateInfoKHR.pNext =
      extMemHandleType & VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_WIN32_BIT_KHR
          ? &vulkanExportMemoryWin32HandleInfoKHR
          : NULL;
  vulkanExportMemoryAllocateInfoKHR.handleTypes = extMemHandleType;
#else
  vulkanExportMemoryAllocateInfoKHR.pNext = NULL;
  vulkanExportMemoryAllocateInfoKHR.handleTypes =
      VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT;
#endif /* _WIN64 */
  VkMemoryAllocateInfo allocInfo = {};
  allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
  allocInfo.pNext = &vulkanExportMemoryAllocateInfoKHR;
  allocInfo.allocationSize = memRequirements.size;
  allocInfo.memoryTypeIndex = findMemoryType(
      m_physicalDevice, memRequirements.memoryTypeBits, properties);

  if (vkAllocateMemory(m_device, &allocInfo, nullptr, &bufferMemory) !=
      VK_SUCCESS) {
    throw std::runtime_error("failed to allocate external buffer memory!");
  }

  vkBindBufferMemory(m_device, buffer, bufferMemory, 0);
}

4.21.2.1.4. 导出 Vulkan 同步对象

在 GPU 上执行的 Vulkan API 调用是异步的。为了定义执行顺序,Vulkan 提供了可与 CUDA 共享的信号量和栅栏。与内存对象类似,信号量可以由 Vulkan 导出,创建信号量时需要根据其类型指定导出标志。信号量分为二进制信号量和时间线信号量。二进制信号量只有一个 1 位计数器,表示已发出信号或未发出信号。时间线信号量有一个 64 位计数器,可以使用同一个信号量来定义执行顺序。simpleVulkan示例中包含时间线信号量和二进制信号量两者的代码路径。

void VulkanBaseApp::createExternalSemaphore(
    VkSemaphore &semaphore, VkExternalSemaphoreHandleTypeFlagBits handleType) {
  VkSemaphoreCreateInfo semaphoreInfo = {};
  semaphoreInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;
  VkExportSemaphoreCreateInfoKHR exportSemaphoreCreateInfo = {};
  exportSemaphoreCreateInfo.sType =
      VK_STRUCTURE_TYPE_EXPORT_SEMAPHORE_CREATE_INFO_KHR;

#ifdef _VK_TIMELINE_SEMAPHORE
  VkSemaphoreTypeCreateInfo timelineCreateInfo;
  timelineCreateInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_TYPE_CREATE_INFO;
  timelineCreateInfo.pNext = NULL;
  timelineCreateInfo.semaphoreType = VK_SEMAPHORE_TYPE_TIMELINE;
  timelineCreateInfo.initialValue = 0;
  exportSemaphoreCreateInfo.pNext = &timelineCreateInfo;
#else
  exportSemaphoreCreateInfo.pNext = NULL;
#endif /* _VK_TIMELINE_SEMAPHORE */
  exportSemaphoreCreateInfo.handleTypes = handleType;
  semaphoreInfo.pNext = &exportSemaphoreCreateInfo;

  if (vkCreateSemaphore(m_device, &semaphoreInfo, nullptr, &semaphore) !=
      VK_SUCCESS) {
    throw std::runtime_error(
        "failed to create synchronization objects for a CUDA-Vulkan!");
  }
}

4.21.2.1.5. 导入内存对象

由 Vulkan 导出的专用和非专用内存对象都可以导入 CUDA。导入 Vulkan 专用内存对象时,必须设置标志cudaExternalMemoryDedicated。

在 Windows 中,可以使用与对象关联的 NT 句柄,将使用VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_WIN32_BIT导出的 Vulkan 内存对象导入 CUDA,如下所示。注意,CUDA 不会接管 NT 句柄的所有权,应用程序有责任在不再需要该句柄时将其关闭。NT 句柄持有对该资源的引用,因此必须先显式释放该句柄,才能释放底层内存。

在 Linux 中,可以使用与对象关联的文件描述符,将使用VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT导出的 Vulkan 内存对象导入 CUDA,如下所示。注意,一旦导入,CUDA 就会接管该文件描述符的所有权。在成功导入后使用该文件描述符会导致未定义行为。

  // from the CUDA example `simpleVulkan`
  void importCudaExternalMemory(void **cudaPtr, cudaExternalMemory_t &cudaMem,
                                VkDeviceMemory &vkMem, VkDeviceSize size,
                                VkExternalMemoryHandleTypeFlagBits handleType) {
    cudaExternalMemoryHandleDesc externalMemoryHandleDesc = {};

    if (handleType & VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_WIN32_BIT) {
      externalMemoryHandleDesc.type = cudaExternalMemoryHandleTypeOpaqueWin32;
    } else if (handleType &
               VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_WIN32_KMT_BIT) {
      externalMemoryHandleDesc.type =
          cudaExternalMemoryHandleTypeOpaqueWin32Kmt;
    } else if (handleType & VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_FD_BIT) {
      externalMemoryHandleDesc.type = cudaExternalMemoryHandleTypeOpaqueFd;
    } else {
      throw std::runtime_error("Unknown handle type requested!");
    }

    externalMemoryHandleDesc.size = size;

#ifdef _WIN64
    externalMemoryHandleDesc.handle.win32.handle =
        (HANDLE)getMemHandle(vkMem, handleType);
#else
    externalMemoryHandleDesc.handle.fd =
        (int)(uintptr_t)getMemHandle(vkMem, handleType);
#endif

    checkCudaErrors(
        cudaImportExternalMemory(&cudaMem, &externalMemoryHandleDesc));

对于使用VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_WIN32_BIT导出的 Vulkan 内存对象,如果存在命名句柄,也可以使用该命名句柄进行导入,如下面的独立代码片段所示。

cudaExternalMemory_t importVulkanMemoryObjectFromNamedNTHandle(LPCWSTR name, unsigned long long size, bool isDedicated) {
   cudaExternalMemory_t extMem = NULL;
   cudaExternalMemoryHandleDesc desc = {};

   memset(&desc, 0, sizeof(desc));

   desc.type = cudaExternalMemoryHandleTypeOpaqueWin32;
   desc.handle.win32.name = (void *)name;
   desc.size = size;
   if (isDedicated) {
       desc.flags |= cudaExternalMemoryDedicated;
   }

   cudaImportExternalMemory(&extMem, &desc);

   return extMem;
}

4.21.2.1.6. 将缓冲区映射到已导入的内存对象

导入内存对象后,必须先对其进行映射才能使用。可以将设备指针映射到已导入的内存对象,如下所示。映射的偏移量和大小必须与使用相应 Vulkan API 创建映射时指定的值相匹配。所有已映射的设备指针都必须使用cudaFree()释放。

    // from the CUDA example `simpleVulkan`, continuation of function `importCudaExternalMemory`
    cudaExternalMemoryBufferDesc externalMemBufferDesc = {};
    externalMemBufferDesc.offset = 0;
    externalMemBufferDesc.size = size;
    externalMemBufferDesc.flags = 0;

    checkCudaErrors(cudaExternalMemoryGetMappedBuffer(cudaPtr, cudaMem,
                                                      &externalMemBufferDesc));
  }

4.21.2.1.7. 将 mipmap 数组映射到已导入的内存对象

可以将 CUDA mipmap 数组映射到已导入的内存对象,如下所示。偏移量、各维尺寸、格式和 mip 级别数必须与使用相应 Vulkan API 创建映射时指定的值相匹配。此外,如果该 mipmap 数组在 Vulkan 中被绑定为颜色目标,则必须设置标志cudaArrayColorAttachment。所有已映射的 mipmap 数组都必须使用cudaFreeMipmappedArray()释放。以下独立代码片段展示了在将 mipmap 数组映射到已导入的内存对象时,如何将 Vulkan 参数转换为相应的 CUDA 参数。

cudaMipmappedArray_t mapMipmappedArrayOntoExternalMemory(cudaExternalMemory_t extMem, unsigned long long offset, cudaChannelFormatDesc *formatDesc, cudaExtent *extent, unsigned int flags, unsigned int numLevels) {
    cudaMipmappedArray_t mipmap = NULL;
    cudaExternalMemoryMipmappedArrayDesc desc = {};

    memset(&desc, 0, sizeof(desc));

    desc.offset = offset;
    desc.formatDesc = *formatDesc;
    desc.extent = *extent;
    desc.flags = flags;
    desc.numLevels = numLevels;

    // Note: 'mipmap' must eventually be freed using cudaFreeMipmappedArray()
    cudaExternalMemoryGetMappedMipmappedArray(&mipmap, extMem, &desc);

    return mipmap;
}
//end mapMipmappedArrayOntoExternalMemory

//begin getCudaChannelFormatDescForVulkanFormat
cudaChannelFormatDesc getCudaChannelFormatDescForVulkanFormat(VkFormat format)
{
    cudaChannelFormatDesc d;

    memset(&d, 0, sizeof(d));
 
    switch (format) {
       case VK_FORMAT_R8_UINT:             d.x = 8;  d.y = 0;  d.z = 0;  d.w = 0;  d.f = cudaChannelFormatKindUnsigned; break;
       case VK_FORMAT_R8_SINT:             d.x = 8;  d.y = 0;  d.z = 0;  d.w = 0;  d.f = cudaChannelFormatKindSigned;   break;
       case VK_FORMAT_R8G8_UINT:           d.x = 8;  d.y = 8;  d.z = 0;  d.w = 0;  d.f = cudaChannelFormatKindUnsigned; break;
       case VK_FORMAT_R8G8_SINT:           d.x = 8;  d.y = 8;  d.z = 0;  d.w = 0;  d.f = cudaChannelFormatKindSigned;   break;
       case VK_FORMAT_R8G8B8A8_UINT:       d.x = 8;  d.y = 8;  d.z = 8;  d.w = 8;  d.f = cudaChannelFormatKindUnsigned; break;
       case VK_FORMAT_R8G8B8A8_SINT:       d.x = 8;  d.y = 8;  d.z = 8;  d.w = 8;  d.f = cudaChannelFormatKindSigned;   break;
       case VK_FORMAT_R16_UINT:            d.x = 16; d.y = 0;  d.z = 0;  d.w = 0;  d.f = cudaChannelFormatKindUnsigned; break;
       case VK_FORMAT_R16_SINT:            d.x = 16; d.y = 0;  d.z = 0;  d.w = 0;  d.f = cudaChannelFormatKindSigned;   break;
       case VK_FORMAT_R16G16_UINT:         d.x = 16; d.y = 16; d.z = 0;  d.w = 0;  d.f = cudaChannelFormatKindUnsigned; break;
       case VK_FORMAT_R16G16_SINT:         d.x = 16; d.y = 16; d.z = 0;  d.w = 0;  d.f = cudaChannelFormatKindSigned;   break;
       case VK_FORMAT_R16G16B16A16_UINT:   d.x = 16; d.y = 16; d.z = 16; d.w = 16; d.f = cudaChannelFormatKindUnsigned; break;
       case VK_FORMAT_R16G16B16A16_SINT:   d.x = 16; d.y = 16; d.z = 16; d.w = 16; d.f = cudaChannelFormatKindSigned;   break;
       case VK_FORMAT_R32_UINT:            d.x = 32; d.y = 0;  d.z = 0;  d.w = 0;  d.f = cudaChannelFormatKindUnsigned; break;
       case VK_FORMAT_R32_SINT:            d.x = 32; d.y = 0;  d.z = 0;  d.w = 0;  d.f = cudaChannelFormatKindSigned;   break;
       case VK_FORMAT_R32_SFLOAT:          d.x = 32; d.y = 0;  d.z = 0;  d.w = 0;  d.f = cudaChannelFormatKindFloat;    break;
       case VK_FORMAT_R32G32_UINT:         d.x = 32; d.y = 32; d.z = 0;  d.w = 0;  d.f = cudaChannelFormatKindUnsigned; break;
       case VK_FORMAT_R32G32_SINT:         d.x = 32; d.y = 32; d.z = 0;  d.w = 0;  d.f = cudaChannelFormatKindSigned;   break;
       case VK_FORMAT_R32G32_SFLOAT:       d.x = 32; d.y = 32; d.z = 0;  d.w = 0;  d.f = cudaChannelFormatKindFloat;    break;
       case VK_FORMAT_R32G32B32A32_UINT:   d.x = 32; d.y = 32; d.z = 32; d.w = 32; d.f = cudaChannelFormatKindUnsigned; break;
       case VK_FORMAT_R32G32B32A32_SINT:   d.x = 32; d.y = 32; d.z = 32; d.w = 32; d.f = cudaChannelFormatKindSigned;   break;
       case VK_FORMAT_R32G32B32A32_SFLOAT: d.x = 32; d.y = 32; d.z = 32; d.w = 32; d.f = cudaChannelFormatKindFloat;    break;
       default: assert(0);
    }
    return d;
}
//end getCudaChannelFormatDescForVulkanFormat

//begin getCudaExtentForVulkanExtent
cudaExtent getCudaExtentForVulkanExtent(VkExtent3D vkExt, uint32_t arrayLayers, VkImageViewType vkImageViewType) {
    cudaExtent e = { 0, 0, 0 };

    switch (vkImageViewType) {
        case VK_IMAGE_VIEW_TYPE_1D:         e.width = vkExt.width; e.height = 0;            e.depth = 0;           break;
        case VK_IMAGE_VIEW_TYPE_2D:         e.width = vkExt.width; e.height = vkExt.height; e.depth = 0;           break;
        case VK_IMAGE_VIEW_TYPE_3D:         e.width = vkExt.width; e.height = vkExt.height; e.depth = vkExt.depth; break;
        case VK_IMAGE_VIEW_TYPE_CUBE:       e.width = vkExt.width; e.height = vkExt.height; e.depth = arrayLayers; break;
        case VK_IMAGE_VIEW_TYPE_1D_ARRAY:   e.width = vkExt.width; e.height = 0;            e.depth = arrayLayers; break;
        case VK_IMAGE_VIEW_TYPE_2D_ARRAY:   e.width = vkExt.width; e.height = vkExt.height; e.depth = arrayLayers; break;
        case VK_IMAGE_VIEW_TYPE_CUBE_ARRAY: e.width = vkExt.width; e.height = vkExt.height; e.depth = arrayLayers; break;
        default: assert(0);
    }

    return e;
}
//end getCudaExtentForVulkanExtent

//begin getCudaMipmappedArrayFlagsForVulkanImage
unsigned int getCudaMipmappedArrayFlagsForVulkanImage(VkImageViewType vkImageViewType,
                                                      VkImageUsageFlags vkImageUsageFlags,
                                                      bool allowSurfaceLoadStore) {
    unsigned int flags = 0;

    switch (vkImageViewType) {
        case VK_IMAGE_VIEW_TYPE_CUBE:       flags |= cudaArrayCubemap;                    break;
        case VK_IMAGE_VIEW_TYPE_CUBE_ARRAY: flags |= cudaArrayCubemap | cudaArrayLayered; break;
        case VK_IMAGE_VIEW_TYPE_1D_ARRAY:   flags |= cudaArrayLayered;                    break;
        case VK_IMAGE_VIEW_TYPE_2D_ARRAY:   flags |= cudaArrayLayered;                    break;
        default: break;
    }
    if (vkImageUsageFlags & VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT) {
        flags |= cudaArrayColorAttachment;
    }

    if (allowSurfaceLoadStore) {
        flags |= cudaArraySurfaceLoadStore;
    }
    
    return flags;
}

4.21.2.1.8. 导入同步对象

可以使用与对象关联的文件描述符,将使用VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_FD_BIT导出的 Vulkan 信号量对象导入 CUDA,如下所示。注意,一旦导入,CUDA 就会接管该文件描述符的所有权。在成功导入后使用该文件描述符会导致未定义行为。

而对于使用VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_WIN32_BIT导出的 Vulkan 信号量对象,可以使用与该对象关联的 NT 句柄将其导入 CUDA,如下所示。注意,CUDA 不会接管 NT 句柄的所有权,应用程序有责任在不再需要该句柄时将其关闭。NT 句柄持有对该资源的引用,因此必须先显式释放该句柄,才能释放底层信号量。

此外,可以使用与对象关联的全局共享 D3DKMT 句柄,将使用VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_WIN32_KMT_BIT导出的 Vulkan 信号量对象导入 CUDA,如下所示。由于全局共享的 D3DKMT 句柄不持有对底层信号量的引用,因此在对该资源的所有其他引用被销毁时,它会自动销毁。

  void importCudaExternalSemaphore(
      cudaExternalSemaphore_t &cudaSem, VkSemaphore &vkSem,
      VkExternalSemaphoreHandleTypeFlagBits handleType) {
    cudaExternalSemaphoreHandleDesc externalSemaphoreHandleDesc = {};

#ifdef _VK_TIMELINE_SEMAPHORE
    if (handleType & VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_WIN32_BIT) {
      externalSemaphoreHandleDesc.type =
          cudaExternalSemaphoreHandleTypeTimelineSemaphoreWin32;
    } else if (handleType &
               VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_WIN32_KMT_BIT) {
      externalSemaphoreHandleDesc.type =
          cudaExternalSemaphoreHandleTypeTimelineSemaphoreWin32;
    } else if (handleType & VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_FD_BIT) {
      externalSemaphoreHandleDesc.type =
          cudaExternalSemaphoreHandleTypeTimelineSemaphoreFd;
    }
#else
    if (handleType & VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_WIN32_BIT) {
      externalSemaphoreHandleDesc.type =
          cudaExternalSemaphoreHandleTypeOpaqueWin32;
    } else if (handleType &
               VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_WIN32_KMT_BIT) {
      externalSemaphoreHandleDesc.type =
          cudaExternalSemaphoreHandleTypeOpaqueWin32Kmt;
    } else if (handleType & VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_FD_BIT) {
      externalSemaphoreHandleDesc.type =
          cudaExternalSemaphoreHandleTypeOpaqueFd;
    }
#endif /* _VK_TIMELINE_SEMAPHORE */
    else {
      throw std::runtime_error("Unknown handle type requested!");
    }

#ifdef _WIN64
    externalSemaphoreHandleDesc.handle.win32.handle =
        (HANDLE)getSemaphoreHandle(vkSem, handleType);
#else
    externalSemaphoreHandleDesc.handle.fd =
        (int)(uintptr_t)getSemaphoreHandle(vkSem, handleType);
#endif

    externalSemaphoreHandleDesc.flags = 0;

    checkCudaErrors(
        cudaImportExternalSemaphore(&cudaSem, &externalSemaphoreHandleDesc));
  }

4.21.2.1.9. 向已导入的同步对象发出信号/等待已导入的同步对象

可以向已导入的 Vulkan 信号量发出信号并等待该信号量,如下所示。向信号量发出信号会将其设置为已发出信号状态;对于时间线信号量,还会将计数器设置为信号调用中指定的值。等待此信号的相应等待操作必须在 Vulkan 中发起。此外,对于二进制信号量,等待此信号的等待操作必须在此信号发出之后才能发起。

等待信号量会一直等待,直到其达到已发出信号状态或指定的等待值。随后,已发出信号的二进制信号量会将其重置回未发出信号状态。此等待操作所等待的相应信号必须在 Vulkan 中发出。此外,对于二进制信号量,必须先发出信号,才能发起此等待操作。

在以下摘自simpleVulkan示例的代码中,只有在 Vulkan 向用于顶点缓冲区的信号量发出信号后,才会调用模拟步骤/CUDA 核函数。在模拟步骤之后,CUDA 会向另一个信号量发出信号,或者在使用时间线信号量的情况下递增同一个信号量,从而使等待此信号量的 Vulkan 部分能够使用更新后的顶点缓冲区继续渲染。

#ifdef _VK_TIMELINE_SEMAPHORE
    static uint64_t waitValue = 1;
    static uint64_t signalValue = 2;

    cudaExternalSemaphoreWaitParams waitParams = {};
    waitParams.flags = 0;
    waitParams.params.fence.value = waitValue;

    cudaExternalSemaphoreSignalParams signalParams = {};
    signalParams.flags = 0;
    signalParams.params.fence.value = signalValue;
    // Wait for vulkan to complete it's work
    checkCudaErrors(cudaWaitExternalSemaphoresAsync(&m_cudaTimelineSemaphore,
                                                    &waitParams, 1, m_stream));
    // Now step the simulation, call CUDA kernel
    m_sim.stepSimulation(time, m_stream);
    // Signal vulkan to continue with the updated buffers
    checkCudaErrors(cudaSignalExternalSemaphoresAsync(
        &m_cudaTimelineSemaphore, &signalParams, 1, m_stream));

    waitValue += 2;
    signalValue += 2;
#else
    cudaExternalSemaphoreWaitParams waitParams = {};
    waitParams.flags = 0;
    waitParams.params.fence.value = 0;

    cudaExternalSemaphoreSignalParams signalParams = {};
    signalParams.flags = 0;
    signalParams.params.fence.value = 0;

    // Wait for vulkan to complete it's work
    checkCudaErrors(cudaWaitExternalSemaphoresAsync(&m_cudaWaitSemaphore,
                                                    &waitParams, 1, m_stream));
    // Now step the simulation, call CUDA kernel
    m_sim.stepSimulation(time, m_stream);
    // Signal vulkan to continue with the updated buffers
    checkCudaErrors(cudaSignalExternalSemaphoresAsync(
        &m_cudaSignalSemaphore, &signalParams, 1, m_stream));
#endif /* _VK_TIMELINE_SEMAPHORE */

4.21.2.1.10. OpenGL 互操作性

OpenGL 互操作性中概述的传统 OpenGL-CUDA 互操作方式,是由 CUDA 直接使用在 OpenGL 中创建的句柄。但是,由于 OpenGL 也可以使用在 Vulkan 中创建的内存和同步对象,因此还存在另一种实现 OpenGL-CUDA 互操作的方式。其基本思想是,可以将由 Vulkan 导出的内存和同步对象同时导入 OpenGL 和 CUDA,然后用它们来协调 OpenGL 与 CUDA 之间的内存访问。有关如何导入由 Vulkan 导出的内存和同步对象的更多详细信息,请参阅以下 OpenGL 扩展:

  • GL_EXT_memory_object

  • GL_EXT_memory_object_fd

  • GL_EXT_memory_object_win32

  • GL_EXT_semaphore

  • GL_EXT_semaphore_fd

  • GL_EXT_semaphore_win32

4.21.2.2. Direct3D 互操作性

Direct3D11 和 Direct3D12 均支持将 Direct3D[11|12] 资源导入 CUDA。我们仅讨论 Direct3D12,有关 Direct3D11,请参阅 CUDA 编程指南 12.9。

4.21.2.2.1. 匹配设备 LUID

导入由 Direct3D12 导出的内存和同步对象时,必须在创建这些对象的同一设备上导入并映射它们。通过比较 CUDA 设备的 LUID 与 Direct3D12 设备的 LUID,可以确定与创建这些对象的 Direct3D12 设备相对应的 CUDA 设备,如以下代码示例所示。注意,Direct3D12 设备不得在链接节点适配器上创建,即ID3D12Device::GetNodeCount返回的节点数量必须为 1。

int getCudaDeviceForD3D12Device(ID3D12Device *d3d12Device) {
    LUID d3d12Luid = d3d12Device->GetAdapterLuid();

    int cudaDeviceCount;
    cudaGetDeviceCount(&cudaDeviceCount);

    for (int cudaDevice = 0; cudaDevice < cudaDeviceCount; cudaDevice++) {
        cudaDeviceProp deviceProp;
        cudaGetDeviceProperties(&deviceProp, cudaDevice);
        char *cudaLuid = deviceProp.luid;

        if (!memcmp(&d3d12Luid.LowPart, cudaLuid, sizeof(d3d12Luid.LowPart)) &&
            !memcmp(&d3d12Luid.HighPart, cudaLuid + sizeof(d3d12Luid.LowPart), sizeof(d3d12Luid.HighPart))) {
            return cudaDevice;
        }
    }
    return cudaInvalidDeviceId;
}

4.21.2.2.2. 导入内存对象

从 NT 句柄导入内存对象有几种不同的方法。注意,当不再需要 NT 句柄时,应用程序有责任将其关闭。NT 句柄持有对资源的引用,因此必须显式释放该句柄,才能释放底层内存。导入 Direct3D 资源时,必须设置标志cudaExternalMemoryDedicated,如下面的代码片段所示。

在调用ID3D12Device::CreateHeap时通过设置标志D3D12_HEAP_FLAG_SHARED创建的可共享 Direct3D12 堆内存对象,可以使用与该对象关联的 NT 句柄导入 CUDA,如下所示。

cudaExternalMemory_t importD3D12HeapFromNTHandle(HANDLE handle, unsigned long long size) {
    cudaExternalMemory_t extMem = NULL;
    cudaExternalMemoryHandleDesc desc = {};

    memset(&desc, 0, sizeof(desc));

    desc.type = cudaExternalMemoryHandleTypeD3D12Heap;
    desc.handle.win32.handle = (void *)handle;
    desc.size = size;

    cudaImportExternalMemory(&extMem, &desc);

    // Input parameter 'handle' should be closed if it's not needed anymore
    CloseHandle(handle);

    return extMem;
}

如果存在命名句柄,也可以使用该句柄导入可共享的 Direct3D12 堆内存对象:

cudaExternalMemory_t importD3D12HeapFromNamedNTHandle(LPCWSTR name, unsigned long long size) {
    cudaExternalMemory_t extMem = NULL;
    cudaExternalMemoryHandleDesc desc = {};

    memset(&desc, 0, sizeof(desc));

    desc.type = cudaExternalMemoryHandleTypeD3D12Heap;
    desc.handle.win32.name = (void *)name;
    desc.size = size;

    cudaImportExternalMemory(&extMem, &desc);

    return extMem;
}

在调用D3D12Device::CreateCommittedResource时通过设置标志D3D12_HEAP_FLAG_SHARED创建的可共享 Direct3D12 已提交资源,可以使用与该对象关联的 NT 句柄导入 CUDA,如下所示。导入 Direct3D12 已提交资源时,必须设置标志cudaExternalMemoryDedicated。

cudaExternalMemory_t importD3D12CommittedResourceFromNTHandle(HANDLE handle, unsigned long long size) {
    cudaExternalMemory_t extMem = NULL;
    cudaExternalMemoryHandleDesc desc = {};

    memset(&desc, 0, sizeof(desc));

    desc.type = cudaExternalMemoryHandleTypeD3D12Resource;
    desc.handle.win32.handle = (void *)handle;
    desc.size = size;
    desc.flags |= cudaExternalMemoryDedicated;

    cudaImportExternalMemory(&extMem, &desc);

    // Input parameter 'handle' should be closed if it's not needed anymore
    CloseHandle(handle);

    return extMem;
}

如果存在命名句柄,也可以使用该句柄导入可共享的 Direct3D12 已提交资源,如下所示。

cudaExternalMemory_t importD3D12CommittedResourceFromNamedNTHandle(LPCWSTR name, unsigned long long size) {
    cudaExternalMemory_t extMem = NULL;
    cudaExternalMemoryHandleDesc desc = {};

    memset(&desc, 0, sizeof(desc));

    desc.type = cudaExternalMemoryHandleTypeD3D12Resource;
    desc.handle.win32.name = (void *)name;
    desc.size = size;
    desc.flags |= cudaExternalMemoryDedicated;

    cudaImportExternalMemory(&extMem, &desc);

    return extMem;
}

4.21.2.2.3. 将缓冲区映射到导入的内存对象

可以将设备指针映射到导入的内存对象,如下所示。映射的偏移量和大小必须与使用相应的 Direct3D12 API 创建映射时指定的值一致。所有映射的设备指针都必须使用cudaFree()释放。

void * mapBufferOntoExternalMemory(cudaExternalMemory_t extMem, unsigned long long offset, unsigned long long size) {
    void *ptr = NULL;
    cudaExternalMemoryBufferDesc desc = {};

    memset(&desc, 0, sizeof(desc));

    desc.offset = offset;
    desc.size = size;

    cudaExternalMemoryGetMappedBuffer(&ptr, extMem, &desc);

    // Note: 'ptr' must eventually be freed using cudaFree()
    return ptr;
}

4.21.2.2.4. 将多级渐远纹理数组映射到导入的内存对象

可以将 CUDA 多级渐远纹理数组映射到导入的内存对象,如下所示。偏移量、维度、格式和 mip 级别数必须与使用相应的 Direct3D12 API 创建映射时指定的值一致。此外,如果该多级渐远纹理数组可以在 Direct3D12 中绑定为渲染目标,则必须设置标志cudaArrayColorAttachment。所有映射的多级渐远纹理数组都必须使用cudaFreeMipmappedArray()释放。以下代码示例展示了在将多级渐远纹理数组映射到导入的内存对象时,如何将参数转换为相应的 CUDA 参数。

cudaMipmappedArray_t mapMipmappedArrayOntoExternalMemory(cudaExternalMemory_t extMem, unsigned long long offset, cudaChannelFormatDesc *formatDesc, cudaExtent *extent, unsigned int flags, unsigned int numLevels) {
    cudaMipmappedArray_t mipmap = NULL;
    cudaExternalMemoryMipmappedArrayDesc desc = {};

    memset(&desc, 0, sizeof(desc));

    desc.offset = offset;
    desc.formatDesc = *formatDesc;
    desc.extent = *extent;
    desc.flags = flags;
    desc.numLevels = numLevels;

    // Note: 'mipmap' must eventually be freed using cudaFreeMipmappedArray()
    cudaExternalMemoryGetMappedMipmappedArray(&mipmap, extMem, &desc);

    return mipmap;
}

cudaChannelFormatDesc getCudaChannelFormatDescForDxgiFormat(DXGI_FORMAT dxgiFormat)
{
    cudaChannelFormatDesc d;

    memset(&d, 0, sizeof(d));

    switch (dxgiFormat) {
        case DXGI_FORMAT_R8_UINT:            d.x = 8;  d.y = 0;  d.z = 0;  d.w = 0;  d.f = cudaChannelFormatKindUnsigned; break;
        case DXGI_FORMAT_R8_SINT:            d.x = 8;  d.y = 0;  d.z = 0;  d.w = 0;  d.f = cudaChannelFormatKindSigned;   break;
        case DXGI_FORMAT_R8G8_UINT:          d.x = 8;  d.y = 8;  d.z = 0;  d.w = 0;  d.f = cudaChannelFormatKindUnsigned; break;
        case DXGI_FORMAT_R8G8_SINT:          d.x = 8;  d.y = 8;  d.z = 0;  d.w = 0;  d.f = cudaChannelFormatKindSigned;   break;
        case DXGI_FORMAT_R8G8B8A8_UINT:      d.x = 8;  d.y = 8;  d.z = 8;  d.w = 8;  d.f = cudaChannelFormatKindUnsigned; break;
        case DXGI_FORMAT_R8G8B8A8_SINT:      d.x = 8;  d.y = 8;  d.z = 8;  d.w = 8;  d.f = cudaChannelFormatKindSigned;   break;
        case DXGI_FORMAT_R16_UINT:           d.x = 16; d.y = 0;  d.z = 0;  d.w = 0;  d.f = cudaChannelFormatKindUnsigned; break;
        case DXGI_FORMAT_R16_SINT:           d.x = 16; d.y = 0;  d.z = 0;  d.w = 0;  d.f = cudaChannelFormatKindSigned;   break;
        case DXGI_FORMAT_R16G16_UINT:        d.x = 16; d.y = 16; d.z = 0;  d.w = 0;  d.f = cudaChannelFormatKindUnsigned; break;
        case DXGI_FORMAT_R16G16_SINT:        d.x = 16; d.y = 16; d.z = 0;  d.w = 0;  d.f = cudaChannelFormatKindSigned;   break;
        case DXGI_FORMAT_R16G16B16A16_UINT:  d.x = 16; d.y = 16; d.z = 16; d.w = 16; d.f = cudaChannelFormatKindUnsigned; break;
        case DXGI_FORMAT_R16G16B16A16_SINT:  d.x = 16; d.y = 16; d.z = 16; d.w = 16; d.f = cudaChannelFormatKindSigned;   break;
        case DXGI_FORMAT_R32_UINT:           d.x = 32; d.y = 0;  d.z = 0;  d.w = 0;  d.f = cudaChannelFormatKindUnsigned; break;
        case DXGI_FORMAT_R32_SINT:           d.x = 32; d.y = 0;  d.z = 0;  d.w = 0;  d.f = cudaChannelFormatKindSigned;   break;
        case DXGI_FORMAT_R32_FLOAT:          d.x = 32; d.y = 0;  d.z = 0;  d.w = 0;  d.f = cudaChannelFormatKindFloat;    break;
        case DXGI_FORMAT_R32G32_UINT:        d.x = 32; d.y = 32; d.z = 0;  d.w = 0;  d.f = cudaChannelFormatKindUnsigned; break;
        case DXGI_FORMAT_R32G32_SINT:        d.x = 32; d.y = 32; d.z = 0;  d.w = 0;  d.f = cudaChannelFormatKindSigned;   break;
        case DXGI_FORMAT_R32G32_FLOAT:       d.x = 32; d.y = 32; d.z = 0;  d.w = 0;  d.f = cudaChannelFormatKindFloat;    break;
        case DXGI_FORMAT_R32G32B32A32_UINT:  d.x = 32; d.y = 32; d.z = 32; d.w = 32; d.f = cudaChannelFormatKindUnsigned; break;
        case DXGI_FORMAT_R32G32B32A32_SINT:  d.x = 32; d.y = 32; d.z = 32; d.w = 32; d.f = cudaChannelFormatKindSigned;   break;
        case DXGI_FORMAT_R32G32B32A32_FLOAT: d.x = 32; d.y = 32; d.z = 32; d.w = 32; d.f = cudaChannelFormatKindFloat;    break;
        default: assert(0);
    }
    return d;
}

cudaExtent getCudaExtentForD3D12Extent(UINT64 width, UINT height, UINT16 depthOrArraySize, D3D12_SRV_DIMENSION d3d12SRVDimension) {
    cudaExtent e = { 0, 0, 0 };

    switch (d3d12SRVDimension) {
        case D3D12_SRV_DIMENSION_TEXTURE1D:        e.width = width; e.height = 0;      e.depth = 0;                break;
        case D3D12_SRV_DIMENSION_TEXTURE2D:        e.width = width; e.height = height; e.depth = 0;                break;
        case D3D12_SRV_DIMENSION_TEXTURE3D:        e.width = width; e.height = height; e.depth = depthOrArraySize; break;
        case D3D12_SRV_DIMENSION_TEXTURECUBE:      e.width = width; e.height = height; e.depth = depthOrArraySize; break;
        case D3D12_SRV_DIMENSION_TEXTURE1DARRAY:   e.width = width; e.height = 0;      e.depth = depthOrArraySize; break;
        case D3D12_SRV_DIMENSION_TEXTURE2DARRAY:   e.width = width; e.height = height; e.depth = depthOrArraySize; break;
        case D3D12_SRV_DIMENSION_TEXTURECUBEARRAY: e.width = width; e.height = height; e.depth = depthOrArraySize; break;
        default: assert(0);
    }

    return e;
}

unsigned int getCudaMipmappedArrayFlagsForD3D12Resource(D3D12_SRV_DIMENSION d3d12SRVDimension, D3D12_RESOURCE_FLAGS d3d12ResourceFlags, bool allowSurfaceLoadStore) {
    unsigned int flags = 0;

    switch (d3d12SRVDimension) {
        case D3D12_SRV_DIMENSION_TEXTURECUBE:      flags |= cudaArrayCubemap;                    break;
        case D3D12_SRV_DIMENSION_TEXTURECUBEARRAY: flags |= cudaArrayCubemap | cudaArrayLayered; break;
        case D3D12_SRV_DIMENSION_TEXTURE1DARRAY:   flags |= cudaArrayLayered;                    break;
        case D3D12_SRV_DIMENSION_TEXTURE2DARRAY:   flags |= cudaArrayLayered;                    break;
        default: break;
    }

    if (d3d12ResourceFlags & D3D12_RESOURCE_FLAG_ALLOW_RENDER_TARGET) {
        flags |= cudaArrayColorAttachment;
    }
    if (allowSurfaceLoadStore) {
        flags |= cudaArraySurfaceLoadStore;
    }

    return flags;
}

4.21.2.2.5. 导入同步对象

在调用ID3D12Device::CreateFence时通过设置标志D3D12_FENCE_FLAG_SHARED创建的可共享 Direct3D12 栅栏对象,可以使用与该对象关联的 NT 句柄导入 CUDA,如下所示。注意,当不再需要该句柄时,应用程序有责任将其关闭。NT 句柄持有对资源的引用,因此必须显式释放该句柄,才能释放底层信号量。

cudaExternalSemaphore_t importD3D12FenceFromNTHandle(HANDLE handle) {
    cudaExternalSemaphore_t extSem = NULL;
    cudaExternalSemaphoreHandleDesc desc = {};

    memset(&desc, 0, sizeof(desc));

    desc.type = cudaExternalSemaphoreHandleTypeD3D12Fence;
    desc.handle.win32.handle = handle;

    cudaImportExternalSemaphore(&extSem, &desc);

    // Input parameter 'handle' should be closed if it's not needed anymore
    CloseHandle(handle);

    return extSem;
}

如果存在命名句柄,也可以使用该句柄导入可共享的 Direct3D12 栅栏对象,如下所示。

cudaExternalSemaphore_t importD3D12FenceFromNamedNTHandle(LPCWSTR name) {
    cudaExternalSemaphore_t extSem = NULL;
    cudaExternalSemaphoreHandleDesc desc = {};
 
    memset(&desc, 0, sizeof(desc));

    desc.type = cudaExternalSemaphoreHandleTypeD3D12Fence;
    desc.handle.win32.name = (void *)name;

    cudaImportExternalSemaphore(&extSem, &desc);

    return extSem;
}

4.21.2.2.6. 向导入的同步对象发出信号或等待这些对象

一旦从 Direct3D12 导入了带有栅栏的信号量,就可以向其发出信号并等待这些信号量。

向栅栏对象发出信号会设置其值。等待此信号的相应等待操作必须在 Direct3D12 中发出。注意,等待此信号的等待操作必须在发出此信号之后发出。

void signalExternalSemaphore(cudaExternalSemaphore_t extSem, unsigned long long value, cudaStream_t stream) {
    cudaExternalSemaphoreSignalParams params = {};

    memset(&params, 0, sizeof(params));

    params.params.fence.value = value;

    cudaSignalExternalSemaphoresAsync(&extSem, &params, 1, stream);
}

栅栏对象会等待,直到其值大于或等于指定值。它所等待的相应信号必须在 Direct3D12 中发出。注意,必须先发出该信号,才能发出此等待操作。

void waitExternalSemaphore(cudaExternalSemaphore_t extSem, unsigned long long value, cudaStream_t stream) {
    cudaExternalSemaphoreWaitParams params = {};

    memset(&params, 0, sizeof(params));

    params.params.fence.value = value;

    cudaWaitExternalSemaphoresAsync(&extSem, &params, 1, stream);
}

4.21.2.3. NVIDIA 软件通信接口互操作性(NVSCI)

NvSciBuf 和 NvSciSync 是为满足以下用途而开发的接口:

  • NvSciBuf:允许应用程序在内存中分配和交换缓冲区

  • NvSciSync:允许应用程序在操作边界处管理同步对象

有关这些接口的更多详细信息,请参阅:https://docs.nvidia.com/drive。

4.21.2.3.1. 导入内存对象

要分配与给定 CUDA 设备兼容的 NvSciBuf 对象,必须在 NvSciBuf 属性列表中通过NvSciBufGeneralAttrKey_GpuId设置相应的 GPU ID,如下所示。应用程序还可以选择指定以下属性:

  • NvSciBufGeneralAttrKey_NeedCpuAccess:指定缓冲区是否需要 CPU 访问

  • NvSciBufRawBufferAttrKey_Align:指定NvSciBufType_RawBuffer的对齐要求

  • NvSciBufGeneralAttrKey_RequiredPerm:对于每个 NvSciBuf 内存对象实例,可以为不同的 UMD 配置不同的访问权限。例如,要为 GPU 提供对缓冲区的只读访问权限,可使用NvSciBufObjDupWithReducePerm()创建一个 NvSciBuf 对象副本,并将NvSciBufAccessPerm_Readonly作为输入参数。然后,将这个新创建的、权限降低的对象副本导入 CUDA,如下所示

  • NvSciBufGeneralAttrKey_EnableGpuCache:用于控制 GPU L2 可缓存性

  • NvSciBufGeneralAttrKey_EnableGpuCompression:用于指定 GPU 压缩

注意

有关这些属性及其有效输入选项的更多详细信息,请参阅 NvSciBuf 文档。

以下代码片段展示了它们的用法示例。

NvSciBufObj createNvSciBufObject() {
   // Raw Buffer Attributes for CUDA
    NvSciBufType bufType = NvSciBufType_RawBuffer;
    uint64_t rawsize = SIZE;
    uint64_t align = 0;
    bool cpuaccess_flag = true;
    NvSciBufAttrValAccessPerm perm = NvSciBufAccessPerm_ReadWrite;

    NvSciRmGpuId gpuid[] ={};
    CUuuid uuid;
    cuDeviceGetUuid(&uuid, dev);

    memcpy(&gpuid[0].bytes, &uuid.bytes, sizeof(uuid.bytes));
    // Disable cache on dev
    NvSciBufAttrValGpuCache gpuCache[] = {{gpuid[0], false}};
    NvSciBufAttrValGpuCompression gpuCompression[] = {{gpuid[0], NvSciBufCompressionType_GenericCompressible}};
    // Fill in values
    NvSciBufAttrKeyValuePair rawbuffattrs[] = {
         { NvSciBufGeneralAttrKey_Types, &bufType, sizeof(bufType) },
         { NvSciBufRawBufferAttrKey_Size, &rawsize, sizeof(rawsize) },
         { NvSciBufRawBufferAttrKey_Align, &align, sizeof(align) },
         { NvSciBufGeneralAttrKey_NeedCpuAccess, &cpuaccess_flag, sizeof(cpuaccess_flag) },
         { NvSciBufGeneralAttrKey_RequiredPerm, &perm, sizeof(perm) },
         { NvSciBufGeneralAttrKey_GpuId, &gpuid, sizeof(gpuid) },
         { NvSciBufGeneralAttrKey_EnableGpuCache, &gpuCache, sizeof(gpuCache) },
         { NvSciBufGeneralAttrKey_EnableGpuCompression, &gpuCompression, sizeof(gpuCompression) }
    };

    // Create list by setting attributes
    err = NvSciBufAttrListSetAttrs(attrListBuffer, rawbuffattrs,
            sizeof(rawbuffattrs)/sizeof(NvSciBufAttrKeyValuePair));

    NvSciBufAttrListCreate(NvSciBufModule, &attrListBuffer);

    // Reconcile And Allocate
    NvSciBufAttrListReconcile(&attrListBuffer, 1, &attrListReconciledBuffer,
                       &attrListConflictBuffer)
    NvSciBufObjAlloc(attrListReconciledBuffer, &bufferObjRaw);
    return bufferObjRaw;
}
NvSciBufObj bufferObjRo; // Readonly NvSciBuf memory obj
// Create a duplicate handle to the same memory buffer with reduced permissions
NvSciBufObjDupWithReducePerm(bufferObjRaw, NvSciBufAccessPerm_Readonly, &bufferObjRo);
return bufferObjRo;

可以使用 NvSciBufObj 句柄将分配的 NvSciBuf 内存对象导入 CUDA,如下所示。应用程序应查询分配的 NvSciBufObj,以获取填写 CUDA 外部内存描述符所需的属性。注意,属性列表和 NvSciBuf 对象应由应用程序维护。如果导入 CUDA 的 NvSciBuf 对象也被其他驱动程序映射,那么应用程序必须根据NvSciBufGeneralAttrKey_GpuSwNeedCacheCoherency输出属性的值,使用 NvSciSync 对象(请参阅导入同步对象)作为适当的屏障,以维持 CUDA 与其他驱动程序之间的相干性。

注意

有关如何分配和维护 NvSciBuf 对象的更多详细信息,请参阅NvSciBuf API 文档。

cudaExternalMemory_t importNvSciBufObject (NvSciBufObj bufferObjRaw) {

    /*************** Query NvSciBuf Object **************/
    NvSciBufAttrKeyValuePair bufattrs[] = {
                { NvSciBufRawBufferAttrKey_Size, NULL, 0 },
                { NvSciBufGeneralAttrKey_GpuSwNeedCacheCoherency, NULL, 0 },
                { NvSciBufGeneralAttrKey_EnableGpuCompression, NULL, 0 }
    };
    NvSciBufAttrListGetAttrs(retList, bufattrs,
        sizeof(bufattrs)/sizeof(NvSciBufAttrKeyValuePair)));
                ret_size = *(static_cast<const uint64_t*>(bufattrs[0].value));

    // Note cache and compression are per GPU attributes, so read values for specific gpu by comparing UUID
    // Read cacheability granted by NvSciBuf
    int numGpus = bufattrs[1].len / sizeof(NvSciBufAttrValGpuCache);
    NvSciBufAttrValGpuCache[] cacheVal = (NvSciBufAttrValGpuCache *)bufattrs[1].value;
    bool ret_cacheVal;
    for (int i = 0; i < numGpus; i++) {
        if (memcmp(gpuid[0].bytes, cacheVal[i].gpuId.bytes, sizeof(CUuuid)) == 0) {
            ret_cacheVal = cacheVal[i].cacheability);
        }
    }

    // Read compression granted by NvSciBuf
    numGpus = bufattrs[2].len / sizeof(NvSciBufAttrValGpuCompression);
    NvSciBufAttrValGpuCompression[] compVal = (NvSciBufAttrValGpuCompression *)bufattrs[2].value;
    NvSciBufCompressionType ret_compVal;
    for (int i = 0; i < numGpus; i++) {
        if (memcmp(gpuid[0].bytes, compVal[i].gpuId.bytes, sizeof(CUuuid)) == 0) {
            ret_compVal = compVal[i].compressionType);
        }
    }

    /*************** NvSciBuf Registration With CUDA **************/

    // Fill up CUDA_EXTERNAL_MEMORY_HANDLE_DESC
    cudaExternalMemoryHandleDesc memHandleDesc;
    memset(&memHandleDesc, 0, sizeof(memHandleDesc));
    memHandleDesc.type = cudaExternalMemoryHandleTypeNvSciBuf;
    memHandleDesc.handle.nvSciBufObject = bufferObjRaw;
    // Set the NvSciBuf object with required access permissions in this step
    memHandleDesc.handle.nvSciBufObject = bufferObjRo;
    memHandleDesc.size = ret_size;
    cudaImportExternalMemory(&extMemBuffer, &memHandleDesc);
    return extMemBuffer;
 }

4.21.2.3.2. 将缓冲区映射到导入的内存对象

可以将设备指针映射到导入的内存对象,如下所示。可以根据分配的NvSciBufObj的属性填写映射的偏移量和大小。所有映射的设备指针都必须使用cudaFree()释放。

void * mapBufferOntoExternalMemory(cudaExternalMemory_t extMem, unsigned long long offset, unsigned long long size) {
    void *ptr = NULL;
    cudaExternalMemoryBufferDesc desc = {};

    memset(&desc, 0, sizeof(desc));

    desc.offset = offset;
    desc.size = size;

    cudaExternalMemoryGetMappedBuffer(&ptr, extMem, &desc);

    // Note: 'ptr' must eventually be freed using cudaFree()
    return ptr;
}

4.21.2.3.3. 将多级渐远纹理数组映射到导入的内存对象

可以将 CUDA 多级渐远纹理数组映射到导入的内存对象,如下所示。可以根据分配的NvSciBufObj的属性填写偏移量、维度和格式。所有映射的多级渐远纹理数组都必须使用cudaFreeMipmappedArray()释放。以下代码示例展示了在将多级渐远纹理数组映射到导入的内存对象时,如何将 NvSciBuf 属性转换为相应的 CUDA 参数。

注意

mip 级别数必须为 1。

cudaMipmappedArray_t mapMipmappedArrayOntoExternalMemory(cudaExternalMemory_t extMem, unsigned long long offset, cudaChannelFormatDesc *formatDesc, cudaExtent *extent, unsigned int flags, unsigned int numLevels) {
    cudaMipmappedArray_t mipmap = NULL;
    cudaExternalMemoryMipmappedArrayDesc desc = {};

    memset(&desc, 0, sizeof(desc));

    desc.offset = offset;
    desc.formatDesc = *formatDesc;
    desc.extent = *extent;
    desc.flags = flags;
    desc.numLevels = numLevels;

    // Note: 'mipmap' must eventually be freed using cudaFreeMipmappedArray()
    cudaExternalMemoryGetMappedMipmappedArray(&mipmap, extMem, &desc);

    return mipmap;
}

4.21.2.3.4. 导入同步对象

可以使用cudaDeviceGetNvSciSyncAttributes()生成与给定 CUDA 设备兼容的 NvSciSync 属性。返回的属性列表可用于创建一个保证与给定 CUDA 设备兼容的NvSciSyncObj。

NvSciSyncObj createNvSciSyncObject() {
    NvSciSyncObj nvSciSyncObj
    int cudaDev0 = 0;
    int cudaDev1 = 1;
    NvSciSyncAttrList signalerAttrList = NULL;
    NvSciSyncAttrList waiterAttrList = NULL;
    NvSciSyncAttrList reconciledList = NULL;
    NvSciSyncAttrList newConflictList = NULL;

    NvSciSyncAttrListCreate(module, &signalerAttrList);
    NvSciSyncAttrListCreate(module, &waiterAttrList);
    NvSciSyncAttrList unreconciledList[2] = {NULL, NULL};
    unreconciledList[0] = signalerAttrList;
    unreconciledList[1] = waiterAttrList;

    cudaDeviceGetNvSciSyncAttributes(signalerAttrList, cudaDev0, CUDA_NVSCISYNC_ATTR_SIGNAL);
    cudaDeviceGetNvSciSyncAttributes(waiterAttrList, cudaDev1, CUDA_NVSCISYNC_ATTR_WAIT);

    NvSciSyncAttrListReconcile(unreconciledList, 2, &reconciledList, &newConflictList);

    NvSciSyncObjAlloc(reconciledList, &nvSciSyncObj);

    return nvSciSyncObj;
}

可以使用 NvSciSyncObj 句柄将 NvSciSync 对象(按上述方式创建)导入 CUDA,如下所示。注意,即使在导入之后,NvSciSyncObj 句柄的所有权仍归应用程序所有。

cudaExternalSemaphore_t importNvSciSyncObject(void* nvSciSyncObj) {
    cudaExternalSemaphore_t extSem = NULL;
    cudaExternalSemaphoreHandleDesc desc = {};

    memset(&desc, 0, sizeof(desc));

    desc.type = cudaExternalSemaphoreHandleTypeNvSciSync;
    desc.handle.nvSciSyncObj = nvSciSyncObj;

    cudaImportExternalSemaphore(&extSem, &desc);

    // Deleting/Freeing the nvSciSyncObj beyond this point will lead to undefined behavior in CUDA

    return extSem;
}

4.21.2.3.5. 向导入的同步对象发出信号或等待这些对象

可以按下述方式向导入的NvSciSyncObj对象发出信号。向由 NvSciSync 支持的信号量对象发出信号,会初始化作为输入传入的栅栏参数。与上述信号对应的等待操作会等待此栅栏参数。此外,等待此信号的等待操作必须在发出此信号之后发出。如果将标志设置为cudaExternalSemaphoreSignalSkipNvSciBufMemSync,则会跳过默认作为发出信号操作的一部分执行的内存同步操作(针对该进程中所有导入的 NvSciBuf)。当NvsciBufGeneralAttrKey_GpuSwNeedCacheCoherency为 FALSE 时,应设置此标志。

void signalExternalSemaphore(cudaExternalSemaphore_t extSem, cudaStream_t stream, void *fence) {
    cudaExternalSemaphoreSignalParams signalParams = {};

    memset(&signalParams, 0, sizeof(signalParams));

    signalParams.params.nvSciSync.fence = (void*)fence;
    signalParams.flags = 0; //OR cudaExternalSemaphoreSignalSkipNvSciBufMemSync

    cudaSignalExternalSemaphoresAsync(&extSem, &signalParams, 1, stream);

}

可以按下述方式等待导入的NvSciSyncObj对象。等待由 NvSciSync 支持的信号量对象,会一直等待,直到相应的信号发出者向输入的栅栏参数发出信号。此外,必须先发出信号,才能发出等待操作。如果将标志设置为cudaExternalSemaphoreWaitSkipNvSciBufMemSync,则会跳过默认作为发出信号操作的一部分执行的内存同步操作(针对该进程中所有导入的 NvSciBuf)。当NvsciBufGeneralAttrKey_GpuSwNeedCacheCoherency为 FALSE 时,应设置此标志。

void waitExternalSemaphore(cudaExternalSemaphore_t extSem, cudaStream_t stream, void *fence) {
     cudaExternalSemaphoreWaitParams waitParams = {};

    memset(&waitParams, 0, sizeof(waitParams));

    waitParams.params.nvSciSync.fence = (void*)fence;
    waitParams.flags = 0; //OR cudaExternalSemaphoreWaitSkipNvSciBufMemSync

    cudaWaitExternalSemaphoresAsync(&extSem, &waitParams, 1, stream);
}

4.22. 驱动程序入口点访问

4.22.1. 简介

Driver Entry Point Access APIs提供了一种获取 CUDA 驱动程序函数地址的方法。从 CUDA 11.3 开始,用户可以使用从这些 API 获得的函数指针调用可用的 CUDA 驱动程序 API。

这些 API 提供的功能类似于其对应接口,即 POSIX 平台上的 dlsym 和 Windows 上的 GetProcAddress。所提供的 API 允许用户:

  • 使用CUDA Driver API.获取驱动程序函数的地址

  • 使用CUDA Runtime API.获取驱动程序函数的地址

  • 请求 CUDA 驱动程序函数的每线程默认流版本。有关更多详细信息,请参阅获取每线程默认流版本。

  • 在使用较旧工具包但配备较新驱动程序的情况下访问新的 CUDA 功能。

4.22.2. 驱动程序函数类型定义

为便于获取 CUDA 驱动程序 API 入口点,CUDA Toolkit 提供了对头文件的访问,这些头文件包含所有 CUDA 驱动程序 API 的函数指针定义。这些头文件随 CUDA Toolkit 一同安装,位于工具包的include/目录中。下表汇总了包含各个 CUDA API 头文件对应的typedefs的头文件。

表 27 CUDA 驱动程序 API 的类型定义头文件

API 头文件

API 类型定义头文件

cuda.h

cudaTypedefs.h

cudaGL.h

cudaGLTypedefs.h

cudaProfiler.h

cudaProfilerTypedefs.h

cudaVDPAU.h

cudaVDPAUTypedefs.h

cudaEGL.h

cudaEGLTypedefs.h

cudaD3D9.h

cudaD3D9Typedefs.h

cudaD3D10.h

cudaD3D10Typedefs.h

cudaD3D11.h

cudaD3D11Typedefs.h

上述头文件本身并不定义实际的函数指针,而是定义函数指针的类型定义。例如,cudaTypedefs.h为驱动程序 APIcuMemAlloc提供了以下类型定义:

typedef CUresult (CUDAAPI *PFN_cuMemAlloc_v3020)(CUdeviceptr_v2 *dptr, size_t bytesize);
typedef CUresult (CUDAAPI *PFN_cuMemAlloc_v2000)(CUdeviceptr_v1 *dptr, unsigned int bytesize);

CUDA 驱动程序符号采用基于版本的命名方案,除第一个版本外,其名称中都带有_v*扩展。当某个特定 CUDA 驱动程序 API 的签名或语义发生变化时,我们会递增相应驱动程序符号的版本号。以cuMemAlloc驱动程序 API 为例,第一个驱动程序符号名称为cuMemAlloc,下一个符号名称为cuMemAlloc_v2。在 CUDA 2.0(2000)中引入的第一个版本的类型定义是PFN_cuMemAlloc_v2000。在 CUDA 3.2(3020)中引入的下一个版本的类型定义是PFN_cuMemAlloc_v3020。

使用typedefs可以更轻松地在代码中定义具有适当类型的函数指针:

PFN_cuMemAlloc_v3020 pfn_cuMemAlloc_v2;
PFN_cuMemAlloc_v2000 pfn_cuMemAlloc_v1;

4.22.3. 获取驱动程序函数

使用驱动程序入口点访问 API 和适当的类型定义,我们可以获取指向任何 CUDA 驱动程序 API 的函数指针。

4.22.3.1. 使用驱动程序 API

驱动程序 API 要求将 CUDA 版本作为参数,以获取所请求驱动程序符号的 ABI 兼容版本。CUDA 驱动程序 API 为每个函数分别定义 ABI,并使用_v*扩展表示。例如,请看cuStreamBeginCapture的各个版本及其在cudaTypedefs.h中对应的typedefs:

// cuda.h
CUresult CUDAAPI cuStreamBeginCapture(CUstream hStream);
CUresult CUDAAPI cuStreamBeginCapture_v2(CUstream hStream, CUstreamCaptureMode mode);

// cudaTypedefs.h
typedef CUresult (CUDAAPI *PFN_cuStreamBeginCapture_v10000)(CUstream hStream);
typedef CUresult (CUDAAPI *PFN_cuStreamBeginCapture_v10010)(CUstream hStream, CUstreamCaptureMode mode);

从上述代码片段中的typedefs可以看出,版本后缀_v10000和_v10010表明上述 API 分别是在 CUDA 10.0 和 CUDA 10.1 中引入的。

#include <cudaTypedefs.h>

// Declare the entry points for cuStreamBeginCapture
PFN_cuStreamBeginCapture_v10000 pfn_cuStreamBeginCapture_v1;
PFN_cuStreamBeginCapture_v10010 pfn_cuStreamBeginCapture_v2;

// Get the function pointer to the cuStreamBeginCapture driver symbol
cuGetProcAddress("cuStreamBeginCapture", &pfn_cuStreamBeginCapture_v1, 10000, CU_GET_PROC_ADDRESS_DEFAULT, &driverStatus);
// Get the function pointer to the cuStreamBeginCapture_v2 driver symbol
cuGetProcAddress("cuStreamBeginCapture", &pfn_cuStreamBeginCapture_v2, 10010, CU_GET_PROC_ADDRESS_DEFAULT, &driverStatus);

参照上述代码片段,要获取驱动程序 APIcuStreamBeginCapture的_v1版本的地址,CUDA 版本参数应恰好为 10.0(10000)。同样,要获取该 API 的_v2版本的地址,CUDA 版本应为 10.1(10010)。指定较高的 CUDA 版本来获取驱动程序 API 的某个特定版本,可能并不总是具有可移植性。例如,在此处使用 11030 仍会返回_v2符号,但如果假设的_v3版本在 CUDA 11.3 中发布,那么当与 CUDA 11.3 驱动程序配合使用时,cuGetProcAddressAPI 就会开始转而返回较新的_v3符号。由于_v2和_v3符号的 ABI 和函数签名可能不同,使用为_v2符号设计的_v10010类型定义来调用_v3函数,会表现出未定义行为。

注意,使用无效的 CUDA 版本请求驱动程序 API 会返回错误CUDA_ERROR_NOT_FOUND。在上述代码示例中,传入小于 10000(CUDA 10.0)的版本将是无效的。

4.22.3.2. 使用运行时 API

运行时 APIcudaGetDriverEntryPointByVersion使用所提供的 CUDA 版本,以与cuGetProcAddress相同的方式获取所请求驱动程序符号的 ABI 兼容版本。在下面的代码片段中,所需的最低 CUDA 版本为 CUDA 11.2,因为cuMemAllocAsync是在该版本中引入的。

#include <cudaTypedefs.h>

int cudaVersion;
// Ensure a CUDA driver >= 11.2 is installed or we will get an error from cuGetProcAddress
status = cuDriverGetVersion(&cudaVersion);
if (cudaVersion >= 11020) {

   // Declare the entry point
   PFN_cuMemAllocAsync_v11020 pfn_cuMemAllocAsync;

   // Initialize the entry point
   cudaGetDriverEntryPointByVersion("cuMemAllocAsync", &pfn_cuMemAllocAsync, 11020, cudaEnableDefault, &driverStatus);

   // Call the entry point
   if(driverStatus == cudaDriverEntryPointSuccess && pfn_cuMemAllocAsync) {
       pfn_cuMemAllocAsync(...);
   }
}

4.22.3.3. 获取每线程默认流版本

某些 CUDA 驱动程序 API 可以配置为具有默认流或每线程默认流语义。具有每线程默认流语义的驱动程序 API,其名称以_ptsz或_ptds为后缀。例如,cuLaunchKernel有一个名为cuLaunchKernel_ptsz的每线程默认流变体。借助驱动程序入口点访问 API,用户可以请求驱动程序 APIcuLaunchKernel的每线程默认流版本,而不是默认流版本。将 CUDA 驱动程序 API 配置为具有默认流或每线程默认流语义会影响同步行为。有关更多详细信息,请参阅此处。

可以通过以下方式之一获取驱动程序 API 的默认流或每线程默认流版本:

  • 使用编译标志--default-stream per-thread或定义宏CUDA_API_PER_THREAD_DEFAULT_STREAM,以获得每线程默认流行为。

  • 分别使用标志CU_GET_PROC_ADDRESS_LEGACY_STREAM/cudaEnableLegacyStream或CU_GET_PROC_ADDRESS_PER_THREAD_DEFAULT_STREAM/cudaEnablePerThreadDefaultStream,强制采用默认流或每线程默认流行为。

4.22.3.4. 访问新的 CUDA 功能

始终建议安装最新的 CUDA 工具包来访问新的 CUDA 驱动程序功能,但如果出于某种原因,用户不想更新或无法获取最新的工具包,则可以使用此 API,仅通过更新 CUDA 驱动程序来访问新的 CUDA 功能。为便于讨论,假设用户正在使用 CUDA 12.3,并且想使用 CUDA 12.5 驱动程序中提供的新驱动程序 APIcuFoo。下面的代码片段展示了这一用例:

int main()
{
    // Manually define the prototype as cudaTypedefs.h in CUDA 12.3 does not have the cuFoo typedef
    typedef CUresult (CUDAAPI *PFN_cuFoo_v12050)(...);
    PFN_cuFoo_v12050 pfn_cuFoo = NULL;
    CUdriverProcAddressQueryResult driverStatus;
    int cudaVersion;

    // Ensure a CUDA driver >= 12.5 is installed or we will get an error from cuGetProcAddress
    CUresult status = cuDriverGetVersion(&cudaVersion);
    if (cudaVersion >= 12050) {
        // Get the address for cuFoo API using cuGetProcAddress. Specify CUDA version as
        // 12050 since cuFoo was introduced then
        CUresult status = cuGetProcAddress("cuFoo", &pfn_cuFoo, 12050, CU_GET_PROC_ADDRESS_DEFAULT, &driverStatus);

        if (status == CUDA_SUCCESS && pfn_cuFoo) {
            pfn_cuFoo(...);
        }
        else {
            printf("Cannot retrieve the address to cuFoo - driverStatus = %d\n", driverStatus);
            assert(0);
        }
    }

    // rest of code here
}

在下一个示例中,我们将讨论如何获取在 CUDA Toolkit 的某个次版本中发布的 API 新版本。注意,在 cuda.h 头文件中,将cuDeviceGetUuid提升到 _v2 的版本宏,直到跨入新的主版本时才会更新。因此,以下示例展示了在 11.4 及后续版本期间如何获取 _v2 版本。

注意,在这种情况下,原始版本(而不是 _v2 版本)的类型定义如下所示:

typedef CUresult (CUDAAPI *PFN_cuDeviceGetUuid_v9020)(CUuuid *uuid, CUdevice_v1 dev);

而 _v2 版本的类型定义如下所示:

typedef CUresult (CUDAAPI *PFN_cuDeviceGetUuid_v11040)(CUuuid *uuid, CUdevice_v1 dev);
#include <cudaTypedefs.h>

CUuuid uuid;
CUdevice dev;
CUresult status;
int cudaVersion;
CUdriverProcAddressQueryResult driverStatus;

status = cuDeviceGet(&dev, 0); // Get device 0
// handle status

// Ensure a CUDA driver >= 11.4 is installed or we will get an error from cuGetProcAddress
status = cuDriverGetVersion(&cudaVersion);
if (cudaVersion >= 11040) {
   PFN_cuDeviceGetUuid_v11040 pfn_cuDeviceGetUuid;
   status = cuGetProcAddress("cuDeviceGetUuid", &pfn_cuDeviceGetUuid, 11040, CU_GET_PROC_ADDRESS_DEFAULT, &driverStatus);
   if(CUDA_SUCCESS == status && pfn_cuDeviceGetUuid) {
      pfn_cuDeviceGetUuid(&uuid, dev);
   }
}

4.22.4. cuGetProcAddress 使用准则

以下是使用cuGetProcAddress时应牢记的准则。

  • 将传递给cuGetProcAddress的 CUDA 版本编码为与类型定义版本一致的值(不要使用CUDA_VERSION之类的编译时常量,也不要使用cuDriverGetVersion返回的版本之类的动态版本)

  • 在调用cuGetProcAddress之前,检查当前驱动程序版本(例如从cuDriverGetVersion获取的版本)是否足够,否则预计会出现错误,或可能返回非预期的符号

4.22.4.1. 运行时 API 使用准则

除非另有说明,CUDA 运行时 APIcudaGetDriverEntryPointByVersion将采用与驱动程序入口点cuGetProcAddress类似的准则,因为它允许用户请求特定的 CUDA 驱动程序版本。

4.22.5. 确定 cuGetProcAddress 失败的原因

cuGetProcAddress 存在两类错误,分别是 (1) API/使用错误和 (2) 无法找到所请求的驱动程序 API。第一类错误会通过 CUresult 返回值返回 API 的错误代码。例如,将 NULL 作为pfn变量传入,或者传入无效的flags。

第二类错误编码在CUdriverProcAddressQueryResult *symbolStatus中,可用于帮助区分导致驱动程序无法找到所请求符号的潜在问题。以下面的示例为例:

// cuDeviceGetExecAffinitySupport was introduced in release CUDA 11.4
#include <cuda.h>
CUdriverProcAddressQueryResult driverStatus;
cudaVersion = ...;
status = cuGetProcAddress("cuDeviceGetExecAffinitySupport", &pfn, cudaVersion, 0, &driverStatus);
if (CUDA_SUCCESS == status) {
    if (CU_GET_PROC_ADDRESS_VERSION_NOT_SUFFICIENT == driverStatus) {
        printf("We can use the new feature when you upgrade cudaVersion to 11.4, but CUDA driver is good to go!\n");
        // Indicating cudaVersion was < 11.4 but run against a CUDA driver >= 11.4
    }
    else if (CU_GET_PROC_ADDRESS_SYMBOL_NOT_FOUND == driverStatus) {
        printf("Please update both CUDA driver and cudaVersion to at least 11.4 to use the new feature!\n");
        // Indicating driver is < 11.4 since string not found, doesn't matter what cudaVersion was
    }
    else if (CU_GET_PROC_ADDRESS_SUCCESS == driverStatus && pfn) {
        printf("You're using cudaVersion and CUDA driver >= 11.4, using new feature!\n");
        pfn();
    }
}

第一种情况的返回代码为CU_GET_PROC_ADDRESS_VERSION_NOT_SUFFICIENT,表示在 CUDA 驱动程序中搜索时找到了symbol,但它是在所提供的cudaVersion之后才添加的。在此示例中,将cudaVersion指定为 11030 或更小的任意值,并使用版本大于或等于 CUDA 11.4 的 CUDA 驱动程序运行时,就会得到CU_GET_PROC_ADDRESS_VERSION_NOT_SUFFICIENT这一结果。这是因为cuDeviceGetExecAffinitySupport是在 CUDA 11.4 (11040) 中添加的。

第二种情况的返回代码为CU_GET_PROC_ADDRESS_SYMBOL_NOT_FOUND,表示在 CUDA 驱动程序中搜索时未找到symbol。这可能有几种原因,例如,驱动程序较旧而不支持该 CUDA 函数,或者只是存在拼写错误。在后一种情况下,与上一个示例类似,如果用户将symbol设为 CUDeviceGetExecAffinitySupport,请注意字符串开头的大写 CU,那么cuGetProcAddress将无法找到该 API,因为字符串不匹配。前一种情况的一个例子可能是,用户针对支持新 API 的 CUDA 驱动程序开发应用程序,却将应用程序部署到较旧的 CUDA 驱动程序上。以上一个示例为例,如果开发者针对 CUDA 11.4 或更高版本进行开发,但部署时使用的是 CUDA 11.3 驱动程序,那么在开发过程中,cuGetProcAddress可能曾经成功,但在部署使用 CUDA 11.3 驱动程序运行的应用程序时,该调用将不再有效,并会在driverStatus中返回CU_GET_PROC_ADDRESS_SYMBOL_NOT_FOUND。

5.1. 计算能力

计算设备的一般规格和功能取决于其计算能力(参见计算能力与流式多处理器版本)。

表 29、表 30和表 31展示了当前支持的各个计算能力所对应的功能和技术规格。

所有 NVIDIA GPU 架构均使用小端表示法。

5.1.1. 获取 GPU 计算能力

CUDA GPU 计算能力页面提供了 NVIDIA GPU 型号与其计算能力之间的完整映射。

也可以使用随NVIDIA 驱动程序提供的nvidia-smi工具来获取 GPU 的计算能力。例如,以下命令将输出系统上可用的 GPU 名称和计算能力:

nvidia-smi --query-gpu=name,compute_cap

在运行时,可以使用 CUDA 运行时 APIcudaDeviceGetAttribute()、CUDA 驱动程序 APIcuDeviceGetAttribute()或 NVML APInvmlDeviceGetCudaComputeCapability()获取计算能力:

#include <cuda_runtime_api.h>

int computeCapabilityMajor, computeCapabilityMinor;
cudaDeviceGetAttribute(&computeCapabilityMajor, cudaDevAttrComputeCapabilityMajor, device_id);
cudaDeviceGetAttribute(&computeCapabilityMinor, cudaDevAttrComputeCapabilityMinor, device_id);
#include <cuda.h>

int computeCapabilityMajor, computeCapabilityMinor;
cuDeviceGetAttribute(&computeCapabilityMajor, CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MAJOR, device_id);
cuDeviceGetAttribute(&computeCapabilityMinor, CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MINOR, device_id);
#include <nvml.h> // required linking with -lnvidia-ml

int computeCapabilityMajor, computeCapabilityMinor;
nvmlDeviceGetCudaComputeCapability(nvmlDevice, &computeCapabilityMajor, &computeCapabilityMinor);

5.1.2. 功能可用性

随某种计算架构引入的大多数计算功能都旨在供所有后续架构使用。表 29通过在某项功能引入之后的计算能力中将该功能的可用性标为“是”来表示这一点。

5.1.2.1. 架构特定功能

从计算能力 9.0 的设备开始,随某种架构引入的专用计算功能可能无法保证在所有后续计算能力上均可用。这些功能称为架构特定功能,旨在加速专用运算,例如张量核心运算,这些运算并非适用于所有类别的计算能力,或者可能会在未来的几代架构中发生重大变化。代码必须使用架构特定编译器目标进行编译(参见功能集编译器目标),才能启用架构特定功能。使用架构特定编译器目标编译的代码只能在与编译时所针对的计算能力完全相同的计算能力上运行。

5.1.2.2. 系列特定功能

从计算能力 10.0 的设备开始,一些架构特定功能由多种计算能力的设备共有。包含这些功能的设备属于同一系列,这些功能也可称为系列特定功能。系列特定功能保证在同一系列的所有设备上均可用。启用系列特定功能需要使用系列特定编译器目标。参见第 5.1.2.3 节。针对系列特定目标编译的代码只能在属于该系列的 GPU 上运行。

5.1.2.3. 功能集编译器目标

编译器可以针对三组计算功能进行编译:

基线功能集:占主导地位的一组计算功能,引入这些功能的目的是使其可用于后续计算架构。表 29汇总了这些功能及其可用性。

架构特定功能集:一小组高度专门化的功能,称为架构特定功能,引入它们是为了加速专用运算,但不保证其在后续计算架构上可用,或者它们可能会发生重大变化。相应的“计算能力 #.#”小节汇总了这些功能。架构特定功能集是系列特定功能集的超集。架构特定编译器目标随计算能力 9.0 的设备一同引入,可通过在编译目标中使用a后缀来选择,例如,将compute_100a或compute_120a指定为计算目标。

系列特定功能集:一些架构特定功能由多种计算能力的 GPU 共有。相应的“计算能力 #.#”小节汇总了这些功能。除少数例外,具有相同计算能力主版本号的后续几代设备均属于同一系列。表 28列出了系列特定目标与设备计算能力之间的兼容性,包括例外情况。系列特定功能集是基线功能集的超集。系列特定编译器目标随计算能力 10.0 的设备一同引入,可通过在编译目标中使用f后缀来选择,例如,将compute_100f或compute_120f指定为计算目标。

从计算能力 9.0 开始,所有设备都具有一组架构特定功能。要在特定 GPU 上使用这些功能的完整集合,必须使用带有a后缀的架构特定编译器目标。此外,从计算能力 10.0 开始,存在一些功能集,它们出现在具有不同计算能力次版本号的多个设备中。这些指令集称为系列特定功能,共有这些功能的设备被称为属于同一系列。系列特定功能是该 GPU 系列所有成员共有的架构特定功能的一个子集。带有f后缀的系列特定编译器目标允许编译器生成使用这一架构特定功能公共子集的代码。

例如:

  • compute_100编译目标不允许使用架构特定功能。此目标将兼容计算能力为 10.0 及更高版本的所有设备。

  • compute_100f系列特定编译目标允许使用整个 GPU 系列共有的架构特定功能子集。此目标将仅兼容属于该 GPU 系列的设备。在此示例中,它兼容计算能力为 10.0、10.3 和 10.7 的设备。系列特定compute_100f目标中可用的功能是基线compute_100目标中可用功能的超集。

  • compute_100a架构特定编译目标允许使用计算能力 10.0 设备中的全部架构特定功能。此目标将仅兼容计算能力为 10.0 的设备,而不兼容其他任何设备。compute_100a目标中可用的功能构成了compute_100f目标中可用功能的超集。

表 28 系列特定兼容性

编译目标

兼容的计算能力

compute_100f

10.0

10.3

10.7

compute_103f

10.3

10.7

compute_107f

10.7 [1]

compute_110f

11.0 [1]

compute_120f

12.0

12.1

compute_121f

12.1 [1]

5.1.3. 功能和技术规格

表 29 各计算能力的功能支持情况

功能支持

计算能力

(所有计算能力均支持未列出的功能)

7.x

8.x

9.0

10.x

11.0

12.x

对共享内存和全局内存中的 128 位整数值进行操作的原子函数(原子函数)

否

是

对全局内存中的float2和float4浮点向量进行操作的原子加法(atomicAdd())

否

是

线程束归约函数(线程束归约函数)

否

是

Bfloat16 精度浮点运算

否

是

128 位精度浮点运算

否

是

硬件加速的memcpy_async(流水线)

否

是

硬件加速的到达/等待分离式屏障(异步屏障)

否

是

L2 缓存驻留管理(L2 缓存控制)

否

是

用于加速动态规划的 DPX 指令(动态规划扩展(DPX)指令)

多条指令

原生

多条指令

分布式共享内存

否

是

线程块簇(线程块簇)

否

是

张量内存加速器(TMA)单元(使用张量内存加速器(TMA))

否

是

请注意,下列表格中使用的单位 KB 和 K 分别对应 1024 字节(即一个 KiB)和 1024。

表 30 各计算能力的设备和流式多处理器(SM)信息

计算能力

7.5

8.0

8.6

8.7

8.9

9.0

10.0

10.3

10.7

11.0

12.x

FP32 与 FP64 吞吐量之比[2]

32:1

2:1

64:1

2:1

64:1

4:1

64:1

每个设备的最大驻留网格数(并发核函数执行)

128

网格的最大维数

3

网格在 x 维上的最大尺寸

231-1

网格在 y 维或 z 维上的最大尺寸

65535

线程块的最大维数

3

线程块在 x 维或 y 维上的最大尺寸

1024

线程块在 z 维上的最大尺寸

64

每个线程块的最大线程数

1024

线程束大小

32

每个 SM 的最大驻留线程块数

16

32

16

24

32

16

24

每个 SM 的最大驻留线程束数

32

64

48

64

32

48

每个 SM 的最大驻留线程数

1024

2048

1536

2048

1024

1536

绿色上下文:useFlags 为 0 时的最小 SM 分区大小

2

4

8

绿色上下文:useFlags 为 0 时每个分区的 SM 协同调度对齐要求

2

8

表 31 各计算能力的内存信息

计算能力

7.5

8.0

8.6

8.7

8.9

9.0

10.0

10.3

10.7

11.0

12.x

每个 SM 的 32 位寄存器数量

64 K

每个线程块的最大 32 位寄存器数量

64 K

每个线程的最大 32 位寄存器数量

255

每个 SM 的最大共享内存容量

64 KB

164 KB

100 KB

164 KB

100 KB

228 KB

328 KB

228 KB

100 KB

每个线程块的最大共享内存容量[3]

64 KB

163 KB

99 KB

163 KB

99 KB

227 KB

327 KB

227 KB

99 KB

共享内存存储体数量

32

每个线程的最大局部内存容量

512 KB

常量内存大小

64 KB

每个 SM 的常量内存缓存工作集

8 KB

每个 SM 的纹理内存缓存工作集

32 或 64 KB

28 KB ~ 192 KB

28 KB ~ 128 KB

28 KB ~ 192 KB

28 KB ~ 128 KB

28 KB ~ 256 KB

8 KB ~ 256 KB

28 KB ~ 256 KB

28 KB ~ 128 KB

表 32 各计算能力的共享内存容量

计算能力

统一数据缓存大小(KB)

SMEM 容量大小(KB)

7.5

96

32, 64

8.0

192

0, 8, 16, 32, 64, 100, 132, 164

8.6

128

0, 8, 16, 32, 64, 100

8.7

192

0, 8, 16, 32, 64, 100, 132, 164

8.9

128

0, 8, 16, 32, 64, 100

9.0

256

0, 8, 16, 32, 64, 100, 132, 164, 196, 228

10.0

256

0, 8, 16, 32, 64, 100, 132, 164, 196, 228

10.3

256

0, 8, 16, 32, 64, 100, 132, 164, 196, 228

10.7 [4]

336

0, 8, 16, 32, 64, 100, 132, 164, 196, 228, 328

11.0

256

0, 8, 16, 32, 64, 100, 132, 164, 196, 228

12.x

128

0, 8, 16, 32, 64, 100

表 33展示了张量核心加速所支持的输入数据类型。在 CUDA 编译工具链中,可以通过内联 PTX 使用张量核心功能集。强烈建议应用程序通过 CUDA-X 库(例如 cuDNN、cuBLAS 和 cuFFT)或通过CUTLASS来使用此功能集,后者是一组 CUDA C++ 模板抽象和 Python 领域特定语言(DSL),旨在实现 CUDA 内各个层级的高性能矩阵-矩阵乘法(GEMM)及相关计算。

表 33 各计算能力的张量核心加速所支持的输入数据类型

计算能力

张量核心输入数据类型

FP64

TF32

BF16

FP16

FP8

FP6

FP4

INT8

INT4

7.5

是

是

是

8.0

是

是

是

是

是

是

8.6

是

是

是

是

是

8.7

是

是

是

是

是

8.9

是

是

是

是

是

是

9.0

是

是

是

是

是

是

10.0

是

是

是

是

是

是

是

是

10.3

是

是

是

是

是

是

是

10.7

是

是

是

是

是

是

是

11.0

是

是

是

是

是

是

是

12.x

是

是

是

是

是

是

是

5.2. CUDA 环境变量

以下一节列出了 CUDA 环境变量。与多进程服务(MPS)相关的环境变量记录在GPU 部署和管理指南中。

5.2.1. 设备枚举和属性

5.2.1.1. CUDA_VISIBLE_DEVICES

该环境变量控制哪些 GPU 设备对 CUDA 应用程序可见,以及枚举这些设备的顺序。

  • 如果未设置该变量,则所有 GPU 设备均可见。

  • 如果将该变量设置为空字符串,则所有 GPU 设备均不可见。

可能的值:以逗号分隔的 GPU 标识符序列。

GPU 标识符以如下形式提供:

  • 整数索引:这些索引对应于 GPU 在系统中的序号,由nvidia-smi确定,从 0 开始。例如,设置CUDA_VISIBLE_DEVICES=2,1会使设备 0 不可见,并在设备 1 之前枚举设备 2。

    • 如果遇到无效索引,则只有索引出现在列表中该无效索引之前的设备才可见。例如,设置CUDA_VISIBLE_DEVICES=0,2,-1,1会使设备 0 和 2 可见,而设备 1 不可见,因为它出现在无效索引-1之后。

  • GPU UUID 字符串:这些字符串应遵循nvidia-smi -L给出的相同格式,例如GPU-8932f937-d72c-4106-c12f-20bd9faed9f6。不过,为方便起见,也允许使用缩写形式;只需从 GPU UUID 的开头起指定足够多的位数,以便在目标系统中唯一标识该 GPU。例如,假设系统中没有其他 GPU 具有相同前缀,那么CUDA_VISIBLE_DEVICES=GPU-8932f937就可能是引用上述 GPU UUID 的一种有效方式。

  • 多实例 GPU(MIG)支持:MIG-<GPU-UUID>/<GPU instance ID>/<compute instance ID>。例如,MIG-GPU-8932f937-d72c-4106-c12f-20bd9faed9f6/1/2。仅支持枚举单个 MIG 实例。

cudaGetDeviceCount()API 返回的设备数量仅包括可见设备,因此,使用整数设备标识符的 CUDA API 仅支持 [0, 可见设备数量 - 1] 范围内的序号。GPU 设备的枚举顺序决定了序号值。例如,在CUDA_VISIBLE_DEVICES=2,1的情况下,调用cudaSetDevice(0)会将设备 2 设置为当前设备,因为它最先被枚举到,并被分配了序号 0。此后调用cudaGetDevice(&device_ordinal)也会将device_ordinal设置为 0,对应于设备 2。

示例:

nvidia-smi -L # Get list of GPU UUIDs
CUDA_VISIBLE_DEVICES=0,1
CUDA_VISIBLE_DEVICES=GPU-8932f937-d72c-4106-c12f-20bd9faed9f6
CUDA_VISIBLE_DEVICES=MIG-GPU-8932f937-d72c-4106-c12f-20bd9faed9f6/1/2

5.2.1.2. CUDA_DEVICE_ORDER

该环境变量控制 CUDA 枚举可用设备的顺序。

可选值:

  • FASTEST_FIRST:使用简单的启发式方法,按从最快到最慢的顺序枚举可用设备(默认)。

  • PCI_BUS_ID:按 PCI 总线 ID 的升序枚举可用设备。PCI 总线 ID 可通过nvidia-smi --query-gpu=name,pci.bus_id获取。

示例:

CUDA_DEVICE_ORDER=FASTEST_FIRST
CUDA_DEVICE_ORDER=PCI_BUS_ID
nvidia-smi --query-gpu=name,pci.bus_id # Get list of PCI bus IDs

5.2.1.3. CUDA_MANAGED_FORCE_DEVICE_ALLOC

该环境变量改变多 GPU 系统中统一内存的物理存储方式。

可选值:数值,可以为零或非零。

  • 非零值:强制驱动程序使用设备内存进行物理存储。进程中使用的所有支持托管内存的设备都必须具备对等兼容性。否则,将返回cudaErrorInvalidDevice。

  • 0:默认行为。

示例:

CUDA_MANAGED_FORCE_DEVICE_ALLOC=0
CUDA_MANAGED_FORCE_DEVICE_ALLOC=1 # force device memory

5.2.2. JIT 编译

5.2.2.1. CUDA_CACHE_DISABLE

该环境变量控制磁盘上的即时(JIT)编译缓存的行为。禁用 JIT 缓存会强制 CUDA 应用程序在每次执行时进行 PTX 到 CUBIN 的编译,除非在二进制文件中找到了适用于运行架构的 CUBIN 代码。同一缓存还用于存储最终化后的 cubin,设置此环境变量也会在必要时强制对 cubin 进行最终化。

禁用 JIT 缓存会增加应用程序首次执行时的加载时间。不过,这有助于减少应用程序占用的磁盘空间,以及诊断不同驱动程序版本或构建标志带来的差异。

可选值:

  • 1:禁用 PTX JIT 和二进制最终化缓存。

  • 0:启用 PTX JIT 缓存(默认)。

示例:

CUDA_CACHE_DISABLE=1 # disables caching
CUDA_CACHE_DISABLE=0 # enables caching

5.2.2.2. CUDA_CACHE_PATH

该环境变量指定即时(JIT)编译缓存的目录路径。同一缓存还用于存储最终化后的 cubin。

可选值:缓存目录的绝对路径(具有适当的访问权限)。默认值为:

  • 在 Windows 上,为%APPDATA%\NVIDIA\ComputeCache

  • 在 Linux 上,为~/.nv/ComputeCache

示例:

CUDA_CACHE_PATH=~/tmp

5.2.2.3. CUDA_CACHE_MAXSIZE

该环境变量指定即时(JIT)编译缓存的大小,以字节为单位。超过此大小的二进制文件不会被缓存。如果需要,会从缓存中逐出较旧的二进制文件,以便为较新的二进制文件腾出空间。同一缓存还用于存储最终化后的 cubin。

可选值:字节数。默认值为:

  • 在桌面/服务器平台上,为1073741824(1 GiB)

  • 在嵌入式平台上,为268435456(256 MiB)

最大大小为4294967296(4 GiB)。

示例:

CUDA_CACHE_MAXSIZE=268435456 # 256 MiB

5.2.2.4. CUDA_FORCE_PTX_JIT和CUDA_FORCE_JIT

这些环境变量指示 CUDA 驱动程序忽略应用程序中嵌入的任何 CUBIN,转而对嵌入的 PTX 代码执行即时(JIT)编译。

强制 JIT 编译会增加应用程序首次执行时的加载时间。不过,这可用于验证应用程序中是否嵌入了 PTX 代码,以及其即时编译是否正常运行。这确保了与未来架构的前向兼容性。

CUDA_FORCE_PTX_JIT会覆盖CUDA_FORCE_JIT。

可选值:

  • 1:强制进行 PTX JIT 编译。

  • 0:默认行为。

示例:

CUDA_FORCE_PTX_JIT=1

5.2.2.5. CUDA_DISABLE_PTX_JIT和CUDA_DISABLE_JIT

这些环境变量禁用对嵌入的 PTX 代码的即时(JIT)编译,并使用应用程序中嵌入的兼容 CUBIN。

如果核函数没有嵌入的二进制代码,或者嵌入的二进制代码是针对不兼容的架构编译的,则该核函数将无法加载。这些环境变量可用于验证应用程序是否为每个核函数生成了兼容的 CUBIN 代码。有关更多详细信息,请参阅二进制兼容性一节。

CUDA_DISABLE_PTX_JIT会覆盖CUDA_DISABLE_JIT。

可选值:

  • 1:禁用 PTX JIT 编译。

  • 0:默认行为。

示例:

CUDA_DISABLE_PTX_JIT=1

5.2.2.6. CUDA_FORCE_PRELOAD_LIBRARIES

该环境变量影响NVVM和即时(JIT)编译所需库的预加载。

可选值:

  • 1:这会强制驱动程序在初始化期间预加载NVVM和即时(JIT)编译所需的库。这会增加内存占用和 CUDA 驱动程序初始化所需的时间。要避免涉及多个线程的某些死锁情况,必须设置此环境变量。

  • 0:默认行为。

示例:

CUDA_FORCE_PRELOAD_LIBRARIES=1

5.2.3. 执行

5.2.3.1. CUDA_LAUNCH_BLOCKING

该环境变量指定禁用还是启用异步核函数启动。

禁用异步执行会导致执行速度变慢,但有助于调试。它会强制 GPU 工作从 CPU 的角度来看同步运行。这样便可以在触发错误的具体 API 调用处观察到 CUDA API 错误,而不是在后续执行过程中才观察到。同步执行有助于调试。

可选值:

  • 1:禁用异步执行。

  • 0:异步执行(默认)。

示例:

CUDA_LAUNCH_BLOCKING=1

5.2.3.2. CUDA_DEVICE_MAX_CONNECTIONS

该环境变量控制并发计算引擎连接和复制引擎连接(工作队列)的数量,将两者均设置为指定值。如果独立的 GPU 任务,即从不同 CUDA 流中启动的核函数或复制操作,映射到同一个工作队列,就会产生虚假依赖关系,由于使用了相同的底层资源,这可能导致 GPU 工作串行化。为降低出现此类虚假依赖关系的概率,建议通过此环境变量控制的工作队列数量大于或等于每个上下文中的活动 CUDA 流数量。

设置此环境变量也会修改复制连接的数量,除非已通过CUDA_DEVICE_MAX_COPY_CONNECTIONS环境变量显式设置了该数量。

可选值:1至32个连接,默认为8(假定未使用 MPS)

示例:

CUDA_DEVICE_MAX_CONNECTIONS=16

5.2.3.3. CUDA_DEVICE_MAX_COPY_CONNECTIONS

该环境变量控制参与复制操作的并发复制连接(工作队列)的数量。它仅影响计算能力为 8.0 及以上的设备。

如果两者都已设置,CUDA_DEVICE_MAX_COPY_CONNECTIONS会覆盖通过CUDA_DEVICE_MAX_CONNECTIONS设置的复制连接数量。

可选值:1至32个连接,默认为8(假定未使用 MPS)

示例:

CUDA_DEVICE_MAX_COPY_CONNECTIONS=16

5.2.3.4. CUDA_SCALE_LAUNCH_QUEUES

该环境变量指定可用于启动工作的队列(命令缓冲区)大小的缩放因子,即设备上可入队的待处理核函数或主机/设备复制操作的总数的缩放因子。

可选值:0.25x、0.5x、2x、4x

  • 除0.25x、0.5x、2x或4x之外的任何值均被解释为1x。

示例:

CUDA_SCALE_LAUNCH_QUEUES=2x

5.2.3.5. CUDA_GRAPHS_USE_NODE_PRIORITY

该环境变量控制 CUDA 图的执行优先级相对于其从启动该图的流所继承的流优先级的关系。

CUDA_GRAPHS_USE_NODE_PRIORITY会覆盖图实例化时的cudaGraphInstantiateFlagUseNodePriority标志。

可选值:

  • 0:继承启动该图的流的优先级(默认)。

  • 1:遵循各节点的启动优先级。CUDA 运行时将节点级优先级视为对已就绪、可运行的图节点的调度提示。

示例:

CUDA_GRAPHS_USE_NODE_PRIORITY=1

5.2.3.6. CUDA_DEVICE_WAITS_ON_EXCEPTION

该环境变量控制发生异常(错误)时 CUDA 应用程序的行为。

启用后,CUDA 应用程序将在发生设备端异常时暂停并等待,从而可以附加调试器(例如cuda-gdb),以便在进程退出或继续执行之前检查 GPU 的实时状态。

可选值:

  • 0:默认行为。

  • 1:发生设备异常时暂停。

示例:

CUDA_DEVICE_WAITS_ON_EXCEPTION=1

5.2.3.7. CUDA_DEVICE_DEFAULT_PERSISTING_L2_CACHE_PERCENTAGE_LIMIT

该环境变量控制 GPU 的 L2 缓存中为持久化访问预留的默认“留出”部分,以 L2 大小的百分比表示。

它适用于支持持久化 L2 缓存的 GPU,具体而言,是指使用CUDA 多进程服务(MPS)时,计算能力为 8.0 或更高的设备。必须在启动 CUDA MPS 控制守护进程之前设置该环境变量,即在运行nvidia-cuda-mps-control -d命令之前设置。

可选值:0 到 100 之间的百分比值,默认为 0。

示例:

CUDA_DEVICE_DEFAULT_PERSISTING_L2_CACHE_PERCENTAGE_LIMIT=25 # 25%

5.2.3.8. CUDA_DISABLE_PERF_BOOST

在 Linux 主机上,将此环境变量设置为 1 会阻止提升设备性能状态,而是可以基于各种启发式方法隐式选择 pstate。此选项有可能用于降低功耗,但由于动态选择性能状态,在某些场景中可能会导致更高的延迟。

示例:

CUDA_DISABLE_PERF_BOOST=1 # perf boost disabled, Linux only.
CUDA_DISABLE_PERF_BOOST=0 # default behavior

5.2.3.9. CUDA_PREFER_SPINLOCKS_ON_HOST

此环境变量向 CUDA 驱动程序和运行时提供提示,使其在某些内部主机端同步路径中优先采用基于自旋的加锁模式。在此模式下,内部锁可能会主动轮询以检查是否可用,而不是阻塞并让出 CPU。这可以通过避免内核态切换和唤醒开销,降低短时间持有的锁的同步延迟,从而改善低到中等争用情况下的快速路径性能(例如,多个主机线程争用同一个 CUDA 对象或执行上下文时)。基于自旋的加锁不提供调度器辅助的公平性或优先级继承。在主机线程被抢占、超额订阅或混合工作负载的情况下,启用基于自旋的加锁可能会增加优先级反转、饥饿或系统响应性下降的风险。

可选值:

  • 0:默认行为。

  • 1:内部同步优先使用自旋锁。

示例:

CUDA_PREFER_SPINLOCKS_ON_HOST=1

5.2.3.10. CUDA_AUTO_BOOST [[已弃用]]

该环境变量影响 GPU 时钟的“自动提升”行为,即动态提升时钟频率。它会覆盖nvidia-smi工具的“自动提升”选项,即nvidia-smi --auto-boost-default=0。

注意

此环境变量已弃用。强烈建议使用nvidia-smi --applications-clocks=<memory,graphics>或NVML API,而不要使用CUDA_AUTO_BOOST环境变量。


5.2.4. 模块加载

5.2.4.1. CUDA_MODULE_LOADING

该环境变量影响 CUDA 运行时加载模块的方式,具体而言,是其初始化设备代码的方式。

可选值:

  • DEFAULT:默认行为,等同于LAZY。

  • LAZY:特定核函数的加载会延迟到使用cuModuleGetFunction()或cuKernelGetFunction()API 调用提取 CUDA 函数句柄CUfunc时。在这种情况下,当 CUBIN 中的第一个核函数被加载或其中的第一个变量被访问时,才会加载该 CUBIN 中的数据。

    • 驱动程序在首次调用核函数时加载所需代码;后续调用不会产生额外开销。这会减少启动时间和 GPU 内存占用。

  • EAGER:在程序初始化时完整加载 CUDA 模块和核函数。CUBIN、FATBIN 或 PTX 文件中的所有核函数和数据都会在对应的cuModuleLoad*和cuLibraryLoad*驱动程序 API 调用时完整加载。

    • 启动时间更长,GPU 内存占用更高。核函数启动开销是可预测的。

示例:

CUDA_MODULE_LOADING=EAGER
CUDA_MODULE_LOADING=LAZY

5.2.4.2. CUDA_MODULE_DATA_LOADING

该环境变量影响 CUDA 运行时加载与模块关联的数据的方式。

这是对CUDA_MODULE_LOADING中针对核函数的设置的补充。此环境变量不会影响核函数的LAZY或EAGER加载。如果未设置此环境变量,数据加载行为将继承自CUDA_MODULE_LOADING。

可选值:

  • DEFAULT:默认行为,等同于LAZY。

  • LAZY:模块数据的加载会延迟到需要 CUDA 函数句柄CUfunc时。在这种情况下,当 CUBIN 中的第一个核函数被加载或其中的第一个变量被访问时,才会加载该 CUBIN 中的数据。

    • 延迟加载数据可能需要上下文同步,这可能会降低并发执行速度。

  • EAGER:CUBIN、FATBIN 或 PTX 文件中的所有数据都会在对应的cuModuleLoad*和cuLibraryLoad*API 调用时完整加载。

示例:

CUDA_MODULE_DATA_LOADING=EAGER

5.2.4.3. CUDA_BINARY_LOADER_THREAD_COUNT

设置加载设备二进制文件时使用的 CPU 线程数量。设置为 0 时,使用的 CPU 线程数量被设置为默认值 1。

可选值:

  • 要使用的线程数量,为整数。默认为 0,此时使用 1 个线程。

示例:

CUDA_BINARY_LOADER_THREAD_COUNT=4

5.2.4.4. CUDA_DEVICE_DEFAULT_STACK_SIZE_MAX

当此环境变量设置为1时,驱动程序会在对应的cuModuleLoad*调用期间,根据该模块中所有函数的最大需求,预先为每个线程分配足以满足最坏情况下需求的栈内存。默认情况下,CUDA 驱动程序按需分配每个线程的栈内存:当核函数启动所需的栈大于当前可用的栈时,驱动程序会在执行前扩充分配的内存。

可选值:

  • 0或未设置:在核函数启动时按需扩充栈内存(默认)。

  • 1:在模块加载时预先分配最大栈内存。

示例:

CUDA_DEVICE_DEFAULT_STACK_SIZE_MAX=1

5.2.5. CUDA 错误日志管理

5.2.5.1. CUDA_LOG_FILE

该环境变量指定一个位置,对于返回错误的受支持 CUDA API 调用,会在错误发生时将描述性错误日志消息打印到该位置。

例如,如果尝试使用无效的网格配置(如kernel<<<1, dim3(1,1,128)>>>(...))启动核函数,该核函数将无法启动,且cudaGetLastError()将返回通用的invalid configuration argument错误。
如果设置了CUDA_LOG_FILE环境变量,用户就可以在日志中看到以下描述性错误消息:[CUDA][E] Block Dimensions (1,1,128) include one or more values that exceed the device limit of (1024,1024,64),并轻松确定所指定的线程块 z 维度无效。有关更多详细信息,请参阅错误日志管理。

可选值:stdout、stderr或有效的文件路径(具有适当的访问权限)

示例:

CUDA_LOG_FILE=stdout
CUDA_LOG_FILE=/tmp/dbg_cuda_log

5.3. C++ 语言支持

nvcc按照以下规范处理 CUDA 和设备代码:

  • C++03(ISO/IEC 14882:2003),--std=c++03标志。

  • C++11(ISO/IEC 14882:2011),--std=c++11标志。

  • C++14(ISO/IEC 14882:2014),--std=c++14标志。

  • C++17(ISO/IEC 14882:2017),--std=c++17标志。

  • C++20(ISO/IEC 14882:2020),--std=c++20标志。

  • C++23(ISO/IEC 14882:2024),--std=c++23标志。

向nvcc传入-std=c++<version>标志会开启与指定版本相关的所有 C++ 特性,并使用对应的 C++ 方言选项调用主机预处理器、编译器和链接器。

编译器支持所支持标准的所有语言特性,但受以下各节中列出的限制约束。

5.3.1. C++11 语言特性

表 34 NVCC 为设备代码支持的 C++11 语言特性

语言特性

C++11 提案

NVCC/CUDA Toolkit 7.x

右值引用

N2118

✅

*this的右值引用

N2439

✅

使用右值初始化类对象

N1610

✅

非静态数据成员初始化器

N2756

✅

可变参数模板

N2242

✅

扩展可变参数模板模板形参

N2555

✅

初始化器列表

N2672

✅

静态断言

N1720

✅

auto类型的变量

N1984

✅

多声明符auto

N1737

✅

移除作为存储类说明符的 auto

N2546

✅

新的函数声明符语法

N2541

✅

lambda 表达式

N2927

✅

表达式的声明类型

N2343

✅

不完整的返回类型

N3276

✅

右尖括号

N1757

✅

函数模板的默认模板实参

DR226

✅

解决表达式的 SFINAE 问题

DR339

✅

别名模板

N2258

✅

extern 模板

N1987

✅

空指针常量

N2431

✅

强类型枚举

N2347

✅

枚举的前置声明

N2764
DR1206

✅

标准化的属性语法

N2761

✅

泛化的常量表达式

N2235

✅

对齐支持

N2341

✅

有条件支持的行为

N1627

✅

将未定义行为改为可诊断的错误

N1727

✅

委托构造函数

N1986

✅

继承构造函数

N2540

✅

显式转换运算符

N2437

✅

新的字符类型

N2249

✅

Unicode 字符串字面量

N2442

✅

原始字符串字面量

N2442

✅

字面量中的通用字符名

N2170

✅

用户定义的字面量

N2765

✅

标准布局类型

N2342

✅

默认化函数

N2346

✅

弃置函数

N2346

✅

扩展的友元声明

N1791

✅

扩展sizeof

N2253
DR850

✅

内联命名空间

N2535

✅

不受限制的联合体

N2544

✅

作为模板实参的局部类型和未命名类型

N2657

✅

基于范围的 for

N2930

✅

显式virtual重写

N2928
N3206
N3272

✅

对垃圾回收和基于可达性的泄漏检测的最低限度支持

N2670

❌

允许移动构造函数抛出异常 [noexcept]

N3050

✅

定义移动特殊成员函数

N3053

✅

并发

序列点

N2239

❌

原子操作

N2427

❌

强比较并交换

N2748

❌

双向栅栏

N2752

❌

内存模型

N2429

❌

数据依赖排序:原子操作与内存模型

N2664

❌

传播异常

N2179

❌

允许在信号处理函数中使用原子操作

N2547

❌

线程局部存储

N2659

❌

并发情况下的动态初始化与析构

N2660

❌

C++11 中的 C99 特性

__func__预定义标识符

N2340

✅

C99 预处理器

N1653

✅

long long

N1811

✅

扩展整数类型

N1988

❌

5.3.2. C++14 语言特性

表 35 NVCC 为设备代码支持的 C++14 语言特性

语言特性

C++14 提案

NVCC/CUDA Toolkit 9.x

调整某些 C++ 按语境转换

N3323

✅

二进制字面量

N3472

✅

具有推导返回类型的函数

N3638

✅

泛化的 lambda 捕获(初始化捕获)

N3648

✅

泛型(多态)lambda 表达式

N3649

✅

变量模板

N3651

✅

放宽对 constexpr 函数的要求

N3652

✅

成员初始化器与聚合体

N3653

✅

明确内存分配

N3664

❌

带大小的释放

N3778

❌

[[deprecated]]属性

N3760

✅

使用单引号作为数字分隔符

N3781

✅

5.3.3. C++17 语言特性

表 36 NVCC 为设备代码支持的 C++17 语言特性

语言特性

C++17 提案

NVCC/CUDA Toolkit 11.x

移除三字符组

N4086

✅

u8字符字面量

N4267

✅

折叠表达式

N4295

✅

命名空间和枚举项的属性

N4266

✅

嵌套命名空间定义

N4230

✅

允许对所有非类型模板实参进行常量求值

N4268

✅

扩展static_assert

N3928

✅

从花括号初始化器列表推导auto的新规则

N3922

✅

允许在模板模板形参中使用 typename

N4051

✅

[[fallthrough]]属性

P0188R1

✅

[[nodiscard]]属性

P0189R1

✅

[[maybe_unused]]属性

P0212R1

✅

扩展聚合初始化

P0017R1

✅

constexprlambda 的措辞

P0170R1

✅

一元折叠与空形参包

P0036R0

✅

泛化基于范围的 for 循环

P0184R0

✅

lambda 按值捕获*this

P0018R3

✅

enum class变量的构造规则

P0138R2

✅

C++ 的十六进制浮点字面量

P0245R1

✅

过度对齐数据的动态内存分配

P0035R4

✅

保证的复制消除

P0135R1

✅

为惯用 C++ 代码细化表达式求值顺序

P0145R3

✅

constexpr if

P0292R2

✅

带初始化器的选择语句

P0305R1

✅

类模板的模板实参推导

P0091R3
P0512R0

✅

使用auto声明非类型模板形参

P0127R2

✅

使用属性命名空间而无需重复

P0028R4

✅

忽略不支持的非标准属性

P0283R2

✅

结构化绑定

P0217R3

✅

移除register关键字已弃用的用法

P0001R1

✅

移除已弃用的operator++(bool)

P0002R1

✅

使异常说明成为类型系统的一部分

P0012R1

✅

用于 C++17 的__has_include

P0061R1

✅

重新表述继承构造函数(核心问题 1941 等)

P0136R1

✅

内联变量

P0386R2

✅

DR 150,模板模板实参的匹配

P0522R0

✅

移除动态异常说明

P0003R5

✅

using 声明中的包展开

P0195R2

✅

byte类型定义

P0298R0

✅

DR 727,类内显式实例化

CWG727

✅

5.3.4. C++20 语言特性

GCC 版本 ≥ 10.0、Clang 版本 ≥ 10.0、Microsoft Visual Studio ≥ 2022,以及 nvc++ 版本 ≥ 20.7。

注意

以“DR:”为前缀的条目是缺陷报告的解决方案。它们对标准进行修正,也适用于较早的 C++ 标准模式(例如 C++17);此处列出它们是为了完整性,并非 C++20 特有。

表 37 NVCC 为设备代码支持的 C++20 语言特性

语言特性

C++20 提案

NVCC/CUDA Toolkit 12.x

位域的默认成员初始化器

P0683R1

✅

修正const限定的成员指针

P0704R1

✅

允许 lambda 捕获[=, this]

P0409R2

✅

用于预处理器逗号省略的__VA_OPT__

P0306R4
P1042R1

✅

指派初始化器

P0329R4

✅

用于泛型 lambda 的熟悉的模板语法

P0428R2

✅

vector 的列表推导

P0702R1

✅

概念

P0734R0
P0857R0
P1084R2
P1141R2
P0848R3
P1616R1
P1452R2
P1972R0
P1980R0
P2092R0
P2103R0
P2113R0

✅

带初始化器的基于范围的 for 语句

P0614R1

✅

简化隐式 lambda 捕获

P0588R1

✅

ADL 与不可见的函数模板

P0846R0

✅

默认化复制构造函数的const不匹配

P0641R2

✅

减少对constexpr函数的急切实例化

P0859R0

✅

一致的比较(operator<=>)

P0515R3
P0905R1
P1120R0
P1185R2
P1186R3
P1630R1
P1946R0
P1959R0
P2002R1
P2085R0

✅

对特化的访问检查

P0692R1

✅

可默认构造且可赋值的无状态 lambda

P0624R2

✅

不求值语境中的 lambda

P0315R4

✅

对空对象的语言支持

P0840R2

✅

放宽基于范围的 for 循环的定制点查找规则

P0962R1

✅

允许对可访问的成员进行结构化绑定

P0969R0

✅

放宽结构化绑定的定制点查找规则

P0961R1

✅

去掉 typename!

P0634R3

✅

允许在 lambda 初始化捕获中进行包展开

P0780R2
P2095R0

✅

likely和unlikely属性的拟议措辞

P0479R5

✅

弃用通过[=]隐式捕获 this 的方式

P0806R2

✅

非类型模板形参中的类类型

P0732R2

✅

非类型模板形参的不一致之处

P1907R1

✅

带填充位的原子比较并交换

P0528R3

✅

用于可变大小类的高效带大小 delete

P0722R3

✅

允许在常量表达式中调用虚函数

P1064R0

✅

禁止聚合体具有用户声明的构造函数

P1008R1

✅

explicit(bool)

P0892R2

✅

有符号整数采用二进制补码

P1236R1

✅

char8_t

P0482R6

✅

立即函数(consteval)

P1073R3
P1937R2

✅

std::is_constant_evaluated

P0595R2

✅

嵌套的inline命名空间

P1094R2

✅

放宽constexpr的限制

P1002R1
P1327R1
P1330R0
P1331R2
P1668R1
P0784R7

✅

特性测试宏

P0941R2

✅

模块

P1103R3
P1766R1
P1811R0
P1703R1
P1874R1
P1979R0
P1779R3
P1857R3
P2115R0
P1815R2

❌

协程

P0912R5

❌

使用圆括号初始化聚合体

P0960R3
P1975R0

✅

DR: new 表达式中的数组大小推导

P1009R2

✅

DR: 从T*到 bool 的转换应视为窄化转换

P1957R2

✅

更严格的 Unicode 要求

P1041R4
P1139R2

✅

结构化绑定扩展

P1091R3
P1381R1

✅

弃用a[b,c]

P1161R3

✅

弃用volatile的某些用法

P1152R4

✅

[[nodiscard("with reason")]]

P1301R4

✅

using enum

P1099R5

✅

聚合体的类模板实参推导

P1816R0
P2082R1

✅

别名模板的类模板实参推导

P1814R0

✅

允许转换为未知边界的数组

P0388R4

✅

constinit

P1143R2

✅

布局兼容性与指针可互转换性特征

P0466R5

✅

DR: 检查抽象类类型

P0929R2

✅

DR: 更多隐式移动

P1825R0

✅

DR: 伪析构函数终止对象生存期

P0593R6

✅

5.3.5. C++23 语言特性

GCC 版本 ≥ 14.0、Clang 版本 ≥ 18.0、Microsoft Visual Studio(不支持),以及 nvc++ 版本 ≥ 24.3。

注意

以“DR:”为前缀的条目是缺陷报告的解决方案。它们对标准进行修正,也适用于较早的 C++ 标准模式(例如 C++17、C++20);此处列出它们是为了完整性,并非 C++23 特有。

注意

NVCC 列中的N/A表示该特性不适用于设备代码(例如,移除未使用的标准措辞,如垃圾回收支持,或由主机定义的行为)。

表 38 NVCC 为设备代码支持的 C++23 语言特性

语言特性

C++23 提案

NVCC/CUDA Toolkit ≥ 13.3

核心问题 411、1656 和 2333 的拟议解决方案;字符和字符串字面量中的数值转义与通用字符转义

P2029R4

✅

(有符号)size_t的字面量后缀

P0330R8

✅

使 lambda 中的()在更多情况下可选(去掉()!)

P1102R2

✅

if consteval

P1938R3

✅

移除垃圾回收支持

P2186R2

N/A

DR: 使用 Unicode 标准附录 31 的 C++ 标识符语法

P1949R7

✅

DR: 允许重复属性

P2156R1

✅

到 bool 的窄化按语境转换

P1401R5

❌

在行拼接前去除空白字符

P2223R2

✅

强制按声明顺序布局

P1847R4

✅

混合字符串字面量拼接

P2201R1

N/A

constexpr 函数中的非字面类型变量(以及标签和 goto)

P2242R3

✅

推导this

P0847R7

✅

一致的字符字面量编码

P2316R2

✅

添加对预处理指令elifdef和elifndef的支持

P2334R1

✅

诊断文本的字符编码

P2246R1

✅

扩展初始化语句以允许别名声明

P2360R0

✅

更改 lambda 尾置返回类型的作用域

P2036R3

✅

多维下标运算符

P2128R6

✅

字符集与编码

P2314R4

✅

auto(x)和auto {x}

P0849R8

✅

C++20 核心提案缺失的特性测试宏

P2493R0

✅

lambda 表达式上的属性

P2173R1

✅

对#warning的支持

P2437R1

✅

移除不可编码的宽字符字面量和多字符宽字符字面量

P2362R3

✅

复合语句末尾的标签(C 兼容性)

P2324R2

✅

带界定符的转义序列

P2290R3

✅

放宽constexpr的某些限制

P2448R2

❌

更简单的隐式移动

P2266R3

✅

命名的通用字符转义

P2071R2

✅

static operator()

P1169R4

✅

static operator[]

P2589R1

✅

扩展浮点类型与标准名称

P1467R9

❌

可移植的假设[[assume]]

P1774R8

✅

支持将 UTF-8 作为可移植的源文件编码

P2295R6

✅

DR: char8_t兼容性与可移植性修正

P2513R4

✅

DR: 撤销对 volatile 按位复合赋值操作的弃用

P2327R1

❌

DR: 放宽对wchar_t的要求以符合现有实践

P2460R2

N/A

DR: 在常量表达式中使用未知指针和引用

P2280R4

❌

DR: 你正在寻找的相等运算符

P2468R2

❌

允许在constexpr函数中使用static constexpr变量

P2647R1

✅

延长基于范围的 for 循环初始化器中临时对象的生存期

P2644R1
P2718R0

✅

DR: consteval需要向上传播

P2564R3

✅

5.3.6. CUDA C++ 标准库

CUDA 提供了 C++ 标准库(STL)的一种实现,称为libcu++。该库具有以下优势:

  • 主机和设备上均可使用这些功能。

  • 兼容 CUDA Toolkit 支持的所有Linux和Windows平台。

  • 兼容 CUDA Toolkit 最近两个主要版本支持的所有GPU 架构。

  • 兼容当前和上一个主要版本的所有CUDA Toolkit。

  • 提供近期标准版本(包括 C++20、C++23 和 C++26)中可用的 C++ 标准库特性的 C++17 向后移植版本。

  • 支持扩展数据类型,例如 128 位整数(__int128)、半精度浮点数(__half)、Bfloat16(__nv_bfloat16)和四倍精度浮点数(__float128)。

  • 针对设备代码进行了高度优化。

此外,libcu++还提供 C++ 标准库中没有的扩展特性,以提高生产力和应用程序性能。这些特性包括数学函数、内存操作、同步原语、容器扩展、CUDA 内建函数的高级抽象、C++ PTX 封装等。

libcu++既作为CUDA Toolkit的一部分提供,也作为开源CCCL代码库的一部分提供。

5.3.7. C 标准库函数

5.3.7.1. clock()和clock64()

__host__ __device__ clock_t   clock();
__device__          long long clock64();

在设备代码中执行时,它返回每个多处理器各自拥有的计数器的值,该计数器在每个时钟周期递增。在核函数开始和结束时对这个计数器采样,将两个值相减,并为每个线程记录结果,即可估算设备执行该线程所花费的时钟周期数。但是,这个值并不代表设备执行该线程指令所花费的实际时钟周期数。由于线程以时间片方式运行,前者大于后者。

提示

  • 对应的CUDA C++ 函数cuda::std::clock()在<cuda/std/ctime>头文件中提供。

  • <cuda/std/chrono>头文件中还提供了一个可移植的C++<chrono>实现,用于类似的目的。

5.3.7.2. printf()

__host__ __device__ __tile__ int printf(const char* format[, arg, ...]);

该函数将核函数中的格式化输出打印到主机端输出流。

核函数内的printf()函数的行为与标准 C 库的printf()函数类似。用户应查阅其主机系统的手册页,以获取对printf()行为的完整说明。本质上,以format传入的字符串会被输出到主机上的一个流中。

printf()命令的执行方式与其他任何设备端函数相同:按线程执行,并在调用线程的上下文中执行。在多线程核函数中,对printf()的一次直接调用将由每个线程使用该线程指定的数据来执行。因此,主机流中将出现多个版本的输出字符串,每个版本对应一个遇到printf()的线程。

C 标准的printf()返回打印的字符数,而 CUDA 的printf()与之不同,返回的是已解析的参数个数。如果格式字符串后没有参数,则返回 0。如果格式字符串为NULL,则返回-1。如果发生内部错误,则返回 -2。

在内部,printf()使用共享的数据结构,因此调用printf()可能会改变线程的执行顺序。具体而言,调用printf()的线程可能会经过比不调用printf()的线程更长的执行路径,而该路径的长度取决于printf()的参数。但是,请注意,除显式的__syncthreads()屏障外,CUDA 不对线程的执行顺序作任何保证。因此,无法判断执行顺序是被printf()改变的,还是被硬件中的其他调度行为改变的。

printf()函数在分块代码中的行为与在 SIMT 设备代码中的行为不同。在分块代码中,

  • 参数除了可以是标量,还可以是分块。传入分块参数时,分块中的每个元素都按相应的格式说明符打印。

  • 返回值始终是所提供的参数个数,即使格式字符串为NULL或发生内部错误也是如此。

  • 格式字符串必须是字面量。

  • 如果参数个数与格式说明符的个数不匹配,则会报告错误。在 SIMT 设备代码中,这种情况会产生警告。


格式说明符

与标准printf()一样,格式说明符采用以下形式:%[flags][width][.precision][size]type

支持以下字段。有关所有行为的完整说明,请参阅广泛可获取的文档。

  • 标志:#、' '、0、+、-

  • 宽度:*、0-9

  • 精度:0-9

  • 大小:h、l、ll

  • 类型:%cdiouxXpeEfgGaAs


限制

printf()输出的最终格式化在主机系统上进行。这意味着格式字符串必须能够被主机系统的编译器和 C 库理解。虽然已经尽一切努力,确保 CUDA 的printf()函数所支持的格式说明符是最常见主机编译器所支持的格式说明符的通用子集,但具体行为仍取决于主机操作系统。

printf()接受标志和类型的所有有效组合。这是因为它无法确定在对最终输出进行格式化的主机系统上,哪些组合有效,哪些组合无效。因此,如果程序输出的格式字符串包含无效组合,则输出可能是未定义的。

除格式字符串外,printf()函数最多可以接受 32 个实参。任何额外的实参都会被忽略,而格式说明符将按原样输出。

由于long类型在 Windows 平台(32 位)和 Linux 平台(64 位)上的大小不同,在 Linux 机器上编译后又在 Windows 机器上运行的核函数,对于所有包含%ld的格式字符串都会产生损坏的输出。为确保安全,建议编译平台与执行平台保持一致。


主机端缓冲区

printf()的输出缓冲区在核函数启动前被设置为固定大小。该缓冲区是循环缓冲区,因此,如果核函数执行期间产生的输出超出缓冲区的容量,较早的输出就会被覆盖。只有在执行以下操作之一时,才会刷新缓冲区:

  • 通过<<< >>>或cuLaunchKernel()启动核函数:在启动开始时刷新;如果CUDA_LAUNCH_BLOCKING环境变量设置为 1,则在启动结束时也会刷新,

  • 通过cudaDeviceSynchronize()、cuCtxSynchronize()、cudaStreamSynchronize()、cuStreamSynchronize()、cudaEventSynchronize()或cuEventSynchronize()进行同步,

  • 通过cudaMemcpy*()或cuMemcpy*()的任何阻塞版本进行内存复制,

  • 通过cuModuleLoad()或cuModuleUnload()加载/卸载模块,

  • 通过cudaDeviceReset()或cuCtxDestroy()销毁上下文。

  • 在执行通过cudaLaunchHostFunc()或cuLaunchHostFunc()添加的流回调之前。

请注意,程序退出时不会自动刷新缓冲区。

以下 API 函数用于设置和获取缓冲区的大小,该缓冲区用于将printf()实参和内部元数据传输到主机。默认大小为一兆字节。

  • cudaDeviceGetLimit(size_t* size,cudaLimitPrintfFifoSize)

  • cudaDeviceSetLimit(cudaLimitPrintfFifoSize, size_t size)


示例

以下代码示例:

#include <stdio.h>

__global__ void helloCUDA(float value) {
    printf("Hello thread %d, value=%f\n", threadIdx.x, value);
}

int main() {
    helloCUDA<<<1, 5>>>(1.2345f);
    cudaDeviceSynchronize();
    return 0;
}

将输出:

Hello thread 2, value=1.2345
Hello thread 1, value=1.2345
Hello thread 4, value=1.2345
Hello thread 0, value=1.2345
Hello thread 3, value=1.2345

请注意,每个线程都会遇到printf()命令。因此,输出的行数与网格中的线程数相同。

参见Compiler Explorer上的示例。


以下代码示例:

#include <stdio.h>

__global__ void helloCUDA(float value) {
    if (threadIdx.x == 0)
        printf("Hello thread %d, value=%f\n", threadIdx.x, value);
}

int main() {
    helloCUDA<<<1, 5>>>(1.2345f);
    cudaDeviceSynchronize();
    return 0;
}

将输出:

Hello thread 0, value=1.2345

显然,if()语句限制了哪些线程会调用printf(),因此只能看到一行输出。

参见Compiler Explorer上的示例。


以下代码示例:

#include "cuda_tile.h"
#include <cstdio>

namespace ct = cuda::tiles;

__tile_global__ void kernel() {
  auto ints = ct::iota<ct::tile<int, ct::shape<4, 4>>>();
  printf("%i\n", ints);
}

int main() {
  kernel<<<1,1>>>();
  cudaDeviceSynchronize();
  return 0;
}

将输出:

[[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11], [12, 13, 14, 15]]

5.3.7.3. memcpy()和memset()

__host__ __device__ __tile__ void* memcpy(void* dest, const void* src, size_t size);

该函数将size个字节从src所指向的内存位置复制到dest所指向的内存位置。

__host__ __device__ __tile__ void* memset(void* ptr, int value, size_t size);

该函数将ptr所指向的内存块中的size个字节设置为value,该值被解释为unsigned char。

提示

建议使用<cuda/std/cstring>头文件中提供的cuda::std::memcpy()和cuda::std::memset()函数,作为memcpy和memset的更安全版本。

5.3.7.4. malloc()和free()

__host__ __device__ void* malloc(size_t size);
// or cuda::std::malloc(), cuda::std::calloc() in the <cuda/std/cstdlib> header

函数malloc()(设备端)、cuda::std::malloc()和cuda::std::calloc()从设备堆中分配至少size个字节,并返回指向所分配内存的指针。如果内存不足以满足请求,则返回NULL。返回的指针保证对齐到 16 字节边界。

__device__ void* __nv_aligned_device_malloc(size_t size, size_t align);
// or cuda::std::aligned_alloc() in the <cuda/std/cstdlib> header

函数__nv_aligned_device_malloc()和C++cuda::std::aligned_alloc()从设备堆中分配至少size个字节,并返回指向所分配内存的指针。如果内存不足以满足所请求的大小或对齐要求,则返回NULL。所分配内存的地址是align的倍数。align必须是非零的二的幂。

__host__ __device__ void free(void* ptr);
// or cuda::std::free() in the <cuda/std/cstdlib> header

设备端函数free()和cuda::std::free()释放ptr所指向的内存,该指针必须由之前对malloc()、cuda::std::malloc()、cuda::std::calloc()、__nv_aligned_device_malloc()或cuda::std::aligned_alloc()的调用返回。如果ptr为NULL,则对free()或cuda::std::free()的调用会被忽略。使用同一个ptr重复调用free()或cuda::std::free()会产生未定义行为。

给定 CUDA 线程通过malloc()、cuda::std::malloc()、cuda::std::calloc()、__nv_aligned_device_malloc()或cuda::std::aligned_alloc()分配的内存,在 CUDA 上下文的整个生存期内都会保持已分配状态,或者直到通过调用free()或cuda::std::free()显式释放为止。其他 CUDA 线程可以使用这些内存,包括后续核函数启动中的线程。任何 CUDA 线程都可以释放另一个线程分配的内存;但是,应注意确保同一个指针不会被释放多次。


堆内存 API

必须在任何在设备代码中分配或释放内存的程序运行之前指定设备内存堆的大小,这也包括使用new和delete关键字的情况。如果任何程序在未显式指定堆大小的情况下使用设备内存堆,则会分配一个八兆字节的默认堆。

以下 API 函数用于获取和设置堆大小:

  • cudaDeviceGetLimit(size_t* size, cudaLimitMallocHeapSize)

  • cudaDeviceSetLimit(cudaLimitMallocHeapSize, size_t size)

实际提供的堆大小将至少为size个字节。cuCtxGetLimit()和cudaDeviceGetLimit()返回当前请求的堆大小。

堆的实际内存分配发生在模块加载到上下文中时,加载既可以通过 CUDA 驱动程序 API 显式进行(参见模块),也可以通过 CUDA 运行时 API 隐式进行。如果内存分配失败,模块加载会产生CUDA_ERROR_SHARED_OBJECT_INIT_FAILED错误。

模块加载后,堆大小便无法更改,而且它不会根据需要动态调整大小。

为设备堆保留的内存,是在通过cudaMalloc()等主机端 CUDA API 调用分配的内存之外额外保留的。


与主机内存 API 的互操作性

通过设备端函数malloc()、cuda::std::malloc()、cuda::std::calloc()、__nv_aligned_device_malloc()、cuda::std::aligned_alloc()或new关键字分配的内存,不能通过cudaMalloc、cudaMemcpy或cudaMemset等运行时或驱动程序 API 调用来使用或释放。同样,通过主机运行时 API 分配的内存,不能使用设备端函数free()、cuda::std::free()或delete关键字来释放。


按线程分配的示例:

#include <stdlib.h>
#include <stdio.h>

__global__ void single_thread_allocation_kernel() {
    size_t size = 123;
    char*  ptr  = (char*) malloc(size);
    memset(ptr, 0, size);
    printf("Thread %d got pointer: %p\n", threadIdx.x, ptr);
    free(ptr);
}

int main() {
    // Set a heap size of 128 megabytes.
    // Note that this must be done before any kernel is launched.
    cudaDeviceSetLimit(cudaLimitMallocHeapSize, 128 * 1024 * 1024);
    single_thread_allocation_kernel<<<1, 5>>>();
    cudaDeviceSynchronize();
    return 0;
}

将输出:

Thread 0 got pointer: 0x20d5ffe20
Thread 1 got pointer: 0x20d5ffec0
Thread 2 got pointer: 0x20d5fff60
Thread 3 got pointer: 0x20d5f97c0
Thread 4 got pointer: 0x20d5f9720

请注意,每个线程都会遇到malloc()和memset()命令,因此会获得并初始化自己分配的内存。

参见Compiler Explorer上的示例。


按线程块分配的示例:

#include <stdlib.h>

__global__ void block_level_allocation_kernel() {
    __shared__ int* data;
    // The first thread in the block performs the allocation and shares the pointer
    // with all other threads through shared memory, so that access can be coalesced.
    if (threadIdx.x == 0) {
        size_t size = blockDim.x * 64; // 64 bytes per thread are allocated.
        data = (int*) malloc(size);
    }
    __syncthreads();
    // Check for failure
    if (data == nullptr)
        return;

    // Threads index into the memory, ensuring coalescence
    for (int i = 0; i < 64; ++i)
        data[i * blockDim.x + threadIdx.x] = threadIdx.x;
    // Ensure all threads complete before freeing
    __syncthreads();

    // Only one thread may free the memory!
    if (threadIdx.x == 0)
        free(data);
}

int main() {
    cudaDeviceSetLimit(cudaLimitMallocHeapSize, 128 * 1024 * 1024);
    block_level_allocation_kernel<<<10, 128>>>();
    cudaDeviceSynchronize();
    return 0;
}

参见Compiler Explorer上的示例。


在核函数启动之间持续保留内存分配的示例:

#include <stdlib.h>
#include <stdio.h>

const int NUM_BLOCKS = 20;

__device__ int* data_ptrs[NUM_BLOCKS]; // Per-block pointer

__global__ void allocate_memory_kernel() {
    // Only the first thread in the block performs the allocation
    // since we need only one allocation per block.
    if (threadIdx.x == 0)
        data_ptrs[blockIdx.x] = (int*) malloc(blockDim.x * 4);
    __syncthreads();
    // Check for failure
    if (data_ptrs[blockIdx.x] == nullptr)
        return;
    // Zero the data with all threads in parallel
    data_ptrs[blockIdx.x][threadIdx.x] = 0;
}

// Simple example: store the thread ID into each element
__global__ void use_memory_kernel() {
    int* ptr = data_ptrs[blockIdx.x];
    if (ptr != nullptr)
        ptr[threadIdx.x] += threadIdx.x;
}

// Print the content of the buffer before freeing it
__global__ void free_memory_kernel() {
    int* ptr = data_ptrs[blockIdx.x];
    if (ptr != nullptr)
        printf("Block %d, Thread %d: final value = %d\n",
            blockIdx.x, threadIdx.x, ptr[threadIdx.x]);
    // Only free from one thread!
    if (threadIdx.x == 0)
        free(ptr);
}

int main() {
    cudaDeviceSetLimit(cudaLimitMallocHeapSize, 128*1024*1024);
    // Allocate memory
    allocate_memory_kernel<<<NUM_BLOCKS, 10>>>();

    // Use memory
    use_memory_kernel<<<NUM_BLOCKS, 10>>>();
    use_memory_kernel<<<NUM_BLOCKS, 10>>>();
    use_memory_kernel<<<NUM_BLOCKS, 10>>>();

    // Free memory
    free_memory_kernel<<<NUM_BLOCKS, 10>>>();
    cudaDeviceSynchronize();
    return 0;
}

参见Compiler Explorer上的示例。

5.3.7.5. alloca()

__host__ __device__ void* alloca(size_t size);

alloca()函数在调用方的栈帧内分配size个字节的内存。返回值是指向所分配内存的指针。从设备代码中调用此函数时,该内存的起始地址按 16 字节对齐。当调用方从alloca()返回时,该内存会自动释放。

注意

在 Windows 平台上,使用alloca()函数之前必须包含<malloc.h>头文件。对alloca()的调用可能导致栈溢出;用户需要相应地调整栈大小。

示例:

__device__ void device_function(int num_items) {
    int4* ptr = (int4*) alloca(num_items * sizeof(int4));
    // use of ptr
    ...
}

5.3.8. Lambda 表达式

编译器通过将 lambda 表达式或闭包类型(C++11)与最内层外围函数作用域的执行空间相关联,来确定其执行空间。如果不存在外围函数作用域,则执行空间指定为__host__。
也可以使用扩展 lambda 语法显式指定执行空间。

示例:

auto global_lambda = [](){ return 0; }; // __host__

void host_function() {
  auto lambda1 = [](){ return 1; };   // __host__
  [](){ return 3; };                  // __host__, closure type (body of a lambda expression)
}

__device__ void device_function() {
  auto lambda2 = [](){ return 2; };   // __device__
}

__global__ void kernel_function(void) {
  auto lambda3 = [](){ return 3; };   // __device__
}

__host__ __device__ void host_device_function() {
  auto lambda4 = [](){ return 4; };   // __host__ __device__
}

__tile__ void tile_function() {
  auto lambda5 = [](){ return 5; };   // __tile__
}

__tile__ __device__ void tile_device_function() {
  auto lambda6 = [](){ return 6; };   // __tile__ __device__
}

using function_ptr_t = int (*)();

__device__ void device_function(float          value,
                                function_ptr_t ptr = [](){ return 4; } /* __host__ */) {}

参见Compiler Explorer上的示例。

5.3.8.1. Lambda 表达式与__global__函数参数

只有当执行空间为__device__或__host__ __device__时,lambda 表达式或闭包类型才能用作__global__函数的实参。全局作用域或命名空间作用域中的 lambda 表达式不能用作__global__函数中的实参。

示例:

template <typename T>
 __global__ void kernel(T input) {}

 __device__ void device_function() {
     // device kernel call requires separate compilation (-rdc=true flag)
     kernel<<<1, 1>>>([](){});
     kernel<<<1, 1>>>([] __device__() {});          // extended lambda
     kernel<<<1, 1>>>([] __host__ __device__() {}); // extended lambda
 }

 auto global_lambda = [] __host__ __device__() {};

 void host_function() {
     kernel<<<1, 1>>>([] __device__() {});          // CORRECT, extended lambda
     kernel<<<1, 1>>>([] __host__ __device__() {}); // CORRECT, extended lambda
 //  kernel<<<1, 1>>>([](){});                      // ERROR, closure type with host execution space
 //  kernel<<<1, 1>>>(global_lambda);               // ERROR, extended lambda, but at global scope
 }

参见Compiler Explorer上的示例。

5.3.8.2. 扩展 lambda

nvcc的--extended-lambda标志允许在 lambda 表达式中显式标注执行空间。这些标注应出现在 lambda 引入符之后、可选的 lambda 声明符之前。
指定--extended-lambda标志时,nvcc会定义宏__CUDACC_EXTENDED_LAMBDA__。
  • 扩展 lambda定义在__host__或__host__ __device__函数的直接块或嵌套块的作用域内。

  • 扩展设备 lambda是使用__device__关键字标注的 lambda 表达式。

  • 扩展主机-设备 lambda是使用__host__ __device__关键字标注的 lambda 表达式。

与标准 lambda 表达式不同,扩展 lambda 可以用作__global__函数中的类型实参。

示例:

void host_function() {
    auto lambda1 = [] {};                      // NOT an extended lambda: no explicit execution space annotations
    auto lambda2 = [] __device__ {};           // extended lambda
    auto lambda3 = [] __host__ __device__ {};  // extended lambda
    auto lambda4 = [] __host__ {};             // NOT an extended lambda
}

__host__ __device__ void host_device_function() {
    auto lambda1 = [] {};                      // NOT an extended lambda: no explicit execution space annotations
    auto lambda2 = [] __device__ {};           // extended lambda
    auto lambda3 = [] __host__ __device__ {};  // extended lambda
    auto lambda4 = [] __host__ {};             // NOT an extended lambda
}

__device__ void device_function() {
    // none of the lambdas within this function are extended lambdas,
    // because the enclosing function is not a __host__ or __host__ __device__  function.
    auto lambda1 = [] {};
    auto lambda2 = [] __device__ {};
    auto lambda3 = [] __host__ __device__ {};
    auto lambda4 = [] __host__ {};
}

auto global_lambda = [] __host__ __device__ { }; // NOT an extended lambda because it is not defined
                                                 // within a __host__ or __host__ __device__ function

5.3.8.3. 扩展 lambda 类型特征

编译器提供了类型特征,用于在编译时检测扩展 lambda 的闭包类型。

bool __nv_is_extended_device_lambda_closure_type(type);

如果type是为扩展__device__lambda 创建的闭包类,则该函数返回true,否则返回false。

bool __nv_is_extended_device_lambda_with_preserved_return_type(type);

如果type是为扩展__device__lambda 创建的闭包类,并且该 lambda 在定义时使用了尾置返回类型,则该函数返回true,否则返回false。如果尾置返回类型的定义引用了任何 lambda 参数名称,则不会保留返回类型。

bool __nv_is_extended_host_device_lambda_closure_type(type);

如果type是为扩展__host__ __device__lambda 创建的闭包类,则该函数返回true,否则返回false。


无论是否启用 lambda 或扩展 lambda,都可以在所有编译模式中使用 lambda 类型特征。如果扩展 lambda 模式未激活,这些特征将始终返回false。

示例:

auto lambda0 = [] __host__ __device__ { };

void host_function() {
    auto lambda1 = [] { };
    auto lambda2 = [] __device__ { };
    auto lambda3 = [] __host__ __device__ { };
    auto lambda4 = [] __device__ () -> double { return 3.14; }
    auto lambda5 = [] __device__ (int x) -> decltype(&x) { return 0; }

    using lambda0_t = decltype(lambda0);
    using lambda1_t = decltype(lambda1);
    using lambda2_t = decltype(lambda2);
    using lambda3_t = decltype(lambda3);
    using lambda4_t = decltype(lambda4);
    using lambda5_t = decltype(lambda5);

    // 'lambda0' is not an extended lambda because it is defined outside function scope
    static_assert(!__nv_is_extended_device_lambda_closure_type(lambda0_t));
    static_assert(!__nv_is_extended_device_lambda_with_preserved_return_type(lambda0_t));
    static_assert(!__nv_is_extended_host_device_lambda_closure_type(lambda0_t));

    // 'lambda1' is not an extended lambda because it has no execution space annotations
    static_assert(!__nv_is_extended_device_lambda_closure_type(lambda1_t));
    static_assert(!__nv_is_extended_device_lambda_with_preserved_return_type(lambda1_t));
    static_assert(!__nv_is_extended_host_device_lambda_closure_type(lambda1_t));

    // 'lambda2' is an extended device-only lambda
    static_assert(__nv_is_extended_device_lambda_closure_type(lambda2_t));
    static_assert(!__nv_is_extended_device_lambda_with_preserved_return_type(lambda2_t));
    static_assert(!__nv_is_extended_host_device_lambda_closure_type(lambda2_t));

    // 'lambda3' is an extended host-device lambda
    static_assert(!__nv_is_extended_device_lambda_closure_type(lambda3_t));
    static_assert(!__nv_is_extended_device_lambda_with_preserved_return_type(lambda3_t));
    static_assert(__nv_is_extended_host_device_lambda_closure_type(lambda3_t));

    // 'lambda4' is an extended device-only lambda with preserved return type
    static_assert(__nv_is_extended_device_lambda_closure_type(lambda4_t));
    static_assert(__nv_is_extended_device_lambda_with_preserved_return_type(lambda4_t));
    static_assert(!__nv_is_extended_host_device_lambda_closure_type(lambda4_t));

    // 'lambda5' is not an extended device-only lambda with preserved return type
    // because it references the operator()'s parameter types in the trailing return type.
    static_assert(__nv_is_extended_device_lambda_closure_type(lambda5_t));
    static_assert(!__nv_is_extended_device_lambda_with_preserved_return_type(lambda5_t));
    static_assert(!__nv_is_extended_host_device_lambda_closure_type(lambda5_t));
}

5.3.8.4. 扩展 lambda 的限制

在调用主机编译器之前,CUDA 编译器会将扩展 lambda 表达式替换为在命名空间作用域中定义的占位类型的实例。该占位类型的模板实参要求取得包含原始扩展 lambda 表达式的函数的地址。这是正确执行任何模板实参涉及扩展 lambda 闭包类型的__global__函数模板所必需的。外围函数按以下方式确定。

根据定义,扩展 lambda 位于__host__或__host__ __device__函数的直接块或嵌套块的作用域内。

  • 如果该函数不是 lambda 表达式的operator(),则将其视为该扩展 lambda 的外围函数。

  • 否则,该扩展 lambda 定义在一个或多个外围 lambda 表达式的operator()的直接块或嵌套块的作用域内。

    • 如果最外层的 lambda 表达式定义在函数F的直接块或嵌套块的作用域内,则F就是所确定的外围函数。

    • 否则,外围函数不存在。

示例:

void host_function() {
    auto lambda1 = [] __device__ { }; // enclosing function for lambda1 is "host_function()"
    auto lambda2 = [] {
        auto lambda3 = [] {
            auto lambda4 = [] __host__ __device__ { }; // enclosing function for lambda4 is "host_function"
        };
    };
}

auto global_lambda = [] {
    auto lambda5 = [] __host__ __device__ { }; // enclosing function for lambda5 does not exist
};

扩展 lambda 的限制

  1. 扩展 lambda 不能定义在另一个扩展 lambda 表达式内部。示例:

    void host_function() {
        auto lambda1 = [] __host__ __device__  {
             // ERROR, extended lambda defined within another extended lambda
            auto lambda2 = [] __host__ __device__ { };
        };
    }
    
  2. 扩展 lambda 不能定义在泛型 lambda 表达式内部。示例:

    void host_function() {
        auto lambda1 = [] (auto) {
             // ERROR, extended lambda defined within a generic lambda
            auto lambda2 = [] __host__ __device__ { };
        };
    }
    
  3. 如果扩展 lambda 定义在一个或多个嵌套 lambda 表达式的直接块或嵌套块的作用域内,则最外层的 lambda 表达式必须定义在某个函数的直接块或嵌套块的作用域内。示例:

    auto lambda1 = []  {
        // ERROR, outer enclosing lambda is not defined within a non-lambda-operator() function
        auto lambda2 = [] __host__ __device__ { };
    };
    
  4. 扩展 lambda 的外围函数必须具有名称,并且其地址必须可访问。如果外围函数是类成员,则必须满足以下条件:

    • 包含该成员函数的所有外围类都必须具有名称。

    • 该成员函数在其所属类中的访问权限不得为私有或受保护。

    • 所有外围类在各自的外层类中的访问权限都不得为私有或受保护。

    示例:

    void host_function() {
        auto lambda1 = [] __device__ { return 0; }; // OK
        {
            auto lambda2 = [] __device__          { return 0; }; // OK
            auto lambda3 = [] __device__ __host__ { return 0; }; // OK
        }
    }
    
    struct MyStruct1 {
        MyStruct1() {
            auto lambda4 = [] __device__ { return 0; }; // ERROR, address of the enclosing function is not accessible
        }
    };
    
    class MyStruct2 {
        void foo() {
            auto temp1 = [] __device__ { return 10; }; // ERROR, enclosing function has private access in parent class
        }
    
        struct MyStruct3 {
            void foo() {
                auto temp1 = [] __device__ { return 10; };  // ERROR, enclosing class MyStruct3 has private access in its parent class
            }
        };
    };
    
  5. 在定义扩展 lambda 的位置,必须能够无歧义地取得外围例程的地址。然而,这并不总是可行,例如,当别名声明遮蔽了同名的模板类型实参时。示例:

    template <typename T>
    struct A {
        using Bar = void;
        void test();
    };
    
    template<>
    struct A<void> { };
    
    template <typename Bar>
    void A<Bar>::test() {
        // In code sent to host compiler, nvcc will inject an address expression here, of the form:
        //   (void (A< Bar> ::*)(void))(&A::test))
        //  However, the class typedef 'Bar' (to void) shadows the template argument 'Bar',
        //  causing the address expression in A<int>::test to actually refer to:
        //    (void (A< void> ::*)(void))(&A::test))
        //  which doesn't take the address of the enclosing routine 'A<int>::test' correctly.
        auto lambda1 = [] __host__ __device__ { return 4; };
    }
    
    int main() {
        A<int> var;
        var.test();
    }
    
  6. 扩展 lambda 不能定义在函数的局部类中。示例:

    void host_function() {
        struct MyStruct {
            void bar() {
                // ERROR, bar() is member of a class that is local to a function
                auto lambda2 = [] __host__ __device__ { return 0; };
            }
        };
    }
    
  7. 扩展 lambda 的外围函数不能具有推导出的返回类型。示例:

    auto host_function() {
        // ERROR, the return type of host_function() is deduced
        auto lambda3 = [] __host__ __device__ { return 0; };
    }
    
  8. 主机-设备扩展 lambda 不能是泛型 lambda,即具有auto参数类型的 lambda。示例:

    void host_function() {
        // ERROR, __host__ __device__ extended lambdas cannot be a generic lambda
        auto lambda1 = [] __host__ __device__ (auto i) { return i; };
    
        // ERROR, a host-device extended lambda cannot be a generic lambda
        auto lambda2 = [] __host__ __device__ (auto... i) {
            return sizeof...(i);
        };
    }
    
  9. 如果外围函数是函数模板或成员模板的实例化,或者该函数是类模板的成员,则这些模板必须满足以下约束:

    • 模板最多只能具有一个可变参数,并且该参数必须列在模板参数列表的最后。

    • 模板参数必须具有名称。

    • 模板实例化的实参类型不能涉及函数局部的类型(扩展 lambda 的闭包类型除外),也不能涉及作为private或protected类成员的类型。

    示例 1:

    template <template <typename...> class T,
              typename... P1,
              typename... P2>
    void bar1(const T<P1...>, const T<P2...>) {
        // ERROR, enclosing function has multiple parameter packs
        auto lambda = [] __device__ { return 10; };
    }
    
    template <template <typename...> class T,
              typename... P1,
              typename    T2>
    void bar2(const T<P1...>, T2) {
        // ERROR, for enclosing function, the parameter pack is not last in the template parameter list
        auto lambda = [] __device__ { return 10; };
    }
    
    template <typename T, T>
    void bar3() {
        // ERROR, for enclosing function, the second template parameter is not named
        auto lambda = [] __device__ { return 10; };
    }
    

    示例 2:

    template <typename T>
    void bar4() {
        auto lambda1 = [] __device__ { return 10; };
    }
    
    class MyStruct {
        struct MyNestedStruct {};
    
        friend int main();
    };
    
    int main() {
        struct MyLocalStruct {};
        // ERROR, enclosing function for device lambda in bar4() is instantiated with a type local to main
        bar4<MyLocalStruct>();
    
        // ERROR, enclosing function for device lambda in bar4 is instantiated with a type
        //        that is a private member of a class
        bar4<MyStruct::MyNestedStruct>();
    }
    
  10. 使用 Microsoft Visual Studio 主机编译器时,外围函数必须具有外部链接。存在此限制是因为主机编译器不支持将非外部链接函数的地址用作模板实参。CUDA 编译器的转换需要这些地址才能支持扩展 lambda。

  11. 使用 Microsoft Visual Studio 主机编译器时,不得在if constexpr块的主体内定义扩展 lambda。

  12. 扩展 lambda 对捕获的变量有以下限制:

    • 在发送给主机编译器的代码中,变量可能会先按值传递给一系列辅助函数,然后才用于直接初始化表示该扩展 lambda 闭包类型的类类型的字段。然而,C++ 标准规定,应使用被捕获的变量直接初始化闭包类型的字段。

    • 变量只能按值捕获。

    • 如果数组的维数大于 7,则无法捕获该数组类型的变量。

    • 对于数组类型的变量,在发送给主机编译器的代码中,首先对闭包类型的数组字段进行默认初始化,然后从被捕获数组变量的相应元素向每个数组元素进行复制赋值。因此,在主机代码中,数组元素类型必须既可默认构造,又可复制赋值。

    • 不能捕获作为可变实参包元素的函数参数。

    • 被捕获变量的类型不能是函数局部的类型(扩展 lambda 闭包类型除外),也不能是private或protected类成员。

    • 主机-设备扩展 lambda 不支持初始化捕获。不过,设备扩展 lambda 支持初始化捕获,但初始化器为数组或类型为std::initializer_list的情况除外。

    • 扩展 lambda 的函数调用运算符不是constexpr。扩展 lambda 的闭包类型不是字面类型。声明扩展 lambda 时不能使用constexpr和consteval说明符。

    • 在词法上嵌套于扩展 lambda 内部的if-constexpr块内,不能隐式捕获变量,除非该变量已在if-constexpr块之外被隐式捕获,或出现在该扩展 lambda 的显式捕获列表中。

    示例:

    void host_function() {
        // CORRECT, an init-capture is allowed for an extended device-only lambda
        auto lambda1 = [x = 1] __device__ () { return x; };
    
        // ERROR, an init-capture is not allowed for an extended host-device lambda
        auto lambda2 = [x = 1] __host__ __device__ () { return x; };
    
        int a = 1;
        // ERROR, an extended __device__ lambda cannot capture variables by reference
        auto lambda3 = [&a] __device__ () { return a; };
    
        // ERROR, by-reference capture is not allowed for an extended device-only lambda
        auto lambda4 = [&x = a] __device__ () { return x; };
    
        struct MyStruct {};
        MyStruct s1;
        // ERROR, a type local to a function cannot be used in the type of a captured variable
        auto lambda6 = [s1] __device__ () { };
    
        // ERROR, an init-capture cannot be of type std::initializer_list
        auto lambda7 = [x = {11}] __device__ () { };
    
        std::initializer_list<int> b = {11,22,33};
        // ERROR, an init-capture cannot be of type std::initializer_list
        auto lambda8 = [x = b] __device__ () { };
    
        int  var     = 4;
        auto lambda9 = [=] __device__ {
            int result = 0;
            if constexpr(false) {
                //ERROR, An extended device-only lambda cannot first-capture 'var' in if-constexpr context
                result += var;
            }
            return result;
        };
    
        auto lambda10 = [var] __device__ {
            int result = 0;
            if constexpr(false) {
                // CORRECT, 'var' already listed in explicit capture list for the extended lambda
                result += var;
            }
            return result;
        };
    
        auto lambda11 = [=] __device__ {
            int result = var;
            if constexpr(false) {
                // CORRECT, 'var' already implicit captured outside the 'if-constexpr' block
                result += var;
            }
            return result;
        };
    }
    
  13. 解析函数时,CUDA 编译器会为函数中的每个扩展 lambda 分配一个计数器值。该计数器值用于传递给主机编译器的替换后的具名类型中。因此,函数内是否存在扩展 lambda 不应依赖于__CUDA_ARCH__的特定值,也不应依赖于__CUDA_ARCH__未被定义。示例:

    template <typename T>
    __global__ void kernel(T in) { in(); }
    
    __host__ __device__ void host_device_function() {
        // ERROR, the number and relative declaration order of
        //        extended lambdas depend on __CUDA_ARCH__
    #if defined(__CUDA_ARCH__)
        auto lambda1 = [] __device__ { return 0; };
        auto lambda2 = [] __host__ __device__ { return 10; };
    #endif
        auto lambda3 = [] __device__ { return 4; };
        kernel<<<1, 1>>>(lambda3);
    }
    
  14. 如上所述,CUDA 编译器会将主机函数中定义的设备扩展 lambda 替换为在命名空间作用域中定义的占位类型。除非特征__nv_is_extended_device_lambda_with_preserved_return_type()对该扩展 lambda 的闭包类型返回true,否则该占位类型不会定义与原始 lambda 声明等效的operator()函数。因此,在主机代码中尝试确定此类 lambda 的operator()函数的返回类型或参数类型可能无法正确工作,因为主机编译器处理的代码与 CUDA 编译器处理的输入代码在语义上不同。不过,在设备代码中对operator()函数的返回类型或参数类型进行自省是可以的。注意,此限制不适用于特征__nv_is_extended_device_lambda_with_preserved_return_type()返回true的主机或设备扩展 lambda。示例:

    #include <cuda/std/type_traits>
    
    const char& getRef(const char* p) { return *p; }
    
    void foo() {
        auto lambda1 = [] __device__ { return "10"; };
    
        // ERROR, attempt to extract the return type of a device lambda in host code
        cuda::std::result_of<decltype(lambda1)()>::type xx1 = "abc";
    
        auto lambda2 = [] __host__ __device__ { return "10"; };
    
        // CORRECT, lambda2 represents a host-device extended lambda
        cuda::std::result_of<decltype(lambda2)()>::type xx2 = "abc";
    
        auto lambda3 = [] __device__ () -> const char* { return "10"; };
    
        // CORRECT, lambda3 represents a device extended lambda with preserved return type
        cuda::std::result_of<decltype(lambda3)()>::type xx2 = "abc";
        static_assert(cuda::std::is_same_v<cuda::std::result_of<decltype(lambda3)()>::type, const char*>);
    
        auto lambda4 = [] __device__ (char x) -> decltype(getRef(&x)) { return 0; };
        // lambda4's return type is not preserved because it references the operator()'s
        // parameter types in the trailing return type.
        static_assert(!__nv_is_extended_device_lambda_with_preserved_return_type(decltype(lambda4)));
    }
    
  15. 对于仅设备扩展 lambda:

    • 仅在设备代码中支持对operator()的参数类型进行自省。

    • 仅在设备代码中支持对operator()的返回类型进行自省,除非特征函数__nv_is_extended_device_lambda_with_preserved_return_type()返回true。

  16. 如果扩展 lambda 从主机代码传递到设备代码,例如作为__global__函数的实参传递,那么无论是否定义了__CUDA_ARCH__宏,也无论其值为何,lambda 体中捕获变量的任何表达式都必须保持不变。之所以有此限制,是因为 lambda 的闭包类布局取决于编译器处理 lambda 表达式时遇到被捕获变量的顺序。如果设备编译与主机编译中的闭包类布局不同,程序可能会执行错误。示例:

    __device__ int result;
    
    template <typename T>
    __global__ void kernel(T in) { result = in(); }
    
    void foo(void) {
        int x1 = 1;
        // ERROR, "x1" is only captured when __CUDA_ARCH__ is defined.
        auto lambda1 = [=] __host__ __device__ {
    #ifdef __CUDA_ARCH__
            return x1 + 1;
    #else
            return 10;
    #endif
        };
        kernel<<<1, 1>>>(lambda1);
    }
    
  17. 如前所述,在发送给主机编译器的代码中,CUDA 编译器会将仅设备扩展 lambda 表达式替换为占位类型实例。该占位类型在主机代码中不定义函数指针转换运算符;不过,在设备代码中会提供该转换运算符。注意,此限制不适用于主机-设备扩展 lambda。示例:

    template <typename T>
    __global__ void kernel(T in) {
        int (*fp)(double) = in;
        fp(0); // CORRECT, conversion in device code is supported
        auto lambda1 = [](double) { return 1; };
    }
    
    void foo() {
        auto lambda_device      = [] __device__ (double) { return 1; };
        auto lambda_host_device = [] __host__ __device__ (double) { return 1; };
        kernel<<<1, 1>>>(lambda_device);
        kernel<<<1, 1>>>(lambda_host_device);
    
        // CORRECT, conversion for a __host__ __device__ lambda is supported in host code
        int (*fp1)(double) = lambda_host_device;
    
        // ERROR, conversion for a device lambda is not supported in host code
        int (*fp2)(double) = lambda_device;
    }
    
  18. 如前所述,在发送给主机编译器的代码中,CUDA 编译器会将仅设备扩展 lambda 表达式或主机-设备扩展 lambda 表达式替换为占位类型实例。此占位类型可能定义 C++ 特殊成员函数,例如构造函数和析构函数。因此,对于扩展 lambda 的闭包类型,某些标准 C++ 类型特征在 CUDA 前端编译器中可能产生与主机编译器中不同的结果。受影响的类型特征如下::std::is_trivially_copyable、std::is_trivially_constructible、std::is_trivially_copy_constructible、std::is_trivially_move_constructible、std::is_trivially_destructible。必须注意确保在实例化__global__、__device__、__constant__或__managed__函数模板或变量模板时不使用这些特征的结果。示例:

    #include <cstdio>
    #include <type_traits>
    
    template <bool b>
    void __global__ kernel() { printf("hi"); }
    
    template <typename T>
    void kernel_launch() {
        // ERROR, this kernel launch may fail, because CUDA frontend compiler and host compiler
        //        may disagree on the result of std::is_trivially_copyable_v trait on the
        //        closure type of the extended lambda
        kernel<std::is_trivially_copyable_v<T>><<<1,1>>>();
        cudaDeviceSynchronize();
    }
    
    int main() {
        int  x       = 0;
        auto lambda1 = [=] __host__ __device__ () { return x; };
        kernel_launch<decltype(lambda1)>();
    }
    

CUDA 编译器会针对1-12中所述的部分情况生成编译器诊断信息;对于情况13-17,不会生成诊断信息,但主机编译器可能无法编译所生成的代码。

5.3.8.5. 主机-设备 lambda 优化注意事项

与仅设备 lambda 不同,主机-设备 lambda 可以从主机代码中调用。如前所述,CUDA 编译器会将主机代码中定义的扩展 lambda 表达式替换为具名占位类型的实例。主机-设备扩展 lambda 的占位类型通过间接函数调用来调用原始 lambda 的operator()。如果未启用扩展 lambda 模式,这些特征将始终返回 false。

间接函数调用的存在可能导致主机编译器对主机-设备扩展 lambda 的优化程度低于对隐式或显式仅为__host__的 lambda 的优化程度。在后一种情况下,主机编译器可以轻松地将 lambda 体内联到调用上下文中。不过,当遇到主机-设备扩展 lambda 时,主机编译器可能无法轻松地内联原始 lambda 体。

5.3.8.6. *this按值捕获

根据 C++11/C++14 规则,当在非static类成员函数中定义 lambda,且 lambda 体引用了类成员变量时,必须按值捕获该类的this指针,而不是所引用的成员变量。如果该 lambda 是在主机函数中定义并在 GPU 上执行的仅设备扩展 lambda 或主机-设备扩展 lambda,那么当this指针指向主机内存时,在 GPU 上访问所引用的成员变量会导致运行时错误。

示例:

#include <cstdio>

template <typename T>
__global__ void foo(T in) { printf("value = %d\n", in()); }

struct MyStruct {
    int var;

    __host__ __device__ MyStruct() : var(10) {};

    void run() {
        auto lambda1 = [=] __device__ {
            // reference to "var" causes the 'this' pointer (MyStruct*) to be captured by value
            return var + 1;
        };
        // Kernel launch fails at run time because 'this->var' is not accessible from the GPU
        foo<<<1, 1>>>(lambda1);
        cudaDeviceSynchronize();
    }
};

int main() {
    MyStruct s1;
    s1.run();
}

C++17 通过引入新的*this捕获模式解决了此问题。在此模式下,编译器会复制*this所指代的对象,而不是按值捕获this指针。P0018R3中更详细地介绍了*this捕获模式。

使用--extended-lambda标志时,CUDA 编译器支持对__device__和__global__函数中定义的 lambda,以及主机代码中定义的仅设备扩展 lambda 使用*this捕获模式。

下面将上述示例修改为使用*this捕获模式:

#include <cstdio>

template <typename T>
__global__ void foo(T in) { printf("\n value = %d", in()); }

struct MyStruct {
    int var;
    __host__ __device__ MyStruct() : var(10) { };

    void run() {
        // note the "*this" capture specification
        auto lambda1 = [=, *this] __device__ {
            // reference to "var" causes the object denoted by '*this' to be captured by
            // value, and the GPU code will access 'copy_of_star_this->var'
            return var + 1;
        };
        // Kernel launch succeeds
        foo<<<1, 1>>>(lambda1);
        cudaDeviceSynchronize();
    }
};

int main() {
    MyStruct s1;
    s1.run();
}

对于主机代码中定义的未加注解的 lambda 或主机-设备扩展 lambda,不允许使用*this捕获模式,除非所选语言方言启用了*this捕获。下面是受支持和不受支持的用法示例:

struct MyStruct {
    int var;
    __host__ __device__ MyStruct() : var(10) { };

    void host_function() {
        // CORRECT, use in an extended device-only lambda
        auto lambda1 = [=, *this] __device__ { return var; };

        // Use in an extended host-device lambda
        // Error if *this capture not enabled by language dialect
        auto lambda2 = [=, *this] __host__ __device__ { return var; };

        // Use in an non-annotated lambda in host function
        // Error if *this capture not enabled by language dialect
        auto lambda3 = [=, *this]  { return var; };
    }

    __device__ void device_function() {
        // CORRECT, use in a lambda defined in a device-only function
        auto lambda1 = [=, *this] __device__ { return var; };

        // CORRECT, use in a lambda defined in a device-only function
        auto lambda2 = [=, *this] __host__ __device__ { return var; };

        // CORRECT, use in a lambda defined in a device-only function
        auto lambda3 = [=, *this]  { return var; };
    }

    __host__ __device__ void host_device_function() {
        // CORRECT, use in an extended device-only lambda
        auto lambda1 = [=, *this] __device__ { return var; };

        // Use in an extended host-device lambda
        // Error if *this capture not enabled by language dialect
        auto lambda2 = [=, *this] __host__ __device__ { return var; };

        // Use in an unannotated lambda in a host-device function
        // Error if *this capture not enabled by language dialect
        auto lambda3 = [=, *this]  { return var; };
    }
};

5.3.8.7. 实参依赖查找(ADL)

如前所述,CUDA 编译器在调用主机编译器之前,会将扩展 lambda 表达式替换为占位类型。该占位类型的一个模板实参使用包含原始 lambda 表达式的函数的地址。这可能导致额外的命名空间参与任何实参类型涉及该扩展 lambda 表达式闭包类型的主机函数调用的实参依赖查找(ADL)。因此,主机编译器可能选中错误的函数。

示例:

namespace N1 {

struct MyStruct {};

template <typename T>
void my_function(T);

}; // namespace N1

namespace N2 {

template <typename T>
int my_function(T);

template <typename T>
void run(T in) { my_function(in); }

} // namespace N2

void bar(N1::MyStruct in) {
    // For extended device-only lambda, the code sent to the host compiler is replaced with
    // the placeholder type instantiation expression
    //    ' __nv_dl_wrapper_t< __nv_dl_tag<void (*)(N1::MyStruct in),(&bar),1> > { }'
    //
    // As a result, the namespace 'N1' participates in ADL lookup of the
    // call to "my_function()" in the body of N2::run, causing ambiguity.
    auto lambda1 = [=] __device__ { };
    N2::run(lambda1);
}

在上述示例中,CUDA 编译器将扩展 lambda 替换为涉及N1命名空间的占位类型。因此,N1命名空间参与了N2::run()体中针对my_function(in)的 ADL 查找,由于发现了多个重载候选项,即N1::my_function和N2::my_function,导致主机编译失败。

5.3.9. 多态函数包装器

nvfunctional头文件提供了一个多态函数包装器类模板nvstd::function。此类模板的实例可以存储、复制和调用任何可调用目标,例如 lambda 表达式。nvstd::function可以在主机代码和设备代码中使用。

示例:

#include <nvfunctional>

__host__            int host_function()        { return 1; }
__device__          int device_function()      { return 2; }
__host__ __device__ int host_device_function() { return 3; }

__global__ void kernel(int* result) {
    nvstd::function<int()> fn1 = device_function;
    nvstd::function<int()> fn2 = host_device_function;
    nvstd::function<int()> fn3 = [](){ return 10; };
    *result                    = fn1() + fn2() + fn3();
}

__host__ __device__ void host_device_test(int* result) {
    nvstd::function<int()> fn1 = host_device_function;
    nvstd::function<int()> fn2 = [](){ return 10; };
    *result                    = fn1() + fn2();
}

__host__ void host_test(int* result) {
    nvstd::function<int()> fn1 = host_function;
    nvstd::function<int()> fn2 = host_device_function;
    nvstd::function<int()> fn3 = [](){ return 10; };
    *result                    = fn1() + fn2() + fn3();
}

无效情况:

  • 主机代码中的nvstd::function实例不能使用__device__函数的地址进行初始化,也不能使用其operator()为__device__函数的仿函数进行初始化。

  • 同样,设备代码中的nvstd::function实例不能使用__host__函数的地址进行初始化,也不能使用其operator()为__host__函数的仿函数进行初始化。

  • 在运行时,不能将nvstd::function实例从主机代码传递到设备代码(反之亦然)。

  • 如果__global__函数从主机代码启动,则不能在__global__函数的参数类型中使用nvstd::function。

无效情况示例:

#include <nvfunctional>

__device__ int device_function() { return 1; }
__host__   int host_function() { return 3; }
auto       lambda_host  = [] { return 0; };

__global__ void k() {
    nvstd::function<int()> fn1 = host_function; // ERROR, initialized with address of __host__ function
    nvstd::function<int()> fn2 = lambda_host;   // ERROR, initialized with address of functor with
                                                //        __host__ operator() function
}

__global__ void kernel(nvstd::function<int()> f1) {}

void foo(void) {
    auto lambda_device = [=] __device__ { return 1; };

    nvstd::function<int()> fn1 = device_function; // ERROR, initialized with address of __device__ function
    nvstd::function<int()> fn2 = lambda_device;   // ERROR, initialized with address of functor with
                                                  //        __device__ operator() function
    kernel<<<1, 1>>>(fn2);                        // ERROR, passing nvstd::function from host to device
}

nvstd::function在nvfunctional头文件中的定义如下:

namespace nvstd {

template <typename RetType, typename ...ArgTypes>
class function<RetType(ArgTypes...)> {
public:
    // constructors
    __device__ __host__ function() noexcept;
    __device__ __host__ function(nullptr_t) noexcept;
    __device__ __host__ function(const function&);
    __device__ __host__ function(function&&);

    template<typename F>
    __device__ __host__ function(F);

    // destructor
    __device__ __host__ ~function();

    // assignment operators
    __device__ __host__ function& operator=(const function&);
    __device__ __host__ function& operator=(function&&);
    __device__ __host__ function& operator=(nullptr_t);
    template<typename F>
    __device__ __host__ function& operator=(F&&);

    // swap
    __device__ __host__ void swap(function&) noexcept;

    // function capacity
    __device__ __host__ explicit operator bool() const noexcept;

    // function invocation
    __device__ RetType operator()(ArgTypes...) const;
};

// null pointer comparisons
template <typename R, typename... ArgTypes>
__device__ __host__
bool operator==(const function<R(ArgTypes...)>&, nullptr_t) noexcept;

template <typename R, typename... ArgTypes>
__device__ __host__
bool operator==(nullptr_t, const function<R(ArgTypes...)>&) noexcept;

template <typename R, typename... ArgTypes>
__device__ __host__
bool operator!=(const function<R(ArgTypes...)>&, nullptr_t) noexcept;

template <typename R, typename... ArgTypes>
__device__ __host__
bool operator!=(nullptr_t, const function<R(ArgTypes...)>&) noexcept;

// specialized algorithms
template <typename R, typename... ArgTypes>
__device__ __host__
void swap(function<R(ArgTypes...)>&, function<R(ArgTypes...)>&);

} // namespace nvstd

5.3.10. C/C++ 语言限制

5.3.10.1. 不支持的特性

  • 设备代码中不支持运行时类型信息(RTTI)和异常:

    • typeid关键字

    • dynamic_cast关键字

    • try/catch/throw关键字

  • 设备代码中不支持long double。

  • 所有平台均不支持三字符组。Windows 上不支持双字符组。

  • 用户定义的operator new、operator new[]、operator delete或operator delete[]不能用于替换编译器提供的相应内建实现,无论在主机上还是设备上,这都被视为未定义行为。

5.3.10.2. 命名空间保留

除非另有说明,否则向顶层命名空间cuda::、nv::或cooperative_groups::,或向这些命名空间内的任何嵌套命名空间添加定义,均属于未定义行为。我们允许将cuda::用作子命名空间,如下所示:

示例:

namespace cuda {   // same for "nv" and "cooperative_groups" namespaces

struct foo;        // ERROR, class declaration in the "cuda" namespace

void bar();        // ERROR, function declaration in the "cuda" namespace

namespace utils {} // ERROR, namespace declaration in the "cuda" namespace

} // namespace cuda
namespace utils {
namespace cuda {

// CORRECT, namespace "cuda" may be used nested within a non-reserved namespace
void bar();

} // namespace cuda
} // namespace utils

// ERROR, Equivalent to adding symbols to namespace "cuda" at global scope
using namespace utils;

5.3.10.3. 指针和内存地址

仅允许在相关内存所在的同一执行空间中对指针进行解引用(*pointer、pointer->member、pointer[0])。以下情况会导致未定义行为,最常见的情况是段错误和应用程序终止。

以下限制适用于函数:

  • 不允许在主机代码中获取__device__函数的地址。

  • 在主机代码中获取的__global__函数的地址不能在设备代码中使用。同样,在设备代码中获取的__global__函数的地址不能在主机代码中使用。

按照内存空间说明符一节所述,通过cudaGetSymbolAddress()获取的__device__或__constant__变量的地址只能在主机代码中使用。

5.3.10.4. 变量

5.3.10.4.1. 局部变量

在主机上执行的函数内,不允许在非extern变量声明上使用__device__、__tile__、__shared__、__managed__和__constant__内存空间说明符。

示例:

__host__ void host_function() {
    int x;                   // CORRECT, __host__ variable
    __device__   int y;      // ERROR,   __device__ variable declaration within a host function
    __tile__     int z;      // ERROR,   __tile__ variable declaration within a host function
    __shared__   int w;      // ERROR,   __shared__ variable declaration within a host function
    __managed__  int h;      // ERROR,   __managed__ variable  declaration within a host function
    __constant__ int i;      // ERROR,   __constant__ variable declaration within a host function
    extern __device__ int j; // CORRECT, extern __device__ variable
}

在设备上执行的函数内,不允许在既非extern也非static的变量声明上使用__device__、__tile__、__constant__和__managed__内存空间说明符。

__device__ void device_function() {
    int x;                   // CORRECT, __device__ variable
    __constant__      int y; // ERROR,   __constant__ variable declaration within a device function
    __managed__       int z; // ERROR,   __managed__ variable  declaration within a device function
    extern __device__ int k; // CORRECT, extern __device__ variable
}

另请参阅静态变量一节。

5.3.10.4.2. const限定的变量

在全局作用域、命名空间作用域或类作用域中声明的、不带内存空间注解(__device__、__tile__或__constant__)的const限定变量被视为主机变量。设备代码不能包含对该变量的引用,也不能获取其地址。

如果满足以下条件,该变量可以直接在设备代码中使用:

  • 它在使用点之前已使用常量表达式进行初始化,

  • 其类型不带volatile限定,并且

  • 它具有以下类型之一:

    • 内建整型,或

    • 内建浮点类型,但主机编译器为 Microsoft Visual Studio 时除外。

从 C++14 开始,建议使用constexpr或inline constexpr(C++17)变量,而不是const限定的变量。constexpr变量不受相同的类型限制,可以直接在设备代码中使用。

__managed__变量不支持const限定的类型。

示例:

const            int   ConstVar          = 10;
const            float ConstFloatVar     = 5.0f;
inline constexpr float ConstexprFloatVar = 5.0f; // C++17

struct MyStruct {
    static const            int   ConstVar          = 20;
//  static const             float ConstFloatVar     = 5.0f; // ERROR, static const variables cannot be float
    static inline constexpr float ConstexprFloatVar = 5.0f; // CORRECT
};

extern const int ExternVar;

__device__ void foo() {
    int array1[ConstVar];                     // CORRECT
    int array2[MyStruct::ConstVar];           // CORRECT

    const     float var1 = ConstFloatVar;     // CORRECT, except when the host compiler is Microsoft Visual Studio.
    constexpr float var2 = ConstexprFloatVar; // CORRECT
//  int             var3 = ExternVar;          // ERROR, "ExternVar" is not initialized with a constant expression
//  int&            var4 = ConstVar;           // ERROR, reference to host variable
//  int*            var5 = &ConstVar;          // ERROR, address of host variable
}

请参阅Compiler Explorer上的示例。

5.3.10.4.3. volatile限定的变量

注意

支持volatile关键字是为了保持与 ISO C++ 的兼容性。不过,它的其余未弃用用法即便有适用于 GPU 的,也为数甚少。

对volatile限定的对象进行读写不是原子操作,并且会被编译为一条或多条volatile 指令,这些指令不保证:

  • 内存操作的顺序,或

  • 硬件执行的内存操作次数与 PTX 指令的数量一致。

在分块代码中,volatile关键字对内存访问的行为没有影响。

CUDA C++ 的volatile不适用于:

  • 线程间同步:请改用通过cuda::atomic_ref、cuda::atomic或原子函数实现的原子操作。

    原子内存操作提供线程间同步保证,并且性能优于volatile操作。不过,CUDA C++ 的volatile操作不提供任何线程间同步保证,因此不适合此用途。以下示例展示了如何使用原子操作在两个线程之间传递消息。

    #include <cuda/atomic>
    
    __global__ void kernel(int* flag, int* data) {
        cuda::atomic_ref<int, cuda::thread_scope_device> atomic_ref{*flag};
        if (threadIdx.x == 0) {
            // Consumer: blocks until flag is set by producer, then reads data
            while(atomic_ref.load(cuda::memory_order_acquire) == 0)
                ;
            if (*data != 42)
                __trap(); // Errors if wrong data read
        }
        else if (threadIdx.x == 1) {
            // Producer: writes data then sets flag
            *data = 42;
            atomic_ref.store(1, cuda::memory_order_release);
        }
    }
    
    #include <cuda/atomic>
    
    __global__ void kernel(cuda::atomic<int, cuda::thread_scope_device>* flag, int* data) {
        if (threadIdx.x == 0) {
            // Consumer: blocks until flag is set by producer, then reads data
            while(flag->load(cuda::memory_order_acquire) == 0)
                ;
            if (*data != 42)
                __trap(); // Errors if wrong data read
        }
        else if (threadIdx.x == 1) {
            // Producer: writes data then sets flag
            *data = 42;
            flag->store(1, cuda::memory_order_release);
        }
    }
    
    __global__ void kernel(int* flag, int* data) {
        if (threadIdx.x == 0) {
            // Consumer: blocks until flag is set by producer, then reads data
            while(atomicAdd(flag, 0) == 0)
                ;                // Load with Relaxed Read-Modify-Write
            __threadfence();     // SequentiallyConsistent fence
            if (*data != 42)
                __trap();        // Errors if wrong data read
        } else if (threadIdx.x == 1) {
            // Producer: writes data then sets flag
            *data = 42;
            __threadfence();     // SequentiallyConsistent fence
            atomicExch(flag, 1); // Store with Relaxed Read-Modify-Write
        }
    }
    
  • 内存映射 IO(MMIO):请改为通过内联 PTX 使用PTX MMIO 操作。

    PTX MMIO 操作严格保持所执行的内存访问次数。不过,CUDA C++ 的volatile操作不保持所执行的内存访问次数,可能以不确定的方式执行比请求次数更多或更少的访问。这使其不适用于 MMIO。以下示例展示了如何使用 PTX MMIO 操作来读取和写入寄存器。

    __global__ void kernel(int* mmio_reg0, int* mmio_reg1) {
        // Write to MMIO register:
        int value = 13;
        asm volatile("st.relaxed.mmio.sys.u32 [%0], %1;"
            :
            : "l"(mmio_reg0), "r"(value) : "memory");
    
        // Read MMIO register:
        asm volatile("ld.relaxed.mmio.sys.u32 %0, [%1];"
            : "=r"(value)
            : "l"(mmio_reg1) : "memory");
    
        if (value != 42)
            __trap(); // Errors if wrong data read
    }
    

5.3.10.4.4. static变量

设备函数中允许使用static局部变量。

包含静态变量的函数在每个执行空间中使用不同的静态变量。例如,

  • __host__ __device__函数具有一个用于主机执行的静态变量副本,以及一个用于设备执行的副本。

  • __tile__ __device__函数具有一个用于分块执行的副本,以及一个用于 SIMT 执行的副本。

如果函数具有__host__执行空间说明符,则仅在定义了__CUDA_ARCH__时,才允许使用具有显式内存空间(例如static __device__/__tile__/__constant__/__shared__/__managed__)的static变量。

下面展示了函数作用域static变量的合法和非法用法示例。

struct TrivialStruct {
    int x;
};

struct NonTrivialStruct {
    __device__ NonTrivialStruct(int x) {}
};

__device__ void device_function(int x) {
    static int v1;              // CORRECT, implicit __device__ memory space specifier
    static int v2 = 11;         // CORRECT, implicit __device__ memory space specifier
//  static int v3 = x;           // ERROR, dynamic initialization is not allowed

    static __managed__  int v4; // CORRECT, explicit
    static __device__   int v5; // CORRECT, explicit
    static __constant__ int v6; // CORRECT, explicit
    static __shared__   int v7; // CORRECT, explicit

    static TrivialStruct    s1;     // CORRECT, implicit __device__ memory space specifier
    static TrivialStruct    s2{22}; // CORRECT, implicit __device__ memory space specifier
//  static TrivialStruct    s3{x};   // ERROR, dynamic initialization is not allowed
//  static NonTrivialStruct s4{3};   // ERROR, dynamic initialization is not allowed
}

请参阅Compiler Explorer上的示例。


__host__ __device__ void host_device_function() {
    static            int v1; // CORRECT, implicit __device__ memory space specifier
//  static __device__ int v2;  // ERROR, __device__-only variable inside a host-device function
#ifdef __CUDA_ARCH__
    static __device__ int v3; // CORRECT, declaration is only visible during device compilation
#else
    static int v4;            // CORRECT, declaration is only visible during host compilation
#endif
}

请参阅Compiler Explorer上的示例。


#include <cassert>

__host__ __device__ int host_device_function() {
    static int v = 0;
    v++;
    return v;
}

__global__ void kernel() {
    int ret = host_device_function(); // v = 1
    assert(ret == 4);                 // FAIL
}

int main() {
    host_device_function();           // v = 1
    host_device_function();           // v = 2
    int ret = host_device_function(); // v = 3
    assert(ret == 3);                 // OK
    kernel<<<1, 1>>>();
    cudaDeviceSynchronize();
}

请参阅Compiler Explorer上的示例。

5.3.10.4.5. extern变量

在全程序编译模式下编译时,不能使用extern关键字将__device__、__tile__、__shared__、__managed__和__constant__变量定义为具有外部链接的变量。在分离编译模式下,此限制也适用于__tile__变量。

唯一的例外是共享内存的动态分配一节所述的动态分配的__shared__变量。

__device__        int x; // OK
extern __device__ int y; // ERROR in whole program compilation mode
extern __shared__ int z; // OK

5.3.10.5. 函数

5.3.10.5.1. 递归

__global__、__tile_global__和__tile__函数不支持递归,而__device__和__host__ __device__函数没有此限制。

5.3.10.5.2. 外部链接

具有外部链接的设备变量或函数在跨多个翻译单元使用时需要分离编译模式。

在分离编译模式下,如果要求某个特定翻译单元中必须存在__device__或__global__函数的定义,则该函数的参数类型和返回类型在该翻译单元中必须是完整类型。此概念也称为单一定义规则使用,或 ODR 使用。

示例:

//first.cu:
struct S;                   // forward declaration
__device__ void foo(S);     // ERROR, type 'S' is an incomplete type
__device__ auto* ptr = foo; // ODR-use, address taken

int main() {}
//second.cu:
struct S {};               // struct definition
__device__ void foo(S) {}  // function definition
# compiler invocation
$ nvcc -std=c++14 -rdc=true first.cu second.cu -o prog
nvlink error   : Prototype doesn't match for '_Z3foo1S' in '/tmp/tmpxft_00005c8c_00000000-18_second.o',
                 first defined in '/tmp/tmpxft_00005c8c_00000000-18_second.o'
nvlink fatal   : merge_elf failed

5.3.10.5.3. 形式参数

不允许在形式参数上使用__device__、__tile__、__shared__、__managed__和__constant__内存空间说明符。

void device_function1(__device__ int x) { } // ERROR, __device__ parameter
void device_function2(__shared__ int x) { } // ERROR, __shared__ parameter

5.3.10.5.4. __global__函数参数

__global__或__tile_global__函数具有以下限制:

  • 它不能具有可变数量的实参,即不能使用 C 省略号语法...和va_list类型。允许使用 C++11 可变参数模板,但须遵守__global__ 可变参数模板一节中所述的限制。

  • 函数参数通过常量内存传递到设备,其总大小限制为 32,764 字节。

  • 函数参数不能为std::initializer_list类型。

  • 多态类参数(virtual)被视为未定义行为。

  • 允许使用 lambda 表达式和闭包类型,但须遵守lambda 表达式与 __global__ 函数参数一节中所述的限制。

  • 对于__tile_global__函数,函数参数不能是按值传递的类、结构体或联合体。

5.3.10.5.5. __global__函数实参传递

从设备代码启动__global__函数时,每个实参都必须是可平凡复制且可平凡析构的。

从主机代码启动__global__函数时,各实参类型可以是非平凡可复制的,也可以是非平凡可析构的。不过,这些类型的处理并不遵循标准 C++ 模型,如下所述。用户代码必须确保此工作流程不会影响程序的正确性。此工作流程在两个方面偏离了标准 C++:

  1. 使用原始内存复制,而非调用复制构造函数

    CUDA Runtime 通过复制原始内存内容将核函数实参传递给__global__函数,最终使用memcpy完成复制。如果某个实参是非平凡可复制的,并提供了用户定义的复制构造函数,则在从主机到设备的复制过程中,会跳过该构造函数调用的操作和副作用。

    示例:

    #include <cassert>
    
    struct MyStruct {
        int  value = 1;
        int* ptr;
    
        MyStruct() = default;
    
        __host__ __device__ MyStruct(const MyStruct&) { ptr = &value; }
    };
    
    __global__ void device_function(MyStruct my_struct) {
        // this assert fails because "my_struct" is obtained by copying
        // the raw memory content and the copy constructor is skipped.
        assert(my_struct.ptr == &my_struct.value); // FAIL
    }
    
    void host_function(MyStruct my_struct) {
        assert(my_struct.ptr == &my_struct.value); // CORRECT
    }
    
    int main() {
        MyStruct my_struct;
        host_function(my_struct);
        device_function<<<1, 1>>>(my_struct); // copy constructor invoked in the host-side only
        cudaDeviceSynchronize();
    }
    

    请参阅Compiler Explorer上的示例。

  2. 析构函数的调用可能早于__global__函数的执行结束

    核函数启动相对于主机执行是异步的。因此,如果__global__函数的某个实参具有非平凡析构函数,那么该析构函数甚至可能在__global__函数执行结束之前就在主机代码中执行。这可能导致析构函数具有副作用的程序出错。

    示例:

    #include <cassert>
    
    __managed__ int var = 0;
    
    struct MyStruct {
        __host__ __device__ ~MyStruct() { var = 3; }
    };
    
    __global__ void device_function(MyStruct my_struct) {
        assert(var == 0); // FAIL, MyStruct::~MyStruct() sets the value to 3
    }
    
    int main() {
        MyStruct my_struct;
        // GPU kernel execution is asynchronous with host execution.
        // As a result, MyStruct::~MyStruct() could be executed before
        // the kernel finishes executing.
        device_function<<<1, 1>>>(my_struct);
        cudaDeviceSynchronize();
    }
    

    请参阅Compiler Explorer上的示例。

5.3.10.6. 类

5.3.10.6.1. 类类型变量

使用__device__、__tile__、__constant__、__managed__或__shared__内存空间的变量定义,不能具有包含非空构造函数或非空析构函数的类类型。如果某个类类型的构造函数是平凡的,或者在翻译单元中的某一点满足以下所有条件,则将其视为空构造函数:

  • 该构造函数已经定义。

  • 该构造函数没有形参,其初始化列表为空,函数体为空复合语句。

  • 其类没有virtual函数、virtual基类或非static数据成员初始化器。

  • 其所有基类的默认构造函数均可视为空构造函数。

  • 对于该类中所有具有类类型(或该类类型的数组类型)的非static数据成员,其默认构造函数均可视为空构造函数。

如果某个类的析构函数是平凡的,或者在翻译单元中的某一点满足以下所有条件,则将其视为空析构函数:

  • 该析构函数已经定义。

  • 该析构函数的函数体为空复合语句。

  • 其类没有virtual函数或virtual基类。

  • 其所有基类的析构函数均可视为空析构函数。

  • 对于该类中所有具有类类型(或该类类型的数组类型)的非static数据成员,其析构函数均可视为空析构函数。

5.3.10.6.2. 数据成员

不允许在class、struct和union数据成员上使用__device__、__tile__、__shared__、__managed__和__constant__内存空间说明符。

仅支持在编译时求值的static数据成员,例如const 限定的变量和constexpr变量。

struct MyStruct {
   static inline constexpr int value1 = 10; // C++17
   static constexpr        int value2 = 10; // C++11
   static const            int value3 = 10;
// static                  int value4; // ERROR
};

5.3.10.6.3. 函数成员

__global__和__tile_global__函数不能是struct、class或union的成员。

允许在friend声明中声明__global__或__tile_global__函数,但不能在该声明中定义该函数。

示例:

struct MyStruct {
    friend __global__ void f();   // CORRECT, friend declaration only

//  friend __global__ void g() {} // ERROR, friend definition
};

请参阅Compiler Explorer上的示例。

5.3.10.6.4. 隐式声明的函数和非虚的显式预置函数

隐式声明的特殊成员函数是指用户未声明时由编译器为类声明的函数;显式预置函数是指用户声明但使用= default标记的函数。隐式声明或显式预置的特殊成员函数包括默认构造函数、复制构造函数、移动构造函数、复制赋值运算符、移动赋值运算符和析构函数。

设F表示一个非virtual函数,该函数是隐式声明的,或在首次声明时被显式预置。F的执行空间说明符是所有调用它的函数的执行空间说明符的并集。注意,在此分析中,__global__调用方将被视为__device__调用方。例如:

class Base {
    int x;
public:
    __host__ __device__ Base() : x(10) {}
};

class Derived : public Base {
    int y;
};

class Other: public Base {
    int z;
};

__device__ void foo() {
    Derived D1;
    Other D2;
}

__host__ void bar() {
    Other D3;
}

在此情况下,隐式声明的构造函数Derived::Derived()将被视为__device__函数,因为它仅由__device__函数foo()调用。隐式声明的构造函数Other::Other()将被视为__host__ __device__函数,因为它既由__device__函数foo()调用,也由__host__函数bar()调用。

此外,如果F是隐式声明的virtual函数(例如virtual析构函数),则对于每个被F重写的虚函数D,如果D不是隐式声明的,就将该虚函数的执行空间添加到F的执行空间集合中。

例如:

struct Base1 {
    virtual __host__ __device__ ~Base1() {}
};

struct Derived1 : Base1 {}; // implicitly-declared virtual destructor
                            // ~Derived1() has __host__ __device__  execution space specifiers

struct Base2 {
    virtual __device__ ~Base2() = default;
};

struct Derived2 : Base2 {}; // implicitly-declared virtual destructor
                            // ~Derived2() has __device__ execution space specifiers

5.3.10.6.5. 多态类

多态类,即具有virtual函数、派生自其他多态类或具有多态数据成员的类,受到以下限制:

  • 将多态对象从设备复制到主机,或从主机复制到设备,包括复制__global__函数的实参,属于未定义行为。

  • 重写的virtual函数的执行空间必须与基类中该函数的执行空间匹配。

示例:

struct MyClass {
    virtual __host__ __device__ void f() {}
};

__global__ void kernel(MyClass my_class) {
    my_class.f(); // undefined behavior
}

int main() {
    MyClass my_class;
    kernel<<<1, 1>>>(my_class);
    cudaDeviceSynchronize();
}

请参阅Compiler Explorer上的示例。


struct BaseClass {
    virtual __host__ __device__ void f() {}
};

struct DerivedClass : BaseClass {
    __device__ void f() override {} // ERROR
};

请参阅Compiler Explorer上的示例。

5.3.10.6.6. Windows 特有的类布局

CUDA 编译器在类布局方面遵循 IA64 ABI,而 Microsoft Visual Studio 则不遵循。这使得无法在主机代码与设备代码之间对特殊对象进行按位复制,如下所述。

设T表示成员指针类型,或满足以下任一条件的类类型:

  • T是多态类

  • T具有多重继承,且具有不止一个直接或间接的空基类。

  • 所有直接和间接基类B均为空类,并且T的第一个字段F的类型在其定义中使用了B,使得B在F的定义中被布局在偏移量 0 处。

使用 Microsoft Visual Studio 编译时,类型为T的类、具有T类型基类的类,或具有T类型数据成员的类,在主机与设备上可能具有不同的类布局和大小。

将此类对象从设备复制到主机,或从主机复制到设备,包括复制__global__函数的实参,属于未定义行为。

5.3.10.7. 模板

如果某个类型满足以下任一条件,则不能将其用作__global__函数或__device__/__constant__变量(C++14)的模板实参:

  • 该类型定义在__host__或__host__ __device__函数作用域内。

  • 该类型没有名称,例如匿名结构体或 lambda 表达式,除非该类型是__device__或__global__函数中的局部类型。

  • 该类型是带有private或protected的类成员,除非该类是__device__或__global__函数中的局部类。

  • 该类型由上述任意类型复合而成。

示例:

template <typename T>
__global__ void kernel() {}

template <typename T>
__device__ int device_var; // C++14

struct {
    int v;
} unnamed_struct;

void host_function() {
    struct LocalStruct {};
//  kernel<LocalStruct><<<1, 1>>>(); // ERROR, LocalStruct is defined within a host function
    int data = 4;
//  cudaMemcpyToSymbol(device_var<LocalStruct>, &data, sizeof(data)); // ERROR, same as above

    auto lambda = [](){};
//  kernel<decltype(lambda)><<<1, 1>>>();         // ERROR, unnamed type
//  kernel<decltype(unnamed_struct)><<<1, 1>>>(); // ERROR, unnamed type
}

class MyClass {
private:
    struct PrivateStruct {};
public:
    static void launch() {
//      kernel<PrivateStruct><<<1, 1>>>(); // ERROR, private type
    }
};

请参阅Compiler Explorer上的示例。

5.3.10.8. 分块代码中的限制

带有__tile__或__tile_global__注解的函数还受到以下额外限制:

  • 分块代码不支持以下语言构造:

    • do、while或for循环内,或 switch 语句内的 return 语句。

    • switch 语句内的 continue 语句,且其中的continue不包含在该 switch 下的循环中。

    • switch 主体内嵌套的 if 块或循环块中的 case/default 标签。

    • 虚函数调用。

    • goto 语句。

    • 产生函数指针、函数引用、成员变量指针或成员函数指针的表达式。

    • 通过函数指针和成员函数指针进行的调用。

    • 通过成员变量指针进行的访问。

    • 128 位整数类型或 128 位浮点类型。

    • 包含位域的类型。

    • 大小超过 16 MB 的类型。

    • 具有虚基类或虚函数的类型。

    • 使用非定位形式的new或delete运算符进行动态内存分配或释放。

  • __tile__或__tile_global__函数必须在声明它的同一翻译单元中具有函数体。

  • 不支持使用__tile__为虚函数添加注解。

  • __tile_global__或__tile__函数不能使用 C 省略号语法...来接受数量可变的实参。

  • __tile_global__或__tile__函数不能直接或间接递归。

  • 对于__tile_global__函数,函数形参不能是按值传递的类、结构体或联合体。

  • 分块代码不能执行设备端核函数启动,也不能从设备端核函数调用中启动分块核函数。

  • 分块代码不支持直接访问__half、__nv_bfloat16及相关扩展浮点类型的__x成员变量。

5.3.11. C++11 限制

5.3.11.1. inline命名空间

如果在外围命名空间中定义了具有相同名称和类型签名的另一个实体,则不允许在inline命名空间内定义以下任一实体:

  • __global__或__tile_global__函数。

  • __device__、__tile__、__constant__、__managed__、__shared__变量。

  • 表面或纹理类型的变量,例如cudaSurfaceObject_t或cudaTextureObject_t。

示例:

__device__ int my_var; // global scope

inline namespace NS {

__device__ int my_var; // namespace scope

} // namespace NS

5.3.11.2. inline无名命名空间

以下实体不能在inline无名命名空间内的命名空间作用域中声明:

  • __global__或__tile_global__函数。

  • __device__、__tile__、__constant__、__managed__、__shared__变量。

  • 表面或纹理类型的变量,例如cudaSurfaceObject_t或cudaTextureObject_t。

5.3.11.3. constexpr函数

__global__函数不能声明为constexpr。默认情况下,与标准函数一样,不能从执行空间不兼容的函数中调用constexpr函数。在本节列出的示例代码中,UB表示“未定义行为”。

  • 在主机编译阶段(未定义__CUDA_ARCH__宏时),从主机函数中调用未带有显式或隐式__host__注解的constexpr函数,属于未定义行为。示例:

    constexpr __device__             int  device_func() { return 0; }
    constexpr __tile__               int  tile_func()   { return 0; }
    
    constexpr __device__ __host___   int host_device_func() { return 0; }
    
    int main() {
        constexpr int x1 = device_func(); // UB: calling a __device__-only constexpr function from host code
        constexpr int x2 = tile_func();   // UB: calling a __tile__-only constexpr function from host code
        constexpr int x3 = host_device_func(); // OK
    
    }
    
  • 在设备编译阶段(已定义__CUDA_ARCH__宏时),从__device__或__global__函数中调用未带有显式或隐式__device__注解的constexpr函数,属于未定义行为。示例:

    constexpr  int host_func() { return 0; }
    
    __device__ void dmain()
    {
        int x = host_func();  // UB: calling a host-only constexpr function from device code
    }
    
  • 在设备编译阶段(已定义__CUDA_ARCH__宏时),从__tile__或__tile_global__函数中调用未带有显式或隐式__tile__注解的constexpr函数,属于未定义行为。示例:

    constexpr  int host_func() { return 0; }
    
    __tile__ void dmain()
    {
        int x = host_func();  // UB: calling a host-only constexpr function from tile code
    }
    

注意,即使相应的模板函数标有关键字constexpr,函数模板特化也可能不是constexpr函数。

宽松的 constexpr 函数支持

可以使用实验性的nvcc标志--expt-relaxed-constexpr来放宽此约束,具体如下所述。nvcc还会定义宏__CUDACC_RELAXED_CONSTEXPR__。在本节列出的示例代码中,UB表示“未定义行为”。

指定--expt-relaxed-constexpr标志后,编译器将支持跨执行空间调用,具体如下:

  1. 如果对constexpr函数的跨执行空间调用发生在需要常量求值的上下文中,例如 constexpr 变量的初始化器中,则支持此调用。示例:

    constexpr __host__ int host_func(int x) { return x + 1; };
    
    __global__ void doit() {
         constexpr int val = host_func(1); // OK: call is in a context that
                                           // requires constant evaluation.
    }
    
    __tile_global__ void tile_doit() {
         constexpr int val = host_func(1); // OK: call is in a context that
                                           // requires constant evaluation.
    }
    
    
    constexpr __device__ int device_func(int x) { return x + 1; }
    
    constexpr __tile__ int tile_func(int x) { return x + 2; }
    
    int main() {
    constexpr int val = device_func(1) + tile_func(1); // OK: call is in a context that
                                                       // requires constant evaluation.
    }
    
  2. 否则:

    1. 在语言规则要求进行常量折叠的上下文之外,不支持从分块代码跨执行空间调用未带有显式或隐式__tile__注解的constexpr函数。示例:

      constexpr __host__ int host_func(int x) { return x + 1; }
      
      __tile__ int doit(int in) {
          in = host_func(in); // UB: call occurs outside of a context that requires
                              // constant evaluation.
          constexpr int other = host_func(10); // OK with -expt-relaxed-constexpr:
                                               // call is  required to be evaluated at compile time
      }
      
    2. 在生成 SIMT 设备代码期间,会为仅限主机的constexpr函数host_func的函数体生成设备代码,除非未使用host_func,或者仅在常量求值上下文中调用它。示例:

      // NOTE: "host_func" is emitted in generated device code because it is
      // called from device code in a non-constexpr context
      constexpr __host__ int host_func(int x) { return x + 1; }
      
      __device__ int doit(int in) {
          in = host_func(in);  // OK, even though argument is not a constant expression
          return in;
      }
      
    3. 适用于__device__函数的所有代码限制,也适用于从 SIMT 设备代码调用的仅限主机的constexpr函数H。不过,编译器可能不会针对H的这些限制发出任何构建时诊断信息。原因在于,诊断信息通常在解析期间生成,但在翻译单元后续位置遇到设备代码对H的调用之前,H可能已经完成了解析。

      例如,H的函数体中不支持以下代码模式(与任何__device__函数一样),但可能不会生成编译器诊断信息:

      • ODR 使用主机变量或仅限主机的非constexpr函数。示例:

        int host_var1, host_var2;
        
        constexpr __host__ int* host_func(bool b) { return b ? &host_var1 : &host_var2; };
        
        __device__ int doit(bool flag) {
            int *ptr;
            ptr = host_func(flag); // UB: host_func() attempts to refer to host variables 'host_var1' and 'host_var2'.
                         // code will compile, but will NOT execute correctly.
            return *ptr;
        }
        
      • 使用异常(throw/catch)和 RTTI(typeid, dynamic_cast)。示例:

        struct Base { };
        struct Derived : public Base { };
        
        // NOTE: "host_func" is emitted in generated device code
        constexpr int host_func(bool b, Base *ptr) {
          if (b) {
            return 1;
          } else if (typeid(ptr) == typeid(Derived)) { // UB: use of typeid in code executing on the GPU
            return 2;
          } else {
            throw int{4}; // UB: use of throw in code executing on the GPU
          }
        }
        
        __device__ void doit(bool flag) {
            int val;
            Derived d;
            val = host_func(flag, &d); //UB: host_func() attempts use typeid and throw(), which are not allowed in code that executes on the GPU
        }
        
    1. 在生成主机代码期间,constexpr非主机函数F的函数体会保留在发送给主机编译器的代码中。如果F的函数体尝试 ODR 使用命名空间作用域的设备变量或__tile__变量,或者非主机的非constexpr函数,则不支持从主机代码调用F(代码可能在没有编译器诊断信息的情况下完成构建,但在运行时可能出现错误行为)。示例:

      __device__ int device_var1, device_var2;
      
      constexpr __device__ int* device_func(bool b) { return b ? &device_var1 : &device_var2; };
      
      __tile__ int tile_var1, tile_var2;
      
      constexpr __tile__ int* tile_func(bool b) { return b ? &tile_var1 : &tile_var2; };
      
      int doit1(bool flag) {
          int *ptr;
          ptr = device_func(flag); // UB: device_func() attempts to refer to device variables 'device_var1' and 'device_var2'
                                   // code will compile, but will NOT execute correctly.
          return *ptr;
      }
      
      int doit2(bool flag) {
          int *ptr;
          ptr = tile_func(flag); // UB: tile_func() attempts to refer to __tile__ variables 'tile_var1' and 'tile_var2'
                                 // code will compile, but will NOT execute correctly.
          return *ptr;
      }
      

警告

由于存在上述限制,且编译器不会对不正确的用法给出诊断信息,因此建议避免从设备代码调用标准 C++ 头文件std::中的函数。这类函数的实现因主机平台而异。相反,强烈建议调用 CUDA C++ 标准库libcu++中cuda::std::命名空间内的等效功能。

5.3.11.4. constexpr变量

默认情况下,与标准变量一样,constexpr变量不能在执行空间不兼容的函数中使用。

在以下情况下,constexpr变量可以直接用于设备代码:

  • C++ 标量类型,不包括指针类型和成员指针类型:

    • nullptr_t.

    • bool.

    • 整型:char、signed char、unsigned、long long等。

    • 浮点类型:float、double。

    • 枚举项:enum和enum class。

  • 类类型:具有constexpr构造函数的class、struct和union。

  • 上述类型的原生数组,例如int[],但仅限于在执行空间为__device__或__host__ __device__的constexpr函数内使用时。

不允许使用constexpr __managed__和constexpr __shared__变量。

示例:

constexpr int ConstexprVar = 4; // scalar type

struct MyStruct {
    static constexpr int ConstexprVar = 100;
};

constexpr MyStruct my_struct = MyStruct{}; // class type

constexpr int array[] = {1, 2, 3};

__device__ constexpr int get_value(int idx) {
    return array[idx];                      // CORRECT
}

__device__ void foo(int idx) {
    int        v1 = ConstexprVar;           // CORRECT
    int        v2 = MyStruct::ConstexprVar; // CORRECT
//  const int &v3 = ConstexprVar1;          // ERROR, reference to host constexpr variable
//  const int *v4 = &ConstexprVar1;         // ERROR, address of host constexpr variable
    int        v5 = get_value(2);           // CORRECT, 'get_value(2)' is a constant expression.
//  int        v6 = get_value(idx);         // ERROR, 'get_value(idx)' is not a constant expression
//  int        v7 = array[2];               // ERROR, 'array' is not scalar type.
    MyStruct   v8 = my_struct;              // CORRECT
}

请参阅Compiler Explorer上的示例。

5.3.11.5. __global__可变参数模板

可变参数__global__或__tile_global__函数模板受到以下限制:

  • 只允许有一个参数包。

  • 参数包必须列在模板形参列表的最后。

示例:

template <typename... Pack>
__global__ void kernel1(); // CORRECT

// template <typename... Pack, template T>
// __global__ void kernel2(); // ERROR, parameter pack is not the last parameter

template <typename... TArgs>
struct MyStruct {};

// template <typename... Pack1, typename... Pack2>
// __global__ void kernel3(MyStruct<Pack1...>, MyStruct<Pack2...>); // ERROR, more than one parameter pack

请参阅Compiler Explorer上的示例。

5.3.11.6. 预置函数= default

CUDA 编译器按隐式声明的函数和显式预置函数中所述的方式,推断显式预置成员函数的执行空间。

编译器会忽略显式预置函数上的执行空间说明符,除非该函数在类外定义或是virtual函数。

示例:

struct MyStruct1 {
    MyStruct1() = default;
};

void host_function() {
    MyStruct1 my_struct; // __host__ __device__ constructor
}

__device__ void device_function() {
    MyStruct1 my_struct; // __host__ __device__ constructor
}

struct MyStruct2 {
    __device__ MyStruct2() = default; // WARNING: __device__ annotation is ignored
};

struct MyStruct3 {
    __host__ MyStruct3();
};
MyStruct3::MyStruct3() = default; // out-of-line definition, not ignored

__device__ void device_function2() {
//  MyStruct3 my_struct; // ERROR, __host__ constructor
}

struct MyStruct4 {
    //  MyStruct4::~MyStruct4 has host execution space, not ignored because virtual
    virtual __host__ ~MyStruct4() = default;
};

__device__ void device_function3() {
    MyStruct4 my_struct4;
    // implicit destructor call for 'my_struct4':
    //    ERROR: call from a __device__ function 'device_function3' to a
    //    __host__ function 'MyStruct4::~MyStruct4'
}

请参阅Compiler Explorer上的示例。

5.3.11.7. [cuda::]std::initializer_list

默认情况下,CUDA 编译器隐式地将[cuda::]std::initializer_list的成员函数视为具有__host__ __device__ __tile__执行空间说明符,因此可以从设备代码直接调用这些成员函数。
nvcc标志--no-host-device-initializer-list会禁用此行为;此后,[cuda::]std::initializer_list的成员函数将被视为__host__函数,无法从设备代码直接调用。

__global__或__tile_global__函数不能具有[cuda::]std::initializer_list类型的形参。

示例:

#include <initializer_list>

__device__ void foo(std::initializer_list<int> in) {}

__device__ void bar() {
    foo({4,5,6}); // (a) initializer list containing only constant expressions.
    int i = 4;
    foo({i,5,6}); // (b) initializer list with at least one  non-constant element.
                  // This form may have better performance than (a).
}

请参阅Compiler Explorer上的示例。

5.3.11.8. [cuda::]std::move, [cuda::]std::forward

默认情况下,CUDA 编译器隐式地将std::move和std::forward函数模板视为具有__host__ __device__ __tile__执行空间说明符,因此可以从设备代码直接调用它们。nvcc标志--no-host-device-move-forward会禁用此行为;此后,std::move和std::forward将被视为__host__函数,无法从设备代码直接调用。

提示

相反,cuda::std::move和cuda::std::forward始终具有__host__ __device__执行空间。

5.3.12. C++14 限制

5.3.12.1. 具有推导返回类型的函数

__global__或__tile_global__函数不能具有推导返回类型auto。

不允许在主机代码中对具有推导返回类型的__device__函数的返回类型进行内省。

注意

CUDA 前端编译器在调用主机编译器之前,会将函数声明更改为具有void返回类型。这可能会破坏主机代码中对__device__函数的推导返回类型的内省。因此,对于在设备函数体之外引用此类推导返回类型的情况,CUDA 编译器将发出编译时错误。

示例:

 __device__ auto device_function(int x) { // deduced return type
     return x;                            // decltype(auto) has the same behavior
 }

 __global__ void kernel() {
     int x = sizeof(device_function(2));         // CORRECT, device code scope
 }

 // const int size = sizeof(device_function(2)); // ERROR, return type deduction on host

 void host_function() {
 //  using T = decltype(device_function(2));     // ERROR, return type deduction on host
 }

void host_fn1() {
  // ERROR, referenced outside device function bodies
  int (*p1)(int) = fn1;

  struct S_local_t {
    // ERROR, referenced outside device function bodies
    decltype(fn2(10)) m1;

    S_local_t() : m1(10) { }
  };
}

// ERROR, referenced outside device function bodies
template <typename T = decltype(fn2)>
void host_fn2() { }

template<typename T> struct MyStruct { };

// ERROR, referenced outside device function bodies
struct S1_derived_t : MyStruct<decltype(fn1)> { };

5.3.12.2. 变量模板

使用 Microsoft 编译器时,__device__、__tile__或__constant__变量模板不能用const限定。

示例:

// ERROR on Windows (non-portable), const-qualified
template <typename T>
__device__ const T var = 0;

 // CORRECT, ptr1 is not const-qualified
template <typename T>
__device__ const T* ptr1 = nullptr;

// ERROR on Windows (non-portable), ptr2 is const-qualified
template <typename T>
__device__ const T* const ptr2 = nullptr;

请参阅Compiler Explorer上的示例。

5.3.13. C++17 限制

5.3.13.1. inline变量

在单个翻译单元中,使用inline变量并不会提供超出常规变量的任何额外功能,也不具备任何实际优势。
nvcc仅在分离编译模式下,或对于具有内部链接的变量,才允许具有__device__、__tile__、__constant__或__managed__内存空间的inline变量。

注意

使用gcc/g++主机编译器时,使用__managed__内存空间说明符声明的inline变量可能对调试器不可见。

示例:

inline        __device__ int device_var1;  // CORRECT, when compiled in Separate Compilation mode (-rdc=true or -dc)
                                           // ERROR, when compiled in Whole Program Compilation mode

static inline __device__ int device_var2;  // CORRECT, internal linkage

namespace {

inline __device__ int device_var3;         // CORRECT, internal linkage

inline __shared__ int shared_var;          // CORRECT, internal linkage

static inline __device__ int device_var4;  // CORRECT, internal linkage

inline __device__ int device_var5;         // CORRECT, internal linkage

} // namespace

请参阅Compiler Explorer上的示例。

5.3.13.2. 结构化绑定

结构化绑定不能使用内存空间说明符声明,例如__device__、__tile__、__shared__、__constant__或__managed__。

示例:

struct S {
    int x, y;
};
// __device__ auto [a, b] = S{4, 5}; // ERROR

5.3.14. C++20 限制

5.3.14.1. 三路比较运算符

__device__和__global__函数支持三路比较运算符(<=>),但某些用法隐式依赖于由主机实现提供的 C++ 标准库功能。使用这些运算符可能需要指定标志--expt-relaxed-constexpr以消除警告,并且该功能要求主机实现满足设备代码的要求。

示例:

#include <compare> // std::strong_ordering implementation

struct S {
    int x, y;

    auto operator<=>(const S&) const = default; // (a)

    __host__ __device__ bool operator<=>(int rhs) const { return false; } // (b)
};

__host__ __device__ bool host_device_function(S a, S b) {
    if (a <=> 1)  // CORRECT, calls a user-defined host-device overload (b)
        return true;
    return a < b; // CORRECT, call to an implicitly-declared function (a)
                  // Note: it requires a device-compatible std::strong_ordering
                  //       implementation provided in the header <compare>
                  //       and the flag --expt-relaxed-constexpr
}

请参阅Compiler Explorer上的示例。

5.3.14.2. consteval函数

无论consteval函数的执行空间是什么,都可以从主机代码和设备代码中调用它们。

示例:

consteval int host_consteval() {
    return 10;
}

__device__ consteval int device_consteval() {
    return 10;
}

__device__ int device_function() {
    return host_consteval();   // CORRECT, even if called from device code
}

__host__ __device__ int host_device_function() {
    return device_function();  // CORRECT, even if called from host-device code
}

5.3.15. C++23 限制

除了上面的C++23 语言特性表中标明的不支持或不适用的特性之外,没有已知的 C++23 特有限制。该表中标记为 ❌ 或 N/A 的条目(包括缺陷报告的解决方案)反映的是缺失或不适用的特性,而不是额外的行为限制。

设备代码限制:

  • 设备代码不支持固定宽度浮点类型float16_t、float32_t、float64_t、float128_t和bfloat16_t。

5.3.15.1. 相等运算符(P2468R2)

尽管 NVCC 并未完全实现DR:你所寻求的相等运算符(P2468R2),但它近似实现了该行为,而且这种近似实现尚未导致用户代码中出现已知故障。

5.4. C/C++ 语言扩展

5.4.1. 函数和变量注解

5.4.1.1. 执行空间说明符

执行空间说明符__host__、__device__、__tile__、__global__和__tile_global__指明函数是在主机、SIMT 还是分块(tile)上下文中执行。

表 39 执行空间说明符

执行空间说明符

执行所在位置

可从何处调用

主机

SIMT

分块

主机

SIMT

分块

__host__、无说明符

✅

❌

❌

✅

❌

❌

__device__

❌

✅

❌

❌

✅

❌

__global__

❌

✅

❌

✅

✅

❌

__tile__

❌

❌

✅

❌

❌

✅

__tile_global__

❌

❌

✅

✅

❌

❌

__host__ __device__ __tile__

✅

✅

✅

✅

✅

✅


__global__和__tile_global__函数的约束:

  • 必须返回void。

  • 不能是class、struct或union的成员。

  • 需要核函数配置中所述的执行配置。

  • 不支持递归。

  • 其他限制请参阅__global__函数参数。

对__global__和__tile_global__函数的调用是异步的。它们会在设备完成执行之前返回到主机线程。


声明为具有多个执行空间的函数(例如__host__ __device__)会针对每个上下文进行编译。可以使用__CUDA_ARCH__宏来区分主机代码路径和设备代码路径:

__host__ __device__ void func() {
#if defined(__CUDA_ARCH__)
    // Device code path
#else
    // Host code path
#endif
}

5.4.1.2. 内存空间说明符

内存空间说明符__device__、__tile__、__managed__、__constant__和__shared__指明变量在设备上的存储位置。

下表汇总了内存空间的属性:

表 40 内存空间说明符

内存空间说明符

位置

可由何者访问

生存期

唯一实例

__device__

设备全局内存

设备线程(网格)/ CUDA Runtime API

程序/CUDA 上下文

每个设备

__tile__

设备全局内存

分块块 / CUDA Runtime API

程序/CUDA 上下文

每个设备

__constant__

设备常量内存

设备线程(网格)/ CUDA Runtime API

程序/CUDA 上下文

每个设备

__managed__

主机和设备(自动)

主机/设备线程

程序

每个程序

__shared__

设备(流式多处理器(SM))

线程块中的线程

线程块

线程块

无说明符

设备(寄存器)

单个线程

单个线程

单个线程


  • 可以使用CUDA Runtime API函数cudaGetSymbolAddress()、cudaGetSymbolSize()、cudaMemcpyToSymbol()和cudaMemcpyFromSymbol(),从主机访问__device__、__tile__和__constant__变量。

  • __constant__变量在设备代码中是只读的,只能在主机上使用CUDA Runtime API进行修改。

以下示例说明了如何使用这些 API:

__device__   float device_var       = 4.0f; // Variable in device memory
__constant__ float constant_mem_var = 4.0f; // Variable in constant memory
                                            // For readability, the following example focuses on a device variable.
int main() {
    float* device_ptr;
    cudaGetSymbolAddress((void**) &device_ptr, device_var);        // Gets address of device_var

    size_t symbol_size;
    cudaGetSymbolSize(&symbol_size, device_var);                   // Retrieves the size of the symbol (4 bytes).

    float host_var;
    cudaMemcpyFromSymbol(&host_var, device_var, sizeof(host_var)); // Copies from device to host.

    host_var = 3.0f;
    cudaMemcpyToSymbol(device_var, &host_var, sizeof(host_var));   // Copies from host to device.
}

请参阅Compiler Explorer上的示例。

5.4.1.2.1. __shared__内存

__shared__内存变量可以具有在编译时确定的静态大小,也可以具有在核函数启动时确定的动态大小。有关在运行时指定共享内存大小的详细信息,请参阅核函数配置一节。

共享内存约束:

  • 具有动态大小的变量必须声明为外部数组或指针。

  • 具有静态大小的变量不能在声明中初始化。

以下示例说明了如何声明__shared__变量并指定其大小:

extern __shared__ char dynamic_smem_pointer[];
// extern __shared__ char* dynamic_smem_pointer; alternative syntax

__global__ void kernel() { // or a __device__ function
    __shared__ int smem_var1[4];                  // static size
    auto smem_var2 = (int*) dynamic_smem_pointer; // dynamic size
}

int main() {
    size_t shared_memory_size = 16;
    kernel<<<1, 1, shared_memory_size>>>();
    cudaDeviceSynchronize();
}

请参阅Compiler Explorer上的示例。

5.4.1.2.2. __managed__内存

__managed__变量具有以下限制:

  • __managed__变量的地址不是常量表达式。

  • __managed__变量不得具有引用类型T&。

  • 当 CUDA 运行时可能处于无效状态时,不得使用__managed__变量的地址或值,包括以下情况:

    • 在对具有static或thread_local存储期的对象进行静态/动态初始化或销毁时。

    • 在调用exit()之后执行的代码中。例如,带有__attribute__((destructor))标记的函数。

    • 在 CUDA 运行时可能尚未初始化时执行的代码中。例如,带有__attribute__((constructor))标记的函数。

  • __managed__变量不能用作decltype()表达式的不带括号的标识符表达式实参。

  • __managed__变量具有与动态分配的托管内存所规定的相同的相干性和一致性行为。

  • 另请参阅对局部变量的限制。

以下是__managed__变量的合法用法和非法用法示例:

#include <cassert>

__device__ __managed__ int global_var = 10; // OK

int* ptr = &global_var;                     // ERROR: use of a managed variable in static initialization

struct MyStruct1 {
    int field;
    MyStruct1() : field(global_var) {};
};

struct MyStruct2 {
    ~MyStruct2() { global_var = 10; }
};

MyStruct1 temp1; // ERROR: use of managed variable in dynamic initialization

MyStruct2 temp2; // ERROR: use of managed variable in the destructor of
                 //        object with static storage duration

__device__ __managed__ const int const_var = 10;         // ERROR: const-qualified type

__device__ __managed__ int&      reference = global_var; // ERROR: reference type

template <int* Addr>
struct MyStruct3 {};

MyStruct3<&global_var> temp;     // ERROR: address of managed variable is not a constant expression

__global__ void kernel(int* ptr) {
    assert(ptr == &global_var);  // OK
    global_var = 20;             // OK
}

int main() {
    int* ptr = &global_var;      // OK
    kernel<<<1, 1>>>(ptr);
    cudaDeviceSynchronize();
    global_var++;                // OK
    decltype(global_var) var1;   // ERROR: managed variable used as unparenthesized argument to decltype

    decltype((global_var)) var2; // OK
}

5.4.1.2.3. __tile__变量

__tile__内存空间说明符类似于__device__。带有__tile__标记的变量分配在设备全局内存中,并可由 CUDA Runtime API 函数访问。与__device__变量不同,__tile__变量可以从分块代码中直接访问。

一般而言,不能从__tile__或__tile_global__代码中通过名称直接访问__device__变量,也不能从__device__或__global__代码中通过名称直接访问__tile__变量。但是,这两类变量都分配在设备全局内存中,指向该内存的指针可以在任一上下文中访问。

以下示例展示了分块变量的合法用法和非法用法:

__device__ int x_device = 0;
__tile__   int x_tile = 0;

// ERROR: Variable cannot be both device and tile
__device__ __tile__ int x_device_tile = 0;

__global__ void simt_function(int* in) {
  x_device = 1; // OK: __device__ variable accessed from SIMT code
  x_tile = 1;   // ERROR: __tile__ variable accessed from SIMT code

  *in = 1; // OK: May point to any device global memory, including x_tile.
}

__tile_global__ void tile_function(int* in) {
  x_device = 1; // ERROR: __device__ variable accessed from tile code
  x_tile = 1;   // OK: __tile__ variable accessed from tile code

  *in = 1; // OK: May point to any device global memory, including x_device.
}

int main() {
  int* x_device_ptr;
  cudaGetSymbolAddress((void**) &x_device_ptr, x_device);    // Gets address of x_device

  int* x_tile_ptr;
  cudaGetSymbolAddress((void**) &x_tile_ptr, x_tile);        // Gets address of x_tile

  // OK: Passing pointer to tile variable into SIMT kernel
  simt_function<<<1,1>>>(x_tile_ptr);
  cudaDeviceSynchronize();

  // OK: Passing pointer to device variable into tile kernel
  tile_function<<<1,1>>>(x_device_ptr);
  cudaDeviceSynchronize();
}

__tile__变量不得包含指针类型或引用类型的子对象。例如:

__tile__ int* ptr; // ERROR

struct S1 { int* ptr; };
__tile__ S1 val; // ERROR

5.4.1.3. 内联说明符

以下说明符可用于控制__host__和__device__函数的内联:

  • __noinline__:指示nvcc不要内联该函数。

  • __forceinline__:强制nvcc在单个翻译单元内内联该函数。

  • __inline_hint__:在使用链接时优化时,启用跨翻译单元的激进内联。

这些说明符互斥。将这些说明符应用于__tile__函数时,它们会被忽略。

5.4.1.4. __restrict__指针

nvcc通过__restrict__关键字支持受限指针。

当两个或更多指针指向重叠的内存区域时,就会发生指针别名。这可能妨碍代码重排和公共子表达式消除等优化。

用 restrict 限定的指针是程序员作出的一项承诺:在该指针的生存期内,只会通过该指针访问它所指向的内存。这使编译器能够执行更激进的优化。

  • 访问设备函数的所有线程都只从中读取;或者

  • 最多只有一个线程向其写入,并且没有其他线程从中读取。

以下示例说明了一个别名问题,并展示了使用受限指针如何帮助编译器减少指令数量:

__device__
void device_function(const float* a, const float* b, float* c) {
    c[0] = a[0] * b[0];
    c[1] = a[0] * b[0];
    c[2] = a[0] * b[0] * a[1];
    c[3] = a[0] * a[1];
    c[4] = a[0] * b[0];
    c[5] = b[0];
    ...
}

由于指针a、b和c可能存在别名,通过c进行的任何写入都可能修改a或b的元素。为了保证功能正确性,编译器不能将a[0]和b[0]加载到寄存器中,将它们相乘,然后将结果同时存储到c[0]和c[1]中。这是因为,如果a[0]和c[0]位于同一位置,结果就会与抽象执行模型不同。编译器无法利用这个公共子表达式。类似地,编译器不能对c[4]的计算与c[0]和c[1]的计算进行重排,因为先前对c[3]的写入可能改变c[4]计算的输入。

通过将a、b和c声明为受限指针,程序员告知编译器这些指针不存在别名。这意味着对c的写入永远不会覆盖a或b的元素。这会将函数原型更改为如下形式:

__device__
void device_function(const float* __restrict__ a, const float* __restrict__ b, float* __restrict__ c);

请注意,所有指针实参都必须是受限的,编译器优化器才能发挥作用。添加__restrict__关键字后,编译器可以任意进行重排和公共子表达式消除,同时保持与抽象执行模型完全相同的功能。

__device__
void device_function(const float* __restrict__ a, const float* __restrict__ b, float* __restrict__ c) {
    float t0 = a[0];
    float t1 = b[0];
    float t2 = t0 * t1;
    float t3 = a[1];
    c[0]     = t2;
    c[1]     = t2;
    c[4]     = t2;
    c[2]     = t2 * t3;
    c[3]     = t0 * t3;
    c[5]     = t1;
    ...
}

请参阅Compiler Explorer上的示例。

其结果是内存访问和计算次数减少,但代价是将加载的值和公共子表达式缓存在寄存器中会增加寄存器压力。

由于寄存器压力在许多 CUDA 代码中是一个关键问题,使用受限指针可能会因降低占用率而对性能产生负面影响。


对__global__函数中带有__restrict__标记的const指针的访问会被编译为只读缓存加载,类似于PTX的ld.global.nc或__ldg()低级加载和存储函数指令。

__global__
void kernel1(const float* in, float* out) {
    *out = *in; // PTX: ld.global
}

__global__
void kernel2(const float* __restrict__ in, float* out) {
    *out = *in;  // PTX: ld.global.nc
}

请参阅Compiler Explorer上的示例。

5.4.1.5. __grid_constant__参数

使用__grid_constant__注解__global__函数参数可防止编译器为每个线程创建该参数的副本。相反,网格中的所有线程都将通过同一地址访问该参数,这可以提高性能。

__grid_constant__参数具有以下属性:

  • 它的生存期与核函数相同。

  • 它是单个核函数私有的,这意味着其他网格(包括子网格)中的线程无法访问该对象。

  • 核函数中的所有线程看到的地址都相同。

  • 它是只读的。修改__grid_constant__对象或其任何子对象(包括mutable成员)属于未定义行为。

要求:

  • 使用__grid_constant__注解的核函数参数必须具有const限定的非引用类型。

  • 所有函数声明在任何__grid_constant__参数上都必须保持一致。

  • 函数模板特化在任何__grid_constant__参数上都必须与主模板声明匹配。

  • 函数模板实例化在任何__grid_constant__参数上也必须与主模板声明匹配。

  • __tile_global__函数参数中的__grid_constant__注解会被忽略。

示例:

struct MyStruct {
    int         x;
    mutable int y;
};

__device__ void external_function(const MyStruct&);

__global__ void kernel(const __grid_constant__ MyStruct s) {
    // s.x++; // Compile error: tried to modify read-only memory
    // s.y++; // Undefined Behavior: tried to modify read-only memory

    // Compiler will NOT create a per-thread local copy of "s":
    external_function(s);
}

请参阅Compiler Explorer上的示例。

5.4.1.6. 注解汇总

下表汇总了 CUDA 注解,并说明每个注解适用于哪个执行空间以及在何处有效。

表 41 注解汇总

注解

__host__ / __device__ / __host__  __device__

__global__

__noinline__、__forceinline__、__inline_hint__

函数

❌

__restrict__

指针参数

指针参数

__grid_constant__

❌

参数

__launch_bounds__

❌

函数

__maxnreg__

❌

函数

__cluster_dims__

❌

函数

5.4.2. 内建类型和变量

5.4.2.1. 主机编译器类型扩展

只要主机编译器支持,CUDA 就允许使用非标准算术类型。支持以下类型:

  • 128 位整数类型__int128。

    • 当主机编译器定义了__SIZEOF_INT128__宏时,在 Linux 上受支持。

  • 128 位浮点类型__float128和_Float128可用于计算能力为 10.0 及更高版本的 GPU 设备。编译器可能使用精度较低的浮点表示来处理__float128类型的常量表达式。

    • 当主机编译器定义了__SIZEOF_FLOAT128__或__FLOAT128__宏时,在 Linux x86 上受支持。

  • _Complex类型仅在主机代码中受支持。

分块代码不支持 128 位整数类型和 128 位浮点类型。

5.4.2.2. 内建变量

用于指定和获取网格及线程块沿 x、y 和 z 维度的核函数配置的值,其类型为dim3。用于获取线程块索引和线程索引的变量,其类型为uint3。dim3和uint3都是平凡结构,由三个分别名为x、y和z的无符号值组成。在 C++11 及更高版本中,dim3的所有分量的默认值均为 1。

仅限设备使用的内建变量:

  • dim3 gridDim:包含网格在 x、y 和 z 维度上的大小,即线程块数量。

  • dim3 blockDim:包含线程块在 x、y 和 z 维度上的大小,即线程数量。

  • uint3 blockIdx:包含线程块在网格中沿 x、y 和 z 维度的索引。

  • uint3 threadIdx:包含线程在线程块中沿 x、y 和 z 维度的索引。

  • int warpSize:一个运行时值,定义为一个线程束中的线程数量,通常为32。有关线程束的定义,另请参阅线程束与 SIMT。

分块代码不支持这些变量。要在分块代码中获取块 ID 或网格大小,请使用cuda::tiles::bid()和cuda::tiles::num_blocks()API。

5.4.2.3. 内建类型

CUDA 提供了从基本整数类型和浮点类型派生的向量类型,主机和设备均支持这些类型。下表列出了可用的向量类型。

表 42 向量类型

C++ 基本类型

向量 X1

向量 X2

向量 X3

向量 X4

signed char

char1

char2

char3

char4

unsigned char

uchar1

uchar2

uchar3

uchar4

signed short

short1

short2

short3

short4

unsigned short

ushort1

ushort2

ushort3

ushort4

signed int

int1

int2

int3

int4

unsigned

uint1

uint2

uint3

uint4

signed long

long1

long2

long3

long4_16a/long4_32a

unsigned long

ulong1

ulong2

ulong3

ulong4_16a/ulong4_32a

signed long long

longlong1

longlong2

longlong3

longlong4_16a/longlong4_32a

unsigned long long

ulonglong1

ulonglong2

ulonglong3

ulonglong4_16a/ulonglong4_32a

float

float1

float2

float3

float4

double

double1

double2

double3

double4_16a/double4_32a

请注意,long4、ulong4、longlong4、ulonglong4和double4在 CUDA 13 中已被弃用,并且可能在未来的版本中移除。


下表详细列出了向量类型的字节大小和对齐要求:

表 43 对齐要求

类型

大小

对齐

char1, uchar1

1

1

char2, uchar2

2

2

char3, uchar3

3

1

char4, uchar4

4

4

short1, ushort1

2

2

short2, ushort2

4

4

short3, ushort3

6

2

short4, ushort4

8

8

int1, uint1

4

4

int2, uint2

8

8

int3, uint3

12

4

int4, uint4

16

16

long1, ulong1

4/8 *

4/8 *

long2, ulong2

8/16 *

8/16 *

long3, ulong3

12/24 *

4/8 *

long4、ulong4(已弃用)

16/32 *

16 *

long4_16a, ulong4_16a

16/32 *

16

long4_32a, ulong4_32a

16/32 *

32

longlong1, ulonglong1

8

8

longlong2, ulonglong2

16

16

longlong3, ulonglong3

24

8

longlong4、ulonglong4(已弃用)

32

16

longlong4_16a, ulonglong4_16a

32

16

longlong4_32a, ulonglong4_32a

32

32

float1

4

4

float2

8

8

float3

12

4

float4

16

16

double1

8

8

double2

16

16

double3

24

8

double4(已弃用)

32

16

double4_16a

32

16

double4_32a

32

32

*long在 C++ LLP64 数据模型(Windows 64 位)中为 4 字节,而在 C++ LP64 数据模型(Linux 64 位)中为 8 字节。


向量类型是结构体。其第一个、第二个、第三个和第四个分量分别可通过x、y、z和w字段访问。

int sum(int4 value) {
    return value.x + value.y + value.z + value.w;
}

它们都有一个形式为make_<type_name>()的工厂函数;例如:

int4 add_one(int x, int y, int z, int w) {
    return make_int4(x + 1, y + 1, z + 1, w + 1);
}

如果主机代码不是使用nvcc编译的,则可以通过包含 CUDA 工具包提供的cuda_runtime.h头文件来导入向量类型及相关函数。

5.4.3. 核函数配置

对__global__或__tile_global__函数的任何调用都必须为该调用指定一个执行配置。此执行配置定义了在设备上执行该函数所使用的网格和线程块的维度,以及关联的流。

执行配置通过在函数名与括号括起的参数列表之间插入形式为<<<grid_dim, block_dim, dynamic_smem_bytes, stream>>>的表达式来指定,其中:

  • grid_dim的类型为dim3,用于指定网格的维度和大小,使grid_dim.x * grid_dim.y * grid_dim.z等于所启动的线程块数量。

  • block_dim的类型为dim3,用于指定每个线程块的维度和大小,使block_dim.x * block_dim.y * block_dim.z等于每个线程块的线程数量。对于__tile_global__核函数,block_dim必须为1,因为编译器将决定启动分块核函数时要调度多少个线程。

  • dynamic_smem_bytes是一个可选的size_t参数,默认值为零。它指定此次调用中,除静态分配的内存之外,为每个线程块动态分配的共享内存字节数。此内存由extern __shared__数组使用(请参阅__shared__ 内存)。

  • stream的类型为cudaStream_t(指针),用于指定关联的流。stream是一个可选参数,默认值为NULL。

以下示例展示了核函数的声明和调用:

__global__ void kernel(float* parameter);

kernel<<<grid_dim, block_dim, dynamic_smem_bytes>>>(parameter);

执行配置的参数先于实际函数的参数求值。

如果grid_dim或block_dim超过了设备所允许的最大大小(如计算能力中所述),或者dynamic_smem_bytes大于扣除静态分配的内存之后可用的共享内存,则函数调用将失败。

5.4.3.1. 线程块簇

计算能力 9.0 及更高版本允许用户指定编译时线程块簇维度,以便核函数能够使用 CUDA 中的簇层次结构。编译时簇维度可以使用__cluster_dims__属性按以下语法指定:__cluster_dims__([x, [y, [z]]])。下面的示例展示了编译时簇大小,其中 X 维度为 2,Y 和 Z 维度为 1。

__global__ void __cluster_dims__(2, 1, 1) kernel(float* parameter);

__cluster_dims__()的默认形式指定核函数将作为网格簇启动。如果未指定簇维度,用户可以在启动时指定。如果在启动时未指定维度,将导致启动时错误。不能在__tile_global__核函数上指定__cluster_dims__属性,因为编译器会决定如何跨簇调度分块核函数。

线程块簇的维度也可以在运行时指定,并且可以使用cudaLaunchKernelEx API 启动带有簇的核函数。此 API 接受一个类型为cudaLaunchConfig_t的配置参数、一个核函数指针和核函数参数。下面的示例展示了运行时核函数配置。

__global__ void kernel(float parameter1, int parameter2) {}

int main() {
    cudaLaunchConfig_t config = {0};
    // The grid dimension is not affected by cluster launch, and is still enumerated
    // using the number of blocks.
    // The grid dimension should be a multiple of cluster size.
    config.gridDim          = dim3{4};  // 4 blocks
    config.blockDim         = dim3{32}; // 32 threads per block
    config.dynamicSmemBytes = 1024;     // 1 KB

    cudaLaunchAttribute attribute[1];
    attribute[0].id               = cudaLaunchAttributeClusterDimension;
    attribute[0].val.clusterDim.x = 2; // Cluster size in X-dimension
    attribute[0].val.clusterDim.y = 1;
    attribute[0].val.clusterDim.z = 1;
    config.attrs    = attribute;
    config.numAttrs = 1;

    float parameter1 = 3.0f;
    int   parameter2 = 4;
    cudaLaunchKernelEx(&config, kernel, parameter1, parameter2);
}

请参阅Compiler Explorer上的示例。

5.4.3.2. 启动边界

如核函数启动与占用率一节所述,使用较少的寄存器可以使更多线程和线程块驻留在一个多处理器上,从而提高性能。

因此,编译器使用启发式方法,在将寄存器溢出和指令数量保持在最低水平的同时,尽量减少寄存器使用量。应用程序可以选择以启动边界的形式向编译器提供附加信息,以辅助这些启发式方法;启动边界通过在__global__函数的定义中使用__launch_bounds__()限定符来指定:

__global__ void
__launch_bounds__(maxThreadsPerBlock, minBlocksPerMultiprocessor, maxBlocksPerCluster)
MyKernel(...) {
    ...
}
  • maxThreadsPerBlock指定应用程序在启动MyKernel()时所使用的每个线程块的最大线程数;它会被编译为.maxntid PTX 编译指示。

  • minBlocksPerMultiprocessor是可选的,用于指定期望的每个多处理器上驻留线程块的最小数量;它会被编译为.minnctapersm PTX 编译指示。

  • maxBlocksPerCluster是可选的,用于指定应用程序在启动MyKernel()时所期望使用的每个簇的最大线程块数;它会被编译为.maxclusterrank PTX 编译指示。

如果指定了启动边界,编译器首先会推导出核函数应使用的寄存器数量上限L。这可确保minBlocksPerMultiprocessor个线程块(如果未指定minBlocksPerMultiprocessor,则为单个线程块)能够驻留在多处理器上,每个线程块包含maxThreadsPerBlock个线程。有关核函数使用的寄存器数量与为每个线程块分配的寄存器数量之间的关系,请参阅占用率一节。随后,编译器按如下方式优化寄存器使用量:

  • 如果初始寄存器使用量超过L,编译器会减少使用量,直到其小于或等于L。这通常会导致局部内存使用量增加和/或指令数量增多。

  • 如果初始寄存器使用量低于L

    • 如果指定了maxThreadsPerBlock,但未指定minBlocksPerMultiprocessor,编译器会使用maxThreadsPerBlock来确定在n个和n + 1个驻留线程块之间转换的寄存器使用量阈值。当少用一个寄存器就能为额外的一个驻留线程块腾出空间时,就会发生这种转换。随后,编译器会采用与未指定启动边界时类似的启发式方法。

    • 如果同时指定了minBlocksPerMultiprocessor和maxThreadsPerBlock,编译器可能会将寄存器使用量增加至不超过L的水平,以减少指令数量并更好地隐藏单线程指令的延迟。

如果执行核函数时出现以下情况,其启动将失败:

  • 每个线程块的线程数超过其启动边界maxThreadsPerBlock。

  • 每个簇的线程块数超过其启动边界maxBlocksPerCluster。

CUDA 核函数每个线程所需的资源可能会以不期望的方式限制最大线程块大小。为保持与未来硬件和工具包的前向兼容性,并确保至少有一个线程块能够在一个流式多处理器(SM)上运行,开发者应包含单参数形式的__launch_bounds__(maxThreadsPerBlock),它指定核函数启动时将使用的最大线程块大小。未这样做可能导致“启动请求的资源过多”错误。在某些情况下,提供双参数版本的__launch_bounds__(maxThreadsPerBlock,minBlocksPerMultiprocessor)可以提高性能。minBlocksPerMultiprocessor的最佳值应通过对每个核函数进行详细分析来确定。

核函数的最优启动边界通常在不同的主要架构修订版本之间有所不同。以下代码示例说明了如何在设备代码中使用__CUDA_ARCH__宏来管理这一点。

#define THREADS_PER_BLOCK  256

#if __CUDA_ARCH__ >= 900
    #define MY_KERNEL_MAX_THREADS  (2 * THREADS_PER_BLOCK)
    #define MY_KERNEL_MIN_BLOCKS   3
#else
    #define MY_KERNEL_MAX_THREADS  THREADS_PER_BLOCK
    #define MY_KERNEL_MIN_BLOCKS   2
#endif

__global__ void
__launch_bounds__(MY_KERNEL_MAX_THREADS, MY_KERNEL_MIN_BLOCKS)
MyKernel(...) {
    ...
}

当以每个线程块的最大线程数调用MyKernel时,该数量被指定为__launch_bounds__()的第一个参数,此时很容易想到在执行配置中使用MY_KERNEL_MAX_THREADS作为每个线程块的线程数:

// Host code
MyKernel<<<blocksPerGrid, MY_KERNEL_MAX_THREADS>>>(...);

然而,这样做行不通,因为如执行空间说明符一节所述,__CUDA_ARCH__在主机代码中未定义。因此,MyKernel将以每个线程块 256 个线程启动。每个线程块的线程数应改为通过以下方式确定:

  • 在编译时使用不依赖于__CUDA_ARCH__的宏或常量,例如

    // Host code
    MyKernel<<<blocksPerGrid, THREADS_PER_BLOCK>>>(...);
    
  • 或者在运行时根据计算能力确定

    // Host code
    cudaGetDeviceProperties(&deviceProp, device);
    int threadsPerBlock = (deviceProp.major >= 9) ? 2 * THREADS_PER_BLOCK : THREADS_PER_BLOCK;
    MyKernel<<<blocksPerGrid, threadsPerBlock>>>(...);
    

--resource-usage编译器选项会报告寄存器使用量。CUDA 性能分析器会报告占用率,可以据此推导出驻留线程块的数量。

5.4.3.3. 每个线程的最大寄存器数量

为支持底层性能调优,CUDA C++ 提供了__maxnreg__()函数限定符,用于将性能调优信息传递给后端优化编译器。__maxnreg__()限定符指定可分配给线程块中单个线程的最大寄存器数量。在__global__函数的定义中:

__global__ void
__maxnreg__(maxNumberRegistersPerThread)
MyKernel(...) {
    ...
}

maxNumberRegistersPerThread变量指定要分配给核函数MyKernel()的线程块中单个线程的最大寄存器数量;它会被编译为.maxnreg PTX 编译指示。

__launch_bounds__()和__maxnreg__()限定符不能同时应用于同一个核函数。

--maxrregcount <N>编译器选项可用于控制文件中所有__global__函数的寄存器使用量。对于带有__maxnreg__限定符的核函数,此选项会被忽略。

5.4.4. 同步原语

5.4.4.1. 线程块同步函数

void __syncthreads();
int  __syncthreads_count(int predicate);
int  __syncthreads_and(int predicate);
int  __syncthreads_or(int predicate);

这些内建函数协调同一个线程块内线程之间的通信。当线程块中的线程访问共享内存或全局内存中的相同地址时,可能会发生写后读、读后写或写后写冒险。通过在此类访问之间同步线程,可以避免这些冒险。

这些内建函数具有以下语义:

  • __syncthreads*()会等待,直到线程块中所有尚未退出的线程同时到达程序中的同一个__syncthreads*()内建函数调用,或退出。

  • __syncthreads*()在参与线程之间提供内存排序:对__syncthreads*()内建函数的调用强先发生于(请参阅C++ 规范 [intro.races])任何参与线程解除等待阻塞或退出。

以下示例展示了如何使用__syncthreads()同步线程块内的线程,并安全地对线程间共享的数组元素求和:

#include <cuda_runtime_api.h>
#include <memory.h>
#include <cstdlib>
#include <ctime>
#include <stdio.h>


// assuming blockDim.x is 128
__global__ void example_syncthreads(int* input_data, int* output_data) 
{
    __shared__ int shared_data[128];
    shared_data[threadIdx.x] = input_data[blockDim.x*blockIdx.x + threadIdx.x];

    // All threads synchronize, guaranteeing all writes to 'shared_data' are ordered 
    // before any thread is unblocked from '__syncthreads()':
    __syncthreads();

    // A single thread safely reads 'shared_data':
    if (threadIdx.x == 0) {
        float sum = 0;
        for (int i = 0; i < blockDim.x; ++i) {
            sum += shared_data[i];
        }
        output_data[blockIdx.x] = sum;
    }
}


void initArray(int* A, int length)
{
     std::srand(std::time({}));
    for(int i=0; i<length; i++)
    {
        A[i] = int(10.f * (rand() / (float)RAND_MAX));
    }
}


int main(int argc, char** arg)
{
    constexpr int block_size = 128;
    constexpr int input_length = 1024;
    constexpr int output_length = input_length / block_size;

    // Pointers to memory vectors
    int* input = nullptr;
    int* output = nullptr;
    int* comparisonResult = (int*)malloc(output_length*sizeof(int));

    // Use unified memory to allocate buffers
    cudaMallocManaged(&input, input_length*sizeof(int));
    cudaMallocManaged(&output, output_length*sizeof(int));
    
    initArray(input, input_length);
    int grid_size = input_length / block_size;

    example_syncthreads<<<grid_size, block_size>>>(input, output);
    cudaDeviceSynchronize();

    for(int i=0; i<output_length; i++)
    {
        comparisonResult[i] = 0;
        for(int j=0; j < block_size; j++)
        {
            comparisonResult[i] += input[i*block_size + j];
        }
    }
    
    for(int i=0; i< output_length; i++)
    {
        if(output[i] != comparisonResult[i])
        {
            printf("Results do not match at index %d: %d != %d\n", i, output[i], comparisonResult[i]);
            exit(-1);
        }
    }
    printf("Test passed\n");

    return 0;
}

允许在条件代码中使用__syncthreads*()内建函数,但前提是整个线程块对该条件的求值结果一致。否则,执行可能会挂起或产生意外的副作用。

以下示例展示了一种有效行为:

// assuming blockDim.x is 128
__global__ void syncthreads_valid_behavior(int* input_data, int* output_data) {
    __shared__ int shared_data[128];
    shared_data[threadIdx.x] = input_data[threadIdx.x];
    if (blockIdx.x > 0) { // CORRECT, uniform condition across all block threads
        __syncthreads();
        output_data[threadIdx.x] = shared_data[128 - threadIdx.x];
    }
}

而以下示例则表现出无效行为,例如核函数挂起,或未定义行为:

// assuming blockDim.x is 128
__global__ void syncthreads_invalid_behavior1(int* input_data, int* output_data) {
    __shared__ int shared_data[256];
    shared_data[threadIdx.x] = input_data[threadIdx.x];
    if (threadIdx.x > 0) { // WRONG, non-uniform condition
        __syncthreads();   // Undefined Behavior
        output_data[threadIdx.x] = shared_data[128 - threadIdx.x];
    }
}
// assuming blockDim.x is 128
__global__ void syncthreads_invalid_behavior2(int* input_data, int* output_data) {
    __shared__ int shared_data[256];
    shared_data[threadIdx.x] = input_data[threadIdx.x];
    for (int i = 0; i < blockDim.x; ++i) {
        if (i == threadIdx.x) { // WRONG, non-uniform condition
            __syncthreads();    // Undefined Behavior
        }
    }
    output_data[threadIdx.x] = shared_data[128 - threadIdx.x];
}

__syncthreads()带谓词的变体:

int __syncthreads_count(int predicate);

与__syncthreads()相同,不同之处在于,它会对线程块中所有尚未退出的线程求值一个谓词,并返回谓词求值结果为非零值的线程数量。

int __syncthreads_and(int predicate);

与__syncthreads()相同,不同之处在于,它会对线程块中所有尚未退出的线程求值该谓词。当且仅当所有这些线程的谓词求值结果均为非零值时,它才返回非零值。

int __syncthreads_or(int predicate);

与__syncthreads()相同,不同之处在于,它会对线程块中所有尚未退出的线程求值该谓词。当且仅当其中一个或多个线程的谓词求值结果为非零值时,它才返回非零值。

5.4.4.2. 线程束同步函数

void __syncwarp(unsigned mask = 0xFFFFFFFF);

内建函数__syncwarp()协调同一个线程束内线程之间的通信。当线程束中的某些线程访问共享内存或全局内存中的相同地址时,可能会发生潜在的写后读、读后写或写后写冒险。通过在这些访问之间同步线程,可以避免这些数据冒险。

调用__syncwarp(mask)会在一个线程束内由mask指定的参与线程之间提供内存排序:对__syncwarp(mask)的调用强先发生于(请参阅C++ 规范 [intro.races])由mask指定的任何线程束线程解除等待阻塞或退出。

这些函数受线程束 __sync 内建函数约束的限制。

以下示例展示了如何使用__syncwarp()同步线程束内的线程,以安全地访问共享内存数组:

__global__ void example_syncwarp(int* input_data, int* output_data) {
    if (threadIdx.x < warpSize) {
        __shared__ int shared_data[warpSize];
        shared_data[threadIdx.x] = input_data[threadIdx.x];

        __syncwarp(); // equivalent to __syncwarp(0xFFFFFFFF)
        if (threadIdx.x == 0)
            output_data[0] = shared_data[1];
    }
}

5.4.4.3. 内存栅栏函数

CUDA 编程模型假定采用弱序内存模型。换言之,CUDA 线程向共享内存、全局内存、页锁定主机内存或对等设备内存写入数据的顺序,不一定与另一个 CUDA 线程或主机线程观察到数据写入的顺序一致。在没有内存栅栏或同步的情况下读取或写入同一内存位置,会导致未定义行为。

在以下示例中,线程 1 执行writeXY(),而线程 2 执行readXY()。

__device__ int X = 1, Y = 2;

__device__ void writeXY() {
    X = 10;
    Y = 20;
}

__device__ void readXY() {
    int B = Y;
    int A = X;
}

这两个线程同时读取和写入相同的内存位置X和Y。任何数据竞争都会导致未定义行为,并且没有已定义的语义。因此,A和B的结果值可以是任意值。

内存栅栏和同步函数强制内存访问采用顺序一致的排序。这些函数的区别在于强制执行排序的线程作用域,但与所访问的内存空间无关,这些内存空间包括共享内存、全局内存、页锁定主机内存以及对等设备的内存。

提示

出于安全性和可移植性考虑,建议尽可能使用libcu++提供的cuda::atomic_thread_fence。

线程块级内存栅栏

// <cuda/atomic> header
cuda::atomic_thread_fence(cuda::memory_order_seq_cst, cuda::thread_scope_block);

确保:

  • 调用线程在调用cuda::atomic_thread_fence()之前对所有内存进行的所有写入,都会被该调用线程所在的线程块中的所有线程观察为发生在该调用线程在调用cuda::atomic_thread_fence()之后对所有内存进行的所有写入之前;

  • 调用线程在调用cuda::atomic_thread_fence()之前从所有内存进行的所有读取,都排在该调用线程在调用cuda::atomic_thread_fence()之后从所有内存进行的所有读取之前。

void __threadfence_block();

确保:

  • 调用线程在调用__threadfence_block()之前对所有内存进行的所有写入,都会被该调用线程所在的线程块中的所有线程观察为发生在该调用线程在调用__threadfence_block()之后对所有内存进行的所有写入之前;

  • 调用线程在调用__threadfence_block()之前从所有内存进行的所有读取,都排在该调用线程在调用__threadfence_block()之后从所有内存进行的所有读取之前。

设备级内存栅栏

cuda::atomic_thread_fence(cuda::memory_order_seq_cst, cuda::thread_scope_device);

确保:

  • 对于调用线程在调用cuda::atomic_thread_fence()之后对所有内存进行的任何写入,设备中的任何线程都不会将其观察为发生在调用线程在调用cuda::atomic_thread_fence()之前对所有内存进行的任何写入之前。

void __threadfence();

确保:

  • 对于调用线程在调用__threadfence()之后对所有内存进行的任何写入,设备中的任何线程都不会将其观察为发生在调用线程在调用__threadfence()之前对所有内存进行的任何写入之前。

系统级内存栅栏

cuda::atomic_thread_fence(cuda::memory_order_seq_cst, cuda::thread_scope_system);

确保:

  • 调用线程在调用cuda::atomic_thread_fence()之前对所有内存进行的所有写入,都会被设备中的所有线程、主机线程以及对等设备中的所有线程观察为发生在该调用线程在调用cuda::atomic_thread_fence()之后对所有内存进行的所有写入之前。

void __threadfence_system();

确保:

  • 调用线程在调用__threadfence_system()之前对所有内存进行的全部写入,在设备中的所有线程、主机线程以及对等设备中的所有线程看来,都发生在该调用线程调用__threadfence_system()之后对所有内存进行的全部写入之前。

在前面的代码示例中,我们可以按如下方式在代码中插入内存栅栏:

#include <cuda/atomic>

__device__ int X = 1, Y = 2;

__device__ void writeXY() {
    X = 10;
    cuda::atomic_thread_fence(cuda::memory_order_seq_cst, cuda::thread_scope_device);
    Y = 20;
}

__device__ void readXY() {
    int B = Y;
    cuda::atomic_thread_fence(cuda::memory_order_seq_cst, cuda::thread_scope_device);
    int A = X;
}
__device__ int X = 1, Y = 2;

__device__ void writeXY() {
    X = 10;
    __threadfence();
    Y = 20;
}

__device__ void readXY() {
    int B = Y;
    __threadfence();
    int A = X;
}

对于这段代码,可以观察到以下结果:

  • A等于 1 且B等于 2,即readXY()在writeXY()之前执行,

  • A等于 10 且B等于 20,即writeXY()在readXY()之前执行。

  • A等于 10 且B等于 2。

  • A为 1 且B为 20 的情况不可能出现,因为内存栅栏确保对X的写入先于对Y的写入可见。

如果线程 1 和线程 2 属于同一个线程块,则使用块级栅栏就足够了。如果线程 1 和线程 2 不属于同一个线程块,那么如果它们是同一设备上的 CUDA 线程,就必须使用设备级栅栏;如果它们是两个不同设备上的 CUDA 线程,就必须使用系统级栅栏。

下面的代码示例展示了一个常见用例,其中线程使用其他线程产生的数据。此核函数在一次调用中计算包含 N 个数的数组的总和。

  • 每个线程块首先对数组的一个子集求和,并将结果存储到全局内存中。

  • 当所有线程块都完成后,最后一个线程块从全局内存中读取每个部分和,并将它们相加以获得最终结果。

  • 为了确定哪个线程块最后完成,每个线程块都以原子方式递增一个计数器,以指示它已完成部分和的计算和存储(有关更多详细信息,请参阅原子函数一节)。最后一个线程块获得的计数器值等于gridDim.x - 1。

如果在存储部分和与递增计数器之间没有栅栏,计数器可能会在部分和存储之前递增。这可能导致计数器达到gridDim.x - 1,并使最后一个线程块在部分和在内存中更新之前就开始读取它们。

注意

内存栅栏只影响内存操作的执行顺序;它不保证这些操作对其他线程的可见性。

在下面的代码示例中,通过将result变量声明为volatile来确保对该变量的内存操作的可见性。有关更多详细信息,请参阅volatile限定的变量一节。

#include <cuda/atomic>

__device__ int count = 0;

__global__ void sum(const float*    array,
                    int             N,
                    volatile float* result) {
    __shared__ bool isLastBlockDone;
    // Each block sums a subset of the input array.
    float partialSum = calculatePartialSum(array, N);

    if (threadIdx.x == 0) {
        // Thread 0 of each block stores the partial sum to global memory.
        // The compiler will use a store operation that bypasses the L1 cache
        // since the "result" variable is declared as volatile.
        // This ensures that the threads of the last block will read the correct
        // partial sums computed by all other blocks.
        result[blockIdx.x] = partialSum;

        // Thread 0 makes sure that the increment of the "count" variable is
        // only performed after the partial sum has been written to global memory.
        cuda::atomic_thread_fence(cuda::memory_order_seq_cst, cuda::thread_scope_device);

        // Thread 0 signals that it is done.
        int count_old = atomicInc(&count, gridDim.x);

        // Thread 0 determines if its block is the last block to be done.
        isLastBlockDone = (count_old == (gridDim.x - 1));
    }
    // Synchronize to make sure that each thread reads the correct value of
    // isLastBlockDone.
    __syncthreads();

    if (isLastBlockDone) {
        // The last block sums the partial sums stored in result[0 .. gridDim.x-1]
        float totalSum = calculateTotalSum(result);

        if (threadIdx.x == 0) {
            // Thread 0 of last block stores the total sum to global memory and
            // resets the count variable, so that the next kernel call works
            // properly.
            result[0] = totalSum;
            count     = 0;
        }
    }
}

5.4.5. 原子函数

原子函数对共享数据执行读-修改-写操作,使这些操作看起来像是在单个步骤中执行的。原子性确保每个操作要么完全完成,要么完全不执行,从而为所有参与线程提供一致的数据视图。

CUDA 以五种方式提供原子函数:

扩展 CUDA C++ 原子函数,cuda::atomic和cuda::atomic_ref。
标准 C++ 原子函数,cuda::std::atomic和cuda::std::atomic_ref。
编译器内建原子函数,__nv_atomic_<op>()。
CUDA Tile C++ 原子函数(例如,cuda::tiles::atomic_load):
  • 它们只允许在分块代码中使用。

  • 它们遵循C++ 标准原子内存序的语义。

  • 它们允许通过cuda::tiles::thread_scope指定线程作用域。

旧式原子函数,atomic<Op>()。
  • 它们只允许在设备代码中使用。

  • 它们仅支持memory_order_relaxedC++ 原子内存语义。

  • 它们允许将原子操作的线程作用域作为函数名的一部分来指定。

  • 与内建原子函数不同,旧式原子函数只确保原子性,并不引入同步点(栅栏)。

  • 它们支持内建原子函数所允许的数据类型的一个子集。原子add操作支持额外的数据类型。

提示

出于效率、安全性和可移植性的考虑,建议使用libcu++提供的扩展 CUDA C++ 原子函数。

5.4.5.1. 旧式原子函数

旧式原子函数对存储在全局内存或共享内存中的 32 位、64 位或 128 位字执行原子读-修改-写操作。例如,atomicAdd()函数读取全局内存或共享内存中特定地址处的一个字,将一个数加到该字上,然后将结果写回同一地址。

  • 原子函数只能在设备函数中使用。

  • 对于__half2、__nv_bfloat162、float2和float4等向量类型,读-修改-写操作针对向量的每个元素执行。不保证对整个向量的单次访问具有原子性。

本节所述原子函数的内存序为cuda::std::memory_order_relaxed,并且仅在特定的线程作用域内具有原子性:

  • 不带后缀的原子 API,例如atomicAdd,在cuda::thread_scope_device作用域内具有原子性。

  • 带有_block后缀的原子 API,例如atomicAdd_block,在cuda::thread_scope_block作用域内具有原子性。

  • 带有_system后缀的原子 API,例如atomicAdd_system,如果满足特定条件,则在cuda::thread_scope_system作用域内具有原子性。

以下示例展示了 CPU 和 GPU 以原子方式更新地址addr处的整数值:

#include <cuda_runtime.h>

__global__ void atomicAdd_kernel(int* addr) {
    atomicAdd_system(addr, 10);
}

void test_atomicAdd(int device_id) {
    int* addr;
    cudaMallocManaged(&addr, 4);
    *addr = 0;

    cudaDeviceProp deviceProp;
    cudaGetDeviceProperties(&deviceProp, device_id);
    if (deviceProp.concurrentManagedAccess != 1) {
        return; // the device does not coherently access managed memory concurrently with the CPU
    }

    atomicAdd_kernel<<<...>>>(addr);
    __sync_fetch_and_add(addr, 10);  // CPU atomic operation
}

请注意,任何原子操作都可以基于atomicCAS()(比较并交换)来实现。例如,针对单精度浮点数的atomicAdd()可以按如下方式实现:

#include <cuda/memory>
#include <cuda/std/bit>

__device__ float customAtomicAdd(float* d_ptr, float value) {
    volatile unsigned* d_ptr_unsigned = reinterpret_cast<unsigned*>(d_ptr);
    unsigned  old_value      = *d_ptr_unsigned;
    unsigned  assumed;
    do {
        assumed                          = old_value;
        float    assumed_float           = cuda::std::bit_cast<float>(assumed);
        float    expected_value          = assumed_float + value;
        unsigned expected_value_unsigned = cuda::std::bit_cast<unsigned>(expected_value);
        old_value                        = atomicCAS(d_ptr_unsigned, assumed, expected_value_unsigned);
    // Note: uses integer comparison to avoid hang in case of NaN (since NaN != NaN)
    } while (assumed != old_value);
    return cuda::std::bit_cast<float>(old_value);
}

请参阅Compiler Explorer上的示例。

5.4.5.1.1. atomicAdd()

T atomicAdd(T* address, T val);

该函数在一个原子事务中执行以下操作:

  1. 读取全局内存或共享内存中地址address处的old值。

  2. 计算old + val。

  3. 将结果存回内存中的同一地址。

该函数返回old值。

atomicAdd()支持以下数据类型:

  • int, unsigned, unsigned long long, float, double, __half2, __half.

  • 计算能力为 8.x 及更高的设备上的__nv_bfloat16、__nv_bfloat162。

  • 计算能力为 9.x 及更高的设备上的float2、float4,且仅支持全局内存地址。

当atomicAdd()应用于向量类型(例如__half2或float4)时,其原子性是针对每个分量分别保证的;不保证对整个向量的单次访问具有原子性。

5.4.5.1.2. atomicSub()

T atomicSub(T* address, T val);

该函数在一个原子事务中执行以下操作:

  1. 读取全局内存或共享内存中地址address处的old值。

  2. 计算old - val。

  3. 将结果存回内存中的同一地址。

该函数返回old值。

atomicSub()支持以下数据类型:

  • int, unsigned

5.4.5.1.3. atomicInc()

unsigned atomicInc(unsigned* address, unsigned val);

该函数在一个原子事务中执行以下操作:

  1. 读取全局内存或共享内存中地址address处的old值。

  2. 计算old >= val ? 0 : (old + 1)。

  3. 将结果存回内存中的同一地址。

该函数返回old值。

5.4.5.1.4. atomicDec()

unsigned atomicDec(unsigned* address, unsigned val);

该函数在一个原子事务中执行以下操作:

  1. 读取全局内存或共享内存中地址address处的old值。

  2. 计算(old == 0 || old > val) ? val : (old - 1)。

  3. 将结果存回内存中的同一地址。

该函数返回old值。

5.4.5.1.5. atomicAnd()

T atomicAnd(T* address, T val);

该函数在一个原子事务中执行以下操作:

  1. 读取全局内存或共享内存中地址address处的old值。

  2. 计算old & val。

  3. 将结果存回内存中的同一地址。

该函数返回old值。

atomicAnd()支持以下数据类型:

  • int, unsigned, unsigned long long.

5.4.5.1.6. atomicOr()

T atomicOr(T* address, T val);

该函数在一个原子事务中执行以下操作:

  1. 读取全局内存或共享内存中地址address处的old值。

  2. 计算old | val。

  3. 将结果存回内存中的同一地址。

该函数返回old值。

atomicOr()支持以下数据类型:

  • int, unsigned, unsigned long long.

5.4.5.1.7. atomicXor()

T atomicXor(T* address, T val);

该函数在一个原子事务中执行以下操作:

  1. 读取全局内存或共享内存中地址address处的old值。

  2. 计算old ^ val。

  3. 将结果存回内存中的同一地址。

该函数返回old值。

atomicXor()支持以下数据类型:

  • int, unsigned, unsigned long long.

5.4.5.1.8. atomicMin()

T atomicMin(T* address, T val);

该函数在一个原子事务中执行以下操作:

  1. 读取全局内存或共享内存中地址address处的old值。

  2. 计算old和val中的最小值。

  3. 将结果存回内存中的同一地址。

该函数返回old值。

atomicMin()支持以下数据类型:

  • int, unsigned, unsigned long long, long long.

5.4.5.1.9. atomicMax()

T atomicMax(T* address, T val);

该函数在一个原子事务中执行以下操作:

  1. 读取全局内存或共享内存中地址address处的old值。

  2. 计算old和val中的最大值。

  3. 将结果存回内存中的同一地址。

该函数返回old值。

atomicMax()支持以下数据类型:

  • int, unsigned, unsigned long long, long long.

5.4.5.1.10. atomicExch()

T atomicExch(T* address, T val);
template<typename T>
T atomicExch(T* address, T val); // only 128-bit types, compute capability 9.x and higher

该函数在一个原子事务中执行以下操作:

  1. 读取全局内存或共享内存中地址address处的old值。

  2. 将val存回内存中的同一地址。

该函数返回old值。

atomicExch()支持以下数据类型:

  • int, unsigned, unsigned long long, float.

C++ 模板函数atomicExch()支持 128 位类型,但须满足以下要求:

  • 计算能力为 9.x 及更高。

  • T必须按 16 字节对齐,即alignof(T) >= 16。

  • T必须是可平凡复制的,即std::is_trivially_copyable_v<T>。

  • 对于 C++03 及更早版本:T必须是可平凡构造的,即std::is_default_constructible_v<T>。

5.4.5.1.11. atomicCAS()

T atomicCAS(T* address, T compare, T val);
template<typename T>
T atomicCAS(T* address, T compare, T val);  // only 128-bit types, compute capability 9.x and higher

该函数在一个原子事务中执行以下操作:

  1. 读取全局内存或共享内存中地址address处的old值。

  2. 计算old == compare ? val : old。

  3. 将结果存回内存中的同一地址。

该函数返回old值。

atomicCAS()支持以下数据类型:

  • int, unsigned, unsigned long long, unsigned short.

C++ 模板函数atomicCAS()支持 128 位类型,但须满足以下要求:

  • 计算能力为 9.x 及更高。

  • T必须按 16 字节对齐,即alignof(T) >= 16。

  • T必须是可平凡复制的,即std::is_trivially_copyable_v<T>。

  • 对于 C++03 及更早版本:T必须是可平凡构造的,即std::is_default_constructible_v<T>。

5.4.5.2. 内建原子函数

CUDA 12.8 及更高版本支持用于原子操作的 CUDA 编译器内建函数,它们遵循与C++ 标准原子操作相同的内存排序语义以及 CUDA 的线程作用域。这些函数遵循GNU 的原子内建函数签名,并增加了一个线程作用域参数。

当支持内建原子函数时,nvcc会定义宏__CUDACC_DEVICE_ATOMIC_BUILTINS__。

下文列出了内存序和线程作用域的原始枚举项,它们用作内建原子函数的order和scope参数:

// atomic memory orders
enum {
   __NV_ATOMIC_RELAXED,
   __NV_ATOMIC_CONSUME,
   __NV_ATOMIC_ACQUIRE,
   __NV_ATOMIC_RELEASE,
   __NV_ATOMIC_ACQ_REL,
   __NV_ATOMIC_SEQ_CST
};
// thread scopes
enum {
   __NV_THREAD_SCOPE_THREAD,
   __NV_THREAD_SCOPE_BLOCK,
   __NV_THREAD_SCOPE_CLUSTER,
   __NV_THREAD_SCOPE_DEVICE,
   __NV_THREAD_SCOPE_SYSTEM
};
  • 内存序对应于C++ 标准原子操作的内存序。

  • 线程作用域遵循cuda::thread_scope的定义。

  • __NV_ATOMIC_CONSUME内存序目前使用更强的__NV_ATOMIC_ACQUIRE内存序来实现。

  • __NV_THREAD_SCOPE_THREAD线程作用域目前使用更广的__NV_THREAD_SCOPE_BLOCK线程作用域来实现。

示例:

__device__ T __nv_atomic_load_n(T*  pointer,
                                int memory_order,
                                int thread_scope = __NV_THREAD_SCOPE_SYSTEM);

原子内建函数具有以下限制:

  • 它们只能在设备函数中使用。

  • 它们不能对局部内存进行操作。

  • 不能获取这些函数的地址。

  • order和scope参数必须是整数字面量;它们不能是变量。

  • sm_90及更高的架构支持线程作用域__NV_THREAD_SCOPE_CLUSTER。

不支持的情况的示例:

 // Not permitted in a host function
 __host__ void bar() {
     unsigned u1 = 1, u2 = 2;
     __nv_atomic_load(&u1, &u2, __NV_ATOMIC_RELAXED, __NV_THREAD_SCOPE_SYSTEM);
 }

 // Not permitted to be applied to local memory
__device__ void foo() {
   unsigned a = 1, b;
   __nv_atomic_load(&a, &b, __NV_ATOMIC_RELAXED, __NV_THREAD_SCOPE_SYSTEM);
}

 // Not permitted as a template default argument.
 // The function address cannot be taken.
 template<void *F = __nv_atomic_load_n>
 class X {
     void *f = F; // The function address cannot be taken.
 };

 // Not permitted to be called in a constructor initialization list.
 class Y {
     int a;
 public:
     __device__ Y(int *b): a(__nv_atomic_load_n(b, __NV_ATOMIC_RELAXED)) {}
 };

5.4.5.2.1. __nv_atomic_fetch_add(), __nv_atomic_add()

__device__ T    __nv_atomic_fetch_add(T* address, T val, int order, int scope = __NV_THREAD_SCOPE_SYSTEM);
__device__ void __nv_atomic_add      (T* address, T val, int order, int scope = __NV_THREAD_SCOPE_SYSTEM);

这些函数在一个原子事务中执行以下操作:

  1. 读取全局内存或共享内存中地址address处的old值。

  2. 计算old + val。

  3. 将结果存回内存中的同一地址。

  • __nv_atomic_fetch_add返回old值。

  • __nv_atomic_add没有返回值。

这些函数支持以下数据类型:

  • int, unsigned, unsigned long long, float, double.

5.4.5.2.2. __nv_atomic_fetch_sub(), __nv_atomic_sub()

__device__ T    __nv_atomic_fetch_sub(T* address, T val, int order, int scope = __NV_THREAD_SCOPE_SYSTEM);
__device__ void __nv_atomic_sub      (T* address, T val, int order, int scope = __NV_THREAD_SCOPE_SYSTEM);

这些函数在一个原子事务中执行以下操作:

  1. 读取全局内存或共享内存中地址address处的old值。

  2. 计算old - val。

  3. 将结果存回内存中的同一地址。

  • __nv_atomic_fetch_sub返回old值。

  • __nv_atomic_sub没有返回值。

这些函数支持以下数据类型:

  • int, unsigned, unsigned long long, float, double.

5.4.5.2.3. __nv_atomic_fetch_and(), __nv_atomic_and()

__device__ T    __nv_atomic_fetch_and(T* address, T val, int order, int scope = __NV_THREAD_SCOPE_SYSTEM);
__device__ void __nv_atomic_and      (T* address, T val, int order, int scope = __NV_THREAD_SCOPE_SYSTEM);

这些函数在一个原子事务中执行以下操作:

  1. 读取全局内存或共享内存中地址address处的old值。

  2. 计算old & val。

  3. 将结果存回内存中的同一地址。

  • __nv_atomic_fetch_and返回old值。

  • __nv_atomic_and没有返回值。

这些函数支持以下数据类型:

  • 大小为 4 或 8 字节的任何整数类型。

5.4.5.2.4. __nv_atomic_fetch_or(), __nv_atomic_or()

__device__ T    __nv_atomic_fetch_or(T* address, T val, int order, int scope = __NV_THREAD_SCOPE_SYSTEM);
__device__ void __nv_atomic_or      (T* address, T val, int order, int scope = __NV_THREAD_SCOPE_SYSTEM);

这些函数在一个原子事务中执行以下操作:

  1. 读取全局内存或共享内存中地址address处的old值。

  2. 计算old | val。

  3. 将结果存回内存中的同一地址。

  • __nv_atomic_fetch_or返回old值。

  • __nv_atomic_or没有返回值。

这些函数支持以下数据类型:

  • 大小为 4 或 8 字节的任何整数类型。

5.4.5.2.5. __nv_atomic_fetch_xor(), __nv_atomic_xor()

__device__ T    __nv_atomic_fetch_xor(T* address, T val, int order, int scope = __NV_THREAD_SCOPE_SYSTEM);
__device__ void __nv_atomic_xor      (T* address, T val, int order, int scope = __NV_THREAD_SCOPE_SYSTEM);

这些函数在一个原子事务中执行以下操作:

  1. 读取全局内存或共享内存中地址address处的old值。

  2. 计算old ^ val。

  3. 将结果存回内存中的同一地址。

  • __nv_atomic_fetch_xor返回old值。

  • __nv_atomic_xor没有返回值。

这些函数支持以下数据类型:

  • 大小为 4 或 8 字节的任何整数类型。

5.4.5.2.6. __nv_atomic_fetch_min(), __nv_atomic_min()

__device__ T    __nv_atomic_fetch_min(T* address, T val, int order, int scope = __NV_THREAD_SCOPE_SYSTEM);
__device__ void __nv_atomic_min      (T* address, T val, int order, int scope = __NV_THREAD_SCOPE_SYSTEM);

这些函数在一个原子事务中执行以下操作:

  1. 读取全局内存或共享内存中地址address处的old值。

  2. 计算old和val中的最小值。

  3. 将结果存回内存中的同一地址。

  • __nv_atomic_fetch_min返回old值。

  • __nv_atomic_min没有返回值。

这些函数支持以下数据类型:

  • unsigned, int, unsigned long long, long long.

5.4.5.2.7. __nv_atomic_fetch_max(), __nv_atomic_max()

__device__ T    __nv_atomic_fetch_max(T* address, T val, int order, int scope = __NV_THREAD_SCOPE_SYSTEM);
__device__ void __nv_atomic_max      (T* address, T val, int order, int scope = __NV_THREAD_SCOPE_SYSTEM);

这些函数在一个原子事务中执行以下操作:

  1. 读取全局内存或共享内存中地址address处的old值。

  2. 计算old和val中的最大值。

  3. 将结果存回内存中的同一地址。

  • __nv_atomic_fetch_max返回old值。

  • __nv_atomic_max没有返回值。

这些函数支持以下数据类型:

  • unsigned, int, unsigned long long, long long

5.4.5.2.8. __nv_atomic_exchange(), __nv_atomic_exchange_n()

__device__ T    __nv_atomic_exchange_n(T* address, T val,          int order, int scope = __NV_THREAD_SCOPE_SYSTEM);
__device__ void __nv_atomic_exchange  (T* address, T* val, T* ret, int order, int scope = __NV_THREAD_SCOPE_SYSTEM);

这些函数在一个原子事务中执行以下操作:

  1. 读取全局内存或共享内存中地址address处的old值。

  2. __nv_atomic_exchange_n将val存储到address所指向的位置。
    __nv_atomic_exchange将old存储到ret所指向的位置,并将地址val处的值存储到address所指向的位置。
  • __nv_atomic_exchange_n返回old值。

  • __nv_atomic_exchange没有返回值。

这些函数支持以下数据类型:

  • 大小为 4、8 或 16 字节的任何数据类型。

  • 计算能力为 9.x 及更高的设备支持 16 字节数据类型。

5.4.5.2.9. __nv_atomic_compare_exchange(), __nv_atomic_compare_exchange_n()

__device__ bool __nv_atomic_compare_exchange  (T* address, T* expected, T* desired, bool weak, int success_order, int failure_order,
                                               int scope = __NV_THREAD_SCOPE_SYSTEM);

__device__ bool __nv_atomic_compare_exchange_n(T* address, T* expected, T desired, bool weak, int success_order, int failure_order,
                                               int scope = __NV_THREAD_SCOPE_SYSTEM);

这些函数在一次原子事务中执行以下操作:

  1. 读取位于全局内存或共享内存中地址address处的old值。

  2. 将old与expected所指向位置的值进行比较。

  3. 如果两者相等,则返回值为true,并将desired存储到address所指向的位置。否则,返回false,并将old存储到expected所指向的位置。

参数weak被忽略,函数会在success_order和failure_order之间选择较强的内存序来执行比较并交换操作。

这些函数支持以下数据类型:

  • 大小为 2、4、8 或 16 字节的任意数据类型。

  • 计算能力为 9.x 及更高的设备支持 16 字节数据类型。

5.4.5.2.10. __nv_atomic_load(), __nv_atomic_load_n()

__device__ void __nv_atomic_load  (T* address, T* ret, int order, int scope = __NV_THREAD_SCOPE_SYSTEM);
__device__ T    __nv_atomic_load_n(T* address,         int order, int scope = __NV_THREAD_SCOPE_SYSTEM);

这些函数在一次原子事务中执行以下操作:

  1. 读取位于全局内存或共享内存中地址address处的old值。

  2. __nv_atomic_load将old存储到ret所指向的位置。
    __nv_atomic_load_n返回old。

这些函数支持以下数据类型:

  • 大小为 1、2、4、8 或 16 字节的任意数据类型。

order不能为__NV_ATOMIC_RELEASE或__NV_ATOMIC_ACQ_REL。

5.4.5.2.11. __nv_atomic_store(), __nv_atomic_store_n()

__device__ void __nv_atomic_store  (T* address, T* val, int order, int scope = __NV_THREAD_SCOPE_SYSTEM);
__device__ void __nv_atomic_store_n(T* address, T  val, int order, int scope = __NV_THREAD_SCOPE_SYSTEM);

这些函数在一次原子事务中执行以下操作:

  1. 读取位于全局内存或共享内存中地址address处的old值。

  2. __nv_atomic_store读取val所指向位置的值,并将其存储到address所指向的位置。
    __nv_atomic_store_n将val存储到address所指向的位置。

order不能为__NV_ATOMIC_CONSUME、__NV_ATOMIC_ACQUIRE或__NV_ATOMIC_ACQ_REL。

5.4.5.2.12. __nv_atomic_thread_fence()

__device__ void __nv_atomic_thread_fence(int order, int scope = __NV_THREAD_SCOPE_SYSTEM);

此原子函数根据指定的内存序,在此线程请求的内存访问之间建立先后顺序。线程作用域参数指定可能观察到此操作排序效果的线程集合。

5.4.6. 线程束函数

以下小节介绍线程束函数,这些函数允许线程束内的线程相互通信并执行计算。

提示

出于效率、安全性和可移植性的考虑,建议尽可能使用CUB的线程束范围的“集体”原语来执行线程束操作。

5.4.6.1. 线程束活跃掩码

unsigned __activemask();

该函数返回一个 32 位整数掩码,表示调用线程束中当前所有活跃线程。如果在线程束中调用__activemask()时第 N 个通道处于活跃状态,则第 N 位被置位。非活跃线程在返回的掩码中由值为 0 的位表示。已退出程序的线程始终被标记为非活跃。

警告

__activemask()不能用于确定哪些线程束通道执行给定的分支。此函数旨在用于机会式线程束级编程,并且仅提供线程束内活跃线程的瞬时快照。

// Check whether at least one thread's predicate evaluates to true
if (pred) {
    // Invalid: the value of 'at_least_one' is non-deterministic
    // and could vary between executions.
    at_least_one = __activemask() > 0;
}

请注意,在__activemask()调用处收敛的线程不保证在后续指令处仍保持收敛,除非这些指令是线程束同步内建函数(__sync)。

例如,编译器可能对指令进行重排,而活跃线程集合可能不会保持不变:

unsigned mask      = __activemask();              // Assume mask == 0xFFFFFFFF (all bits set, all threads active)
int      predicate = threadIdx.x % 2 == 0;        // 1 for even threads, 0 for odd threads
int      result    = __any_sync(mask, predicate); // Active threads might not be preserved

5.4.6.2. 线程束投票函数

int      __all_sync   (unsigned mask, int predicate);
int      __any_sync   (unsigned mask, int predicate);
unsigned __ballot_sync(unsigned mask, int predicate);

线程束投票函数使给定线程束中的线程能够执行归约并广播操作。这些函数从线程束中每个尚未退出的线程获取一个整数predicate作为输入,并将这些值与零进行比较。随后,以以下方式之一在线程束的活跃线程之间组合(归约)比较结果,并将单个返回值广播给每个参与线程:

__all_sync(unsigned mask, predicate):

对mask中所有尚未退出的线程求predicate的值,如果对所有这些线程求得的predicate值都非零,则返回非零值。

__any_sync(unsigned mask, predicate):

对mask中所有尚未退出的线程求predicate的值,如果对其中一个或多个线程求得的predicate值非零,则返回非零值。

__ballot_sync(unsigned mask, predicate):

对mask中所有尚未退出的线程求predicate的值,并返回一个整数;如果对线程束中第 N 个线程求得的predicate值非零,且第 N 个线程处于活跃状态,则该整数的第 N 位被置位。否则,第 N 位为零。

这些函数受线程束 __sync 内建函数约束的限制。

警告

这些内建函数不提供任何内存顺序保证。

5.4.6.3. 线程束匹配函数

提示

建议使用libcu++的cuda::device::warp_match_all()函数作为__match_all_sync函数的更通用、更安全的替代方案。

unsigned __match_any_sync(unsigned mask, T value);
unsigned __match_all_sync(unsigned mask, T value, int *pred);

线程束匹配函数在线程束内尚未退出的线程之间对一个变量执行广播并比较操作。

__match_any_sync

返回mask中具有按位相同的value的尚未退出线程的掩码。

__match_all_sync

如果mask中所有尚未退出的线程都具有按位相同的value,则返回mask;否则返回 0。如果mask中所有尚未退出的线程都具有按位相同的value,则将谓词pred设置为true;否则将该谓词设置为假。

T可以为int、unsigned、long、unsigned long、long long、unsigned long long、float或double。

这些函数受线程束 __sync 内建函数约束的限制。

警告

这些内建函数不提供任何内存顺序保证。

5.4.6.4. 线程束归约函数

提示

出于效率、安全性和可移植性的考虑,建议尽可能使用CUB的线程束范围的“集体”原语来执行线程束归约。

计算能力为 8.x 或更高的设备支持这些函数。

T        __reduce_add_sync(unsigned mask, T value);
T        __reduce_min_sync(unsigned mask, T value);
T        __reduce_max_sync(unsigned mask, T value);

unsigned __reduce_and_sync(unsigned mask, unsigned value);
unsigned __reduce_or_sync (unsigned mask, unsigned value);
unsigned __reduce_xor_sync(unsigned mask, unsigned value);

__reduce_<op>_sync内建函数在同步mask中指定的所有尚未退出的线程之后,对value中提供的数据执行归约操作。

__reduce_add_sync, __reduce_min_sync, __reduce_max_sync

对mask中指定的每个尚未退出的线程在value中提供的值执行算术加法、最小值或最大值归约操作,并返回其结果。T可以是unsigned或signed整数。

__reduce_and_sync, __reduce_or_sync, __reduce_xor_sync

对mask中指定的每个尚未退出的线程在value中提供的值执行按位与、按位或或按位异或归约操作,并返回其结果。

这些函数受线程束 __sync 内建函数约束的限制。

警告

这些内建函数不提供任何内存顺序保证。

5.4.6.5. 线程束洗牌函数

提示

建议使用libcu++的cuda::device::warp_shuffle()函数作为__shfl_sync()和__shfl_<op>_sync()内建函数的更通用、更安全的替代方案。

T __shfl_sync     (unsigned mask, T value, int      srcLane,  int width=warpSize);
T __shfl_up_sync  (unsigned mask, T value, unsigned delta,    int width=warpSize);
T __shfl_down_sync(unsigned mask, T value, unsigned delta,    int width=warpSize);
T __shfl_xor_sync (unsigned mask, T value, int      laneMask, int width=warpSize);

线程束洗牌函数在线程束内尚未退出的线程之间交换一个值,而不使用共享内存。

__shfl_sync():从索引指定的通道直接复制。

该内建函数返回 ID 由srcLane指定的线程所持有的value值。

  • 如果width小于warpSize,则线程束的每个子区段都作为一个独立实体运行,其起始逻辑通道 ID 为 0。

  • 如果srcLane超出范围[0, width - 1],则结果对应于同一子区段内的srcLane % width所持有的值。


__shfl_up_sync():从 ID 小于调用者的通道复制。

该内建函数通过从调用者的通道 ID 中减去delta来计算源通道 ID。返回计算得到的通道 ID 所持有的value值:其效果是将value在线程束内向上移动delta个通道。

  • 如果width小于warpSize,则线程束的每个子区段都作为一个独立实体运行,其起始逻辑通道 ID 为 0。

  • 源通道索引不会以width的值为界回绕,因此较低的delta个通道将保持不变。


__shfl_down_sync():从 ID 大于调用者的通道复制。

该内建函数通过将delta加到调用者的通道 ID 上来计算源通道 ID。返回计算得到的通道 ID 所持有的value值:其效果是将value在线程束内向下移动delta个通道。

  • 如果width小于warpSize,则线程束的每个子区段都作为一个独立实体运行,其起始逻辑通道 ID 为 0。

  • 与__shfl_up_sync()一样,源通道的 ID 编号不会以 width 的值为界回绕,因此较高的delta个通道实际上将保持不变。


__shfl_xor_sync():根据自身通道 ID 的按位异或结果,从某个通道复制。

该内建函数通过对调用者的通道 ID 与laneMask执行按位异或来计算源通道 ID:返回计算得到的通道 ID 所持有的value值。此模式实现了一种蝶形寻址模式,用于树形归约和广播。

  • 如果width小于warpSize,则每组width个连续线程都能够访问前面各组中的元素。但是,如果它们尝试访问后面各组线程中的元素,则会返回它们自身的value值。


T可以为:

  • int、unsigned、long、unsigned long、long long、unsigned long long、float或double。

  • 包含cuda_fp16.h头文件时,可以为__half和__half2。

  • 包含cuda_bf16.h头文件时,可以为__nv_bfloat16和__nv_bfloat162。

线程只能从正在活跃参与这些内建函数的另一个线程读取数据。如果目标线程处于非活跃状态,则获取的值未定义。

width必须是范围[1, warpSize]内的 2 的幂,即 1、2、4、8、16 或 32。其他值将产生未定义的结果。

这些函数受线程束 __sync 内建函数约束的限制。

有效的线程束洗牌用法示例:

int laneId = threadIdx.x % warpSize;
int data   = ...

// all warp threads get 'data' from lane 0
int result1 = __shfl_sync(0xFFFFFFFF, data, 0);

if (laneId < 4) {
    // lanes 0, 1, 2, 3 get 'data' from lane 1
    int result2 = __shfl_sync(0xb1111, data, 1);
}

// lanes [0 - 15] get 'data' from lane 0
// lanes [16 - 31] get 'data' from lane 16
int result3 = __shfl_sync(0xFFFFFFFF, value, warpSize / 2);

// each lane gets 'data' from the lane two positions above
// lanes 30, 31 get their original value
int result4 = __shfl_down_sync(0xFFFFFFFF, data, 2);

无效的线程束洗牌用法示例:

int laneId = threadIdx.x % warpSize;
int value  = ...
 // undefined behavior: lane 0 does not participate in the call
int result = (laneId > 0) ? __shfl_sync(0xFFFFFFFF, value, 0) : 0;

if (laneId <= 4) {
    // undefined behavior: destination lanes 5, 6 are not active for lanes 3, 4
    result = __shfl_down_sync(0b11111, value, 2);
}

// undefined behavior: width is not a power of 2
__shfl_sync(0xFFFFFFFF, value, 0, /*width=*/31);

警告

这些内建函数并不隐含内存屏障。它们不保证任何内存顺序。

示例 1:在线程束内广播单个值

#include <cassert>
#include <cuda/warp>

__global__ void warp_broadcast_kernel(int input) {
    int laneId = threadIdx.x % 32;
    int value;
    if (laneId == 0) { // unused variable for all threads except lane 0
        value = input;
    }
    value = cuda::device::warp_shuffle_idx(value, 0); // Synchronize all threads in warp, and get "value" from lane 0
    assert(value == input);
}

int main() {
    warp_broadcast_kernel<<<1, 32>>>(1234);
    cudaDeviceSynchronize();
    return 0;
}
#include <assert.h>

__global__ void warp_broadcast_kernel(int input) {
    int laneId = threadIdx.x % 32;
    int value;
    if (laneId == 0) { // unused variable for all threads except lane 0
        value = input;
    }
    value = __shfl_sync(0xFFFFFFFF, value, 0); // Synchronize all threads in warp, and get "value" from lane 0
    assert(value == input);
}

int main() {
    warp_broadcast_kernel<<<1, 32>>>(1234);
    cudaDeviceSynchronize();
    return 0;
}

请参见Compiler Explorer上的示例。

示例 2:在由 8 个线程组成的子分区内执行包含式加法扫描

提示

建议使用cub::WarpScan函数来实现高效且通用的线程束扫描功能。

#include <cstdio>
#include <cub/cub.cuh>

__global__ void scan_sub_partition_with_8_threads_kernel() {
    using WarpScan    = cub::WarpScan<int, 8>;
    using TempStorage = typename WarpScan::TempStorage;
    __shared__ TempStorage temp_storage;

    int laneId = threadIdx.x % 32;
    int value  = 31 - laneId; // starting value to accumulate
    int partial_sum;
    WarpScan(temp_storage).InclusiveSum(value, partial_sum);
    printf("Thread %d final value = %d\n", threadIdx.x, partial_sum);
}

int main() {
    scan_sub_partition_with_8_threads_kernel<<<1, 32>>>();
    cudaDeviceSynchronize();
    return 0;
}
#include <stdio.h>

__global__ void scan_sub_partition_with_8_threads_kernel() {
    int laneId = threadIdx.x % 32;
    int value  = 31 - laneId; // starting value to accumulate
    // Loop to accumulate scan within my partition.
    // Scan requires log2(8) == 3 steps for 8 threads
    for (int delta = 1; delta <= 4; delta *= 2) {
        int tmp         = __shfl_up_sync(0xFFFFFFFF, value, delta, /*width=*/8); // read from laneId - delta
        int source_lane = laneId % 8 - delta;
        if (source_lane >= 0) // lanes with 'source_lane < 0' have their value unchanged
            value += tmp;
    }
    printf("Thread %d final value = %d\n", threadIdx.x, value);
}

int main() {
    scan_sub_partition_with_8_threads_kernel<<<1, 32>>>();
    cudaDeviceSynchronize();
    return 0;
}

请参见Compiler Explorer上的示例。

示例 3:线程束内归约

提示

建议使用cub::WarpReduce函数来实现高效且通用的线程束归约功能。

#include <cstdio>
#include <cub/cub.cuh>
#include <cuda/warp>

__global__ void warp_reduce_kernel() {
    using WarpReduce  = cub::WarpReduce<int>;
    using TempStorage = typename WarpReduce::TempStorage;
    __shared__ TempStorage temp_storage;

    int laneId     = threadIdx.x % 32;
    int value      = 31 - laneId; // starting value to accumulate
    auto aggregate = WarpReduce(temp_storage).Sum(value);
    aggregate      = cuda::device::warp_shuffle_idx(aggregate, 0);
    printf("Thread %d final value = %d\n", threadIdx.x, aggregate);
}

int main() {
    warp_reduce_kernel<<<1, 32>>>();
    cudaDeviceSynchronize();
    return 0;
}
#include <stdio.h>

__global__ void warp_reduce_kernel() {
    int laneId = threadIdx.x % 32;
    int value  = 31 - laneId; // starting value to accumulate
    // Use XOR mode to perform butterfly reduction
    // A full-warp reduction requires log2(32) == 5 steps
    for (int i = 1; i <= 16; i *= 2)
        value += __shfl_xor_sync(0xFFFFFFFF, value, i);
    // "value" now contains the sum across all threads
    printf("Thread %d final value = %d\n", threadIdx.x, value);
}

int main() {
    warp_reduce_kernel<<<1, 32>>>();
    cudaDeviceSynchronize();
    return 0;
}

请参见Compiler Explorer上的示例。

5.4.6.6. 线程束__sync内建函数约束

所有线程束__sync内建函数,例如:

  • __shfl_sync, __shfl_up_sync, __shfl_down_sync, __shfl_xor_sync

  • __match_any_sync, __match_all_sync

  • __reduce_add_sync, __reduce_min_sync, __reduce_max_sync, __reduce_and_sync, __reduce_or_sync, __reduce_xor_sync

  • __syncwarp

都使用mask参数来指示哪些线程束线程参与调用。此参数确保在硬件执行内建函数之前正确收敛。

mask中的每一位都对应于一个线程的通道 ID(threadIdx.x % warpSize)。该内建函数会等待,直到mask中指定的所有尚未退出的线程束线程都到达调用处。
必须满足以下约束才能正确执行:
  • 每个调用线程在mask中的对应位都必须被置位。

  • 每个未调用线程在mask中的对应位都必须设置为零。已退出的线程将被忽略。

  • mask中指定的所有尚未退出的线程都必须使用相同的mask值执行该内建函数。

  • 线程束线程可以使用不同的mask值并发调用该内建函数,前提是这些掩码互不相交。即使在分歧控制流中,这种情况也是有效的。

如果出现以下情况,线程束__sync函数的行为将无效(例如核函数挂起)或未定义:

  • 某个调用线程未在mask中指定。

  • mask中指定的某个尚未退出的线程既未最终退出,也未在相同程序位置以相同的mask值调用该内建函数。

  • 在条件代码中,对于mask中指定的所有尚未退出的线程,所有条件的求值结果都必须相同。

注意

当所有线程束线程都参与调用时,即当mask设置为0xFFFFFFFF时,这些内建函数可达到最佳效率。

有效的线程束内建函数用法示例:

__global__ void valid_examples() {
    if (threadIdx.x < 4) {        // threads 0, 1, 2, 3 are active
        __all_sync(0b1111, pred); // CORRECT, threads 0, 1, 2, 3 participate in the call
    }

    if (threadIdx.x == 0)
        return; // exit
    // CORRECT, all non-exited threads participate in the call
    __all_sync(0xFFFFFFFF, pred);
}

互不相交的mask示例:

__global__ void example_syncwarp_with_mask(int* input_data, int* output_data) {
    if (threadIdx.x < warpSize) {
        __shared__ int shared_data[warpSize];
        shared_data[threadIdx.x] = input_data[threadIdx.x];

        unsigned mask = threadIdx.x < 16 ? 0xFFFF : 0xFFFF0000; // CORRECT
        __syncwarp(mask);
        if (threadIdx.x == 0 || threadIdx.x == 16)
            output_data[threadIdx.x] = shared_data[threadIdx.x + 1];
    }
}
__global__ void example_syncwarp_with_mask_branches(int* input_data, int* output_data) {
    if (threadIdx.x < warpSize) {
        __shared__ int shared_data[warpSize];
        shared_data[threadIdx.x] = input_data[threadIdx.x];

        if (threadIdx.x < 16) {
            unsigned mask = 0xFFFF; // CORRECT
            __syncwarp(mask);
            output_data[threadIdx.x] = shared_data[15 - threadIdx.x];
        }
        else {
            unsigned mask = 0xFFFF0000; // CORRECT
            __syncwarp(mask);
            output_data[threadIdx.x] = shared_data[31 - threadIdx.x];
        }
    }
}

无效的线程束内建函数用法示例:

if (threadIdx.x < 4) {           // threads 0, 1, 2, 3 are active
    __all_sync(0b0000011, pred); // WRONG, threads 2, 3 are active but not set in mask
    __all_sync(0b1111111, pred); // WRONG, threads 4, 5, 6 are not active but set in mask
}

// WRONG, participating threads have a different and overlapping mask
__all_sync(threadIdx.x == 0 ? 1 : 0xFFFFFFFF, pred);

5.4.7. CUDA 特定宏

5.4.7.1. __CUDA_ARCH__

宏__CUDA_ARCH__表示正在为其编译代码的 NVIDIA GPU 的虚拟架构。其值可能与设备的实际计算能力不同。此宏支持编写针对特定 GPU 架构的专用代码路径,这对于获得最佳性能或使用架构特定的特性和指令可能是必要的。此宏还可用于区分主机代码和设备代码。

__CUDA_ARCH__仅在设备代码中定义,即在__device__、__host__ __device__和__global__函数中定义。该宏的值与nvcc选项compute_<version>相关联,其关系为__CUDA_ARCH__ = <version> * 10。

示例:

nvcc --generate-code arch=compute_80,code=sm_90 prog.cu

将__CUDA_ARCH__定义为800。


__CUDA_ARCH__约束

1.以下实体的类型签名不得依赖于是否定义了__CUDA_ARCH__,也不得依赖于其值。

  • __global__函数和函数模板。

  • __device__和__constant__变量。

  • 纹理和表面。

示例:

#if !defined(__CUDA_ARCH__)
    typedef int my_type;
#else
    typedef double my_type;
#endif

__device__ my_type my_var;           // ERROR: my_var's type depends on __CUDA_ARCH__

__global__ void kernel(my_type in) { // ERROR: kernel's type depends on __CUDA_ARCH__
    ...
}

2.__global__函数模板的实例化不得依赖于是否定义了__CUDA_ARCH__或其值。也就是说,具有等价模板实参的相同实例化必须存在于所有设备程序和主机程序中(无论这些实例化是否会在运行时被启动)。

示例:

__device__ int result;

template <typename T>
__global__ void kernel(T in) {
    result = in;
}

__host__ __device__ void host_device_function(void) {
#if !defined(__CUDA_ARCH__)
    kernel<<<1, 1>>>(1); // ERROR: "kernel<int>" instantiation only
                            //        when __CUDA_ARCH__ is undefined!
#endif
}

int main(void) {
    host_device_function();
    cudaDeviceSynchronize();
    return 0;
}

可以通过将实际计算从__global__函数移到由前者调用的__device__函数模板中来避免此问题。这样,在__global__函数内,便可使用__CUDA_ARCH__有条件地以不同实参实例化__device__函数模板。

3.在分离编译模式下,具有外部链接的函数或变量定义是否存在,不得依赖于__CUDA_ARCH__的定义或其值。

示例:

#if !defined(__CUDA_ARCH__)
    void host_function(void) {} // ERROR: The definition of host_function()
                                //        is only present when __CUDA_ARCH__
                                //        is undefined
#endif

4.在分离编译中,不得在头文件中使用预处理器宏__CUDA_ARCH__,以防止目标文件具有不同的行为。或者,所有目标文件都必须针对相同的虚拟架构进行编译。如果在头文件中定义了弱函数或模板函数,且其行为依赖于__CUDA_ARCH__,那么当不同目标文件针对不同的计算架构进行编译时,该函数在这些目标文件中的实例可能发生冲突。

例如,如果头文件a.h包含:

template<typename T>
__device__ T* get_ptr() {
#if __CUDA_ARCH__ == 900
    return nullptr; /* no address */
#else
    __shared__ T arr[256];
    return arr;
#endif
}

那么,如果a.cu和b.cu都包含a.h并针对相同类型实例化get_ptr(),且b.cu期望一个非NULL地址,并使用以下命令编译:

nvcc -arch=compute_70 -dc a.cu
nvcc -arch=compute_80 -dc b.cu
nvcc -arch=sm_80 a.o b.o

Only one version of the ``get_ptr()`` function is used at link time, so the behavior depends on which version is chosen. To avoid this issue, either ``a.cu`` and ``b.cu`` must be compiled for the same compute architecture, or ``__CUDA_ARCH__`` should not be used in the shared header function.

对于上述不受支持的__CUDA_ARCH__用法,编译器不保证会生成诊断信息。

5.4.7.2. __CUDA_ARCH_SPECIFIC__和__CUDA_ARCH_FAMILY_SPECIFIC__

宏__CUDA_ARCH_SPECIFIC__和__CUDA_ARCH_FAMILY_SPECIFIC__分别被定义用于标识具有架构特定特性和系列特定特性的 GPU 设备。有关更多信息,请参见特性集编译器目标一节。

与__CUDA_ARCH__类似,__CUDA_ARCH_SPECIFIC__和__CUDA_ARCH_FAMILY_SPECIFIC__仅在设备代码中定义,即在__device__、__host__ __device__和__global__函数中定义。这些宏与nvcc选项compute_<version>a和compute_<version>f相关联。

nvcc --generate-code arch=compute_100a,code=sm_100a prog.cu
  • __CUDA_ARCH__ == 1000.

  • __CUDA_ARCH_SPECIFIC__ == 1000.

  • __CUDA_ARCH_FAMILY_SPECIFIC__ == 1000.

nvcc --generate-code arch=compute_100f,code=sm_103f prog.cu
  • __CUDA_ARCH__ == 1000.

  • __CUDA_ARCH_FAMILY_SPECIFIC__ == 1000.

  • __CUDA_ARCH_SPECIFIC__未定义。

nvcc -arch=sm_100 prog.cu
  • __CUDA_ARCH__ == 1000.

  • __CUDA_ARCH_FAMILY_SPECIFIC__未定义。

  • __CUDA_ARCH_SPECIFIC__未定义。

nvcc -arch=sm_100a prog.cu
# equivalent to:
nvcc --generate-code arch=sm_100a,compute_100,compute_100a prog.cu
  • __CUDA_ARCH__ == 1000.

  • __CUDA_ARCH_FAMILY_SPECIFIC__未定义。

  • __CUDA_ARCH_SPECIFIC__ == 1000和__CUDA_ARCH_SPECIFIC__未定义这两种情况都会生成。

5.4.7.3. CUDA 特性测试宏

nvcc提供以下用于特性测试的预处理器宏。当 CUDA 前端编译器支持某项特性时,就会定义相应的宏。

5.4.7.4. __nv_pure__属性

在 C/C++ 中,纯函数对其参数没有副作用,并且可以访问全局变量,但不会修改它们。

CUDA 提供了__nv_pure__属性,主机函数和设备函数均支持该属性。编译器将__nv_pure__转换为 GNU 的pure属性或 Microsoft Visual Studio 的noalias属性。

__device__ __nv_pure__
int add(int a, int b) {
    return a + b;
}

5.4.8. CUDA 专用函数

5.4.8.1. 地址空间谓词函数

地址空间谓词函数用于确定指针的地址空间。

提示

建议使用libcu++提供的cuda::device::is_address_from()和cuda::device::is_object_from()函数,作为地址空间谓词内建函数的可移植且更安全的替代方案。

__device__ unsigned __isGlobal      (const void* ptr);
__device__ unsigned __isShared      (const void* ptr);
__device__ unsigned __isConstant    (const void* ptr);
__device__ unsigned __isGridConstant(const void* ptr);
__device__ unsigned __isLocal       (const void* ptr);

如果ptr包含指定地址空间中某个对象的通用地址,这些函数返回1,否则返回0。如果实参是NULL指针,则其行为未指定。

  • __isGlobal():全局内存空间。

  • __isShared():共享内存空间。

  • __isConstant():常量内存空间。

  • __isGridConstant():用__grid_constant__标注的核函数参数。

  • __isLocal():局部内存空间。

5.4.8.2. 地址空间转换函数

CUDA 指针(T*)可以访问对象,而不受对象存储位置的限制。例如,无论int对象位于全局内存还是共享内存中,int*都可以访问这些对象。

地址空间转换函数用于在通用地址与特定地址空间中的地址之间进行转换。当编译器无法确定指针的地址空间时,例如跨越翻译单元或与 PTX 指令交互时,这些函数非常有用。

__device__ size_t __cvta_generic_to_global  (const void* ptr); // PTX: cvta.to.global
__device__ size_t __cvta_generic_to_shared  (const void* ptr); // PTX: cvta.to.shared
__device__ size_t __cvta_generic_to_constant(const void* ptr); // PTX: cvta.to.const
__device__ size_t __cvta_generic_to_local   (const void* ptr); // PTX: cvta.to.local
__device__ void* __cvta_global_to_generic  (size_t raw_ptr); // PTX: cvta.global
__device__ void* __cvta_shared_to_generic  (size_t raw_ptr); // PTX: cvta.shared
__device__ void* __cvta_constant_to_generic(size_t raw_ptr); // PTX: cvta.const
__device__ void* __cvta_local_to_generic   (size_t raw_ptr); // PTX: cvta.local

以与 PTX 指令互操作为例,ld.shared.s32 r0, [ptr]; PTX 指令要求ptr指向共享内存地址空间。对于具有指向__shared__内存中对象的int*指针的 CUDA 程序,在将该指针传递给 PTX 指令之前,需要调用__cvta_generic_to_shared将其转换到共享地址空间,如下所示:

__shared__ int smem_var;
smem_var        = 42;
size_t smem_ptr = __cvta_generic_to_shared(&smem_var);
int    output;
asm volatile("ld.shared.s32 %0, [%1];" : "=r"(output) : "l"(smem_ptr) : "memory");
assert(output == 42);

利用这些地址表示的一种常见优化是,利用共享空间、局部空间和常量空间的地址范围小于 32 位这一事实来减小数据结构的大小,这样就可以存储 32 位地址而不是 64 位指针,从而节省寄存器。此外,32 位算术运算比 64 位算术运算更快。要获得这些地址的 32 位整数表示,可通过将无符号 64 位整数强制转换为无符号 32 位整数,将 64 位值截断为 32 位:

__shared__ int smem_var;
uint32_t       smem_ptr_32bit = static_cast<uint32_t>(__cvta_generic_to_shared(&smem_var));

要从这种 32 位表示恢复通用地址,请将该地址零扩展回无符号 64 位整数,然后调用相应的地址空间转换函数:

size_t smem_ptr_64bit = static_cast<size_t>(smem_ptr_32bit); // zero-extend to 64 bits
void*  generic_ptr    = __cvta_shared_to_generic(smem_ptr_64bit);
assert(generic_ptr == &smem_var);

5.4.8.3. 低级加载和存储函数

T __ldg(const T* address);

函数__ldg()执行只读 L1/Tex 缓存加载。它支持所有 C++ 基本类型、CUDA 向量类型(三分量类型除外)以及扩展浮点类型,例如__half、__half2、__nv_bfloat16和__nv_bfloat162。


T __ldcg(const T* address);
T __ldca(const T* address);
T __ldcs(const T* address);
T __ldlu(const T* address);
T __ldcv(const T* address);

这些函数使用PTX ISA指南中指定的缓存运算符执行加载。它们支持所有 C++ 基本类型、CUDA 向量类型(三分量类型除外)以及扩展浮点类型,例如__half、__half2、__nv_bfloat16和__nv_bfloat162。


void __stwb(T* address, T value);
void __stcg(T* address, T value);
void __stcs(T* address, T value);
void __stwt(T* address, T value);

这些函数使用PTX ISA指南中指定的缓存运算符执行存储。它们支持所有 C++ 基本类型、CUDA 向量类型(三分量类型除外)以及扩展浮点类型,例如__half、__half2、__nv_bfloat16和__nv_bfloat162。

5.4.8.4. __trap()

提示

建议使用libcu++提供的cuda::std::terminate()函数(C++ 参考),作为__trap()的可移植替代方案。

可以从任意设备线程调用__trap()函数来发起陷阱操作。

void __trap();

核函数的执行会中止,并在主机程序中引发中断。调用__trap()会导致 CUDA 上下文损坏,使后续的 CUDA 调用和核函数调用失败。

5.4.8.5. __nanosleep()

__device__ void __nanosleep(unsigned nanoseconds);

函数__nanosleep(ns)将线程挂起,休眠时间约为ns纳秒。最长休眠时间约为一毫秒。

示例:

以下代码实现了一个采用指数退避的互斥锁。

__device__ void mutex_lock(unsigned* mutex) {
    unsigned ns = 8;
    while (atomicCAS(mutex, 0, 1) == 1) {
        __nanosleep(ns);
        if (ns < 256) {
            ns *= 2;
        }
    }
}

__device__ void mutex_unlock(unsigned *mutex) {
    atomicExch(mutex, 0);
}

5.4.8.6. 动态规划扩展(DPX)指令

DPX 函数组可以对最多三个 16 位或 32 位有符号或无符号整数参数求最小值和最大值,以及执行加法与求最小值/最大值的融合运算。此外,还提供可选的 ReLU 功能,即将值钳制到零。

比较函数:

  • 三个参数。语义:max(a, b, c)、min(a, b, c)。

     int __vimax3_s32  (     int,      int,      int);
unsigned __vimax3_s16x2(unsigned, unsigned, unsigned);
unsigned __vimax3_u32  (unsigned, unsigned, unsigned);
unsigned __vimax3_u16x2(unsigned, unsigned, unsigned);

     int __vimin3_s32  (     int,      int,      int);
unsigned __vimin3_s16x2(unsigned, unsigned, unsigned);
unsigned __vimin3_u32  (unsigned, unsigned, unsigned);
unsigned __vimin3_u16x2(unsigned, unsigned, unsigned);
  • 两个参数,带 ReLU。语义:max(a, b, 0)、max(min(a, b), 0)。

     int __vimax_s32_relu  (     int,      int);
unsigned __vimax_s16x2_relu(unsigned, unsigned);

     int __vimin_s32_relu  (     int,      int);
unsigned __vimin_s16x2_relu(unsigned, unsigned);
  • 三个参数,带 ReLU。语义:max(a, b, c, 0)、max(min(a, b, c), 0)。

     int __vimax3_s32_relu  (     int,      int,      int);
unsigned __vimax3_s16x2_relu(unsigned, unsigned, unsigned);

     int __vimin3_s32_relu  (     int,      int,      int);
unsigned __vimin3_s16x2_relu(unsigned, unsigned, unsigned);
  • 两个参数,同时返回哪个参数较小/较大:

     int __vibmax_s32  (     int,      int, bool* pred);
unsigned __vibmax_u32  (unsigned, unsigned, bool* pred);
unsigned __vibmax_s16x2(unsigned, unsigned, bool* pred);
unsigned __vibmax_u16x2(unsigned, unsigned, bool* pred);

     int __vibmin_s32  (     int,      int, bool* pred);
unsigned __vibmin_u32  (unsigned, unsigned, bool* pred);
unsigned __vibmin_s16x2(unsigned, unsigned, bool* pred);
unsigned __vibmin_u16x2(unsigned, unsigned, bool* pred);

加法与求最小值/最大值的融合运算:

  • 三个参数,将(第一个 + 第二个)与第三个进行比较。语义:max(a + b, c)、min(a + b, c)

     int __viaddmax_s32  (     int,     int,       int);
unsigned __viaddmax_s16x2(unsigned, unsigned, unsigned);
unsigned __viaddmax_u32  (unsigned, unsigned, unsigned);
unsigned __viaddmax_u16x2(unsigned, unsigned, unsigned);

     int __viaddmin_s32  (     int,     int,       int);
unsigned __viaddmin_s16x2(unsigned, unsigned, unsigned);
unsigned __viaddmin_u32  (unsigned, unsigned, unsigned);
unsigned __viaddmin_u16x2(unsigned, unsigned, unsigned);
  • 三个参数,带 ReLU,将(第一个 + 第二个)与第三个以及零进行比较。语义:max(a + b, c, 0)、max(min(a + b, c), 0)

     int __viaddmax_s32_relu  (     int,      int,      int);
unsigned __viaddmax_s16x2_relu(unsigned, unsigned, unsigned);

     int __viaddmin_s32_relu  (     int,      int,      int);
unsigned __viaddmin_s16x2_relu(unsigned, unsigned, unsigned);

这些指令根据计算能力采用硬件加速或软件模拟。有关计算能力要求,请参阅算术指令一节。

完整的 API 可在CUDA Math API 文档中找到。


DPX 是实现动态规划算法的非常有用的工具,例如基因组学中的 Smith-Waterman 和 Needleman-Wunsch 算法,以及路线优化中的 Floyd-Warshall 算法。

三个有符号 32 位整数的最大值,带 ReLU:

int a           = -15;
int b           = 8;
int c           = 5;
int max_value_0 = __vimax3_s32_relu(a, b, c); // max(-15, 8, 5, 0) = 8
int d           = -2;
int e           = -4;
int max_value_1 = __vimax3_s32_relu(a, d, e); // max(-15, -2, -4, 0) = 0

两个 32 位有符号整数之和、另一个 32 位有符号整数以及零三者中的最小值(ReLU):

int a           = -5;
int b           = 6;
int c           = -2;
int max_value_0 = __viaddmax_s32_relu(a, b, c); // max(-5 + 6, -2, 0) = max(1, -2, 0) = 1
int d           = 4;
int max_value_1 = __viaddmax_s32_relu(a, d, c); // max(-5 + 4, -2, 0) = max(-1, -2, 0) = 0

两个无符号 32 位整数的最小值,并确定哪个值较小:

unsigned a = 9;
unsigned b = 6;
bool     smaller_value;
unsigned min_value = __vibmin_u32(a, b, &smaller_value); // min_value is 6, smaller_value is true

三对无符号 16 位整数的最大值:

unsigned a         = 0x00050002;
unsigned b         = 0x00070004;
unsigned c         = 0x00020006;
unsigned max_value = __vimax3_u16x2(a, b, c); // max(5, 7, 2) and max(2, 4, 6), so max_value is 0x00070006

5.4.9. 编译器优化提示

编译器优化提示用附加信息修饰代码,以帮助编译器优化生成的代码。

  • 这些内建函数在 SIMT 设备代码中始终可用,但在分块代码中不受支持。

  • 对主机代码的支持取决于主机编译器。

5.4.9.1. #pragma unroll

默认情况下,编译器会展开迭代次数已知的小型循环。不过,可以使用#pragma unroll指令来控制任意给定循环的展开。该指令必须紧接在循环之前,且仅对该循环生效。

后面可以选择跟随一个整型常量表达式。整型常量表达式的各种情况如下:

  • 如果未提供该表达式,且循环的迭代次数为常量,则循环将被完全展开。

  • 如果该表达式的求值结果为0或1,则循环不会被展开。

  • 如果该表达式为非正整数或大于INT_MAX,则此 pragma 指令将被忽略,并发出警告。

示例:

struct MyStruct {
    static constexpr int value = 4;
};

inline constexpr int Count = 4;

__device__ void foo(int* p1, int* p2) {
    // no argument specified, the loop will be completely unrolled
    #pragma unroll
    for (int i = 0; i < 12; ++i)
        p1[i] += p2[i] * 2;

    // unroll value = 5
    #pragma unroll (Count + 1)
    for (int i = 0; i < 12; ++i)
        p1[i] += p2[i] * 4;

    // unroll value = 1, loop unrolling disabled
    #pragma unroll 1
    for (int i = 0; i < 12; ++i)
        p1[i] += p2[i] * 8;

    // unroll value = 4
    #pragma unroll (MyStruct::value)
    for (int i = 0; i < 12; ++i)
        p1[i] += p2[i] * 16;

    // negative value, pragma unroll ignored
    #pragma unroll -1
    for (int i = 0; i < 12; ++i)
        p1[i] += p2[i] * 2;
}

请参阅Compiler Explorer上的示例。

5.4.9.2. __builtin_assume_aligned()

提示

建议使用libcu++提供的cuda::std::assume_aligned()函数(C++ 参考),作为这些内建函数的可移植且更安全的替代方案。

void* __builtin_assume_aligned(const void* ptr, size_t align)
void* __builtin_assume_aligned(const void* ptr, size_t align, <integral type> offset)

这些内建函数使编译器能够假定返回的指针至少按align字节对齐。

  • 三参数版本使编译器能够假定(char*) ptr - offset至少按align字节对齐。

align必须是二的幂,且为整数字面量。

示例:

void* res1 = __builtin_assume_aligned(ptr, 32);    // compiler can assume 'res1' is at least 32-byte aligned
void* res2 = __builtin_assume_aligned(ptr, 32, 8); // compiler can assume 'res2 = (char*) ptr - 8' is at least 32-byte aligned

5.4.9.3. __builtin_assume()和__assume()

void __builtin_assume(bool predicate)
void __assume        (bool predicate) // only with Microsoft Compiler

该内建函数使编译器能够假定布尔实参为真。如果该实参在运行时为假,则行为未定义。请注意,如果该实参具有副作用,则行为未指定。

示例:

__device__ bool is_greater_than_zero(int value) {
    return value > 0;
}

__device__ bool f(int value) {
    __builtin_assume(value > 0);
    return is_greater_than_zero(value); // returns true, without evaluating the condition
}

5.4.9.4. __builtin_constant_p()

int __builtin_constant_p(expression)

如果编译器能够确定expression是编译时常量,该内建函数返回1,否则返回0。不会对该表达式求值,因此expression中的副作用不会发生。

示例:

__device__ int select(int value) {
    return __builtin_constant_p(value) ? value * 2 : value;
}

__global__ void kernel(int* out) {
    #pragma unroll
    for (int i = 0; i < 4; ++i) {
        out[i] = select(i); // out[i] = i * 2
    }
}

5.4.9.5. __builtin_expect()

long __builtin_expect(long input, long expected)

该内建函数告知编译器,预期input等于expected,并返回input的值。它通常用于向编译器提供分支预测信息。其行为类似于 C++20 的[[likely]]和[[unlikely]]属性。

示例:

// indicate to the compiler that likely "var == 0"
if (__builtin_expect(var, 0))
    doit();

5.4.9.6. __builtin_unreachable()

void __builtin_unreachable(void)

该内建函数告知编译器,控制流永远不会到达调用该函数的位置。如果控制流在运行时确实到达此位置,则程序的行为未定义。

该函数可用于避免为不可达分支生成代码,并禁用针对不可达代码的编译器警告。

示例:

// indicates to the compiler that the default case label is never reached.
switch (in) {
    case 1:  return 4;
    case 2:  return 10;
    default: __builtin_unreachable();
}

5.4.9.7. 自定义 ABI pragma 指令

#pragma nv_abi指令通过保留函数所用寄存器的数量,使以分离编译模式编译的应用程序能够达到与全程序编译相近的性能。

使用此 pragma 指令的语法如下,其中EXPR表示任意整型常量表达式:

#pragma nv_abi preserve_n_data(EXPR) preserve_n_control(EXPR)
  • #pragma nv_abi后面的参数均为可选参数,并且可以按任意顺序提供;不过,至少需要一个参数。

  • preserve_n参数限制函数调用期间保留的寄存器数量:

    • preserve_n_data(EXPR)限制数据寄存器的数量。

    • preserve_n_control(EXPR)限制控制寄存器的数量。

#pragma nv_abi指令可以紧接在设备函数声明或定义之前。

#pragma nv_abi preserve_n_data(16)
__device__ void dev_func();

#pragma nv_abi preserve_n_data(16) preserve_n_control(8)
__device__ int dev_func() {
    return 0;
}

或者,也可以将它直接放在设备函数内部 C++ 表达式语句中的间接函数调用之前。请注意,虽然支持对自由函数的间接函数调用,但不支持对函数引用或类成员函数的间接调用。

__device__ int dev_func1();

struct MyStruct {
    __device__ int member_func2();
};

__device__ void test() {
    auto* dev_func_ptr = &dev_func1; // type: int (*)(void)
    #pragma nv_abi preserve_n_control(8)
    int v1 = dev_func_ptr();         // CORRECT, indirect call

    #pragma nv_abi preserve_n_control(8)
    int v2 = dev_func1();            // WRONG, direct call; the pragma has no effect
                                     // dev_func1 has type: int(void)

    auto& dev_func_ref = &dev_func1; // type: int (&)(void)
    #pragma nv_abi preserve_n_control(8)
    int v3 = dev_func_ref();         // WRONG, call to a reference
                                     // the pragma has no effect

    auto member_function_ptr = &MyStruct::member_func2; // type: int (MyStruct::*)(void)
    #pragma nv_abi preserve_n_control(8)
    int v4 = member_function_ptr();  // WRONG, indirect call to member function
                                     // the pragma has no effect
}

当此 pragma 指令应用于设备函数的声明或定义时,它会修改对该函数的任何调用的自定义 ABI 属性。当它放置在间接函数调用位置时,仅影响该特定调用的 ABI 属性。请注意,放置在调用位置时,此 pragma 指令仅影响间接函数调用;它对直接函数调用没有影响。

#pragma nv_abi preserve_n_control(8)
__device__ int dev_func3();

__device__ int dev_func4();

__device__ void test() {
    int v1 = dev_func3();            // CORRECT, the pragma affects the direct call

    auto* dev_func_ptr = &dev_func4; // type: int (*)(void)
    #pragma nv_abi preserve_n_control(8)
    int v2 = dev_func_ptr();         // CORRECT, the pragma affects the indirect call

    int v3 = dev_func_ptr();         // WRONG, the pragma has no effect
}

请注意,如果函数声明及其对应定义的 pragma 指令参数不匹配,则程序是非良构的。

5.4.9.8. MMA 吞吐量 pragma 指令

nv_mma_throughput pragma 指令用于启用专门针对矩阵乘加运算调优的编译器优化。它仅在入口函数作用域内有效,即仅对__global__函数有效。通过沿调用路径传播的优化,其影响可能扩展到从该入口函数调用的__device__函数。

示例:

#pragma nv_mma_throughput
__global__  void kernel(){
...
}

注意

nv_mma_throughput pragma 指令仍处于实验阶段。它所启用的设置是在一组数量有限的 NVIDIA 内部工作负载上调优的。不保证每个核函数的性能都会得到提升。

5.4.10. 调试与诊断

5.4.10.1. 断言

#define assert(expression) /* unspecified */

如果expression等于零,assert()宏会停止核函数执行。如果程序在调试器中运行,则会触发断点,允许使用调试器检查设备的当前状态。否则,expression等于零的每个线程都会在通过cudaDeviceSynchronize()、cudaStreamSynchronize()或cudaEventSynchronize()与主机同步后,向 stderr 输出一条消息。该消息的格式如下:

<filename>:<line number>:<function>:
block: [blockIdx.x,blockIdx.y,blockIdx.z],
thread: [threadIdx.x,threadIdx.y,threadIdx.z]
Assertion `<expression>` failed.

核函数的执行会中止,并在主机程序中引发中断。assert()宏会导致 CUDA 上下文损坏,使任何后续的 CUDA 调用或核函数调用均以cudaErrorAssert失败。

如果expression不等于零,则核函数执行不受影响。

例如,源文件test.cu中的以下程序

#include <assert.h>

 __global__ void testAssert(void) {
     int is_one        = 1;
     int should_be_one = 0;

     // This will have no effect
     assert(is_one);

     // This will halt kernel execution
     assert(should_be_one);
 }

 int main(void) {
     testAssert<<<1,1>>>();
     cudaDeviceSynchronize();
     return 0;
 }

将输出:

test.cu:11: void testAssert(): block: [0,0,0], thread: [0,0,0] Assertion `should_be_one` failed.

断言用于调试。由于它们可能影响性能,建议在生产代码中禁用断言。可以在包含assert.h或<cassert>之前定义NDEBUG预处理器宏,或使用编译器标志-DNDEBUG,以在编译时禁用断言。请注意,表达式不应具有副作用;否则,禁用断言将影响代码的功能。

assert()宏在__device__和__tile__代码中均可用。

5.4.10.2. 断点函数

可以从任意设备线程调用__brkpt()函数来挂起核函数的执行。

void __brkpt();

5.4.10.3. 诊断 pragma 指令

以下 pragma 指令可用于管理在发出特定诊断消息时所触发错误的严重级别。

#pragma nv_diag_suppress
#pragma nv_diag_warning
#pragma nv_diag_error
#pragma nv_diag_default
#pragma nv_diag_once

这些 pragma 指令的用法如下:

#pragma nv_diag_xxx <error_number1>, <error_number2> ...

使用警告消息中显示的错误编号来指定受影响的诊断。任何诊断都可以更改为错误,但只有警告才能在更改为错误后抑制或恢复其严重级别。nv_diag_default pragma 指令将诊断的严重级别恢复到发出任何其他 pragma 指令之前生效的级别,即经过任何命令行选项修改后的消息正常严重级别。以下示例抑制foo()的declared but never referenced警告:

#pragma nv_diag_suppress 177 // "declared but never referenced"
void foo() {
    int i = 0;
}

#pragma nv_diag_default 177
void bar() {
    int i = 0;
}

以下 pragma 指令可用于保存和恢复当前的诊断 pragma 指令状态:

#pragma nv_diagnostic push
#pragma nv_diagnostic pop

示例:

#pragma nv_diagnostic push
#pragma nv_diag_suppress 177 // "declared but never referenced"
void foo() {
    int i = 0;
}

#pragma nv_diagnostic pop
void bar() {
    int i = 0; // raise a warning
}

请注意,这些指令仅影响nvcc CUDA 前端编译器。它们对主机编译器没有影响。

当支持诊断 pragma 指令时,nvcc会定义宏__NVCC_DIAG_PRAGMA_SUPPORT__。

5.4.11. 线程束矩阵函数

C++ 线程束矩阵运算利用张量核心加速求解形式为D=A*B+C的矩阵问题。在计算能力为 7.0 或更高的设备上,支持对混合精度浮点数据执行这些运算。这需要一个线程束中的所有线程相互协作。此外,只有当整个线程束中条件的求值结果完全相同时,才允许在条件代码中使用这些运算,否则代码执行很可能挂起。

5.4.11.1. 说明

以下所有函数和类型均在命名空间nvcuda::wmma中定义。亚字节操作被视为预览功能,即它们的数据结构和 API 可能发生变化,并且可能与未来版本不兼容。这项额外功能在nvcuda::wmma::experimental命名空间中定义。

template<typename Use, int m, int n, int k, typename T, typename Layout=void> class fragment;

void load_matrix_sync(fragment<...> &a, const T* mptr, unsigned ldm);
void load_matrix_sync(fragment<...> &a, const T* mptr, unsigned ldm, layout_t layout);
void store_matrix_sync(T* mptr, const fragment<...> &a, unsigned ldm, layout_t layout);
void fill_fragment(fragment<...> &a, const T& v);
void mma_sync(fragment<...> &d, const fragment<...> &a, const fragment<...> &b, const fragment<...> &c, bool satf=false);
fragment

一个重载类,包含分布在线程束所有线程中的一部分矩阵。矩阵元素到fragment内部存储的映射未指定,并且可能在未来架构中发生变化。

仅允许某些特定的模板实参组合。第一个模板形参指定片段将如何参与矩阵运算。Use可接受的值为:

  • 当片段用作第一个乘数A时,为matrix_a,

  • 当片段用作第二个乘数B时,为matrix_b,或者

  • 当片段用作源累加器或目标累加器(分别为C或D)时,为accumulator。

    m、n和k尺寸描述了参与乘加运算的线程束范围的矩阵分块的形状。每个分块的维度取决于其角色。对于matrix_a,分块的维度为m x k;对于matrix_b,维度为k x n;而accumulator分块的维度为m x n。

    数据类型T对于乘数可以是double、float、__half、__nv_bfloat16、char或unsigned char,对于累加器可以是double、float、int或__half。如元素类型和矩阵大小中所述,仅支持有限的累加器类型与乘数类型组合。必须为matrix_a和matrix_b片段指定 Layout 参数。row_major和col_major分别表示矩阵中同一行或同一列的元素在内存中是连续的。accumulator矩阵的Layout参数应保留默认值void。仅在加载或存储累加器时才指定行布局或列布局,如下所述。

load_matrix_sync

等待线程束中的所有通道都到达 load_matrix_sync,然后从内存加载矩阵片段 a。mptr必须是一个按 256 位对齐的指针,指向内存中矩阵的第一个元素。ldm描述连续行(对于行主序布局)或连续列(对于列主序布局)之间以元素为单位的步长;对于__half元素类型,它必须是 8 的倍数,对于float元素类型,它必须是 4 的倍数。(即在这两种情况下都是 16 字节的倍数)。如果片段是accumulator,则必须将layout实参指定为mem_row_major或mem_col_major。对于matrix_a和matrix_b片段,布局由片段的layout参数推断。对于线程束中的所有线程,mptr、ldm、layout的值以及a的所有模板参数都必须相同。线程束中的所有线程都必须调用此函数,否则结果未定义。

store_matrix_sync

等待线程束中的所有通道都到达 store_matrix_sync,然后将矩阵片段 a 存储到内存。mptr必须是一个按 256 位对齐的指针,指向内存中矩阵的第一个元素。ldm描述连续行(对于行主序布局)或连续列(对于列主序布局)之间以元素为单位的步长;对于__half元素类型,它必须是 8 的倍数,对于float元素类型,它必须是 4 的倍数。(即在这两种情况下都是 16 字节的倍数)。输出矩阵的布局必须指定为mem_row_major或mem_col_major。对于线程束中的所有线程,mptr、ldm、layout的值以及 a 的所有模板参数都必须相同。

fill_fragment

用常量值v填充矩阵片段。由于矩阵元素到各个片段的映射未作规定,因此通常由线程束中的所有线程使用相同的v值调用此函数。

mma_sync

等待线程束中的所有通道都到达 mma_sync,然后执行线程束同步的矩阵乘加运算D=A*B+C。也支持原地运算C=A*B+C。对于线程束中的所有线程,satf的值和每个矩阵片段的模板参数都必须相同。此外,片段A、B、C和D之间的模板参数m、n和k必须匹配。线程束中的所有线程都必须调用此函数,否则结果未定义。

如果satf(饱和至有限值)模式为true,则目标累加器还适用以下数值属性:

  • 如果某个元素的结果为正无穷大,则相应的累加器将包含+MAX_NORM

  • 如果某个元素的结果为负无穷大,则相应的累加器将包含-MAX_NORM

  • 如果某个元素的结果为 NaN,则相应的累加器将包含+0

由于矩阵元素到每个线程的fragment的映射未作规定,因此必须在调用store_matrix_sync之后从内存(共享内存或全局内存)访问单个矩阵元素。在一种特殊情况下,即线程束中的所有线程都将对所有片段元素统一执行逐元素运算时,可以使用以下fragment类成员实现直接元素访问。

enum fragment<Use, m, n, k, T, Layout>::num_elements;
T fragment<Use, m, n, k, T, Layout>::x[num_elements];

例如,以下代码将一个accumulator矩阵分块缩放为原来的一半。

wmma::fragment<wmma::accumulator, 16, 16, 16, float> frag;
float alpha = 0.5f; // Same value for all threads in warp
/*...*/
for(int t=0; t<frag.num_elements; t++)
frag.x[t] *= alpha;

5.4.11.2. 替代浮点格式

在计算能力为 8.0 及更高版本的设备上,张量核心支持替代类型的浮点运算。

__nv_bfloat16

此数据格式是一种替代的 fp16 格式,其范围与 f32 相同,但精度较低(7 位)。您可以直接通过cuda_bf16.h中提供的__nv_bfloat16类型使用此数据格式。数据类型为__nv_bfloat16的矩阵片段必须与float类型的累加器组合使用。支持的形状和运算与__half相同。

tf32

此数据格式是张量核心支持的一种特殊浮点格式,其范围与 f32 相同,但精度较低(>=10 位)。此格式的内部布局由实现定义。要在 WMMA 运算中使用此浮点格式,必须手动将输入矩阵转换为 tf32 精度。

为便于转换,提供了一个新的内建函数__float_to_tf32。虽然此内建函数的输入和输出实参都是float类型,但输出在数值上将为tf32。这种新精度仅用于张量核心,如果与其他float类型运算混用,结果的精度和范围将是未定义的。

一旦输入矩阵(matrix_a或matrix_b)转换为 tf32 精度,将具有precision::tf32精度的fragment与传给load_matrix_sync的float数据类型组合使用,即可利用这一新功能。两个累加器片段的数据类型都必须为float。唯一支持的矩阵大小是 16x16x8(m-n-k)。

片段中的元素表示为float,因此从element_type<T>到storage_element_type<T>的映射为:

precision::tf32 -> float

5.4.11.3. 双精度

在计算能力为 8.0 及更高版本的设备上,张量核心支持双精度浮点运算。要使用这一新功能,必须使用具有double类型的fragment。mma_sync运算将使用 .rn(舍入到最近偶数)舍入修饰符执行。

5.4.11.4. 子字节运算

子字节 WMMA 运算提供了访问张量核心低精度功能的方式。它们被视为预览功能,即其数据结构和 API 可能发生变化,并且可能与未来版本不兼容。可通过nvcuda::wmma::experimental命名空间使用此功能:

namespace experimental {
    namespace precision {
        struct u4; // 4-bit unsigned
        struct s4; // 4-bit signed
        struct b1; // 1-bit
   }
    enum bmmaBitOp {
        bmmaBitOpXOR = 1, // compute_75 minimum
        bmmaBitOpAND = 2  // compute_80 minimum
    };
    enum bmmaAccumulateOp { bmmaAccumulateOpPOPC = 1 };
}

对于 4 位精度,可用的 API 保持不变,但必须将experimental::precision::u4或experimental::precision::s4指定为片段的数据类型。由于片段的元素被打包在一起,因此该片段的num_storage_elements将小于num_elements。因此,子字节片段的num_elements变量返回子字节类型element_type<T>的元素数量。这也适用于单比特精度,在这种情况下,从element_type<T>到storage_element_type<T>的映射如下:

experimental::precision::u4 -> unsigned (8 elements in 1 storage element)
experimental::precision::s4 -> int (8 elements in 1 storage element)
experimental::precision::b1 -> unsigned (32 elements in 1 storage element)
T -> T  //all other types

子字节片段允许的布局始终是:matrix_a采用row_major,matrix_b采用col_major。

对于子字节运算,load_matrix_sync中的ldm值对于元素类型experimental::precision::u4和experimental::precision::s4应为 32 的倍数,对于元素类型experimental::precision::b1应为 128 的倍数(即在这两种情况下都是 16 字节的倍数)。

注意

对以下 MMA 指令变体的支持已弃用,并将在 sm_90 中移除:

  • experimental::precision::u4

  • experimental::precision::s4

  • experimental::precision::b1,其中bmmaBitOp设置为bmmaBitOpXOR

bmma_sync

等待线程束中的所有通道都执行了 bmma_sync,然后执行线程束同步的位矩阵乘加运算D = (A op B) + C,其中op由逻辑运算bmmaBitOp及其后由bmmaAccumulateOp定义的累加运算组成。可用的运算包括:

bmmaBitOpXOR,对matrix_a中的一行与matrix_b的 128 位列执行 128 位异或运算

bmmaBitOpAND,对matrix_a中的一行与matrix_b的 128 位列执行 128 位与运算,可在计算能力为 8.0 及更高版本的设备上使用。

累加运算始终为bmmaAccumulateOpPOPC,用于统计置位的位数。

5.4.11.5. 限制

张量核心所需的特殊格式可能因设备架构的每个主版本和次版本而异。线程仅持有整个矩阵的一个片段(一种不透明的、特定于架构的 ABI 数据结构),这使情况更加复杂;开发者不得对各个参数如何映射到参与矩阵乘加运算的寄存器作出假设。

由于片段是特定于架构的,如果函数 A 和函数 B 是针对不同但链接兼容的架构编译的,并被链接到同一个设备可执行文件中,则将片段从函数 A 传递到函数 B 是不安全的。在这种情况下,片段的大小和布局将特定于一种架构,在另一种架构中使用 WMMA API 将导致不正确的结果,或者可能导致数据损坏。

sm_70 和 sm_75 就是两个链接兼容但片段布局不同的架构示例。

fragA.cu: void foo() { wmma::fragment<...> mat_a; bar(&mat_a); }
fragB.cu: void bar(wmma::fragment<...> *mat_a) { // operate on mat_a }
// sm_70 fragment layout
$> nvcc -dc -arch=compute_70 -code=sm_70 fragA.cu -o fragA.o
// sm_75 fragment layout
$> nvcc -dc -arch=compute_75 -code=sm_75 fragB.cu -o fragB.o
// Linking the two together
$> nvcc -dlink -arch=sm_75 fragA.o fragB.o -o frag.o

这种未定义行为也可能无法在编译时检测到,也无法由工具在运行时检测到,因此需要格外小心,确保片段的布局一致。当所链接的旧版库既是针对不同但链接兼容的架构构建的,又期望传入 WMMA 片段时,最容易出现这种链接风险。

请注意,在弱链接的情况下(例如 CUDA C++ 内联函数),链接器可能选择任何可用的函数定义,这可能导致在编译单元之间进行隐式传递。

为避免此类问题,在通过外部接口传递矩阵时,应始终先将矩阵存储到内存(例如wmma::store_matrix_sync(dst, …);),然后就可以将其作为指针类型安全地传递给bar()[例如float *dst]。

请注意,由于 sm_70 可以在 sm_75 上运行,因此可以将上述示例中的 sm_75 代码改为 sm_70,并在 sm_75 上正确运行。不过,在与其他单独编译的 sm_75 二进制文件链接时,建议应用程序中包含 sm_75 原生代码。

5.4.11.6. 元素类型和矩阵大小

张量核心支持多种元素类型和矩阵大小。下表列出了所支持的matrix_a、matrix_b和accumulator矩阵的各种组合:

矩阵 A

矩阵 B

累加器

矩阵大小(m-n-k)

__half

__half

float

16x16x16

__half

__half

float

32x8x16

__half

__half

float

8x32x16

__half

__half

__half

16x16x16

__half

__half

__half

32x8x16

__half

__half

__half

8x32x16

unsigned char

unsigned char

int

16x16x16

unsigned char

unsigned char

int

32x8x16

unsigned char

unsigned char

int

8x32x16

signed char

signed char

int

16x16x16

signed char

signed char

int

32x8x16

signed char

signed char

int

8x32x16

替代浮点格式支持:

矩阵 A

矩阵 B

累加器

矩阵大小(m-n-k)

__nv_bfloat16

__nv_bfloat16

float

16x16x16

__nv_bfloat16

__nv_bfloat16

float

32x8x16

__nv_bfloat16

__nv_bfloat16

float

8x32x16

precision::tf32

precision::tf32

float

16x16x8

双精度支持:

矩阵 A

矩阵 B

累加器

矩阵大小(m-n-k)

double

double

double

8x8x4

对子字节运算的实验性支持:

矩阵 A

矩阵 B

累加器

矩阵大小(m-n-k)

precision::u4

precision::u4

int

8x8x32

precision::s4

precision::s4

int

8x8x32

precision::b1

precision::b1

int

8x8x128

5.4.11.7. 示例

以下代码在单个线程束中实现了 16x16x16 矩阵乘法。

#include <mma.h>
using namespace nvcuda;

__global__ void wmma_ker(half *a, half *b, float *c) {
   // Declare the fragments
   wmma::fragment<wmma::matrix_a, 16, 16, 16, half, wmma::col_major> a_frag;
   wmma::fragment<wmma::matrix_b, 16, 16, 16, half, wmma::row_major> b_frag;
   wmma::fragment<wmma::accumulator, 16, 16, 16, float> c_frag;

   // Initialize the output to zero
   wmma::fill_fragment(c_frag, 0.0f);

   // Load the inputs
   wmma::load_matrix_sync(a_frag, a, 16);
   wmma::load_matrix_sync(b_frag, b, 16);

   // Perform the matrix multiplication
   wmma::mma_sync(c_frag, a_frag, b_frag, c_frag);

   // Store the output
   wmma::store_matrix_sync(c, c_frag, 16, wmma::mem_row_major);
}

5.5. 浮点计算

5.5.1. 浮点数简介

自 1985 年二进制浮点算术IEEE-754 标准获采纳以来,几乎所有主流计算系统,包括 NVIDIA 的 CUDA 架构,都已实现该标准。IEEE-754 标准规定了应如何对浮点算术的结果进行近似。

要获得准确的结果,并在满足所需精度的同时实现最高性能,考虑浮点行为的多个方面很重要。这在运算由不同类型硬件执行的异构计算环境中尤为重要。

以下各节回顾浮点计算的基本属性,并介绍融合乘加(FMA)运算和点积。这些示例说明了不同的实现选择如何影响准确性。

5.5.1.1. 浮点格式

浮点格式和功能由IEEE-754 标准定义。

该标准规定,二进制浮点数据必须编码在三个字段中:

  • 符号:用一位表示正数或负数。

  • 指数:对加上数值偏置后的以 2 为底的指数进行编码。

  • 有效数(也称为尾数或小数部分):对该数的小数值进行编码。

浮点编码
图内文字中文对照
Sign
符号
Exponent
指数
Mantissa
尾数

最新的 IEEE-754 标准定义了以下二进制格式的编码和属性:

  • 16 位,也称为半精度,对应于 CUDA 中的__half数据类型。

  • 32 位,也称为单精度,对应于 C、C++ 和 CUDA 中的float数据类型。

  • 64 位,也称为双精度,对应于 C、C++ 和 CUDA 中的double数据类型。

  • 128 位,也称为四倍精度,对应于 CUDA 中的__float128或_Float128数据类型。

这些类型具有以下位长:

IEEE-754 浮点编码
图内文字中文对照
IEEE-754 128-bit Floating-point
IEEE-754 128 位浮点数
IEEE-754 64-bit Floating-point
IEEE-754 64 位浮点数
IEEE-754 32-bit Floating-point
IEEE-754 32 位浮点数
IEEE-754 16-bit Floating-point
IEEE-754 16 位浮点数

正规值的浮点编码所对应的数值按如下方式计算:

(−1)sign×1.mantissa×2exponent−bias(-1)^\mathrm{sign} \times 1.\mathrm{mantissa} \times 2^{\mathrm{exponent} - \mathrm{bias}}

对于次正规值,公式修改为:

(−1)sign×0.mantissa×21−bias(-1)^\mathrm{sign} \times 0.\mathrm{mantissa} \times 2^{1-\mathrm{bias}}
对于单精度和双精度,指数分别采用127127和10231023的偏置。1.1.的整数部分隐含在小数部分中。
例如,数值−192=(−1)1×27×1.5-192 = (-1)^1 \times 2^7 \times 1.5被编码为负号、值为77的指数以及小数部分0.50.5。因此,指数77用位串表示时,对于float其值为7 + 127 = 134 = 10000110,对于double其值为7 + 1023 = 1030 = 10000000110。尾数0.5 = 2^-1用首位为1的二进制值表示。下图展示了−192-192的单精度和双精度二进制编码:
``-192`` 的浮点表示
图内文字中文对照
IEEE-754 64-bit Floating-point
IEEE-754 64 位浮点数
IEEE-754 32-bit Floating-point
IEEE-754 32 位浮点数

由于小数字段使用的位数有限,并非所有实数都能被精确表示。例如,分数2/32 / 3的数学值的二进制表示为0.10101010...,其二进制小数点后有无限多位。因此,必须先对2/32 / 3进行舍入,才能将其表示为精度有限的浮点数。IEEE-754 规定了舍入规则和模式。最常用的模式是舍入到最近值,中间值取偶数,简称舍入到最近值。

5.5.1.2. 正规值与次正规值

任何指数字段既不全为零也不全为一的浮点值都称为正规值。

浮点值的一个重要特征是,最小的可表示正正规数FLT_MIN与零之间存在很大的间隔。这个间隔远大于FLT_MIN与第二小的正规数之间的间隔。

为解决此问题,引入了浮点次正规数,也称为非正规数。次正规浮点值的表示方式是,将指数的所有位设为零,并将有效数中的至少一位设为一。次正规数是 IEEE-754 浮点标准的必需组成部分。

次正规数允许精度逐渐损失,而不是突然向零舍入。但是,次正规数的计算开销更高。因此,不要求严格准确性的应用程序可以选择避免使用次正规数,以提高性能。nvcc编译器允许通过设置-ftz=true选项(刷零)来禁用次正规数,--use_fast_math也包含此选项。

下图以简化的可视化方式展示了单精度下最小正规值和次正规值的编码:

最小正规值和次正规值的表示
图内文字中文对照
Smallest normal value (32-bit)
最小正规数值(32 位)
Subnormal values (32-bit)
次正规数值(32 位)
(excluding all bits set to 0)
(不包括所有位均为 0 的情况)

其中,X同时表示0和1。

5.5.1.3. 特殊值

IEEE-754 标准为浮点数定义了三种特殊值:

零:

  • 数学意义上的零。

  • 请注意,浮点零有两种可能的表示形式:+0和-0。这与整数零的表示形式不同。

  • +0 == -0的求值结果为true。

  • 零的编码将指数和有效数中的所有位都设置为0。

无穷大:

  • 浮点数遵循饱和算术的行为,在这种算术中,溢出可表示范围的运算会产生+Infinity或-Infinity。

  • 无穷大的编码将指数中的所有位都设置为1,将有效数中的所有位都设置为0。无穷大值恰好有两种编码。

  • 涉及无穷大和有限非零值的算术运算通常会产生无穷大。Inf * 0.0、Inf - Inf、Inf / Inf和0.0 / 0.0等不定式会产生 NaN。

非数(NaN):

  • NaN 是一个特殊符号,表示未定义或不可表示的值。常见示例有0.0 / 0.0、sqrt(-1.0)或+Inf - Inf。

  • NaN 的编码将指数中的所有位都设置为1,有效数则可以采用除所有位均为 0 以外的任何位模式。共有2mantissa+1−22^{\mathrm{mantissa} + 1} - 2种可能的编码。

  • 任何涉及 NaN 的算术运算都会产生 NaN。

  • 任何涉及 NaN 的有序比较(<、<=、>、>=、==)都会产生false,包括NaN == NaN(不具有自反性)。无序比较NaN != NaN返回true。

  • NaN 有两种形式:

    • 静默 NaNqNaN用于传播由无效运算或无效值产生的错误。无效的算术运算通常会产生静默 NaN。其编码将有效数的最高有效位设置为1。

    • 信号 NaNsNaN旨在引发无效运算异常。信号 NaN 通常是显式创建的。其编码将有效数的最高有效位设置为0。

    • 静默 NaN 和信号 NaN 的确切位模式由实现定义。CUDA 提供cuda::std::numeric_limits<T>::quiet_NaN和cuda::std::numeric_limits<T>::signaling_NaN常量来获取这些特殊值。

下图以简化的可视化方式展示了特殊值的编码:

无穷大和 NaN 的浮点表示
图内文字中文对照
Zero (32-bit)
零(32 位)
Infinity (32-bit)
无穷大(32 位)
NaN (32-bit)
非数值(32 位)
Quiet NaN (32-bit)
静默非数值(32 位)
Signaling NaN (32-bit)
信号非数值(32 位)
(excluding all bits set to 0)
(不包括所有位均为 0 的情况)

其中,X同时表示0和1。

5.5.1.4. 结合律

需要注意的是,由于浮点算术的精度有限,数学算术的规则和性质并不直接适用于浮点算术。下面的示例展示了单精度值A、B和C,以及使用不同结合方式计算它们的和所得的精确数学值。

A=21×1.00000000000000000000001B=20×1.00000000000000000000001C=23×1.00000000000000000000001(A+B)+C=23×1.01100000000000000000001011A+(B+C)=23×1.01100000000000000000001011\begin{split}\begin{aligned} A &= 2^{1} \times 1.00000000000000000000001 \\ B &= 2^{0} \times 1.00000000000000000000001 \\ C &= 2^{3} \times 1.00000000000000000000001 \\ (A + B) + C &= 2^{3} \times 1.01100000000000000000001011 \\ A + (B + C) &= 2^{3} \times 1.01100000000000000000001011 \end{aligned}\end{split}

从数学上讲,(A+B)+C(A + B) + C等于A+(B+C)A + (B + C)。

令rn(x)\mathrm{rn}(x)表示对xx进行一次舍入。按照 IEEE-754,在舍入到最近值模式下使用单精度浮点算术进行相同的计算,可得:

A+B=21×1.1000000000000000000000110000…rn(A+B)=21×1.10000000000000000000010B+C=23×1.0010000000000000000000100100…rn(B+C)=23×1.00100000000000000000001A+B+C=23×1.0110000000000000000000101100…rn(rn(A+B)+C)=23×1.01100000000000000000010rn(A+rn(B+C))=23×1.01100000000000000000001\begin{split}\begin{aligned} A + B &= 2^{1} \times 1.1000000000000000000000110000\ldots \\ \mathrm{rn}(A+B) &= 2^{1} \times 1.10000000000000000000010 \\ B + C &= 2^{3} \times 1.0010000000000000000000100100\ldots \\ \mathrm{rn}(B+C) &= 2^{3} \times 1.00100000000000000000001 \\ A + B + C &= 2^{3} \times 1.0110000000000000000000101100\ldots \\ \mathrm{rn}\big(\mathrm{rn}(A+B) + C\big) &= 2^{3} \times 1.01100000000000000000010 \\ \mathrm{rn}\big(A + \mathrm{rn}(B+C)\big) &= 2^{3} \times 1.01100000000000000000001 \end{aligned}\end{split}

上面还计算了精确的数学结果以供参考。按照 IEEE-754 计算的结果与精确的数学结果不同。此外,和rn(rn(A+B)+C)\mathrm{rn}(\mathrm{rn}(A + B) + C)与rn(A+rn(B+C))\mathrm{rn}(A + \mathrm{rn}(B + C))所对应的结果也互不相同。在此情况下,rn(A+rn(B+C))\mathrm{rn}(A + \mathrm{rn}(B + C))比rn(rn(A+B)+C)\mathrm{rn}(\mathrm{rn}(A + B) + C)更接近正确的数学结果。

这个示例表明,即使所有基本运算都符合 IEEE-754,看似相同的计算也可能产生不同的结果。

5.5.1.5. 融合乘加(FMA)

融合乘加(FMA)运算只需一次舍入即可计算出结果。如果不使用 FMA,计算结果将需要两次舍入:一次用于乘法,一次用于加法。由于 FMA 只进行一次舍入,因此它产生的结果更加准确。

融合乘加运算对 NaN 传播的影响可能与两个独立运算不同。但是,FMA 对 NaN 的处理并非在所有目标平台上都完全相同。当存在多个 NaN 操作数时,不同的实现可能优先选择静默 NaN,也可能传播某个操作数的载荷。此外,当存在多个 NaN 操作数时,IEEE-754 并未严格要求确定性的载荷选择顺序。NaN 也可能出现在中间计算中,例如∞×0+1\infty \times 0 + 1或1×∞−∞1 \times \infty - \infty,从而产生由实现定义的 NaN 载荷。


为清楚起见,首先考虑一个使用十进制算术的示例,以说明 FMA 运算的工作方式。我们将使用总共五位数的精度来计算x2−1x^2 - 1,其中小数点后有四位数。

  • 对于x=1.0008x = 1.0008,正确的数学结果为x2−1=1.60064×10−4x^2 - 1 = 1.60064 \times 10^{-4}。仅使用小数点后四位数时,最接近的数为1.6006×10−41.6006 \times 10^{-4}。

  • 融合乘加运算仅通过一次舍入rn(x×x−1)=1.6006×10−4\mathrm{rn}(x \times x - 1) = 1.6006 \times 10^{-4}即可得到正确结果。

  • 另一种方法是分别计算乘法和加法步骤。x2=1.00160064x^2 = 1.00160064转化为rn(x×x)=1.0016\mathrm{rn}(x \times x) = 1.0016。最终结果为rn(rn(x×x)−1)=1.6000×10−4\mathrm{rn}(\mathrm{rn}(x \times x) -1) = 1.6000 \times 10^{-4}。

分别对乘法和加法进行舍入,得到的结果偏差为0.000640.00064。相应的 FMA 计算误差仅为0.000040.00004,其结果最接近正确的数学答案。结果汇总如下:

x=1.0008x2=1.00160064x2−1=1.60064×10−4true valuern(x2−1)=1.6006×10−4fused multiply-addrn(x2)=1.0016rn(rn(x2)−1)=1.6000×10−4multiply, then add\begin{split}\begin{aligned} x &= 1.0008 \\ x^{2} &= 1.00160064 \\ x^{2} - 1 &= 1.60064 \times 10^{-4} && \text{true value} \\ \mathrm{rn}\big(x^{2} - 1\big) &= 1.6006 \times 10^{-4} && \text{fused multiply-add} \\ \mathrm{rn}\big(x^{2}\big) &= 1.0016 \\ \mathrm{rn}\big(\mathrm{rn}(x^{2}) - 1\big) &= 1.6000 \times 10^{-4} && \text{multiply, then add} \end{aligned}\end{split}

下面是另一个使用二进制单精度值的示例:

A=20×1.00000000000000000000001B=−20×1.00000000000000000000010rn(A×A+B)=2−46×1.00000000000000000000000fused multiply-addrn(rn(A×A)+B)=0multiply, then add\begin{split}\begin{aligned} A &= 2^{0} \times 1.00000000000000000000001 \\ B &= -2^{0} \times 1.00000000000000000000010 \\ \mathrm{rn}\big(A \times A + B\big) &= 2^{-46} \times 1.00000000000000000000000 && \text{fused multiply-add} \\ \mathrm{rn}\big(\mathrm{rn}(A \times A) + B\big) &= 0 && \text{multiply, then add} \end{aligned}\end{split}
  • 分别计算乘法和加法会导致所有精度位丢失,得到00。

  • 相比之下,计算 FMA 可得到与数学值相等的结果。

融合乘加有助于防止减法消去过程中的精度损失。当大小相近、符号相反的量相加时,就会发生减法消去。在这种情况下,许多高位会相互抵消,导致有意义的位数减少。融合乘加在乘法期间计算双倍位宽的乘积。因此,即使在加法期间发生减法消去,乘积中仍有足够的有效位来产生精确的结果。


CUDA 中的融合乘加支持:

CUDA 通过多种方式为float和double两种数据类型提供融合乘加运算:


主机平台上的融合乘加支持:

是否使用融合运算取决于该运算在平台上是否可用以及代码的编译方式。在比较 CPU 和 GPU 的结果时,了解主机平台对融合乘加的支持非常重要。

  • 编译器标志和融合乘加硬件支持:

    • GCC和Clang使用-mfma,NVC++使用-Mfma,Microsoft Visual Studio使用/fp:contract。

    • 具有 AVX2 ISA 的 x86 平台,例如,使用 GCC 或 Clang 并指定-mavx2标志编译的代码,以及使用 Microsoft Visual Studio 并指定/arch:AVX2编译的代码。

    • 具有 Advanced SIMD(Neon)ISA 的 Arm64(AArch64)平台。

  • C 标准库函数fma(x, y, z)和fmaf(x, y, z)。

  • C++ 标准库函数std::fma(x, y, z)和std::fmaf(x, y, z)。

  • CUDA C++ 标准库函数cuda::std::fma(x, y, z)和cuda::std::fmaf(x, y, z)。

5.5.1.6. 点积示例

考虑求两个短向量a→\overrightarrow{a}和b→\overrightarrow{b}的点积的问题,这两个向量都含有四个元素。

a→=[a1a2a3a4]b→=[b1b2b3b4]a→⋅b→=a1b1+a2b2+a3b3+a4b4\begin{split}\overrightarrow{a} = \begin{bmatrix} a_{1} \\ a_{2} \\ a_{3} \\ a_{4} \end{bmatrix} \qquad \overrightarrow{b} = \begin{bmatrix} b_{1} \\ b_{2} \\ b_{3} \\ b_{4} \end{bmatrix} \qquad \overrightarrow{a} \cdot \overrightarrow{b} = a_{1}b_{1} + a_{2}b_{2} + a_{3}b_{3} + a_{4}b_{4}\end{split}

虽然这个运算很容易用数学形式写出,但在软件中实现它有多种选择,可能导致略有不同的结果。这里介绍的所有策略所使用的运算都完全符合 IEEE-754。

示例算法 1:计算点积的最简单方法是对乘积进行顺序求和,并保持乘法和加法分离。

最终结果可以表示为((((a1×b1)+(a2×b2))+(a3×b3))+(a4×b4))((((a_1 \times b_1) + (a_2 \times b_2)) + (a_3 \times b_3)) + (a_4 \times b_4))。

示例算法 2:使用融合乘加顺序计算点积。

最终结果可以表示为(a4×b4)+((a3×b3)+((a2×b2)+(a1×b1+0)))(a_4 \times b_4) + ((a_3 \times b_3) + ((a_2 \times b_2) + (a_1 \times b_1 + 0)))。

示例算法 3:使用分治策略计算点积。首先,分别求出向量前半部分和后半部分的点积。然后,使用加法合并这些结果。该算法称为“并行算法”,因为这两个子问题彼此独立,可以并行计算。但是,该算法并不要求采用并行实现;它可以用单个线程实现。

最终结果可以表示为((a1×b1)+(a2×b2))+((a3×b3)+(a4×b4))((a_1 \times b_1) + (a_2 \times b_2)) + ((a_3 \times b_3) + (a_4 \times b_4))。

5.5.1.7. 舍入

IEEE-754 标准要求支持若干运算。这些运算包括加法、减法、乘法、除法、平方根、融合乘加、求余数等算术运算,以及转换、缩放、符号和比较运算。对于给定的格式和舍入模式,可以保证这些运算的结果在该标准的所有实现中都一致。


舍入模式

IEEE-754 标准定义了四种舍入模式:舍入到最近值、向正方向舍入、向负方向舍入和向零舍入。CUDA 支持全部四种模式。默认情况下,运算使用舍入到最近值。可以使用数学内建函数为单个运算选择其他舍入模式。

舍入模式

含义

rn

舍入到最近值,距离相等时取偶数

rz

向零舍入

ru

向∞\infty舍入

rd

向−∞-\infty舍入

5.5.1.8. 关于主机/设备计算准确性的说明

浮点计算结果的准确性受多种因素影响。本节总结了在浮点计算中获得可靠结果的重要注意事项。其中一些方面已在前面的章节中作了更详细的介绍。

在比较 CPU 和 GPU 的结果时,这些方面也很重要。必须谨慎解释主机执行与设备执行之间的差异。存在差异并不一定意味着 GPU 的结果不正确,或 GPU 存在问题。

结合律:

有限精度的浮点加法和乘法不满足结合律,因为它们经常产生无法用目标格式直接表示的数学值,因此需要舍入。这些运算的求值顺序会影响舍入误差的累积方式,并且可能显著改变最终结果。

融合乘加:

融合乘加在单次运算中计算a×b+ca \times b + c,从而获得更高的准确性和更短的执行时间。是否使用它会影响最终结果的准确性。融合乘加依赖硬件支持,可以通过调用相关函数显式启用,也可以通过编译器优化标志隐式启用。

精度:

提高浮点精度可能提高结果的准确性。更高的精度可以减少有效数字的损失,并能够表示更大范围的值。但是,更高精度的类型吞吐量更低,并且会消耗更多寄存器。此外,使用这些类型显式存储输入和输出会增加内存使用量和数据移动量。

编译器标志和优化:

所有主流编译器都提供了多种优化标志,用于控制浮点运算的行为。

  • GCC(-O3)、Clang(-O3)、nvcc(-O3)和 Microsoft Visual Studio(/O2)的最高优化级别不会影响浮点语义。但是,内联、循环展开、向量化和公共子表达式消除可能会影响结果。NVC++ 编译器还需要使用标志-Kieee -Mnofma才能获得符合 IEEE-754 的语义。

  • 有关影响浮点行为的选项的详细信息,请参阅GCC、Clang、Microsoft Visual Studio 编译器、nvc++和Arm C/C++ 编译器的文档。

  • 另请参阅nvcc用户手册,了解专门影响 CUDA 设备代码中浮点行为的编译器标志的详细说明:-ftz、-prec-div、-prec-sqrt、-fmad、--use_fast_math。除了这些浮点选项之外,在用户程序的具体情境中验证其他编译器优化的影响也很重要。建议用户通过广泛的测试来验证结果的正确性,并比较启用优化时获得的结果与禁用所有设备代码优化时获得的结果;另请参阅-G编译器标志。

库实现:

在 IEEE-754 标准之外定义的函数不能保证正确舍入,并且依赖由实现定义的行为。因此,结果可能在不同平台之间存在差异,包括主机、设备以及不同设备架构之间。

确定性结果:

确定性结果是指在相同的指定条件下使用相同的输入运行时,每次都计算出逐位相同的数值输出。这些条件包括:

  • 硬件依赖性,例如在相同的 CPU 处理器或 GPU 设备上执行。

  • 编译器方面,例如编译器版本以及编译器标志和优化。

  • 影响计算的运行时条件,例如舍入模式或环境变量。

  • 计算的输入完全相同。

  • 线程配置,包括参与计算的线程数及其组织方式,例如线程块和网格大小。

  • 算术原子操作的顺序取决于硬件调度,而硬件调度在不同运行之间可能发生变化。

充分利用 CUDA 库:

CUDA 数学库、C 标准库数学函数和C++ 标准库数学函数旨在提高开发者实现常用功能时的生产力,特别是浮点数学和数值计算密集型例程。这些功能提供了一致的高级接口,经过优化,并针对各种平台和边界情况进行了广泛测试。建议用户充分利用这些库,避免繁琐的手工重新实现。

5.5.2. 浮点数据类型

CUDA 支持 Bfloat16、半精度、单精度、双精度和四倍精度浮点数据类型。下表汇总了 CUDA 支持的浮点数据类型及其要求。

表 44 支持的浮点类型

精度/名称

数据类型

IEEE-754

头文件/内置

要求

Bfloat16

__nv_bfloat16

❌

<cuda_bf16.h>

计算能力 8.0 或更高。

半精度

__half

✅

<cuda_fp16.h>

单精度

float

✅

内置

双精度

double

✅

内置

四倍精度

__float128/_Float128

✅

内置

数学函数使用<crt/device_fp128_functions.h>

主机编译器支持且计算能力为 10.0 或更高。

C 或 C++ 中的写法分别为_Float128和__float128,也取决于主机编译器的支持。

CUDA 还支持TensorFloat-32(TF32)、微缩放(MX)浮点类型以及其他较低精度数值格式,它们并非用于通用计算,而是用于涉及张量核心的专门用途。其中包括 4 位、6 位和 8 位浮点类型。更多详细信息,请参阅CUDA Math API。

下图给出了所支持浮点数据类型的尾数和指数大小。

浮点类型:尾数和指数的大小
图内文字中文对照
IEEE-754 128-bit Floating-point
IEEE-754 128 位浮点数
IEEE-754 64-bit Floating-point
IEEE-754 64 位浮点数
IEEE-754 32-bit Floating-point
IEEE-754 32 位浮点数
IEEE-754 16-bit Floating-point
IEEE-754 16 位浮点数

下表给出了所支持浮点数据类型的范围。

表 45 支持的浮点类型的属性

精度/名称

最大值

最小正值

最小正非正规数

机器精度

Bfloat16

≈2128\approx 2^{128}

≈3.39⋅1038\approx 3.39 \cdot 10^{38}

2−1262^{-126}

≈1.18⋅10−38\approx 1.18 \cdot 10^{-38}

2−1332^{-133}

2−72^{-7}

半精度

≈216\approx 2^{16}

6550465504

2−142^{-14}

≈6.1⋅10−5\approx 6.1 \cdot 10^{-5}

2−242^{-24}

2−102^{-10}

单精度

≈2128\approx 2^{128}

≈3.40⋅1038\approx 3.40 \cdot 10^{38}

2−1262^{-126}

≈1.18⋅10−38\approx 1.18 \cdot 10^{-38}

2−1492^{-149}

2−232^{-23}

双精度

≈21024\approx 2^{1024}

≈1.8⋅10308\approx 1.8 \cdot 10^{308}

2−10222^{-1022}

≈2.22⋅10−308\approx 2.22 \cdot 10^{-308}

2−10742^{-1074}

2−522^{-52}

四倍精度

≈216384\approx 2^{16384}

≈1.19⋅104932\approx 1.19 \cdot 10^{4932}

2−163822^{-16382}

≈3.36⋅10−4932\approx 3.36 \cdot 10^{-4932}

2−164942^{-16494}

2−1122^{-112}

提示

CUDA C++ Standard Library在<cuda/std/limits>头文件中提供了cuda::std::numeric_limits,用于查询所支持浮点类型的属性和范围,包括微缩放格式(MX)。有关可查询属性的列表,请参阅C++ 参考。

复数支持:


5.5.3. CUDA 与 IEEE-754 的符合性

所有 GPU 设备都遵循IEEE 754-2019二进制浮点算术标准,但存在以下限制:

  • 没有可动态配置的舍入模式;不过,大多数运算支持多种固定的 IEEE 舍入模式,可通过特定命名的设备内建函数进行选择。

  • 没有检测浮点异常的机制,因此所有运算的行为都如同 IEEE-754 异常始终被屏蔽一样。如果发生异常事件,则给出 IEEE-754 定义的默认屏蔽响应。因此,尽管支持发信号 NaNSNaN编码,但它们不会发信号,而是作为静默异常处理。

  • 浮点运算可能改变输入 NaN 载荷的位模式。绝对值和取负等运算也可能不符合 IEEE 754 的要求,这可能导致 NaN 的符号以实现定义的方式更新。

为最大限度提高结果的可移植性,建议用户使用nvcc编译器浮点选项的默认设置:-ftz=false、-prec-div=true和-prec-sqrt=true,并且不使用--use_fast_math选项。注意,默认情况下允许浮点表达式的重新结合和收缩,与--fmad=true选项类似。有关这些编译标志的详细说明,另请参阅nvcc用户手册。

当舍入为整数后的值超出目标整数格式的范围时,IEEE-754 和 C/C++ 语言标准都没有明确规定浮点值到整数值的转换。GPU 设备将值钳位到范围内的行为在PTX ISA 转换指令一节中进行了说明。不过,如果超出范围的转换不是直接通过 PTX 指令调用的,编译器优化可能会利用未指定行为条款,从而导致未定义行为,并使 CUDA 程序无效。CUDA Math 文档针对各个函数/内建函数向用户发出警告。例如,请考虑__double2int_rz()指令。这可能与主机编译器和库实现的行为不同。

原子函数对非正规数的行为:

无论编译器标志-ftz如何设置,原子运算对于浮点非正规数都具有以下行为:

  • 全局内存上的原子单精度浮点加法始终在冲零模式下运行,即行为等同于 PTXadd.rn.ftz.f32语义。

  • 共享内存上的原子单精度浮点加法始终在支持非正规数的情况下运行,即行为等同于 PTXadd.rn.f32语义。

5.5.4. CUDA 与 C/C++ 的符合性

浮点异常:

与主机实现不同,设备代码中支持的数学运算符和函数既不设置全局errno变量,也不报告浮点异常来指示错误。因此,如果需要错误诊断机制,用户应为这些函数实现额外的输入和输出筛查。

浮点运算中的未定义行为:

数学运算中出现未定义行为的常见情形包括:

  • 传递给数学运算符和函数的参数无效:

    • 使用未初始化的浮点变量。

    • 在浮点变量的生存期之外使用该变量。

    • 有符号整数溢出。

    • 解引用无效指针。

  • 浮点特有的未定义行为:

    • 将浮点值转换为无法表示结果的整数类型属于未定义行为。这也包括 NaN 和无穷大。

用户有责任确保 CUDA 程序的有效性。无效参数可能导致未定义行为,并受到编译器优化的影响。

与整数除以零不同,浮点数除以零不是未定义行为,也不受编译器优化的影响;它属于特定于实现的行为。包括 CUDA 在内,符合IEC-60559(IEEE-754)的 C++ 实现会产生无穷大。注意,无效的浮点运算会产生 NaN,不应将其误解为未定义行为。例如,零除以零以及无穷大除以无穷大。

浮点字面量的可移植性:

C 和 C++ 都允许使用十进制或十六进制记数法表示浮点值。C99和C++17支持十六进制浮点字面量,它以科学记数法表示一个能够用二进制精确表达的实数值。不过,这并不能保证该字面量将映射到目标变量中存储的实际值(请参阅下一段)。相反,十进制浮点字面量可能表示一个无法用二进制表达的数值。

根据C++ 标准规则,十六进制和十进制浮点字面量会舍入为最近的可表示值,该值可能较大,也可能较小,具体以实现定义的方式选择。主机与设备的这种舍入行为可能不同。

float f1 = 0.5f;    // 0.5, '0.5f' is a decimal floating-point literal
float f2 = 0x1p-1f; // 0.5, '0x1p-1f' is a hexadecimal floating-point literal
float f3 = 0.1f;
// f1, f2 are represented as 0 01111110 00000000000000000000000
// f3     is represented as  0 01111011 10011001100110011001101

同一浮点表达式的运行时求值和编译时求值存在以下可移植性问题:

  • 浮点表达式的运行时求值可能受到所选舍入模式、浮点收缩(FMA)和重新结合编译器设置,以及浮点异常的影响。注意,CUDA 不支持浮点异常,并且舍入模式默认设为舍入到最近值,中间值舍入到偶数。可以使用内建函数选择其他舍入模式。

  • 编译器可能对常量表达式使用更高精度的内部表示。

  • 编译器可能执行常量折叠、常量传播和公共子表达式消除等优化,从而导致不同的最终值或比较结果。

C 标准数学库说明:

常用数学函数的主机实现以特定于平台的方式映射到C 标准数学库函数。这些函数由主机编译器和相应的主机libm(如果可用)提供。

  • 主机编译器未提供的函数在crt/math_functions.h头文件中实现。例如,erfinv()就在其中实现。

  • 较不常用的函数,例如rhypot()和cyl_bessel_i0(),仅在设备代码中可用。

如前所述,数学函数的主机实现和设备实现是独立的。有关这些函数行为的更多详细信息,请参阅主机实现的文档。


5.5.5. 浮点功能的提供方式

CUDA 支持的数学函数通过以下方式提供:

C/C++ 语言内置算术运算符:

  • x + y, x - y, x * y, x / y, x++, x--, x += y, x -= y, x *= y, x /= y.

  • 支持单精度、双精度和四倍精度类型,分别为float、double和__float128/_Float128。

    • 通过分别包含<cuda_fp16.h>和<cuda_bf16.h>头文件,还可以支持__half和__nv_bfloat16类型。

    • 对__float128/_Float128类型的支持取决于主机编译器和设备计算能力,请参阅支持的浮点类型表。

  • 它们在主机代码和设备代码中均可用。

  • 它们的行为受nvcc优化标志的影响。

CUDA C++ Standard Library 数学函数:

  • 通过<cuda/std/cmath>头文件和cuda::std::命名空间提供 C++<cmath>头文件函数的完整集合。

  • 支持 IEEE-754 标准浮点类型__half、float、double、__float128,以及 Bfloat16__nv_bfloat16。

    • 对__float128的支持取决于主机编译器和设备计算能力,请参阅支持的浮点类型表。

  • 它们在主机代码和设备代码中均可用。

  • 它们通常依赖CUDA Math API 函数。因此,主机代码与设备代码之间的准确度可能不同。

  • 它们的行为受nvcc优化标志的影响。

  • 根据 C++23 和 C++26 标准规范,部分功能也支持用于常量表达式,例如constexpr函数。

CUDA C Standard Library 数学函数(CUDA Math API):

  • 提供 C<math.h>头文件函数的一个子集。

  • 支持单精度和双精度类型,分别为float和double。

    • 它们在主机代码和设备代码中均可用。

    • 它们不需要额外的头文件。

    • 它们的行为受nvcc优化标志的影响。

  • <math.h>头文件的部分功能也可用于__half、__nv_bfloat16和__float128/_Float128类型。这些函数的名称与 C 标准库中的函数名称相似。

    • __half和__nv_bfloat16类型分别需要<cuda_fp16.h>和<cuda_bf16.h>头文件。它们在主机代码和设备代码中的可用性按各个函数分别定义。

    • 对__float128/_Float128类型的支持取决于主机编译器和设备计算能力,请参阅支持的浮点类型表。相关函数需要crt/device_fp128_functions.h头文件,并且仅在设备代码中可用。

  • 它们在主机代码与设备代码中的准确度可能不同。

非标准 CUDA 数学函数(CUDA Math API):

  • 提供不属于 C/C++ 标准库的数学功能。

  • 主要支持单精度和双精度类型,分别为float和double。

    • 它们在主机代码和设备代码中的可用性按各个函数分别定义。

    • 它们不需要额外的头文件。

    • 它们在主机代码与设备代码中的准确度可能不同。

  • 仅有有限的一组函数支持__nv_bfloat16、__half和__float128/_Float128。

    • __half和__nv_bfloat16类型分别需要<cuda_fp16.h>和<cuda_bf16.h>头文件。

    • 对__float128/_Float128类型的支持取决于主机编译器和设备计算能力,请参阅支持的浮点类型表。相关函数需要crt/device_fp128_functions.h头文件。

    • 它们仅在设备代码中可用。

  • 它们的行为受nvcc优化标志的影响。

数学内建函数(CUDA Math API):

  • 支持单精度和双精度类型,分别为float和double。

  • 它们仅在设备代码中可用。

  • 它们比相应的CUDA Math API 函数更快,但准确度更低。

  • 它们的行为不受nvcc浮点优化标志-prec-div=false、-prec-sqrt=false和-fmad=true的影响。唯一的例外是-ftz=true,它也包含在-use_fast_math中。

表 46 数学功能特性汇总

功能

支持的类型

主机

设备

受浮点优化标志
的影响(仅适用于float和double)

C/C++ 语言内置算术运算符

float, double, __half, __nv_bfloat16, __float128/_Float128, cuda::std::complex

✅

✅

✅

CUDA C++ Standard Library 数学函数

float, double, __half, __nv_bfloat16, __float128, cuda::std::complex

✅

✅

✅

__nv_fp8_e4m3, __nv_fp8_e5m2, __nv_fp8_e8m0, __nv_fp6_e2m3, __nv_fp6_e3m2, __nv_fp4_e2m1 *

CUDA C Standard Library 数学函数

float, double

✅

✅

✅

__nv_bfloat16、__half,支持有限,名称相似

按各个函数分别确定

__float128/_Float128,支持有限,名称相似

❌

✅

非标准 CUDA 数学函数

float, double

按各个函数分别确定

✅

__nv_bfloat16、__half、__float128/_Float128,支持有限

❌

✅

内建函数

float, double

❌

✅

仅受-ftz=true影响,该标志也包含在-use_fast_math中

*CUDA C++ Standard Library 函数支持对小型浮点类型进行查询,例如numeric_limits<T>、fpclassify()、isfinite()、isnormal()、isinf()和isnan()。

以下各节在适用时提供其中一些函数的准确度信息,使用 ULP 进行量化。有关最低有效位单位(ULP)定义的更多信息,请参阅 Jean-Michel Muller 的论文论 ulp(x) 的定义。


5.5.6. 内置算术运算符

用于单精度、双精度和四倍精度类型的 C/C++ 语言内置运算符,例如x + y、x - y、x * y、x / y、x++、x--以及倒数1 / x,均符合 IEEE-754 标准。使用舍入到最近值,中间值舍入到偶数的舍入模式时,它们保证最大 ULP 误差为零。它们在主机代码和设备代码中均可用。

nvcc编译标志-fmad=true(也包含在--use_fast_math中)允许将浮点乘法和加法/减法收缩为浮点乘加运算,并对单精度类型float的最大 ULP 误差产生以下影响:

nvcc编译标志-prec-div=false(也包含在--use_fast_math中)对单精度类型float的除法运算符/的最大 ULP 误差有如下影响:


5.5.7. CUDA C++ 数学标准库函数

CUDA 通过cuda::std::命名空间全面支持C++ 标准库数学函数。这些功能是<cuda/std/cmath>头文件的一部分。它们在主机代码和设备代码中均可用。

以下各节说明与CUDA 数学 API的映射关系,以及各函数在设备上执行时的误差界限。

  • 最大 ULP 误差是指函数返回值与按照舍入到最近值、中间值取偶数的舍入模式得到的相应精度的正确舍入结果之间,以 ULP 为单位的差值的绝对值的最大观测值。

  • 这些误差界限来自广泛但并非穷尽的测试。因此,不保证这些界限。

5.5.7.1. 基本运算

用于基本运算的CUDA 数学 API在主机代码和设备代码中均可用,__float128除外。

以下所有函数的最大 ULP 误差均为零。

表 47 C++ 数学标准库函数
C 数学 API 映射
基本运算

cuda::std函数

含义

__nv_bfloat16

__half

float

double

__float128

fabs(x)

∣x∣|x|

__habs(x)

__habs(x)

fabsf(x)

fabs(x)

__nv_fp128_fabs(x)

fmod(x, y)

xy\dfrac{x}{y}的余数,计算方式为x−trunc(xy)⋅yx - \mathrm{trunc}\left(\dfrac{x}{y}\right) \cdot y

N/A

N/A

fmodf(x, y)

fmod(x, y)

__nv_fp128_fmod(x, y)

remainder(x, y)

xy\dfrac{x}{y}的余数,计算方式为x−rint(xy)⋅yx - \mathrm{rint}\left(\dfrac{x}{y}\right) \cdot y

N/A

N/A

remainderf(x, y)

remainder(x, y)

__nv_fp128_remainder(x, y)

remquo(x, y, iptr)

xy\dfrac{x}{y}的余数和商

N/A

N/A

remquof(x, y, iptr)

remquo(x, y, iptr)

N/A

fma(x, y, z)

x⋅y+zx \cdot y + z

__hfma(x, y, z),仅限设备

__hfma(x, y, z),仅限设备

fmaf(x, y, z)

fma(x, y, z)

__nv_fp128_fma(x, y, z)

fmax(x, y)

max⁡(x,y)\max(x, y)

__hmax(x, y)

__hmax(x, y)

fmaxf(x, y)

fmax(x, y)

__nv_fp128_fmax(x, y)

fmin(x, y)

min⁡(x,y)\min(x, y)

__hmin(x, y)

__hmin(x, y)

fminf(x, y)

fmin(x, y)

__nv_fp128_fmin(x, y)

fdim(x, y)

max⁡(x−y,0)\max(x-y, 0)

N/A

N/A

fdimf(x, y)

fdim(x, y)

__nv_fp128_fdim(x, y)

nan(str)

由字符串表示得到的 NaN 值

N/A

N/A

nanf(str)

nan(str)

N/A

* 标记为“N/A”的数学函数不原生支持 CUDA 扩展浮点类型,例如 __half 和 __nv_bfloat16。在这些情况下,通过转换为 float 类型,然后将结果转换回原类型来模拟这些函数。

5.5.7.2. 指数函数

用于指数函数的CUDA 数学 API仅对float和double类型在主机代码和设备代码中均可用。

表 48 C++ 数学标准库函数
C 数学 API 映射及精度(最大 ULP)
指数函数

cuda::std函数

含义

__nv_bfloat16

__half

float

double

__float128


exp(x)


exe^x

hexp(x)

0 ULP

hexp(x)

0 ULP

expf(x)

2 ULP

exp(x)

1 ULP

__nv_fp128_exp(x)

1 ULP


exp2(x)


2x2^x

hexp2(x)

0 ULP

hexp2(x)

0 ULP

exp2f(x)

2 ULP

exp2(x)

1 ULP

__nv_fp128_exp2(x)

1 ULP


expm1(x)


ex−1e^x - 1


N/A


N/A

expm1f(x)

1 ULP

expm1(x)

1 ULP

__nv_fp128_expm1(x)

1 ULP


log(x)


ln⁡(x)\ln(x)

hlog(x)

0 ULP

hlog(x)

0 ULP

logf(x)

1 ULP

log(x)

1 ULP

__nv_fp128_log(x)

1 ULP


log10(x)


log⁡10(x)\log_{10}(x)

hlog10(x)

0 ULP

hlog10(x)

0 ULP

log10f(x)

2 ULP

log10(x)

1 ULP

__nv_fp128_log10(x)

1 ULP


log2(x)


log⁡2(x)\log_2(x)

hlog2(x)

0 ULP

hlog2(x)

0 ULP

log2f(x)

1 ULP

log2(x)

1 ULP

__nv_fp128_log2(x)

1 ULP


log1p(x)


ln⁡(1+x)\ln(1+x)


N/A


N/A

log1pf(x)

1 ULP

log1p(x)

1 ULP

__nv_fp128_log1p(x)

1 ULP

* 标记为“N/A”的数学函数不原生支持 CUDA 扩展浮点类型,例如 __half 和 __nv_bfloat16。在这些情况下,通过转换为 float 类型,然后将结果转换回原类型来模拟这些函数。

5.5.7.3. 幂函数

用于幂函数的CUDA 数学 API仅对float和double类型在主机代码和设备代码中均可用。

表 49 C++ 数学标准库函数
C 数学 API 映射及精度(最大 ULP)
幂函数

cuda::std函数

含义

__nv_bfloat16

__half

float

double

__float128


pow(x, y)


xyx^y


N/A


N/A

powf(x, y)

4 ULP

pow(x, y)

2 ULP

__nv_fp128_pow(x, y)

1 ULP


sqrt(x)


x\sqrt{x}

hsqrt(x)

0 ULP

hsqrt(x)

0 ULP

sqrtf(x)

▪ 0 ULP
▪ 使用--use_fast_math时为 1 ULP

sqrt(x)

0 ULP

__nv_fp128_sqrt(x)

0 ULP


cbrt(x)


x3\sqrt[3]{x}


N/A


N/A

cbrtf(x)

1 ULP

cbrt(x)

1 ULP


N/A


hypot(x, y)


x2+y2\sqrt{x^2 + y^2}


N/A


N/A

hypotf(x, y)

3 ULP

hypot(x, y)

2 ULP

__nv_fp128_hypot(x, y)

1 ULP

* 标记为“N/A”的数学函数不原生支持 CUDA 扩展浮点类型,例如 __half 和 __nv_bfloat16。在这些情况下,通过转换为 float 类型,然后将结果转换回原类型来模拟这些函数。

5.5.7.4. 三角函数

用于三角函数的CUDA 数学 API仅对float和double类型在主机代码和设备代码中均可用。

表 50 C++ 数学标准库函数
C 数学 API 映射及精度(最大 ULP)
三角函数

cuda::std函数

含义

__nv_bfloat16

__half

float

double

__float128


sin(x)


sin⁡(x)\sin(x)

hsin(x)

0 ULP

hsin(x)

0 ULP

sinf(x)

2 ULP

sin(x)

2 ULP

__nv_fp128_sin(x)

1 ULP


cos(x)


cos⁡(x)\cos(x)

hcos(x)

0 ULP

hcos(x)

0 ULP

cosf(x)

2 ULP

cos(x)

2 ULP

__nv_fp128_cos(x)

1 ULP


tan(x)


tan⁡(x)\tan(x)


N/A


N/A

tanf(x)

4 ULP

tan(x)

2 ULP

__nv_fp128_tan(x)

1 ULP


asin(x)


sin⁡−1(x)\sin^{-1}(x)


N/A


N/A

asinf(x)

2 ULP

asin(x)

2 ULP

__nv_fp128_asin(x)

1 ULP


acos(x)


cos⁡−1(x)\cos^{-1}(x)


N/A


N/A

acosf(x)

2 ULP

acos(x)

2 ULP

__nv_fp128_acos(x)

1 ULP


atan(x)


tan⁡−1(x)\tan^{-1}(x)


N/A


N/A

atanf(x)

2 ULP

atan(x)

2 ULP

__nv_fp128_atan(x)

1 ULP


atan2(y, x)


tan⁡−1(yx)\tan^{-1}\left(\dfrac{y}{x}\right)


N/A


N/A

atan2f(y, x)

3 ULP

atan2(y, x)

2 ULP


N/A

* 标记为“N/A”的数学函数不原生支持 CUDA 扩展浮点类型,例如 __half 和 __nv_bfloat16。在这些情况下,通过转换为 float 类型,然后将结果转换回原类型来模拟这些函数。

5.5.7.5. 双曲函数

用于双曲函数的CUDA 数学 API仅对float和double类型在主机代码和设备代码中均可用。

表 51 C++ 数学标准库函数
C 数学 API 映射及精度(最大 ULP)
双曲函数

cuda::std函数

含义

__nv_bfloat16

__half

float

double

__float128


sinh(x)


sinh⁡(x)\sinh(x)


N/A


N/A

sinhf(x)

3 ULP

sinh(x)

2 ULP

__nv_fp128_sinh(x)

1 ULP


cosh(x)


cosh⁡(x)\cosh(x)


N/A


N/A

coshf(x)

2 ULP

cosh(x)

1 ULP

__nv_fp128_cosh(x)

1 ULP


tanh(x)


tanh⁡(x)\tanh(x)

htanh(x)

0 ULP

htanh(x)

0 ULP

tanhf(x)

2 ULP

tanh(x)

1 ULP

__nv_fp128_tanh(x)

1 ULP


asinh(x)


sinh⁡−1(x)\operatorname{sinh}^{-1}(x)


N/A


N/A

asinhf(x)

3 ULP

asinh(x)

3 ULP

__nv_fp128_asinh(x)

1 ULP


acosh(x)


cosh⁡−1(x)\operatorname{cosh}^{-1}(x)


N/A


N/A

acoshf(x)

4 ULP

acosh(x)

3 ULP

__nv_fp128_acosh(x)

1 ULP


atanh(x)


tanh⁡−1(x)\operatorname{tanh}^{-1}(x)


N/A


N/A

atanhf(x)

3 ULP

atanh(x)

2 ULP

__nv_fp128_atanh(x)

1 ULP

* 标记为“N/A”的数学函数不原生支持 CUDA 扩展浮点类型,例如 __half 和 __nv_bfloat16。在这些情况下,通过转换为 float 类型,然后将结果转换回原类型来模拟这些函数。

5.5.7.6. 误差函数与伽马函数

用于误差函数与伽马函数的CUDA 数学 API对于float和double类型,在主机代码和设备代码中均可用。

误差函数与伽马函数不原生支持 CUDA 扩展浮点类型,例如__half和__nv_bfloat16。在这些情况下,通过转换为float类型,然后将结果转换回原类型来模拟这些函数。

表 52 C++ 数学标准库函数
C 数学 API 映射及精度(最大 ULP)
误差函数与伽马函数

cuda::std函数

含义

float

double


erf(x)


2π∫0xe−t2dt\dfrac{2}{\sqrt{\pi}} \int_0^x e^{-t^2} dt

erff(x)

2 ULP

erf(x)

2 ULP


erfc(x)


1−erf(x)1 - \mathrm{erf}(x)

erfcf(x)

4 ULP

erfc(x)

5 ULP


tgamma(x)


Γ(x)\Gamma(x)

tgammaf(x)

5 ULP

tgamma(x)

10 ULP


lgamma(x)


ln⁡∣Γ(x)∣\ln |\Gamma(x)|

lgammaf(x)

▪ 当x∉[−10.001,−2.264]x \notin [-10.001, -2.264]时为 6 ULP
▪ 其他情况下更大

lgamma(x)

▪ 当x∉[−23.0001,−2.2637]x \notin [-23.0001, -2.2637]时为 4 ULP
▪ 其他情况下更大

5.5.7.7. 舍入至最近整数的浮点运算

用于舍入至最近整数的浮点运算的CUDA 数学 API仅对float和double类型在主机代码和设备代码中均可用。

以下所有函数的最大 ULP 误差均为零。

表 53 C++ 数学标准库函数
C 数学 API 映射
舍入至最近整数的浮点运算

cuda::std函数

含义

__nv_bfloat16

__half

float

double

__float128

ceil(x)

⌈x⌉\lceil x \rceil

hceil(x)

hceil(x)

ceilf(x)

ceil(x)

__nv_fp128_ceil(x)

floor(x)

⌊x⌋\lfloor x \rfloor

hfloor(x)

hfloor(x)

floorf(x)

floor(x)

__nv_fp128_floor(x)

trunc(x)

截断为整数

htrunc(x)

htrunc(x)

truncf(x)

trunc(x)

__nv_fp128_trunc(x)

round(x)

舍入到最近的整数,中间值向远离零的方向舍入

N/A

N/A

roundf(x)

round(x)

__nv_fp128_round(x)

nearbyint(x)

舍入到最近的整数,中间值取偶数

N/A

N/A

nearbyintf(x)

nearbyint(x)

N/A

rint(x)

舍入到最近的整数,中间值取偶数

hrint(x)

hrint(x)

rintf(x)

rint(x)

__nv_fp128_rint(x)

lrint(x)

舍入到最近的整数,中间值取偶数(返回long int)

N/A

N/A

lrintf(x)

lrint(x)

N/A

llrint(x)

舍入到最近的整数,中间值取偶数(返回long long int)

N/A

N/A

llrintf(x)

llrint(x)

N/A

lround(x)

舍入到最近的整数,中间值向远离零的方向舍入(返回long int)

N/A

N/A

lroundf(x)

lround(x)

N/A

llround(x)

舍入到最近的整数,中间值向远离零的方向舍入(返回long long int)

N/A

N/A

llroundf(x)

llround(x)

N/A

* 标记为“N/A”的数学函数不原生支持 CUDA 扩展浮点类型,例如 __half 和 __nv_bfloat16。在这些情况下,通过转换为 float 类型,然后将结果转换回原类型来模拟这些函数。

性能注意事项

将单精度或双精度浮点操作数舍入为整数的推荐方法是使用函数rintf()和rint(),而不是roundf()和round()。这是因为roundf()和round()在设备代码中映射为多条指令,而rintf()和rint()映射为一条指令。truncf()、trunc()、ceilf()、ceil()、floorf()和floor()也各自映射为一条指令。

5.5.7.8. 浮点数处理函数

用于浮点数处理函数的CUDA 数学 API在主机代码和设备代码中均可用,__float128除外。

浮点数处理函数不原生支持 CUDA 扩展浮点类型,例如__half和__nv_bfloat16。在这些情况下,通过转换为float类型,然后将结果转换回原类型来模拟这些函数。

以下所有函数的最大 ULP 误差均为零。

表 54 C++ 数学标准库函数
C 数学 API 映射
浮点数处理函数

cuda::std函数

含义

float

double

__float128

frexp(x, exp)

提取尾数和指数

frexpf(x, exp)

frexp(x, exp)

__nv_fp128_frexp(x, nptr)

ldexp(x, n)

x⋅2nx \cdot 2^{\mathrm{n}}

ldexpf(x, n)

ldexp(x, n)

__nv_fp128_ldexp(x, n)

modf(x, iptr)

提取整数部分和小数部分

modff(x, iptr)

modf(x, iptr)

__nv_fp128_modf(x, iptr)

scalbn(x, n)

x⋅2nx \cdot 2^n

scalbnf(x, n)

scalbn(x, n)

N/A

scalbln(x, n)

x⋅2nx \cdot 2^n

scalblnf(x, n)

scalbln(x, n)

N/A

ilogb(x)

⌊log⁡2(∣x∣)⌋\lfloor \log_2(|x|) \rfloor

ilogbf(x)

ilogb(x)

__nv_fp128_ilogb(x)

logb(x)

⌊log⁡2(∣x∣)⌋\lfloor \log_2(|x|) \rfloor

logbf(x)

logb(x)

N/A

nextafter(x, y)

朝yy方向的下一个可表示值

nextafterf(x, y)

nextafter(x, y)

N/A

copysign(x, y)

将yy的符号复制到xx

copysignf(x, y)

copysign(x, y)

__nv_fp128_copysign(x, y)

5.5.7.9. 分类与比较

用于分类与比较函数的CUDA 数学 API在主机代码和设备代码中均可用,__float128除外。

以下所有函数的最大 ULP 误差均为零。

表 55 C++ 数学标准库函数
C 数学 API 映射
分类与比较函数

cuda::std函数

含义

__nv_bfloat16

__half

float

double

__float128

fpclassify(x)

对xx进行分类

N/A

N/A

N/A

N/A

N/A

isfinite(x)

检查xx是否为有限值

N/A

N/A

isfinite(x)

isfinite(x)

N/A

isinf(x)

检查xx是否为无穷值

__hisinf(x)

__hisinf(x)

isinf(x)

isinf(x)

N/A

isnan(x)

检查xx是否为 NaN

__hisnan(x)

__hisnan(x)

isnan(x)

isnan(x)

__nv_fp128_isnan(x)

isnormal(x)

检查xx是否为正规数

N/A

N/A

N/A

N/A

N/A

signbit(x)

检查符号位是否已置位

N/A

N/A

signbit(x)

signbit(x)

N/A

isgreater(x, y)

检查是否x>yx > y

__hgt(x, y)

__hgt(x, y)

N/A

N/A

N/A

isgreaterequal(x, y)

检查是否x≥yx \geq y

__hge(x, y)

__hge(x, y)

N/A

N/A

N/A

isless(x, y)

检查是否x<yx < y

__hlt(x, y)

__hlt(x, y)

N/A

N/A

N/A

islessequal(x, y)

检查是否x≤yx \leq y

__hle(x, y)

__hle(x, y)

N/A

N/A

N/A

islessgreater(x, y)

检查是否x<yx < y或x>yx > y

__hne(x, y)

__hne(x, y)

N/A

N/A

N/A

isunordered(x, y)

检查xx、yy或两者是否为 NaN

N/A

N/A

N/A

N/A

__nv_fp128_isunordered(x, y)

* 标记为“N/A”的数学函数不原生支持 __half 和 __nv_bfloat16 等 CUDA 扩展浮点类型。

5.5.8. 非标准 CUDA 数学函数

CUDA 提供了不属于 C/C++ 标准库、而是作为扩展提供的数学函数。对于单精度和双精度函数,其在主机和设备代码中的可用性按函数分别定义。

本节规定了各函数在设备上执行时的误差界限。

  • 最大 ULP 误差表示函数返回值与按照舍入到最近值、相等时取偶数的舍入模式获得的相应精度的正确舍入结果之间,以 ULP 为单位的差值的绝对值的最大观测值。

  • 误差界限来自广泛但并非穷尽的测试。因此,这些界限不受保证。

表 56 非标准 CUDA 数学函数
float和double
的映射与精度(最大 ULP)

含义

float

double

xy\dfrac{x}{y}

fdividef(x, y),仅限设备

0 ULP,与x / y相同


N/A


10x10^x

exp10f(x)

2 ULP

exp10(x)

1 ULP


x2+y2+z2\sqrt{x^2 + y^2 + z^2}

norm3df(x, y, z),仅限设备

3 ULP

norm3d(x, y, z),仅限设备

2 ULP


x2+y2+z2+t2\sqrt{x^2 + y^2 + z^2 + t^2}

norm4df(x, y, z, t),仅限设备

3 ULP

norm4d(x, y, z, t),仅限设备

2 ULP


∑i=0dim−1pi2\sqrt{\sum_{i=0}^{\mathrm{dim}-1} p_i^{2}}

normf(dim, p),仅限设备

无法提供误差界限,因为所使用的快速算法会因舍入而损失精度

norm(dim, p),仅限设备

无法提供误差界限,因为所使用的快速算法会因舍入而损失精度

1x\dfrac{1}{\sqrt{x}}

rsqrtf(x)

2 ULP

rsqrt(x)

1 ULP

1x3\dfrac{1}{\sqrt[3]{x}}

rcbrtf(x)

1 ULP

rcbrt(x)

1 ULP

1x2+y2\dfrac{1}{\sqrt{x^2 + y^2}}

rhypotf(x, y),仅限设备

2 ULP

rhypot(x, y),仅限设备

1 ULP

1x2+y2+z2\dfrac{1}{\sqrt{x^2 + y^2 + z^2}}

rnorm3df(x, y, z),仅限设备

2 ULP

rnorm3d(x, y, z),仅限设备

1 ULP

1x2+y2+z2+t2\dfrac{1}{\sqrt{x^2 + y^2 + z^2 + t^2}}

rnorm4df(x, y, z, t),仅限设备

2 ULP

rnorm4d(x, y, z, t),仅限设备

1 ULP


1∑i=0dim−1pi2\dfrac{1}{\sqrt{\sum_{i=0}^{\mathrm{dim}-1} p_i^{2}}}

rnormf(dim, p),仅限设备

无法提供误差界限,因为所使用的快速算法会因舍入而损失精度

rnorm(dim, p),仅限设备

无法提供误差界限,因为所使用的快速算法会因舍入而损失精度


cos⁡(πx)\cos(\pi x)

cospif(x)

1 ULP

cospi(x)

2 ULP


sin⁡(πx)\sin(\pi x)

sinpif(x)

1 ULP

sinpi(x)

2 ULP


sin⁡(πx),cos⁡(πx)\sin(\pi x), \cos(\pi x)

sincospif(x, sptr, cptr)

1 ULP

sincospi(x, sptr, cptr)

2 ULP


Φ(x)\Phi(x)

normcdff(x)

5 ULP

normcdf(x)

5 ULP


Φ−1(x)\Phi^{-1}(x)

normcdfinvf(x)

5 ULP

normcdfinv(x)

8 ULP


erfc−1(x)\mathrm{erfc}^{-1}(x)

erfcinvf(x)

4 ULP

erfcinv(x)

6 ULP


ex2erfc(x)e^{x^2}\mathrm{erfc}(x)

erfcxf(x)

4 ULP

erfcx(x)

4 ULP


erf−1(x)\mathrm{erf}^{-1}(x)

erfinvf(x)

2 ULP

erfinv(x)

5 ULP


I0(x)I_0(x)

cyl_bessel_i0f(x),仅限设备

6 ULP

cyl_bessel_i0(x),仅限设备

6 ULP


I1(x)I_1(x)

cyl_bessel_i1f(x),仅限设备

6 ULP

cyl_bessel_i1(x),仅限设备

6 ULP


J0(x)J_0(x)

j0f(x)

▪ 当∣x∣<8|x| < 8时为 9 ULP
▪ 否则,最大绝对误差=2.2⋅10−6= 2.2 \cdot 10^{-6}

j0(x)

▪ 当∣x∣<8|x| < 8时为 7 ULP
▪ 否则,最大绝对误差=5⋅10−12= 5 \cdot 10^{-12}


J1(x)J_1(x)

j1f(x)

▪ 当∣x∣<8|x| < 8时为 9 ULP
▪ 否则,最大绝对误差=2.2⋅10−6= 2.2 \cdot 10^{-6}

j1(x)

▪ 当∣x∣<8|x| < 8时为 7 ULP
▪ 否则,最大绝对误差=5⋅10−12= 5 \cdot 10^{-12}


Jn(x)J_n(x)

jnf(n, x)

当n=128n = 128时,最大绝对误差=2.2⋅10−6= 2.2 \cdot 10^{-6}

jn(n, x)

当n=128n = 128时,最大绝对误差=5⋅10−12= 5 \cdot 10^{-12}


Y0(x)Y_0(x)

y0f(x)

▪ 当∣x∣<8|x| < 8时为 9 ULP
▪ 否则,最大绝对误差=2.2⋅10−6= 2.2 \cdot 10^{-6}

y0(x)

▪ 当∣x∣<8|x| < 8时为 7 ULP
▪ 否则,最大绝对误差=5⋅10−12= 5 \cdot 10^{-12}


Y1(x)Y_1(x)

y1f(x)

▪ 当∣x∣<8|x| < 8时为 9 ULP
▪ 否则,最大绝对误差=2.2⋅10−6= 2.2 \cdot 10^{-6}

y1(x)

▪ 当∣x∣<8|x| < 8时为 7 ULP
▪ 否则,最大绝对误差=5⋅10−12= 5 \cdot 10^{-12}


Yn(x)Y_n(x)

ynf(n, x)

▪ 当∣x∣<n|x| < n时为⌈2+2.5n⌉\lceil 2 + 2.5n \rceil
▪ 否则,最大绝对误差=2.2⋅10−6= 2.2 \cdot 10^{-6}

yn(n, x)

当∣x∣>1.5n|x| > 1.5n时,最大绝对误差=5⋅10−12= 5 \cdot 10^{-12}

用于__half、__nv_bfloat16和__float128/_Float128的非标准 CUDA 数学函数仅在设备代码中可用。

表 57 非标准 CUDA 数学函数
__nv_bfloat16、__half、__float128/_Float128
的映射与精度(最大 ULP)

含义

__nv_bfloat16

__half

__float128/_Float128

1x\dfrac{1}{x}

hrcp(x)

0 ULP

hrcp(x)

0 ULP


N/A


10x10^x

hexp10(x)

0 ULP

hexp10(x)

0 ULP

__nv_fp128_exp10(x)

1 ULP

1x\dfrac{1}{\sqrt{x}}

hrsqrt(x)

0 ULP

hrsqrt(x)

0 ULP


N/A


tanh⁡(x)\tanh(x)(近似)

htanh_approx(x)

1 ULP

htanh_approx(x)

1 ULP


N/A

5.5.9. 内建函数

数学内建函数是其对应的CUDA C 标准库数学函数的速度更快、精度更低的版本。

  • 它们使用相同的名称,并加上__前缀,例如__sinf(x)。

  • 它们仅在设备代码中可用。

  • 它们速度更快,因为它们映射到更少的原生指令。

  • 标志--use_fast_math会自动将相应的CUDA Math API 函数转换为内建函数。受影响函数的完整列表请参见–use_fast_math 的影响一节。

5.5.9.1. 基本内建函数

一部分数学内建函数允许指定舍入模式:

  • 带有_rn后缀的函数使用舍入到最近值、相等时取偶数的舍入模式进行运算。

  • 带有_rz后缀的函数使用向零舍入的舍入模式进行运算。

  • 带有_ru后缀的函数使用向上舍入(朝正无穷方向)的舍入模式进行运算。

  • 带有_rd后缀的函数使用向下舍入(朝负无穷方向)的舍入模式进行运算。

__fadd_[rn,rz,ru,rd]()、__dadd_[rn,rz,ru,rd]()、__fmul_[rn,rz,ru,rd]()和__dmul_[rn,rz,ru,rd]()函数映射到加法和乘法运算,编译器绝不会将这些运算合并为FFMA或DFMA指令。相比之下,由*和+运算符生成的加法和乘法通常会合并为FFMA或DFMA。

下表列出了单精度和双精度浮点内建函数。它们的最大 ULP 误差均为 0,并且均符合 IEEE 标准。

5.5.9.2. 仅限单精度的内建函数

下表列出了单精度浮点内建函数及其最大 ULP 误差。

  • 最大 ULP 误差表示函数返回值与按照舍入到最近值、相等时取偶数的舍入模式获得的相应精度的正确舍入结果之间,以 ULP 为单位的差值的绝对值的最大观测值。

  • 误差界限来自广泛但并非穷尽的测试。因此,这些界限不受保证。

表 59 仅限单精度的浮点内建函数
的映射与精度(最大 ULP)

函数

含义

最大 ULP 误差

__fdividef(x, y)

xy\dfrac{x}{y}

当∣y∣∈[2−126,2126]|y| \in [2^{-126}, 2^{126}]时为22

__frsqrt_rn(x)

1x\dfrac{1}{\sqrt{x}}

0 ULP

__expf(x)

exe^x

2+⌊∣1.173⋅x∣⌋2 + \lfloor |1.173 \cdot x| \rfloor

__exp10f(x)

10x10^x

2+⌊∣2.97⋅x∣⌋2 + \lfloor |2.97 \cdot x| \rfloor

__powf(x, y)

xyx^y

由exp2f(y * __log2f(x))推导得出

__logf(x)

ln⁡(x)\ln(x)

▪ 当x∈[0.5,2]x \in [0.5, 2]时,绝对误差为2−21.412^{-21.41}
▪ 否则为 3 ULP

__log2f(x)

log⁡2(x)\log_2(x)

▪ 当x∈[0.5,2]x \in [0.5, 2]时,绝对误差为2−222^{-22}
▪ 否则为 2 ULP

__log10f(x)

log⁡10(x)\log_{10}(x)

▪ 当x∈[0.5,2]x \in [0.5, 2]时,绝对误差为2−242^{-24}
▪ 否则为 3 ULP

__sinf(x)

sin⁡(x)\sin(x)

▪ 当x∈[−π,π]x \in [-\pi, \pi]时,绝对误差为2−21.412^{-21.41}
▪ 否则更大

__cosf(x)

cos⁡(x)\cos(x)

▪ 当x∈[−π,π]x \in [-\pi, \pi]时,绝对误差为2−21.412^{-21.41}
▪ 否则更大

__sincosf(x, sptr, cptr)

sin⁡(x),cos⁡(x)\sin(x), \cos(x)

按分量分别与__sinf(x)和__cosf(x)相同

__tanf(x)

tan⁡(x)\tan(x)

由__sinf(x) * (1 / __cosf(x))推导得出

__tanhf(x)

tanh⁡(x)\tanh(x)

▪ 最大相对误差:2−112^{-11}
▪ 即使使用-ftz=true编译器标志,次正规结果也不会被冲刷为零。

5.5.9.3. --use_fast_math的影响

nvcc编译器标志--use_fast_math会将在设备代码中调用的一部分CUDA Math API 函数转换为对应的内建函数。注意,CUDA C++ 标准库函数也受此标志影响。有关使用内建函数代替 CUDA Math API 函数所产生影响的更多详细信息,请参见内建函数一节。

一种更稳健的方法是有选择地将数学函数调用替换为内建版本,仅在性能提升值得这样做,并且可以接受精度降低和特殊情况处理方式不同等属性变化的地方进行替换。

5.5.10. 参考文献

  1. IEEE 754-2019 浮点算术标准。

  2. Jean-Michel Muller。关于 ulp(x) 的定义。INRIA/LIP 研究报告,2005 年。

  3. Nathan Whitehead、Alex Fit-Florea。精度与性能:NVIDIA GPU 的浮点运算及 IEEE 754 合规性。Nvidia 报告,2011 年。

  4. David Goldberg。每位计算机科学家都应了解的浮点算术知识。《ACM 计算综述》,1991 年 3 月。

  5. David Monniaux。验证浮点计算时的陷阱。《ACM 编程语言与系统汇刊》,2008 年 5 月。

  6. Peter Dinda、Conor Hetland。开发者理解 IEEE 浮点数吗?。IEEE 国际并行与分布式处理研讨会(IPDPS),2018 年。

5.6. 设备可调用的 API 和内建函数

本章包含可从 CUDA 核函数和设备代码中调用的 API 和内建函数的参考资料及 API 文档。

5.6.1. 内存屏障原语接口

原语 API 是用于访问cuda::barrier功能的类 C 接口。通过包含<cuda_awbarrier_primitives.h>头文件即可使用这些原语。

5.6.1.1. 数据类型

typedef /* implementation defined */ __mbarrier_t;
typedef /* implementation defined */ __mbarrier_token_t;

5.6.1.2. 内存屏障原语 API

uint32_t __mbarrier_maximum_count();
void __mbarrier_init(__mbarrier_t* bar, uint32_t expected_count);
  • bar必须是指向__shared__内存的指针。

  • expected_count <= __mbarrier_maximum_count()

  • 将*bar当前阶段和下一阶段的预期到达计数初始化为expected_count。

void __mbarrier_inval(__mbarrier_t* bar);
  • bar必须是指向驻留在共享内存中的屏障对象的指针。

  • 在将相应共享内存用于其他用途之前,必须使*bar失效。

__mbarrier_token_t __mbarrier_arrive(__mbarrier_t* bar);
  • *bar的初始化必须先发生于此调用。

  • 待到达计数不得为零。

  • 以原子方式递减屏障当前阶段的待到达计数。

  • 返回一个与递减操作紧前的屏障状态关联的到达令牌。

__mbarrier_token_t __mbarrier_arrive_and_drop(__mbarrier_t* bar);
  • *bar的初始化必须先发生于此调用。

  • 待到达计数不得为零。

  • 以原子方式递减屏障当前阶段的待到达计数和下一阶段的预期计数。

  • 返回一个与递减操作紧前的屏障状态关联的到达令牌。

bool __mbarrier_test_wait(__mbarrier_t* bar, __mbarrier_token_t token);
  • token必须与*bar的紧前阶段或当前阶段关联。

  • 如果token与*bar的紧前阶段关联,则返回true,否则返回false。

bool __mbarrier_test_wait_parity(__mbarrier_t* bar, bool phase_parity);
  • phase_parity必须指示*bar的当前阶段或紧前阶段的奇偶性。值true对应编号为奇数的阶段,值false对应编号为偶数的阶段。

  • 如果phase_parity指示*bar的紧前阶段的整数奇偶性,则返回true,否则返回false。

bool __mbarrier_try_wait(__mbarrier_t* bar, __mbarrier_token_t token, uint32_t max_sleep_nanosec);
  • token必须与*bar的紧前阶段或当前阶段关联。

  • 如果token与*bar的紧前阶段关联,则返回true。否则,正在执行的线程可能被挂起。挂起的线程会在指定阶段完成时恢复执行(返回true),或者在该阶段完成之前经过一段取决于系统的时限后恢复执行(返回false)。

  • max_sleep_nanosec指定以纳秒为单位的时限,该时限可用于替代取决于系统的时限。

bool __mbarrier_try_wait_parity(__mbarrier_t* bar, bool phase_parity, uint32_t max_sleep_nanosec);
  • phase_parity必须指示*bar的当前阶段或紧前阶段的奇偶性。值true对应编号为奇数的阶段,值false对应编号为偶数的阶段。

  • 如果phase_parity指示*bar的紧前阶段的整数奇偶性,则返回true。否则,正在执行的线程可能被挂起。挂起的线程会在指定阶段完成时恢复执行(返回true),或者在该阶段完成之前经过一段取决于系统的时限后恢复执行(返回false)。

  • max_sleep_nanosec指定以纳秒为单位的时限,该时限可用于替代取决于系统的时限。

5.6.2. 流水线原语接口

流水线原语为<cuda/pipeline>中提供的功能提供了类似 C 的接口。包含<cuda_pipeline.h>头文件即可使用流水线原语接口。在不兼容 ISO C++ 2011 的模式下编译时,请包含<cuda_pipeline_primitives.h>头文件。

注意

流水线原语 API 仅支持跟踪满足特定大小和对齐要求的从全局内存到共享内存的异步复制。它提供的功能等同于具有cuda::thread_scope_thread的cuda::pipeline对象。

5.6.2.1. memcpy_async原语

void __pipeline_memcpy_async(void* __restrict__ dst_shared,
                             const void* __restrict__ src_global,
                             size_t size_and_align,
                             size_t zfill=0);
  • 请求提交以下操作以进行异步求值:

    size_t i = 0;
    for (; i < size_and_align - zfill; ++i) ((char*)dst_shared)[i] = ((char*)src_global)[i]; /* copy */
    for (; i < size_and_align; ++i) ((char*)dst_shared)[i] = 0; /* zero-fill */
    
  • 要求:

    • dst_shared必须是指向memcpy_async的共享内存目标位置的指针。

    • src_global必须是指向memcpy_async的全局内存源位置的指针。

    • size_and_align必须为 4、8 或 16。

    • zfill <= size_and_align.

    • size_and_align必须是dst_shared和src_global的对齐值。

  • 任何线程在等待memcpy_async操作完成之前修改源内存或观察目标内存,都会构成竞争条件。在提交memcpy_async操作与等待其完成之间,以下任何行为都会引入竞争条件:

    • 从dst_shared加载。

    • 向dst_shared或src_global存储。

    • 对dst_shared或src_global执行原子更新。

5.6.2.2. 提交原语

void __pipeline_commit();
  • 将已提交的memcpy_async作为当前批次提交到流水线。

5.6.2.3. 等待原语

void __pipeline_wait_prior(size_t N);
  • 令{0, 1, 2, ..., L}为与给定线程对__pipeline_commit()的调用相关联的索引序列。

  • 等待至少到L-N为止(含该批次)的所有批次完成。

5.6.2.4. 到达屏障原语

void __pipeline_arrive_on(__mbarrier_t* bar);
  • bar指向共享内存中的一个屏障。

  • 将屏障到达计数加一,当执行顺序先于此调用的所有 memcpy_async 操作都已完成时,将到达计数减一,因此对到达计数的净影响为零。用户有责任确保增加到达计数时不会超过__mbarrier_maximum_count()。

5.6.3. 协作组 API

5.6.3.1. cooperative_groups.h

5.6.3.1.1. thread_block 类

任何 CUDA 程序员都已经熟悉一种特定的线程组:线程块。协作组扩展引入了一种新的数据类型thread_block,用于在核函数内显式表示这一概念。

class thread_block;

通过以下方式构造:

thread_block g = this_thread_block();

公有成员函数:

static void sync():使组中指定的线程同步,等同于g.barrier_wait(g.barrier_arrive())

thread_block::arrival_token barrier_arrive():到达 thread_block 屏障,返回一个需要传入barrier_wait()的令牌。

void barrier_wait(thread_block::arrival_token&& t):在thread_block屏障上等待,以右值引用的形式接受barrier_arrive()返回的到达令牌。

static unsigned int thread_rank():调用线程的序号,范围为 [0, num_threads)

static dim3 group_index():线程块在已启动网格内的三维索引

static dim3 thread_index():线程在已启动线程块内的三维索引

static dim3 dim_threads():已启动线程块的维度,以线程为单位

static unsigned int num_threads():组中的线程总数

旧版成员函数(别名):

static unsigned int size():组中的线程总数(num_threads()的别名)

static dim3 group_dim():已启动线程块的维度(dim_threads()的别名)

示例:

/// Loading an integer from global into shared memory
__global__ void kernel(int *globalInput) {
    __shared__ int x;
    thread_block g = this_thread_block();
    // Choose a leader in the thread block
    if (g.thread_rank() == 0) {
        // load from global into shared for all threads to work with
        x = (*globalInput);
    }
    // After loading data into shared memory, you want to synchronize
    // if all threads in your thread block need to see it
    g.sync(); // equivalent to __syncthreads();
}

注意:组中的所有线程都必须参与集合操作,否则行为未定义。

相关内容:thread_block数据类型派生自更通用的thread_group数据类型,后者可用于表示更广泛的组类别。

5.6.3.1.2. cluster_group 类

此组对象表示在单个簇中启动的所有线程。这些 API 可用于计算能力为 9.0 及以上的所有硬件。在这些情况下,当启动非簇网格时,这些 API 会假定存在一个 1x1x1 的簇。

class cluster_group;

通过以下方式构造:

cluster_group g = this_cluster();

公有成员函数:

static void sync():使组中指定的线程同步,等同于g.barrier_wait(g.barrier_arrive())

static cluster_group::arrival_token barrier_arrive():到达簇屏障,返回一个需要传入barrier_wait()的令牌。

static void barrier_wait(cluster_group::arrival_token&& t):在簇屏障上等待,以右值引用的形式接受barrier_arrive()返回的到达令牌。

static unsigned int thread_rank():调用线程的序号,范围为 [0, num_threads)

static unsigned int block_rank():调用线程块的序号,范围为 [0, num_blocks)

static unsigned int num_threads():组中的线程总数

static unsigned int num_blocks():组中的线程块总数

static dim3 dim_threads():已启动簇的维度,以线程为单位

static dim3 dim_blocks():已启动簇的维度,以线程块为单位

static dim3 block_index():调用线程块在已启动簇内的三维索引

static unsigned int query_shared_rank(const void *addr):获取共享内存地址所属的线程块序号

static T* map_shared_rank(T *addr, int rank):获取簇中另一个线程块的共享内存变量的地址

旧版成员函数(别名):

static unsigned int size():组中的线程总数(num_threads()的别名)

5.6.3.1.3. grid_group 类

此组对象表示在单个网格中启动的所有线程。除sync()之外的 API 始终可用,但要能够在整个网格范围内同步,需要使用协作启动 API。

class grid_group;

通过以下方式构造:

grid_group g = this_grid();

公有成员函数:

bool is_valid() const:返回 grid_group 是否能够同步

void sync() const:使组中指定的线程同步,等同于g.barrier_wait(g.barrier_arrive())

grid_group::arrival_token barrier_arrive():到达网格屏障,返回一个需要传入barrier_wait()的令牌。

void barrier_wait(grid_group::arrival_token&& t):在网格屏障上等待,以右值引用的形式接受barrier_arrive()返回的到达令牌。

static unsigned long long thread_rank():调用线程的序号,范围为 [0, num_threads)

static unsigned long long block_rank():调用线程块的序号,范围为 [0, num_blocks)

static unsigned long long cluster_rank():调用簇的序号,范围为 [0, num_clusters)

static unsigned long long num_threads():组中的线程总数

static unsigned long long num_blocks():组中的线程块总数

static unsigned long long num_clusters():组中的簇总数

static dim3 dim_blocks():已启动网格的维度,以线程块为单位

static dim3 dim_clusters():已启动网格的维度,以簇为单位

static dim3 block_index():线程块在已启动网格内的三维索引

static dim3 cluster_index():簇在已启动网格内的三维索引

旧版成员函数(别名):

static unsigned long long size():组中的线程总数(num_threads()的别名)

static dim3 group_dim():已启动网格的维度(dim_blocks()的别名)

5.6.3.1.4. thread_block_tile 类

分块组的模板化版本,其中使用模板参数指定分块的大小。在编译时已知此大小,就有可能实现更优化的执行。

template <unsigned int Size, typename ParentT = void>
class thread_block_tile;

通过以下方式构造:

template <unsigned int Size, typename ParentT>
_CG_QUALIFIER thread_block_tile<Size, ParentT> tiled_partition(const ParentT& g)

Size必须是 2 的幂且小于或等于 1024。注意事项部分介绍了在计算能力为 7.5 或更低的硬件上创建大小大于 32 的分块所需的额外步骤。

ParentT是划分出此组的父组类型。它会自动推断,但取值为 void 时,此信息将存储在组句柄中,而非类型中。

公有成员函数:

void sync() const:使组中指定的线程同步

unsigned long long num_threads() const:组中的线程总数

unsigned long long thread_rank() const:调用线程的序号,范围为 [0, num_threads)

unsigned long long meta_group_size() const:返回划分父组时创建的组数。

unsigned long long meta_group_rank() const:组在从父组划分出的分块集合中的线性序号(以 meta_group_size 为界)

T shfl(T var, unsigned int src_rank) const:请参阅线程束洗牌函数,注意:对于大于 32 的大小,组中的所有线程都必须指定相同的 src_rank,否则行为未定义。

T shfl_up(T var, int delta) const:请参阅线程束洗牌函数,仅适用于小于或等于 32 的大小。

T shfl_down(T var, int delta) const:请参阅线程束洗牌函数,仅适用于小于或等于 32 的大小。

T shfl_xor(T var, int delta) const:请参阅线程束洗牌函数,仅适用于小于或等于 32 的大小。

int any(int predicate) const:请参阅线程束投票函数

int all(int predicate) const:请参阅线程束投票函数

unsigned int ballot(int predicate) const:请参阅线程束投票函数,仅适用于小于或等于 32 的大小。

unsigned int match_any(T val) const:请参阅线程束匹配函数,仅适用于小于或等于 32 的大小。

unsigned int match_all(T val, int &pred) const:请参阅线程束匹配函数,仅适用于小于或等于 32 的大小。

旧版成员函数(别名):

unsigned long long size() const:组中的线程总数(num_threads()的别名)

注意:

  • 此处使用了thread_block_tile模板化数据结构,组大小作为模板参数而非实参传递给tiled_partition调用。

  • 使用 C++11 或更高版本编译时,shfl, shfl_up, shfl_down, and shfl_xor函数接受任意类型的对象。这意味着,只要满足以下约束,就可以对非整数类型进行洗牌:

    • 满足可平凡复制的条件,即is_trivially_copyable<T>::value == true

    • 对于小于或等于 32 的分块大小,满足sizeof(T) <= 32;对于更大的分块,满足sizeof(T) <= 8

  • 在计算能力为 7.5 或更低的硬件上,大小大于 32 的分块需要预留少量内存。可以使用cooperative_groups::block_tile_memory结构体模板来完成此操作,该结构体必须驻留在共享内存或全局内存中。

    template <unsigned int MaxBlockSize = 1024>
    struct block_tile_memory;
    

    MaxBlockSize指定当前线程块中的最大线程数。在仅以较少线程数启动的核函数中,可以使用此参数将block_tile_memory的共享内存用量降至最低。

    随后需要将此block_tile_memory传入cooperative_groups::this_thread_block,使得能够将生成的thread_block划分为大小大于 32 的分块。接受block_tile_memory参数的this_thread_block重载是一个集合操作,必须由thread_block中的所有线程调用。

    可以在计算能力为 8.0 或更高的硬件上使用block_tile_memory,以便编写一份面向多种不同计算能力的源代码。在不需要它的情况下,将其在共享内存中实例化应当不消耗任何内存。

示例:

/// The following code will create two sets of tiled groups, of size 32 and 4 respectively:
/// The latter has the provenance encoded in the type, while the first stores it in the handle
thread_block block = this_thread_block();
thread_block_tile<32> tile32 = tiled_partition<32>(block);
thread_block_tile<4, thread_block> tile4 = tiled_partition<4>(block);
/// The following code will create tiles of size 128 on all Compute Capabilities.
/// block_tile_memory can be omitted on Compute Capability 8.0 or higher.
__global__ void kernel(...) {
    // reserve shared memory for thread_block_tile usage,
    //   specify that block size will be at most 256 threads.
    __shared__ block_tile_memory<256> shared;
    thread_block thb = this_thread_block(shared);

    // Create tiles with 128 threads.
    auto tile = tiled_partition<128>(thb);

    // ...
}

5.6.3.1.5. coalesced_group 类

在 CUDA 的 SIMT 架构中,多处理器在硬件层面上以每组 32 个线程的方式执行线程,这些组称为线程束。如果应用程序代码中存在依赖数据的条件分支,导致线程束内的线程发生分歧,则线程束会串行执行每个分支,并禁用不在该路径上的线程。在路径上保持活跃的线程称为合并线程。协作组提供了发现并创建包含所有合并线程的组的功能。

通过coalesced_threads()构造组句柄是机会式的。它返回该时刻的活跃线程集合,不保证具体返回哪些线程(只要它们处于活跃状态),也不保证它们在整个执行过程中始终保持合并状态(执行集合操作时会将它们重新聚集在一起,但之后它们可以再次发生分歧)。

class coalesced_group;

通过以下方式构造:

coalesced_group active = coalesced_threads();

公有成员函数:

void sync() const:使组中指定的线程同步

unsigned long long num_threads() const:组中的线程总数

unsigned long long thread_rank() const:调用线程的序号,范围为 [0, num_threads)

unsigned long long meta_group_size() const:返回划分父组时创建的组数。如果此组是通过查询活跃线程集合创建的,例如coalesced_threads(),则meta_group_size()的值将为 1。

unsigned long long meta_group_rank() const:组在从父组划分出的分块集合中的线性序号(以 meta_group_size 为界)。如果此组是通过查询活跃线程集合创建的,例如coalesced_threads(),则meta_group_rank()的值将始终为 0。

T shfl(T var, unsigned int src_rank) const:请参阅线程束洗牌函数

T shfl_up(T var, int delta) const:请参阅线程束洗牌函数

T shfl_down(T var, int delta) const:请参阅线程束洗牌函数

int any(int predicate) const:请参阅线程束投票函数

int all(int predicate) const:请参阅线程束投票函数

unsigned int ballot(int predicate) const:请参阅线程束投票函数

unsigned int match_any(T val) const:请参阅线程束匹配函数

unsigned int match_all(T val, int &pred) const:请参阅线程束匹配函数

旧版成员函数(别名):

unsigned long long size() const:组中的线程总数(num_threads()的别名)

注意:

使用 C++11 或更高版本编译时,shfl, shfl_up, and shfl_down函数接受任意类型的对象。这意味着,只要满足以下约束,就可以对非整数类型进行洗牌:

  • 满足可平凡复制的条件,即is_trivially_copyable<T>::value == true

  • sizeof(T) <= 32

示例:

/// Consider a situation whereby there is a branch in the
/// code in which only the 2nd, 4th and 8th threads in each warp are
/// active. The coalesced_threads() call, placed in that branch, will create (for each
/// warp) a group, active, that has three threads (with
/// ranks 0-2 inclusive).
__global__ void kernel(int *globalInput) {
    // Lets say globalInput says that threads 2, 4, 8 should handle the data
    if (threadIdx.x == *globalInput) {
        coalesced_group active = coalesced_threads();
        // active contains 0-2 inclusive
        active.sync();
    }
}

5.6.3.2. cooperative_groups/async.h

5.6.3.2.1. memcpy_async

memcpy_async是一种组范围的集合 memcpy,利用硬件加速来支持从全局内存到共享内存的非阻塞内存事务。给定组中指定的一组线程,memcpy_async将通过单个流水线阶段移动指定数量的字节或输入类型的元素。此外,为了在使用memcpy_asyncAPI 时获得最佳性能,共享内存和全局内存都需要按 16 字节对齐。需要特别注意的是,虽然在一般情况下这是一个 memcpy,但只有当源为全局内存、目标为共享内存,并且两者都能够以 16、8 或 4 字节对齐进行寻址时,它才是异步的。异步复制的数据只能在 wait 或 wait_prior 调用表明相应阶段已完成向共享内存移动数据之后读取。

必须等待所有未完成的请求可能会损失一些灵活性(但会使操作更简单)。为了高效地重叠数据传输与执行,能够在等待并处理请求N的同时发起一个N+1memcpy_async请求非常重要。为此,请使用memcpy_async,并使用基于阶段的集合wait_priorAPI 等待它。更多详细信息请参阅wait 和 wait_prior。

用法 1

template <typename TyGroup, typename TyElem, typename TyShape>
void memcpy_async(
  const TyGroup &group,
  TyElem *__restrict__ _dst,
  const TyElem *__restrict__ _src,
  const TyShape &shape
);

执行``shape`` 字节的复制。

用法 2

template <typename TyGroup, typename TyElem, typename TyDstLayout, typename TySrcLayout>
void memcpy_async(
  const TyGroup &group,
  TyElem *__restrict__ dst,
  const TyDstLayout &dstLayout,
  const TyElem *__restrict__ src,
  const TySrcLayout &srcLayout
);

执行``min(dstLayout, srcLayout)`` 个元素的复制。如果布局的类型为cuda::aligned_size_t<N>,则两者必须指定相同的对齐值。

勘误在 CUDA 11.1 中引入的、同时接受 src 和 dst 输入布局的memcpy_asyncAPI 要求以元素而非字节为单位提供布局。元素类型从TyElem推断得出,其大小为sizeof(TyElem)。如果使用cuda::aligned_size_t<N>类型作为布局,则指定的元素数乘以sizeof(TyElem)必须是 N 的倍数,并且建议使用std::byte或char作为元素类型。

如果指定的复制形状或布局的类型为cuda::aligned_size_t<N>,则将保证对齐值至少为min(16, N)。在这种情况下,dst和src指针都需要按 N 字节对齐,并且复制的字节数需要是 N 的倍数。

代码生成要求:计算能力最低为 5.0,异步性要求计算能力为 8.0,C++11

需要包含cooperative_groups/memcpy_async.h头文件。

示例:

/// This example streams elementsPerThreadBlock worth of data from global memory
/// into a limited sized shared memory (elementsInShared) block to operate on.
#include <cooperative_groups.h>
#include <cooperative_groups/memcpy_async.h>

namespace cg = cooperative_groups;

__global__ void kernel(int* global_data) {
    cg::thread_block tb = cg::this_thread_block();
    const size_t elementsPerThreadBlock = 16 * 1024;
    const size_t elementsInShared = 128;
    __shared__ int local_smem[elementsInShared];

    size_t copy_count;
    size_t index = 0;
    while (index < elementsPerThreadBlock) {
        cg::memcpy_async(tb, local_smem, elementsInShared, global_data + index, elementsPerThreadBlock - index);
        copy_count = min(elementsInShared, elementsPerThreadBlock - index);
        cg::wait(tb);
        // Work with local_smem
        index += copy_count;
    }
}

5.6.3.2.2. wait和wait_prior

template <typename TyGroup>
void wait(TyGroup & group);

template <unsigned int NumStages, typename TyGroup>
void wait_prior(TyGroup & group);

wait和wait_prior集合操作允许等待 memcpy_async 复制完成。wait会阻塞调用线程,直到所有先前的复制完成。wait_prior允许最近的 NumStages 个阶段仍未完成,并等待所有更早的请求。因此,在总共请求了N次复制的情况下,它会等待,直到前N-NumStages次复制完成,而最后NumStages次复制可能仍在进行中。wait和wait_prior都会使指定的组同步。

代码生成要求:计算能力最低为 5.0,异步性要求计算能力为 8.0,C++11

需要包含cooperative_groups/memcpy_async.h头文件。

示例:

/// This example streams elementsPerThreadBlock worth of data from global memory
/// into a limited sized shared memory (elementsInShared) block to operate on in
/// multiple (two) stages. As stage N is kicked off, we can wait on and operate on stage N-1.
#include <cooperative_groups.h>
#include <cooperative_groups/memcpy_async.h>

namespace cg = cooperative_groups;

__global__ void kernel(int* global_data) {
    cg::thread_block tb = cg::this_thread_block();
    const size_t elementsPerThreadBlock = 16 * 1024 + 64;
    const size_t elementsInShared = 128;
    __align__(16) __shared__ int local_smem[2][elementsInShared];
    int stage = 0;
    // First kick off an extra request
    size_t copy_count = elementsInShared;
    size_t index = copy_count;
    cg::memcpy_async(tb, local_smem[stage], elementsInShared, global_data, elementsPerThreadBlock - index);
    while (index < elementsPerThreadBlock) {
        // Now we kick off the next request...
        cg::memcpy_async(tb, local_smem[stage ^ 1], elementsInShared, global_data + index, elementsPerThreadBlock - index);
        // ... but we wait on the one before it
        cg::wait_prior<1>(tb);

        // Its now available and we can work with local_smem[stage] here
        // (...)
        //

        // Calculate the amount of data that was actually copied, for the next iteration.
        copy_count = min(elementsInShared, elementsPerThreadBlock - index);
        index += copy_count;

        // A cg::sync(tb) might be needed here depending on whether
        // the work done with local_smem[stage] can release threads to race ahead or not
        // Wrap to the next stage
        stage ^= 1;
    }
    cg::wait(tb);
    // The last local_smem[stage] can be handled here
}

5.6.3.3. cooperative_groups/partition.h

5.6.3.3.1. tiled_partition

template <unsigned int Size, typename ParentT>
thread_block_tile<Size, ParentT> tiled_partition(const ParentT& g);
thread_group tiled_partition(const thread_group& parent, unsigned int tilesz);

tiled_partition方法是一种集体操作,它将父组划分为一维、行优先排列的子组分块。总共将创建 ((size(parent)/tilesz) 个子组,因此父组的大小必须能被Size整除。允许的父组为thread_block或thread_block_tile。

实现可能会使调用线程等待,直到父组的所有成员都已调用该操作后才恢复执行。功能仅限于原生硬件大小 1/2/4/8/16/32,并且cg::size(parent)必须大于Size参数。tiled_partition的模板版本也支持 64/128/256/512 这些大小,但在计算能力为 7.5 或更低的设备上需要执行一些额外步骤,详情请参阅thread_block_tile 类。

代码生成要求:计算能力最低为 5.0,大小大于 32 时要求使用 C++11

5.6.3.3.2. labeled_partition

template <typename Label>
coalesced_group labeled_partition(const coalesced_group& g, Label label);
template <unsigned int Size, typename Label>
coalesced_group labeled_partition(const thread_block_tile<Size>& g, Label label);

labeled_partition方法是一种集体操作,它将父组划分为一维子组,各子组内的线程处于合并状态。实现将对条件标签求值,并将标签值相同的线程分配到同一个组中。

Label可以是任意整数类型。

实现可能会使调用线程等待,直到父组的所有成员都已调用该操作后才恢复执行。

注意:此功能仍在评估中,将来可能会略有变化。

代码生成要求:计算能力最低为 7.0,C++11

5.6.3.3.3. binary_partition

coalesced_group binary_partition(const coalesced_group& g, bool pred);
template <unsigned int Size>
coalesced_group binary_partition(const thread_block_tile<Size>& g, bool pred);

binary_partition()方法是一种集体操作,它将父组划分为一维子组,各子组内的线程处于合并状态。实现将对谓词求值,并将值相同的线程分配到同一个组中。这是labeled_partition()的一种特化形式,其中标签只能为 0 或 1。

实现可能会使调用线程等待,直到父组的所有成员都已调用该操作后才恢复执行。

示例:

/// This example divides a 32-sized tile into a group with odd
/// numbers and a group with even numbers
_global__ void oddEven(int *inputArr) {
    auto block = cg::this_thread_block();
    auto tile32 = cg::tiled_partition<32>(block);

    // inputArr contains random integers
    int elem = inputArr[block.thread_rank()];
    // after this, tile32 is split into 2 groups,
    // a subtile where elem&1 is true and one where its false
    auto subtile = cg::binary_partition(tile32, (elem & 1));
}

5.6.3.4. cooperative_groups/reduce.h

5.6.3.4.1. Reduce运算符

以下是可使用reduce执行的一些基本操作的函数对象原型。

namespace cooperative_groups {
  template <typename Ty>
  struct cg::plus;

  template <typename Ty>
  struct cg::less;

  template <typename Ty>
  struct cg::greater;

  template <typename Ty>
  struct cg::bit_and;

  template <typename Ty>
  struct cg::bit_xor;

  template <typename Ty>
  struct cg::bit_or;
}

归约受限于实现在编译时可获得的信息。因此,为了利用计算能力 8.0 中引入的内建函数,cg::命名空间提供了几个与硬件相对应的函数对象。除less/greater之外,这些对象与 C++ STL 中提供的对象看起来类似。与 STL 存在任何差异的原因在于,这些函数对象在设计上旨在实际对应硬件内建函数的操作。

功能描述:

  • cg::plus:接受两个值,并使用 operator+ 返回两者之和。

  • cg::less:接受两个值,并使用 operator< 返回较小的值。其不同之处在于,返回的是较小的值,而不是布尔值。

  • cg::greater:接受两个值,并使用 operator< 返回较大的值。其不同之处在于,返回的是较大的值,而不是布尔值。

  • cg::bit_and:接受两个值,并返回 operator& 的结果。

  • cg::bit_xor:接受两个值,并返回 operator^ 的结果。

  • cg::bit_or:接受两个值,并返回 operator| 的结果。

示例:

{
    // cg::plus<int> is specialized within cg::reduce and calls __reduce_add_sync(...) on CC 8.0+
    cg::reduce(tile, (int)val, cg::plus<int>());

    // cg::plus<float> fails to match with an accelerator and instead performs a standard shuffle based reduction
    cg::reduce(tile, (float)val, cg::plus<float>());

    // While individual components of a vector are supported, reduce will not use hardware intrinsics for the following
    // It will also be necessary to define a corresponding operator for vector and any custom types that may be used
    int4 vec = {...};
    cg::reduce(tile, vec, cg::plus<int4>())

    // Finally lambdas and other function objects cannot be inspected for dispatch
    // and will instead perform shuffle based reductions using the provided function object.
    cg::reduce(tile, (int)val, [](int l, int r) -> int {return l + r;});
}

5.6.3.4.2. reduce

template <typename TyGroup, typename TyArg, typename TyOp>
auto reduce(const TyGroup& group, TyArg&& val, TyOp&& op) -> decltype(op(val, val));

reduce对传入组中指定的各线程提供的数据执行归约操作。它利用硬件加速(在 compute 80 及更高版本的设备上)执行算术加法、最小值或最大值运算,以及逻辑与、或或异或运算,同时也在较早代的硬件上提供软件回退实现。只有 4B 类型可以获得硬件加速。

group:有效的组类型为coalesced_group和thread_block_tile。

val:满足以下要求的任意类型:

  • 满足可平凡复制的条件,即is_trivially_copyable<TyArg>::value == true

  • 对于coalesced_group和大小小于或等于 32 的分块,要求sizeof(T) <= 32;对于更大的分块,要求sizeof(T) <= 8

  • 具有适用于给定函数对象的算术运算符或比较运算符。

注意:组中的不同线程可以为此参数传递不同的值。

op:可为整数类型提供硬件加速的有效函数对象为plus(), less(), greater(), bit_and(), bit_xor(), bit_or()。这些对象必须被构造,因此需要 TyVal 模板实参,即plus<int>()。归约也支持可使用operator()调用的 lambda 表达式和其他函数对象。

异步归约

template <typename TyGroup, typename TyArg, typename TyAtomic, typename TyOp>
void reduce_update_async(const TyGroup& group, TyAtomic& atomic, TyArg&& val, TyOp&& op);

template <typename TyGroup, typename TyArg, typename TyAtomic, typename TyOp>
void reduce_store_async(const TyGroup& group, TyAtomic& atomic, TyArg&& val, TyOp&& op);

template <typename TyGroup, typename TyArg, typename TyOp>
void reduce_store_async(const TyGroup& group, TyArg* ptr, TyArg&& val, TyOp&& op);

API 的*_async变体异步计算结果,由其中一个参与线程将结果存储到指定目标或用于更新指定目标,而不是由每个线程返回结果。要观察这些异步调用的效果,调用这些操作的线程组或包含这些线程的更大的组需要进行同步。

  • 对于原子存储或更新变体,atomic参数可以是CUDA C++ Standard Library中提供的cuda::atomic或cuda::atomic_ref。此 API 变体仅可在 CUDA C++ Standard Library 支持这些类型的平台和设备上使用。归约结果用于根据指定的op以原子方式更新原子对象,例如,对于cg::plus(),结果会以原子方式加到原子对象上。atomic所持有的类型必须与TyArg的类型匹配。原子对象的作用域必须包含组中的所有线程,如果多个组并发使用同一个原子对象,则作用域必须包含使用该原子对象的所有组中的所有线程。原子更新以宽松内存顺序执行。

  • 对于指针存储变体,归约结果将以弱存储方式存储到dst指针所指的位置。

5.6.3.5. cooperative_groups/scan.h

5.6.3.5.1. inclusive_scan和exclusive_scan

template <typename TyGroup, typename TyVal, typename TyFn>
auto inclusive_scan(const TyGroup& group, TyVal&& val, TyFn&& op) -> decltype(op(val, val));

template <typename TyGroup, typename TyVal>
TyVal inclusive_scan(const TyGroup& group, TyVal&& val);

template <typename TyGroup, typename TyVal, typename TyFn>
auto exclusive_scan(const TyGroup& group, TyVal&& val, TyFn&& op) -> decltype(op(val, val));

template <typename TyGroup, typename TyVal>
TyVal exclusive_scan(const TyGroup& group, TyVal&& val);

inclusive_scan和exclusive_scan对传入组中指定的各线程提供的数据执行扫描操作。对于exclusive_scan,每个线程的结果是对thread_rank小于该线程的那些线程的数据进行归约所得的结果。inclusive_scan的结果还在归约中包含调用线程的数据。

group:有效的组类型为coalesced_group和thread_block_tile。

val:满足以下要求的任意类型:

  • 满足可平凡复制的条件,即is_trivially_copyable<TyArg>::value == true

  • 对于coalesced_group和大小小于或等于 32 的分块,要求sizeof(T) <= 32;对于更大的分块,要求sizeof(T) <= 8

  • 具有适用于给定函数对象的算术运算符或比较运算符。

注意:组中的不同线程可以为此参数传递不同的值。

op:为方便使用而定义的函数对象是cooperative_groups/reduce.h中描述的plus(), less(), greater(), bit_and(), bit_xor(), bit_or()。这些对象必须被构造,因此需要 TyVal 模板实参,即plus<int>()。inclusive_scan和exclusive_scan也支持可使用operator()调用的 lambda 表达式和其他函数对象。不带此参数的重载使用cg::plus<TyVal>()。

扫描更新

template <typename TyGroup, typename TyAtomic, typename TyVal, typename TyFn>
auto inclusive_scan_update(const TyGroup& group, TyAtomic& atomic, TyVal&& val, TyFn&& op) -> decltype(op(val, val));

template <typename TyGroup, typename TyAtomic, typename TyVal>
TyVal inclusive_scan_update(const TyGroup& group, TyAtomic& atomic, TyVal&& val);

template <typename TyGroup, typename TyAtomic, typename TyVal, typename TyFn>
auto exclusive_scan_update(const TyGroup& group, TyAtomic& atomic, TyVal&& val, TyFn&& op) -> decltype(op(val, val));

template <typename TyGroup, typename TyAtomic, typename TyVal>
TyVal exclusive_scan_update(const TyGroup& group, TyAtomic& atomic, TyVal&& val);

*_scan_update集体操作接受一个额外的参数atomic,该参数可以是CUDA C++ Standard Library中提供的cuda::atomic或cuda::atomic_ref。这些 API 变体仅可在 CUDA C++ Standard Library 支持这些类型的平台和设备上使用。这些变体将根据op,使用组中所有线程的输入值之和对atomic执行更新。每个线程都会将atomic的先前值与扫描结果组合并返回。atomic所持有的类型必须与TyVal的类型匹配。原子对象的作用域必须包含组中的所有线程,如果多个组并发使用同一个原子对象,则作用域必须包含使用该原子对象的所有组中的所有线程。原子更新以宽松内存顺序执行。

以下伪代码说明了扫描的更新变体如何工作:

/*
 inclusive_scan_update behaves as the following block,
 except both reduce and inclusive_scan is calculated simultaneously.
auto total = reduce(group, val, op);
TyVal old;
if (group.thread_rank() == selected_thread) {
    atomically {
        old = atomic.load();
        atomic.store(op(old, total));
    }
}
old = group.shfl(old, selected_thread);
return op(inclusive_scan(group, val, op), old);
*/

需要包含cooperative_groups/scan.h头文件。

使用 exclusive_scan 进行流压缩的示例:

#include <cooperative_groups.h>
#include <cooperative_groups/scan.h>
namespace cg = cooperative_groups;

// put data from input into output only if it passes test_fn predicate
template<typename Group, typename Data, typename TyFn>
__device__ int stream_compaction(Group &g, Data *input, int count, TyFn&& test_fn, Data *output) {
    int per_thread = count / g.num_threads();
    int thread_start = min(g.thread_rank() * per_thread, count);
    int my_count = min(per_thread, count - thread_start);

    // get all passing items from my part of the input
    //  into a contagious part of the array and count them.
    int i = thread_start;
    while (i < my_count + thread_start) {
        if (test_fn(input[i])) {
            i++;
        }
        else {
            my_count--;
            input[i] = input[my_count + thread_start];
        }
    }

    // scan over counts from each thread to calculate my starting
    //  index in the output
    int my_idx = cg::exclusive_scan(g, my_count);

    for (i = 0; i < my_count; ++i) {
        output[my_idx + i] = input[thread_start + i];
    }
    // return the total number of items in the output
    return g.shfl(my_idx + my_count, g.num_threads() - 1);
}

使用 exclusive_scan_update 进行动态缓冲区空间分配的示例:

#include <cooperative_groups.h>
#include <cooperative_groups/scan.h>
namespace cg = cooperative_groups;

// Buffer partitioning is static to make the example easier to follow,
// but any arbitrary dynamic allocation scheme can be implemented by replacing this function.
__device__ int calculate_buffer_space_needed(cg::thread_block_tile<32>& tile) {
    return tile.thread_rank() % 2 + 1;
}

__device__ int my_thread_data(int i) {
    return i;
}

__global__ void kernel() {
    __shared__ extern int buffer[];
    __shared__ cuda::atomic<int, cuda::thread_scope_block> buffer_used;

    auto block = cg::this_thread_block();
    auto tile = cg::tiled_partition<32>(block);
    buffer_used = 0;
    block.sync();

    // each thread calculates buffer size it needs
    int buf_needed = calculate_buffer_space_needed(tile);

    // scan over the needs of each thread, result for each thread is an offset
    // of that thread’s part of the buffer. buffer_used is atomically updated with
    // the sum of all thread's inputs, to correctly offset other tile’s allocations
    int buf_offset =
        cg::exclusive_scan_update(tile, buffer_used, buf_needed);

    // each thread fills its own part of the buffer with thread specific data
    for (int i = 0 ; i < buf_needed ; ++i) {
        buffer[buf_offset + i] = my_thread_data(i);
    }

    block.sync();
    // buffer_used now holds total amount of memory allocated
    // buffer is {0, 0, 1, 0, 0, 1 ...};
}

5.6.3.6. cooperative_groups/sync.h

5.6.3.6.1. barrier_arrive和barrier_wait

T::arrival_token T::barrier_arrive();
void T::barrier_wait(T::arrival_token&&);

barrier_arrive和barrier_wait成员函数提供了一个类似于cuda::barrier的同步 API(了解更多)。协作组会自动初始化组屏障,但由于这些操作具有集体性质,到达和等待操作还有一项额外限制:组中的所有线程都必须在每个阶段到达屏障一次并等待屏障一次。当使用某个组调用barrier_arrive时,对该组调用任何集体操作或再次到达屏障的结果都是未定义的,直到通过barrier_wait调用观察到屏障阶段完成。在barrier_wait上阻塞的线程可能会在其他线程调用barrier_wait之前从同步中释放,但只能在组中的所有线程都已调用barrier_arrive之后释放。组类型T可以是任意一种隐式组。这允许线程在到达之后、等待同步完成之前执行独立的工作,从而隐藏一部分同步延迟。barrier_arrive返回一个arrival_token对象,该对象必须传递给相应的barrier_wait。令牌会以这种方式被消耗,不能用于另一次barrier_wait调用。

使用 barrier_arrive 和 barrier_wait 在整个簇中同步共享内存初始化的示例:

#include <cooperative_groups.h>

using namespace cooperative_groups;

void __device__ init_shared_data(const thread_block& block, int *data);
void __device__ local_processing(const thread_block& block);
void __device__ process_shared_data(const thread_block& block, int *data);

__global__ void cluster_kernel() {
    extern __shared__ int array[];
    auto cluster = this_cluster();
    auto block   = this_thread_block();

    // Use this thread block to initialize some shared state
    init_shared_data(block, &array[0]);

    auto token = cluster.barrier_arrive(); // Let other blocks know this block is running and data was initialized

    // Do some local processing to hide the synchronization latency
    local_processing(block);

    // Map data in shared memory from the next block in the cluster
    int *dsmem = cluster.map_shared_rank(&array[0], (cluster.block_rank() + 1) % cluster.num_blocks());

    // Make sure all other blocks in the cluster are running and initialized shared data before accessing dsmem
    cluster.barrier_wait(std::move(token));

    // Consume data in distributed shared memory
    process_shared_data(block, dsmem);
    cluster.sync();
}

5.6.3.6.2. sync

static void T::sync();

template <typename T>
void sync(T& group);

sync对组中指定的线程进行同步。组类型T可以是任意现有的组类型,因为它们都支持同步。它既可以作为每种组类型的成员函数使用,也可以作为以组为参数的自由函数使用。

5.6.3.6.2.1. 网格同步

在引入协作组之前,CUDA 编程模型只允许在核函数完成边界处在线程块之间进行同步。核函数边界会伴随隐式的状态失效,并因此可能带来性能影响。

例如,在某些用例中,应用程序具有大量小型核函数,每个核函数代表处理流水线中的一个阶段。当前 CUDA 编程模型要求存在这些核函数,以确保在下一流水线阶段工作的线程块准备好使用数据之前,当前流水线阶段的线程块已经生成数据。在这种情况下,提供全局线程块间同步的能力将允许重构应用程序,使其具有持久线程块,这些线程块能够在给定阶段完成时在设备上进行同步。

要在核函数内部对整个网格进行同步,只需使用grid.sync()函数:

grid_group grid = this_grid();
grid.sync();

而在启动核函数时,必须使用cudaLaunchCooperativeKernelCUDA 运行时启动 API 或CUDA driver equivalent,而不是<<<...>>>执行配置语法。

示例:

为了保证线程块同时驻留在 GPU 上,需要仔细考虑启动的线程块数量。例如,可以按如下方式启动与 SM 数量相同的线程块:

int dev = 0;
cudaDeviceProp deviceProp;
cudaGetDeviceProperties(&deviceProp, dev);
// initialize, then launch
cudaLaunchCooperativeKernel((void*)my_kernel, deviceProp.multiProcessorCount, numThreads, args);

或者,可以按如下方式使用占用率计算器,计算每个 SM 可以同时容纳多少个线程块,从而最大限度地提高可用并行度:

/// This will launch a grid that can maximally fill the GPU, on the default stream with kernel arguments
int numBlocksPerSm = 0;
 // Number of threads my_kernel will be launched with
int numThreads = 128;
cudaDeviceProp deviceProp;
cudaGetDeviceProperties(&deviceProp, dev);
cudaOccupancyMaxActiveBlocksPerMultiprocessor(&numBlocksPerSm, my_kernel, numThreads, 0);
// launch
void *kernelArgs[] = { /* add kernel args */ };
dim3 dimBlock(numThreads, 1, 1);
dim3 dimGrid(deviceProp.multiProcessorCount*numBlocksPerSm, 1, 1);
cudaLaunchCooperativeKernel((void*)my_kernel, dimGrid, dimBlock, kernelArgs);

一种良好的做法是,首先查询设备属性cudaDevAttrCooperativeLaunch,以确保设备支持协作式启动:

int dev = 0;
int supportsCoopLaunch = 0;
cudaDeviceGetAttribute(&supportsCoopLaunch, cudaDevAttrCooperativeLaunch, dev);

如果设备 0 支持该属性,此操作会将supportsCoopLaunch设置为 1。仅支持计算能力为 6.0 及更高的设备。此外,需要在以下任一环境下运行:

  • 不使用 MPS 的 Linux 平台

  • 使用 MPS 的 Linux 平台,且设备的计算能力为 7.0 或更高

  • 最新的 Windows 平台

5.6.4. CUDA 设备运行时

CUDA 设备运行时是在核函数代码内可用的 API,提供了许多与主机上的 CUDA 运行时 API 相同的能力。这些 API 最常用于CUDA 动态并行或设备图启动的情境中。

5.6.4.1. 在 CUDA 代码中包含设备运行时 API

与主机端运行时 API 类似,CUDA 设备运行时 API 的原型会在程序编译期间自动包含。无需显式包含cuda_device_runtime_api.h。

5.6.4.2. CUDA 设备运行时中的内存

5.6.4.2.1. 配置选项

设备运行时系统软件的资源分配由主机程序通过cudaDeviceSetLimit()API 进行控制。必须在启动任何核函数之前设置限制,并且在 GPU 正在运行程序时不得更改这些限制。

可以设置以下命名限制:

限制

行为

cudaLimitDevRuntimePendingLaunchCount

控制为缓冲尚未开始执行的核函数启动和事件而预留的内存量,这些核函数启动和事件尚未开始执行是由于依赖关系尚未解决或执行资源不足。当缓冲区已满时,在设备端核函数启动期间尝试分配启动槽会失败并返回cudaErrorLaunchOutOfResources,而尝试分配事件槽会失败并返回cudaErrorMemoryAllocation。启动槽的默认数量为 2048。应用程序可以通过设置cudaLimitDevRuntimePendingLaunchCount来增加启动槽和/或事件槽的数量。分配的事件槽数量是该限制值的两倍。

cudaLimitStackSize

控制每个 GPU 线程的栈大小,以字节为单位。CUDA 驱动程序会在每次核函数启动时根据需要自动增大每个线程的栈大小。每次启动后,此大小不会重置为原始值。要将每个线程的栈大小设置为不同的值,可以调用cudaDeviceSetLimit()来设置此限制。栈的大小将立即调整,如有必要,设备将阻塞,直到先前请求的所有任务都完成。可以调用cudaDeviceGetLimit()来获取当前每个线程的栈大小。

5.6.4.2.2. 分配与生存期

cudaMalloc()和cudaFree()在主机环境与设备环境中具有不同的语义。从主机调用时,cudaMalloc()会从未使用的设备内存中分配一个新区域。从设备运行时调用时,这些函数映射到设备端的malloc()和free()。这意味着,在设备环境中,可分配的内存总量受限于设备malloc()堆的大小,该大小可能小于可用的未使用设备内存。此外,在主机程序中对在设备上通过cudaMalloc()分配的指针调用cudaFree()是错误的,反之亦然。

主机上的cudaMalloc()

设备上的cudaMalloc()

主机上的cudaFree()

支持

不支持

设备上的cudaFree()

不支持

支持

分配限制

可用设备内存

cudaLimitMallocHeapSize

5.6.4.2.2.1. 内存声明
5.6.4.2.2.1.1. 设备内存和常量内存

在文件作用域内使用__device__或__constant__内存空间说明符声明的内存,在使用设备运行时时行为完全相同。所有核函数都可以读取或写入设备变量,无论核函数最初是由主机还是设备运行时启动的。等价地,所有核函数对在模块作用域中声明的__constant__都具有相同的视图。

5.6.4.2.2.1.2. 纹理和表面

设备运行时不允许在设备代码内创建或销毁纹理对象或表面对象。在主机上创建的纹理对象和表面对象可以在设备上自由使用和传递。无论在何处创建,动态创建的纹理对象始终有效,并且可以从父核函数传递给子核函数。

注意

设备运行时不支持在从设备启动的核函数内使用旧式模块作用域(即计算能力 2.0 或 Fermi 风格)的纹理和表面。模块作用域(旧式)纹理可以在主机上创建,并像用于任何核函数一样用于设备代码中,但只能由顶层核函数(即从主机启动的核函数)使用。

5.6.4.2.2.1.3. 共享内存变量声明

在 CUDA C++ 中,共享内存既可以声明为大小静态确定的文件作用域变量或函数作用域变量,也可以声明为extern变量,其大小由核函数的调用方在运行时通过启动配置参数确定。这两种声明类型在设备运行时中均有效。

__global__ void permute(int n, int *data) {
   extern __shared__ int smem[];
   if (n <= 1)
       return;

   smem[threadIdx.x] = data[threadIdx.x];
   __syncthreads();

   permute_data(smem, n);
   __syncthreads();

   // Write back to GMEM since we can't pass SMEM to children.
   data[threadIdx.x] = smem[threadIdx.x];
   __syncthreads();

   if (threadIdx.x == 0) {
       permute<<< 1, 256, n/2*sizeof(int) >>>(n/2, data);
       permute<<< 1, 256, n/2*sizeof(int) >>>(n/2, data+n/2);
   }
}

void host_launch(int *data) {
    permute<<< 1, 256, 256*sizeof(int) >>>(256, data);
}
5.6.4.2.2.1.4. 常量内存

不能从设备修改常量。常量只能从主机修改,但如果有一个并发执行的网格在其生命周期中的任何时刻访问该常量,那么此时从主机修改该常量的行为是未定义的。

5.6.4.2.2.1.5. 符号地址

在核函数内,只需通过&运算符即可引用设备端符号(即标记为__device__的符号),因为所有全局作用域的设备变量都位于核函数的可见地址空间中。这也适用于__constant__符号,不过在这种情况下,指针将引用只读数据。

由于可以直接引用设备端符号,那些引用符号的 CUDA 运行时 API(例如cudaMemcpyToSymbol()或cudaGetSymbolAddress())便没有必要,设备运行时也不支持它们。这意味着,无法在正在运行的核函数内更改常量数据,即使是在子核函数启动之前也不行,因为对__constant__空间的引用是只读的。

5.6.4.3. SM ID 和线程束 ID

请注意,在 PTX 中,%smid和%warpid被定义为易变值。设备运行时可能会将线程块重新调度到不同的 SM 上,以便更高效地管理资源。因此,依赖%smid或%warpid在线程或线程块的整个生命周期内保持不变是不安全的。

5.6.4.4. 启动设置 API

设备端核函数启动介绍了从设备代码启动核函数的语法,该语法使用与主机 CUDA 运行时 API 相同的三重尖括号启动表示法。

核函数启动是一种通过设备运行时库公开的系统级机制。也可以通过cudaGetParameterBuffer()和cudaLaunchDevice() API 直接从 PTX 使用该机制。CUDA 应用程序可以自行调用这些 API,其要求与 PTX 相同。在这两种情况下,用户均需负责按照规范,以正确的格式正确填充所有必要的数据结构。这些数据结构保证向后兼容。

与主机端启动一样,设备端运算符<<<>>>会映射到底层核函数启动 API。这使得以 PTX 为目标的用户能够执行启动。NVCC 编译器前端会将<<<>>>转换为这些调用。

表 61 新增的仅限设备的启动实现函数

运行时 API 启动函数

与主机运行时行为的差异说明(如无说明,则行为相同)

cudaGetParameterBuffer

从<<<>>>自动生成。请注意,API 与对应的主机 API 不同。

cudaLaunchDevice

从<<<>>>自动生成。请注意,API 与对应的主机 API 不同。

这些启动函数的 API 与 CUDA 运行时 API 中的 API 不同,其定义如下:

extern   device   cudaError_t cudaGetParameterBuffer(void **params);
extern __device__ cudaError_t cudaLaunchDevice(void *kernel,
                                        void *params, dim3 gridDim,
                                        dim3 blockDim,
                                        unsigned int sharedMemSize = 0,
                                        cudaStream_t stream = 0);

5.6.4.5. 设备管理

设备运行时不支持多 GPU;设备运行时只能操作其当前正在执行所在的设备。不过,允许查询系统中任何支持 CUDA 的设备的属性。

5.6.4.6. API 参考

此处详细介绍了设备运行时所支持的 CUDA 运行时 API 部分。主机运行时 API 和设备运行时 API 的语法完全相同;除特别说明外,语义也相同。下表概述了这些 API 与主机端可用版本的比较。

表 62 支持的 API 函数

运行时 API 函数

详细信息

cudaDeviceGetCacheConfig

cudaDeviceGetLimit

cudaGetLastError

最近一次错误是每线程状态,而非每线程块状态

cudaPeekAtLastError

cudaGetErrorString

cudaGetDeviceCount

cudaDeviceGetAttribute

可返回任意设备的属性

cudaGetDevice

始终返回从主机端看到的当前设备 ID

cudaStreamCreateWithFlags

必须传入cudaStreamNonBlocking标志

cudaStreamDestroy

cudaStreamWaitEvent

cudaEventCreateWithFlags

必须传入cudaEventDisableTiming标志

cudaEventRecord

cudaEventDestroy

cudaFuncGetAttributes

cudaMemcpyAsync

关于所有memcpy/memset函数的注意事项:

  • 仅支持异步memcpy/set函数

  • 仅允许设备到设备的memcpy

  • 不得传入局部内存指针或共享内存指针

cudaMemcpy2DAsync

cudaMemcpy3DAsync

cudaMemsetAsync

cudaMemset2DAsync

cudaMemset3DAsync

cudaRuntimeGetVersion

cudaMalloc

不得在设备上对主机上创建的指针调用cudaFree,反之亦然

cudaFree

cudaOccupancyMaxActiveBlocksPerMultiprocessor

cudaOccupancyMaxPotentialBlockSize

cudaOccupancyMaxPotentialBlockSizeVariableSMem

5.6.4.7. API 错误和启动失败

与 CUDA 运行时通常的情况一样,任何函数都可能返回错误码。最近一次返回的错误码会被记录,并可通过cudaGetLastError()调用获取。错误按线程记录,因此每个线程都可以识别其产生的最近一次错误。错误码的类型为cudaError_t。

与主机端启动类似,设备端启动可能因多种原因(参数无效等)而失败。用户必须调用cudaGetLastError()来确定启动是否产生了错误,但启动后没有错误并不意味着子核函数已成功完成。

对于设备端异常,例如访问无效地址,子网格中的错误将返回给主机。

5.6.4.8. 设备运行时流

CUDA 设备运行时公开了一些特殊的命名流,它们为从设备启动的核函数和图提供特定行为。与设备图启动相关的命名流在设备启动中有介绍。另外两个可在 CUDA 设备运行时中用于核函数和 memcpy 操作的命名流是cudaStreamTailLaunch和cudaStreamTailLaunch。本节介绍这些命名流的具体行为。

设备运行时提供命名流和未命名(NULL)流。流句柄不能传递给父网格或子网格。换言之,应将流视为创建它的网格所私有的。

设备上不支持主机端 NULL 流的跨流屏障语义(详见下文)。为了保持与主机运行时的语义兼容性,所有设备流都必须使用cudaStreamCreateWithFlags() API 并传入cudaStreamNonBlocking标志来创建。CUDA 设备运行时中不提供cudaStreamCreate() API。

由于设备运行时不支持cudaStreamSynchronize()和cudaStreamQuery(),当应用程序需要知道在流中启动的子核函数已完成时,应改用启动到cudaStreamTailLaunch流中的核函数。

5.6.4.8.1. 隐式(NULL)流

在主机程序中,未命名(NULL)流与其他流之间具有额外的屏障同步语义(详见阻塞流、非阻塞流与默认流)。设备运行时提供一个由线程块中所有线程共享的隐式未命名流,但由于所有命名流都必须使用cudaStreamNonBlocking标志创建,启动到 NULL 流中的工作不会对其他任何流(包括其他线程块的 NULL 流)中尚未完成的工作引入隐式依赖。

5.6.4.8.2. 即发即弃流

即发即弃命名流(cudaStreamFireAndForget)允许用户使用更少的样板代码启动即发即弃工作,并且没有流跟踪开销。其功能与每次启动时创建一个新流并启动到该流中完全相同,但速度更快。

即发即弃启动会被立即调度以启动,而不依赖于先前启动的网格完成。除通过父网格结束时的隐式同步外,其他任何网格启动都不能依赖即发即弃启动的完成。因此,在父网格的即发即弃工作完成之前,尾部启动或父网格所在流中的下一个网格都不会启动。

// In this example, C2's launch will not wait for C1's completion
__global__ void P( ... ) {
   C1<<< ... , cudaStreamFireAndForget >>>( ... );
   C2<<< ... , cudaStreamFireAndForget >>>( ... );
}

即发即弃流不能用于记录事件或等待事件。尝试这样做将导致cudaErrorInvalidValue。在编译时定义了CUDA_FORCE_CDP1_IF_SUPPORTED的情况下,不支持即发即弃流。使用即发即弃流要求以 64 位模式编译。

5.6.4.8.3. 尾部启动流

尾部启动命名流(cudaStreamTailLaunch)允许网格将一个新网格调度为在自身完成后启动。在大多数情况下,应该可以使用尾部启动来实现与cudaDeviceSynchronize()相同的功能。

每个网格都有自己的尾部启动流。在尾部流开始执行之前,由网格启动的所有非尾部启动工作都会隐式同步。也就是说,父网格的尾部启动只有在父网格及其启动到普通流、每线程流或即发即弃流中的所有工作均已完成后才会启动。如果两个网格被启动到同一个网格的尾部启动流中,则后一个网格只有在前一个网格及其所有后代工作均已完成后才会启动。

// In this example, C2 will only launch after C1 completes.
__global__ void P( ... ) {
   C1<<< ... , cudaStreamTailLaunch >>>( ... );
   C2<<< ... , cudaStreamTailLaunch >>>( ... );
}

启动到尾部启动流中的网格只有在父网格的所有工作均完成后才会启动,这包括父网格在所有非尾部启动流中启动的所有其他网格(及其后代),也包括在尾部启动之后执行或启动的工作。

// In this example, C will only launch after all X, F and P complete.
__global__ void P( ... ) {
   C<<< ... , cudaStreamTailLaunch >>>( ... );
   X<<< ... , cudaStreamPerThread >>>( ... );
   F<<< ... , cudaStreamFireAndForget >>>( ... )
}

父网格所在流中的下一个网格不会在父网格的尾部启动工作完成之前启动。换言之,尾部启动流的行为就好像它被插入到其父网格与其父网格所在流中的下一个网格之间一样。

// In this example, P2 will only launch after C completes.
__global__ void P1( ... ) {
   C<<< ... , cudaStreamTailLaunch >>>( ... );
}

__global__ void P2( ... ) {
}

int main ( ... ) {
   ...
   P1<<< ... >>>( ... );
   P2<<< ... >>>( ... );
   ...
}

每个网格只有一个尾部启动流。要通过尾部启动来启动并发网格,可以采用以下示例所示的方式。

// In this example,  C1 and C2 will launch concurrently after P's completion
__global__ void T( ... ) {
   C1<<< ... , cudaStreamFireAndForget >>>( ... );
   C2<<< ... , cudaStreamFireAndForget >>>( ... );
}

__global__ void P( ... ) {
   ...
   T<<< ... , cudaStreamTailLaunch >>>( ... );
}

尾部启动流不能用于记录事件或等待事件。尝试这样做将导致cudaErrorInvalidValue。在编译时定义了CUDA_FORCE_CDP1_IF_SUPPORTED的情况下,不支持尾部启动流。使用尾部启动流要求以 64 位模式编译。

5.6.4.9. ECC 错误

CUDA 核函数内的代码无法收到 ECC 错误通知。整个启动树完成后,ECC 错误会在主机端报告。在嵌套程序执行期间出现的任何 ECC 错误,要么会产生异常,要么会继续执行(取决于错误和配置)。

5.7. CUDA C++ 内存模型

标准 C++ 所呈现的观点是,线程同步的成本均匀且较低。

CUDA C++ 则不同:线程之间的距离越远,线程同步的成本就越高。同一线程块内各线程之间的同步成本较低,但系统中运行于多个 GPU 和 CPU 上的任意线程之间的同步成本较高。

为应对不均匀且并非始终较低的线程同步成本,CUDA C++ 在cuda::命名空间中使用线程作用域扩展了标准 C++ 内存模型和并发机制,同时默认保留标准 C++ 的语法和语义。

5.7.1. 线程作用域

线程作用域指定了哪些类型的线程可以使用cuda::atomic或cuda::barrier等同步原语相互同步。

namespace cuda {

enum thread_scope {
  thread_scope_system,
  thread_scope_device,
  thread_scope_block,
  thread_scope_thread
};

}  // namespace cuda

5.7.1.1. 作用域关系

每个程序线程都通过一个或多个线程作用域关系与其他每个程序线程相关联:
  • 系统中的每个线程都通过系统线程作用域与系统中的其他每个线程相关联:cuda::thread_scope_system。

  • 每个 GPU 线程都通过设备线程作用域与同一 CUDA 设备中且位于同一内存同步域内的其他每个 GPU 线程相关联:cuda::thread_scope_device。

  • 每个 GPU 线程都通过线程块线程作用域与同一 CUDA 线程块中的其他每个 GPU 线程相关联:cuda::thread_scope_block。

  • 每个线程都通过线程线程作用域与自身相关联:cuda::thread_scope_thread。

5.7.2. 同步原语

命名空间std::和cuda::std::中的类型,与命名空间cuda::中以cuda::thread_scope_system作用域实例化的相应类型具有相同的行为。

5.7.3. 原子性

如果满足以下条件,则原子操作在其指定的作用域内具有原子性:

  • 它指定的作用域不是cuda::thread_scope_system,或

  • 作用域为cuda::thread_scope_system且:

注意

  • [0] 如果PageableMemoryAccessUsesHostPagetables为0,则对内存映射文件或hugetlbfs分配的内存进行的原子操作不具有原子性。

  • [1] 在这些情况下,只有hostNativeAtomicSupported为1时,系统作用域的张量归约操作才在系统作用域内具有原子性。系统作用域的张量归约操作包括 PTX{multimem.}cp.reduce.async.bulk.tensor.relaxed.sys指令族及公开这些指令的 API,例如cuda::ptx::cp_reduce_async_bulk_tensor。

  • [2] 如果hostNativeAtomicSupported为0,则作用于系统分配的内存或映射内存中自然对齐的 16 字节宽对象的系统作用域原子加载或存储操作需要系统支持。NVIDIA 尚未知晓任何缺乏此项支持的系统,也没有可用于检测此类系统的 CUDA API 查询。

有关系统分配的内存、托管内存、映射内存、CPU 内存和 GPU 内存的更多信息,请参阅本指南的相关章节。

5.7.4. 数据竞争

将 ISO/IEC IS 14882(C++ 标准)的intro.races 第 21 段修改如下:

如果程序的执行包含两个可能并发且相互冲突的动作,其中至少有一个在包含执行另一操作的线程的作用域内不是原子操作,且二者互不先发生于对方,则该执行包含数据竞争,但下文所述的信号处理程序的特殊情况除外。任何此类数据竞争都会导致未定义行为。[…]

将 ISO/IEC IS 14882(C++ 标准)的thread.barrier.class 第 4 段修改如下

4. 对barrier除析构函数以外的成员函数的并发调用不会引入数据竞争,如同它们是原子操作一样。[…]

将 ISO/IEC IS 14882(C++ 标准)的thread.latch.class 第 2 段修改如下:

2. 并发调用latch的成员函数(析构函数除外)不会引入数据竞争,如同这些调用是原子操作一样。[…]

将 ISO/IEC IS 14882(C++ 标准)的thread.sema.cnt 第 3 段修改如下:

3. 并发调用counting_semaphore的成员函数(析构函数除外)不会引入数据竞争,如同这些调用是原子操作一样。

将 ISO/IEC IS 14882(C++ 标准)的thread.stoptoken.intro 第 5 段修改如下:

调用函数request_stop、stop_requested和stop_possible不会引入数据竞争,如同这些调用是原子操作一样。[…]

将 ISO/IEC IS 14882(C++ 标准)的atomics.fences 第 2 至第 4 段修改如下:

如果存在均作用于某个原子对象 M 的原子操作 X 和 Y,使得 A 按顺序先于 X,X 修改 M,Y 按顺序先于 B,并且 Y 读取 X 写入的值,或者读取假设 X 是释放操作时以其为首的假想释放序列中任何副作用写入的值,且每个操作(A、B、X 和 Y)指定的作用域都包含执行其他各操作的线程,则释放栅栏 A 与获取栅栏 B 同步。

如果存在原子操作 X,使得 A 按顺序先于 X,X 修改 M,并且 B 读取 X 写入的值,或者读取假设 X 是释放操作时以其为首的假想释放序列中任何副作用写入的值,且每个操作(A、B 和 X)指定的作用域都包含执行其他各操作的线程,则释放栅栏 A 与在原子对象 M 上执行获取操作的原子操作 B 同步。

如果存在某个作用于 M 的原子操作 X,使得 X 按顺序先于 B,并且读取 A 写入的值,或者读取以 A 为首的释放序列中任何副作用写入的值,且每个操作(A、B 和 X)指定的作用域都包含执行其他各操作的线程,则在原子对象 M 上执行释放操作的原子操作 A 与获取栅栏 B 同步。

5.7.5. 示例:消息传递

以下示例通过标志f,将线程块0中的一个线程存储到x变量的消息传递给线程块1中的一个线程:

初始状态

int x = 0, f = 0;

线程块 0 的线程 0

x = 42;
cuda::atomic_ref<int, cuda::thread_scope_device> flag(f);
flag.store(1, memory_order_release);

线程块 1 的线程 0

cuda::atomic_ref<int, cuda::thread_scope_device> flag(f);
while(flag.load(memory_order_acquire) != 1);
assert(x == 42);

在前一示例的以下变体中,两个线程在没有同步的情况下并发访问f对象,这会导致数据竞争,并表现出未定义行为:

初始状态

int x = 0, f = 0;

线程块 0 的线程 0

x = 42;
cuda::atomic_ref<int, cuda::thread_scope_block> flag(f);
flag.store(1, memory_order_release); // UB: data race

线程块 1 的线程 0

cuda::atomic_ref<int, cuda::thread_scope_device> flag(f);
while(flag.load(memory_order_acquire) != 1); // UB: data race
assert(x == 42);

虽然对f的内存操作(存储和加载)是原子的,但存储操作的作用域是“线程块作用域”。由于该存储由线程块 0 的线程 0 执行,因此它仅包含线程块 0 中的所有其他线程。然而,执行加载的线程位于线程块 1 中,也就是说,它不在由线程块 0 中执行的存储操作所包含的作用域内,导致该存储和加载不是“原子的”,从而引入数据竞争。

更多示例请参见PTX 内存一致性模型试金石测试。

5.8. CUDA C++ 执行模型

CUDA C++ 旨在为所有设备执行线程提供并行前向进展 [intro.progress.9],以便使用 CUDA C++ 对现有 C++ 应用程序进行并行化。

[intro.progress]
  • [intro.progress.7]:对于提供并发前向进展保证的执行线程,实现确保只要该线程尚未终止,它最终就会取得进展。

    [注意 5:无论其他执行线程(如果有)是否曾经或正在取得进展,这一点都适用。最终满足此要求意味着,这将在未指定但有限的时间内发生。注意结束]

  • [intro.progress.9]:对于提供并行前向进展保证的执行线程,如果它尚未执行任何执行步骤,则不要求实现确保该线程最终会取得进展;一旦此线程执行了一个步骤,它就提供并发前向进展保证。

    [注意 6:这并未规定何时启动此执行线程的要求,通常由创建此执行线程的实体来规定。例如,一个提供并发前向进展保证、并以任意顺序逐一执行某个任务集中的任务的执行线程,满足这些任务的并行前向进展要求。注意结束]

CUDA C++ 编程语言是 C++ 编程语言的扩展。本节记录了对当前ISO 国际标准 ISO/IEC 14882《编程语言 C++》草案中[intro.progress]一节的修改和扩展。修改过的各节会明确指出,差异以粗体显示。所有其他各节均为新增内容。

5.8.1. 主机线程

由主机实现创建、用于执行main、std::thread和std::jthread的执行线程所提供的前向进展,是由主机实现定义的行为[intro.progress]。通用主机实现应提供并发前向进展。

如果主机实现提供并发前向进展 [intro.progress.7],则 CUDA C++ 为设备线程提供并行前向进展 [intro.progress.9]。

5.8.2. 设备线程

一旦某个设备线程取得进展:

  • 如果它属于一个协作网格,则其网格中的所有设备线程最终都必须取得进展。

  • 否则,其线程块簇中的所有设备线程最终都必须取得进展。

    [注意:不保证其他线程块簇中的线程最终会取得进展。注意结束。]

    [注意:这意味着其线程块内的所有设备线程最终都必须取得进展。注意结束。]

将[intro.progress.1]修改如下(修改内容以粗体显示):

实现可以假定任何主机线程最终都会执行以下操作之一:

  1. 终止,

  2. 调用函数std::this_thread::yield([thread.thread.this]),

  3. 调用库 I/O 函数,

  4. 通过 volatile 泛左值执行访问,

  5. 执行同步操作或原子操作,或者

  6. 继续执行平凡无限循环([stmt.iter.general])。

实现可以假定任何设备线程最终都会执行以下操作之一:

  1. 终止,

  2. 调用库 I/O 函数,

  3. 通过 volatile 泛左值执行访问,但所指示的对象具有自动存储期的情况除外,或者

  4. 执行同步操作或原子读取操作,但所指示的对象具有自动存储期的情况除外。

[注意:设备线程相对于主机线程的某些当前限制是我们已知的实现缺陷,我们可能会随着时间的推移修复这些缺陷。例如,设备线程最终仅对具有自动存储期的对象执行 volatile 操作或原子操作时会产生未定义行为。然而,设备线程相对于主机线程的其他限制是有意作出的选择。这些选择使某些性能优化成为可能,而如果设备线程严格遵循 C++ 标准,这些优化就无法实现。例如,为最终仅执行原子写入或栅栏的程序提供前向进展,会降低整体性能,却几乎没有实际益处。注意结束。]

对[intro.progress.1]的修改所导致的主机线程与设备线程之间前向进展保证差异的示例。

以下示例分别使用“host.threads.<id>”和“device.threads.<id>”来引用上述主机线程和设备线程的实现假设中逐项列出的子条款。

1// Example: Execution.Model.Device.0
2// Outcome: grid eventually terminates per device.threads.4 because the atomic object does not have automatic storage duration.
3__global__ void ex0(cuda::atomic_ref<int, cuda::thread_scope_device> atom) {
4    if (threadIdx.x == 0) {
5        while(atom.load(cuda::memory_order_relaxed) == 0);
6    } else if (threadIdx.x == 1) {
7        atom.store(1, cuda::memory_order_relaxed);
8    }
9}
1// Example: Execution.Model.Device.1
2// Allowed outcome: No thread makes progress because device threads don't support host.threads.2.
3__global__ void ex1() {
4    while(true) cuda::std::this_thread::yield();
5}
1// Example: Execution.Model.Device.2
2// Allowed outcome: No thread makes progress because device threads don't support host.threads.4
3// for objects with automatic storage duration (see exception in device.threads.3).
4__global__ void ex2() {
5    volatile bool True = true;
6    while(True);
7}
1// Example: Execution.Model.Device.3
2// Allowed outcome: No thread makes progress because device threads don't support host.threads.5
3// for objects with automatic storage duration (see exception in device.threads.4).
4__global__ void ex3() {
5    cuda::atomic<bool, cuda::thread_scope_thread> True = true;
6    while(True.load());
7}
1// Example: Execution.Model.Device.4
2// Allowed outcome: No thread makes progress because device threads don't support host.thread.6.
3__global void ex4() {
4    while(true) { /* empty */ }
5}

5.8.3. CUDA API

CUDA API 调用最终必须返回,或者确保至少一个设备线程取得进展。

CUDA 查询函数(例如cudaStreamQuery、cudaEventQuery等)不得在没有设备线程取得进展的情况下持续返回cudaErrorNotReady。

[注意:该设备线程不必与 API 调用“相关”,例如,对一个流或进程执行操作的 API 可以确保另一个流或进程上的设备线程取得进展。注意结束。]

[注意:测试程序是否符合 CUDA API 前向进展要求的一种简单但不充分的方法是,在设置以下环境变量后运行程序:CUDA_DEVICE_MAX_CONNECTIONS=1 CUDA_LAUNCH_BLOCKING=1,然后检查程序是否仍会终止。如果不会,则程序存在错误。此方法并不充分,因为它无法发现所有前向进展错误,但确实可以发现许多此类错误。注意结束。]

CUDA API 前向进展保证的示例。
 1// Example: Execution.Model.API.1
 2// Outcome: if no other device threads (e.g., from other processes) are making progress,
 3// this program terminates and returns cudaSuccess.
 4// Rationale: CUDA guarantees that if the device is empty:
 5// - `cudaDeviceSynchronize` eventually ensures that at least one device-thread makes progress, which implies that eventually `hello_world` grid and one of its device-threads start.
 6// - All thread-block threads eventually start (due to "if a device thread makes progress, all other threads in its thread-block cluster eventually make progress").
 7// - Once all threads in thread-block arrive at `__syncthreads` barrier, all waiting threads are unblocked.
 8// - Therefore all device threads eventually exit the `hello_world`` grid.
 9// - And `cudaDeviceSynchronize`` eventually unblocks.
10__global__ void hello_world() { __syncthreads(); }
11int main() {
12    hello_world<<<1,2>>>();
13    return (int)cudaDeviceSynchronize();
14}
 1// Example: Execution.Model.API.2
 2// Allowed outcome: eventually, no thread makes progress.
 3// Rationale: the `cudaDeviceSynchronize` API below is only called if a device thread eventually makes progress and sets the flag.
 4// However, CUDA only guarantees that `producer` device thread eventually starts if the synchronization API is called.
 5// Therefore, the host thread may never be unblocked from the flag spin-loop.
 6cuda::atomic<int, cuda::thread_scope_system> flag = 0;
 7__global__ void producer() { flag.store(1); }
 8int main() {
 9    cudaHostRegister(&flag, sizeof(flag));
10    producer<<<1,1>>>();
11    while (flag.load() == 0);
12    return cudaDeviceSynchronize();
13}
 1// Example: Execution.Model.API.3
 2// Allowed outcome: eventually, no thread makes progress.
 3// Rationale: same as Example.Model.API.2, with the addition that a single CUDA query API call does not guarantee
 4// the device thread eventually starts, only repeated CUDA query API calls do (see Execution.Model.API.4).
 5cuda::atomic<int, cuda::thread_scope_system> flag = 0;
 6__global__ void producer() { flag.store(1); }
 7int main() {
 8    cudaHostRegister(&flag, sizeof(flag));
 9    producer<<<1,1>>>();
10    (void)cudaStreamQuery(0);
11    while (flag.load() == 0);
12    return cudaDeviceSynchronize();
13}
 1// Example: Execution.Model.API.4
 2// Outcome: terminates.
 3// Rationale: same as Execution.Model.API.3, but this example repeatedly calls
 4// a CUDA query API in within the flag spin-loop, which guarantees that the device thread
 5// eventually makes progress.
 6cuda::atomic<int, cuda::thread_scope_system> flag = 0;
 7__global__ void producer() { flag.store(1); }
 8int main() {
 9    cudaHostRegister(&flag, sizeof(flag));
10    producer<<<1,1>>>();
11    while (flag.load() == 0) {
12        (void)cudaStreamQuery(0);
13    }
14    return cudaDeviceSynchronize();
15}

5.8.3.1. 依赖项

设备线程在其所有依赖项完成之前不得启动。

[注意:例如,可以通过CUDA 流命令创建阻止设备线程开始取得进展的依赖项。这些依赖项可能包括对CUDA 事件、CUDA 核函数以及其他事项完成的依赖。注意结束。]

由依赖项产生的 CUDA API 前向进展保证的示例
 1// Example: Execution.Model.Stream.0
 2// Allowed outcome: eventually, no thread makes progress.
 3// Rationale: while CUDA guarantees that one device thread makes progress, since there
 4// is no dependency between `first` and `second`, it does not guarantee which thread,
 5// and therefore it could always pick the device thread from `second`, which then never
 6// unblocks from the spin-loop.
 7// That is, `second` may starve `first`.
 8cuda::atomic<int, cuda::thread_scope_system> flag = 0;
 9__global__ void first() { flag.store(1, cuda::memory_order_relaxed); }
10__global__ void second() { while(flag.load(cuda::memory_order_relaxed) == 0) {} }
11int main() {
12    cudaHostRegister(&flag, sizeof(flag));
13    cudaStream_t s0, s1;
14    cudaStreamCreate(&s0);
15    cudaStreamCreate(&s1);
16    first<<<1,1,0,s0>>>();
17    second<<<1,1,0,s1>>>();
18    return cudaDeviceSynchronize();
19}
 1// Example: Execution.Model.Stream.1
 2// Outcome: terminates.
 3// Rationale: same as Execution.Model.Stream.0, but this example has a stream dependency
 4// between first and second, which requires CUDA to run the grids in order.
 5cuda::atomic<int, cuda::thread_scope_system> flag = 0;
 6__global__ void first() { flag.store(1, cuda::memory_order_relaxed); }
 7__global__ void second() { while(flag.load(cuda::memory_order_relaxed) == 0) {} }
 8int main() {
 9    cudaHostRegister(&flag, sizeof(flag));
10    cudaStream_t s0;
11    cudaStreamCreate(&s0);
12    first<<<1,1,0,s0>>>();
13    second<<<1,1,0,s0>>>();
14    return cudaDeviceSynchronize();
15}

6. 声明

6.1. 声明

本文档仅供参考,不应被视为对产品某种功能、状态或质量的保证。NVIDIA Corporation(“NVIDIA”)对本文档所含信息的准确性或完整性不作任何明示或暗示的陈述或保证,并且不对其中的任何错误承担责任。NVIDIA 对使用此类信息或由此产生的后果,以及因使用此类信息而可能导致的对第三方专利或其他权利的任何侵犯,均不承担责任。本文档不构成开发、发布或交付任何材料(定义见下文)、代码或功能的承诺。

NVIDIA 保留随时对本文档进行更正、修改、增强、改进及任何其他变更的权利,恕不另行通知。

客户应在下订单前获取最新的相关信息,并应核实此类信息是最新且完整的。

NVIDIA 产品的销售受订单确认时提供的 NVIDIA 标准销售条款和条件约束,除非 NVIDIA 和客户的授权代表签署的单独销售协议另有约定(“销售条款”)。NVIDIA 特此明确反对将客户的任何一般条款和条件适用于本文档所述 NVIDIA 产品的购买。本文档不直接或间接形成任何合同义务。

NVIDIA 产品并非为用于医疗、军事、航空器、航天或生命支持设备而设计,也未获授权或保证适用于此类设备;同样,也并非为用于可合理预期 NVIDIA 产品的故障或失灵会造成人身伤害、死亡、财产损害或环境损害的应用而设计,亦未获授权或保证适用于此类应用。NVIDIA 对在此类设备或应用中纳入和/或使用 NVIDIA 产品不承担任何责任,因此,此类纳入和/或使用的风险由客户自行承担。

NVIDIA 不对基于本文档的产品适合任何指定用途作出任何陈述或保证。NVIDIA 不一定会对每个产品的所有参数进行测试。客户应自行承担全部责任,评估并确定本文档所含任何信息的适用性,确保产品适合并适用于客户计划的应用,并针对该应用执行必要的测试,以避免应用或产品出现缺陷。客户产品设计中的缺陷可能影响 NVIDIA 产品的质量和可靠性,并可能导致出现超出本文档所含内容的附加或不同条件和/或要求。对于可能基于或归因于以下情形的任何缺陷、损害、费用或问题,NVIDIA 均不承担相关责任:(i) 以任何违反本文档的方式使用 NVIDIA 产品,或 (ii) 客户的产品设计。

本文档不授予 NVIDIA 任何专利权、版权或其他 NVIDIA 知识产权项下的任何明示或暗示许可。NVIDIA 发布的有关第三方产品或服务的信息,不构成 NVIDIA 对使用此类产品或服务的许可,也不构成对其的保证或认可。使用此类信息可能需要获得第三方根据其专利或其他知识产权授予的许可,或获得 NVIDIA 根据其专利或其他知识产权授予的许可。

只有事先获得 NVIDIA 的书面批准,在不作改动且完全遵守所有适用出口法律和法规的情况下复制,并附带所有相关条件、限制和声明,才允许复制本文档中的信息。

本文档及所有 NVIDIA 设计规范、参考板、文件、图纸、诊断资料、清单及其他文档(统称或单独称为“材料”)均按“原样”提供。NVIDIA 不对这些材料作任何明示、暗示、法定或其他保证,并明确声明不承担关于不侵权、适销性及特定用途适用性的所有暗示保证。在法律未禁止的范围内,无论在何种情况下,NVIDIA 均不对因使用本文档而产生的任何损害承担责任,包括但不限于任何直接、间接、特殊、附带、惩罚性或后果性损害,无论损害的原因如何,也无论依据何种责任理论,即使 NVIDIA 已被告知可能发生此类损害,亦是如此。无论客户因何种原因可能遭受任何损害,NVIDIA 就本文所述产品对客户承担的总计及累计责任,均应根据该产品的销售条款予以限制。

6.2. OpenCL

OpenCL 是 Apple Inc. 的商标,由 Khronos Group Inc. 经许可使用。

6.3. 商标

NVIDIA 和 NVIDIA 徽标是 NVIDIA Corporation 在美国和其他国家的商标或注册商标。其他公司和产品名称可能是其各自关联公司的商标。

原著 © 2007 至 2026 NVIDIA Corporation 及其关联公司。保留所有权利。
中文译本仅作学习参考,非 NVIDIA 官方出版物。技术含义有疑问时请核对NVIDIA 英文原文。
关于本译本

输入关键词搜索全书。