CUDA C++编程指南

CUDA模型和接口的编程指南。

版本12.8的变更

1. 简介

1.1. 使用GPU的优势

图形处理单元(GPU)1在相近的价格和功耗范围内,能提供比CPU高得多的指令吞吐量和内存带宽。许多应用程序利用这些更高性能在GPU上运行得比CPU更快(参见GPU Applications)。其他计算设备如FPGA虽然也非常节能,但编程灵活性远不如GPU。

GPU和CPU之间的能力差异源于它们设计目标的根本不同。CPU的设计目标是尽可能快地执行一系列称为线程的操作,并能并行执行几十个这样的线程;而GPU的设计初衷则是擅长并行执行数千个线程(通过牺牲单线程性能来换取更高的整体吞吐量)。

GPU专为高度并行计算而设计,因此其架构将更多晶体管用于数据处理而非数据缓存和流控制。图1展示了CPU与GPU芯片资源分配的典型对比示意图。

The GPU Devotes More Transistors to Data Processing

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

将更多晶体管用于数据处理(例如浮点计算)有利于高度并行计算;GPU可以通过计算来隐藏内存访问延迟,而不是依赖大型数据缓存和复杂的流控制来避免长内存访问延迟,这两者在晶体管成本方面都非常昂贵。

通常来说,应用程序会同时包含并行部分和串行部分,因此系统会采用GPU和CPU混合的设计方案,以实现整体性能最大化。具有高度并行性的应用程序可以利用GPU的大规模并行特性,从而获得比CPU更高的性能。

1.2. CUDA®:通用并行计算平台与编程模型

2006年11月,NVIDIA®推出了CUDA®,这是一个通用并行计算平台和编程模型,它利用NVIDIA GPU中的并行计算引擎,以比CPU更高效的方式解决许多复杂的计算问题。

CUDA附带了一个软件开发环境,允许开发者使用C++作为高级编程语言。如图2所示,它还支持其他语言、应用程序接口或基于指令的方法,例如FORTRAN、DirectCompute和OpenACC。

GPU Computing Applications. CUDA is designed to support various languages and application programming interfaces.

图2 GPU计算应用。CUDA旨在支持多种语言和应用程序编程接口。

1.3. 可扩展的编程模型

多核CPU和众核GPU的出现意味着主流处理器芯片现在已成为并行系统。面临的挑战是开发能够透明扩展并行性以利用日益增多的处理器核心的应用软件,就像3D图形应用能透明地将其并行性扩展到具有不同核心数量的众核GPU上一样。

CUDA并行编程模型旨在克服这一挑战,同时为熟悉C等标准编程语言的程序员保持较低的学习曲线。

其核心是三个关键抽象概念——线程组层次结构、共享内存和屏障同步——这些概念通过最少的语言扩展直接向程序员开放。

这些抽象提供了细粒度的数据并行和线程并行,嵌套在粗粒度的数据并行和任务并行之中。它们指导程序员将问题划分为可以由线程块并行独立解决的粗粒度子问题,并将每个子问题进一步划分为可以由块内所有线程协作并行解决的更细粒度部分。

这种分解方式通过让线程在解决每个子问题时相互协作,既保留了语言表达能力,又实现了自动可扩展性。实际上,每个线程块都可以按任意顺序、并发或顺序地在GPU上的任意可用多处理器上进行调度,因此如图3所示,编译后的CUDA程序可以在任意数量的多处理器上执行,且只有运行时系统需要知道实际的多处理器数量。

这种可扩展的编程模型使得GPU架构能够通过简单地增加多处理器数量和内存分区来覆盖广泛的市场范围:从高性能发烧友级GeForce GPU和专业级Quadro与Tesla计算产品,到各种价格亲民的主流GeForce GPU(所有支持CUDA的GPU列表请参见CUDA-Enabled GPUs)。

Automatic Scalability

图3 自动扩展能力

注意

GPU的核心架构由一系列流式多处理器(SM)阵列构成(详见硬件实现)。多线程程序会被划分为多个线程块,这些线程块彼此独立执行。因此,拥有更多多处理器的GPU能够以更短时间自动完成程序执行,相比多处理器较少的GPU具有明显优势。

1.4. 文档结构

本文档分为以下部分:

1

图形限定词源于GPU最初创建时的背景。二十年前,GPU被设计为专门用于加速图形渲染的处理器。在实时、高清、3D图形市场需求的持续推动下,它已发展成为一种通用处理器,可用于远不止图形渲染的更多工作负载。

2. 编程模型

本章通过概述CUDA编程模型在C++中的实现方式,介绍其背后的主要概念。

关于CUDA C++的详细描述,请参阅编程接口

本章及下一章使用的向量加法示例完整代码可在vectorAdd CUDA示例中找到。

2.1. 内核

CUDA C++ 扩展了 C++,允许程序员定义称为内核的 C++ 函数。与常规 C++ 函数仅执行一次不同,当调用这些内核时,它们会被 N 个不同的CUDA 线程并行执行 N 次。

内核使用__global__声明说明符定义,针对给定内核调用执行该内核的CUDA线程数量通过新的<<<...>>>执行配置语法指定(参见执行配置)。每个执行内核的线程都会被分配一个唯一的线程ID,该ID可通过内置变量在内核中访问。

作为示例,以下示例代码使用内置变量threadIdx,将大小为N的两个向量AB相加,并将结果存储到向量C中。

// Kernel definition
__global__ void VecAdd(float* A, float* B, float* C)
{
    int i = threadIdx.x;
    C[i] = A[i] + B[i];
}

int main()
{
    ...
    // Kernel invocation with N threads
    VecAdd<<<1, N>>>(A, B, C);
    ...
}

在这里,执行VecAdd()的每个N线程都会执行一次成对加法。

2.2. 线程层次结构

为了方便起见,threadIdx是一个三维向量,因此可以使用一维、二维或三维线程索引来标识线程,形成一维、二维或三维的线程组,称为线程块。这为跨域元素(如向量、矩阵或体积)调用计算提供了一种自然的方式。

线程索引与其线程ID之间的关系非常直接:对于一维块,它们是相同的;对于大小为(Dx, Dy)的二维块,索引为(x, y)的线程ID是(x + y Dx);对于大小为(Dx, Dy, Dz)的三维块,索引为(x, y, z)的线程ID是(x + y Dx + z Dx Dy)

例如,以下代码将两个大小为NxN的矩阵AB相加,并将结果存储到矩阵C中。

// Kernel definition
__global__ void MatAdd(float A[N][N], float B[N][N],
                       float C[N][N])
{
    int i = threadIdx.x;
    int j = threadIdx.y;
    C[i][j] = A[i][j] + B[i][j];
}

int main()
{
    ...
    // Kernel invocation with one block of N * N * 1 threads
    int numBlocks = 1;
    dim3 threadsPerBlock(N, N);
    MatAdd<<<numBlocks, threadsPerBlock>>>(A, B, C);
    ...
}

每个线程块的线程数量是有限制的,因为一个块的所有线程都驻留在同一个流式多处理器核心上,并且必须共享该核心的有限内存资源。在当前GPU上,一个线程块最多可包含1024个线程。

然而,一个内核可以由多个形状相同的线程块执行,因此线程总数等于每个块的线程数乘以块的数量。

线程块被组织成一维、二维或三维的网格,如图4所示。网格中的线程块数量通常由待处理数据的大小决定,该数量一般会超过系统中处理器的数量。

Grid of Thread Blocks

图4 线程块网格

<<<...>>>语法中指定的每个块的线程数和每个网格的块数可以是intdim3类型。如上面的示例所示,可以指定二维块或网格。

网格中的每个块可以通过一维、二维或三维的唯一索引来标识,该索引在内核中通过内置的blockIdx变量访问。线程块的维度在内核中通过内置的blockDim变量访问。

扩展之前的MatAdd()示例以处理多个块,代码如下所示。

// Kernel definition
__global__ void MatAdd(float A[N][N], float B[N][N],
float C[N][N])
{
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    int j = blockIdx.y * blockDim.y + threadIdx.y;
    if (i < N && j < N)
        C[i][j] = A[i][j] + B[i][j];
}

int main()
{
    ...
    // Kernel invocation
    dim3 threadsPerBlock(16, 16);
    dim3 numBlocks(N / threadsPerBlock.x, N / threadsPerBlock.y);
    MatAdd<<<numBlocks, threadsPerBlock>>>(A, B, C);
    ...
}

线程块大小为16x16(256个线程),虽然在此例中是任意选择的,但这是一个常见的选择。网格被创建为包含足够多的块,以便像之前一样每个矩阵元素对应一个线程。为简单起见,此示例假设每个维度中每个网格的线程数能被该维度中每个块的线程数整除,尽管实际情况并非必须如此。

线程块需要独立执行。必须能够以任意顺序、并行或串行执行这些块。这种独立性要求允许线程块以任何顺序调度并在任意数量的核心上运行,如图3所示,使程序员能够编写可随核心数量扩展的代码。

块内的线程可以通过共享内存共享数据,并通过同步执行来协调内存访问,从而实现协作。更准确地说,可以通过调用__syncthreads()内置函数在内核中指定同步点;__syncthreads()充当一个屏障,块中的所有线程都必须在此等待,然后才能继续执行。Shared Memory提供了一个使用共享内存的示例。除了__syncthreads()之外,Cooperative Groups API还提供了一组丰富的线程同步原语。

为了实现高效协作,共享内存应设计为靠近每个处理器核心的低延迟内存(类似于L1缓存),而__syncthreads()则需保持轻量级特性。

2.2.1. 线程块集群

随着NVIDIA Compute Capability 9.0的推出,CUDA编程模型引入了一个称为线程块集群(Thread Block Clusters)的可选层次结构,该结构由线程块组成。类似于线程块中的线程保证在流式多处理器上协同调度,集群中的线程块也保证在GPU处理集群(GPC)上协同调度。

与线程块类似,集群也被组织成一维、二维或三维的线程块集群网格,如图5所示。集群中的线程块数量可由用户自定义,在CUDA中支持的最大可移植集群大小为每个集群8个线程块。 需要注意的是,在GPU硬件或MIG配置过小无法支持8个多处理器的情况下,最大集群尺寸会相应减小。识别这些较小配置以及支持超过8个线程块集群尺寸的较大配置是架构相关的,可通过cudaOccupancyMaxPotentialClusterSize API进行查询。

Grid of Thread Block Clusters

图5 线程块集群网格

注意

在使用集群支持启动的内核中,出于兼容性考虑,gridDim变量仍表示线程块数量的维度。可以通过Cluster Group API来查找块在集群中的层级。

可以通过两种方式在内核中启用线程块集群:一种是使用编译时内核属性__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);
}

线程块集群大小也可以在运行时设置,并且可以使用CUDA内核启动API cudaLaunchKernelEx来启动内核。以下代码示例展示了如何使用可扩展API启动集群内核。

// 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);
    }
}

在计算能力为9.0的GPU中,集群中的所有线程块保证会在单个GPU处理集群(GPC)上协同调度,并允许集群中的线程块使用Cluster Group API cluster.sync()进行硬件支持的同步。集群组还提供成员函数,分别通过num_threads()num_blocks() API查询以线程数或块数表示的集群组大小。线程或块在集群组中的排名可以分别使用dim_threads()dim_blocks() API查询。

属于集群的线程块可以访问分布式共享内存。集群中的线程块能够对分布式共享内存中的任何地址进行读取、写入和原子操作。Distributed Shared Memory展示了一个在分布式共享内存中执行直方图计算的示例。

2.3. 内存层次结构

CUDA线程在执行过程中可以从多个内存空间访问数据,如图6所示。每个线程都有私有的本地内存。每个线程块都有共享内存,该内存对块内的所有线程可见且生命周期与线程块相同。线程块集群中的线程块可以相互对共享内存执行读取、写入和原子操作。所有线程都可以访问相同的全局内存。

所有线程还可以访问两个额外的只读内存空间:常量内存空间和纹理内存空间。全局内存、常量内存和纹理内存空间针对不同的内存使用场景进行了优化(参见设备内存访问)。纹理内存还为某些特定数据格式提供了不同的寻址模式和数据过滤功能(参见纹理和表面内存)。

全局内存、常量内存和纹理内存空间在同一应用程序的内核启动过程中是持久存在的。

Memory Hierarchy

图6 内存层次结构

2.4. 异构编程

图7所示,CUDA编程模型假设CUDA线程在一个物理独立的设备上执行,该设备作为运行C++程序的主机的协处理器。例如,当内核在GPU上执行而C++程序的其余部分在CPU上执行时,就是这种情况。

CUDA编程模型还假设主机和设备各自在DRAM中维护独立的内存空间,分别称为主机内存设备内存。因此,程序通过调用CUDA运行时(详见编程接口)来管理内核可见的全局内存、常量内存和纹理内存空间。这包括设备内存的分配与释放,以及主机内存与设备内存之间的数据传输。

统一内存(Unified Memory)提供托管内存来桥接主机和设备内存空间。托管内存可作为具有统一地址空间的单一连贯内存映像,供系统中所有CPU和GPU访问。这一功能支持设备内存的超额分配,并通过消除在主机和设备间显式镜像数据的需求,能极大简化应用程序移植工作。有关统一内存的介绍,请参阅统一内存编程

Heterogeneous Programming

图7 异构编程

注意

串行代码在主机上执行,而并行代码在设备上执行。

2.5. 异步SIMT编程模型

在CUDA编程模型中,线程是执行计算或内存操作的最低抽象级别。从基于NVIDIA安培GPU架构的设备开始,CUDA编程模型通过异步编程模型为内存操作提供加速。异步编程模型定义了异步操作相对于CUDA线程的行为。

异步编程模型定义了异步屏障的行为,用于CUDA线程之间的同步。该模型还解释并定义了如何通过cuda::memcpy_async在GPU进行计算的同时,异步地将数据从全局内存中移动。

2.5.1. 异步操作

异步操作定义为由CUDA线程发起、并由另一个线程异步执行的操作。在编写良好的程序中,一个或多个CUDA线程会与该异步操作进行同步。发起异步操作的CUDA线程不必包含在这些同步线程中。

这样的异步线程(虚拟线程)总是与发起异步操作的CUDA线程相关联。异步操作使用同步对象来同步操作的完成。这种同步对象可以由用户显式管理(例如cuda::memcpy_async),也可以在库中隐式管理(例如cooperative_groups::memcpy_async)。

同步对象可以是cuda::barriercuda::pipeline。这些对象在异步屏障使用cuda::pipeline的异步数据拷贝中有详细说明。这些同步对象可以在不同的线程作用域中使用。作用域定义了可以使用同步对象与异步操作同步的线程集合。下表定义了CUDA C++中可用的线程作用域以及可以与每个作用域同步的线程。

线程作用域

描述

cuda::thread_scope::thread_scope_thread

只有发起异步操作的CUDA线程会进行同步。

cuda::thread_scope::thread_scope_block

与发起线程在同一线程块中的所有或任意CUDA线程将同步。

cuda::thread_scope::thread_scope_device

与发起线程位于同一GPU设备中的所有或任何CUDA线程将同步。

cuda::thread_scope::thread_scope_system

与发起线程位于同一系统中的所有或任意CUDA或CPU线程将同步。

这些线程作用域作为标准C++的扩展在CUDA Standard C++库中实现。

2.6. 计算能力

设备的计算能力由一个版本号表示,有时也称为"SM版本"。这个版本号标识了GPU硬件支持的功能,应用程序在运行时通过它来确定当前GPU可用的硬件特性和/或指令。

计算能力由主版本号X和次版本号Y组成,表示为X.Y

具有相同主修订号的设备属于相同的核心架构。主修订号9对应基于NVIDIA Hopper GPU架构的设备,8对应基于NVIDIA Ampere GPU架构的设备,7对应基于Volta架构的设备,6对应基于Pascal架构的设备,5对应基于Maxwell架构的设备,3对应基于Kepler架构的设备。

次版本号对应核心架构的增量改进,可能包括新功能。

Turing是计算能力7.5设备的架构,是基于Volta架构的增量更新版本。

CUDA-Enabled GPUs 列出了所有支持CUDA的设备及其计算能力。Compute Capabilities 提供了每个计算能力的技术规格。

注意

特定GPU的计算能力版本不应与CUDA版本(例如CUDA 7.5、CUDA 8、CUDA 9)混淆,后者是CUDA软件平台的版本。应用程序开发者使用CUDA平台来创建能在多代GPU架构上运行的应用程序,包括尚未发明的未来GPU架构。虽然新版本的CUDA平台通常通过支持新架构的计算能力版本来添加对该GPU架构的原生支持,但新版本的CUDA平台通常也包含与硬件代际无关的软件功能。

从CUDA 7.0和CUDA 9.0开始,分别不再支持TeslaFermi架构。

3. 编程接口

CUDA C++为熟悉C++编程语言的用户提供了一条简单路径,可以轻松编写由设备执行的程序。

它包含了对C++语言的最小扩展集和一个运行时库。

核心语言扩展已在编程模型中介绍。它们允许程序员将内核定义为C++函数,并在每次调用函数时使用新语法指定网格和块维度。所有扩展的完整描述可在C++语言扩展中找到。包含这些扩展的任何源文件都必须按照使用NVCC编译中概述的方式用nvcc进行编译。

运行时在CUDA Runtime中介绍。它提供了在主机上执行的C和C++函数,用于分配和释放设备内存、在主机内存与设备内存之间传输数据、管理多设备系统等。运行时的完整描述可在CUDA参考手册中找到。

运行时建立在更低层次的C API——CUDA驱动API之上,应用程序也可以访问该驱动API。驱动API通过暴露更低层次的概念(例如CUDA上下文——设备端的主机进程对应物,以及CUDA模块——设备端的动态加载库对应物)提供了额外的控制层级。大多数应用程序不需要这种额外控制层级,因此不采用驱动API;而使用运行时API时,上下文和模块管理是隐式进行的,这使得代码更为简洁。由于运行时API可与驱动API互操作,大多数需要某些驱动API功能的应用程序可以默认使用运行时API,仅在必要时才调用驱动API。驱动API在Driver API中介绍,并在参考手册中完整描述。

3.1. 使用NVCC编译

内核可以使用CUDA指令集架构编写,称为PTX,这在PTX参考手册中有详细描述。然而,通常更有效的方法是使用高级编程语言如C++。无论哪种情况,内核都必须通过nvcc编译成二进制代码才能在设备上执行。

nvcc 是一个编译器驱动程序,它简化了编译 C++PTX 代码的过程:它提供了简单且熟悉的命令行选项,并通过调用实现不同编译阶段的工具集合来执行这些选项。本节概述了 nvcc 的工作流程和命令选项。完整描述可以在 nvcc 用户手册中找到。

3.1.1. 编译工作流程

3.1.1.1. 离线编译

使用nvcc编译的源文件可以包含主机代码(即在主机上执行的代码)和设备代码(即在设备上执行的代码)的混合。nvcc的基本工作流程包括将设备代码与主机代码分离,然后:

  • 将设备代码编译为汇编形式(PTX代码)和/或二进制形式(cubin对象),

  • 并通过将Kernels中引入的<<<...>>>语法(在执行配置中有更详细说明)替换为必要的CUDA运行时函数调用,来修改主机代码,以便从PTX代码和/或cubin对象加载并启动每个已编译的内核。

修改后的主机代码可以输出为C++代码,留待使用其他工具进行编译;或者通过让nvcc在最后编译阶段调用主机编译器,直接输出为目标代码。

应用程序可以:

  • 要么链接到已编译的主机代码(这是最常见的情况),

  • 或者忽略修改后的主机代码(如果有的话),使用CUDA驱动API(参见Driver API)来加载和执行PTX代码或cubin对象。

3.1.1.2. 即时编译

应用程序在运行时加载的任何PTX代码都会被设备驱动程序进一步编译为二进制代码。这被称为即时编译。即时编译会增加应用程序的加载时间,但允许应用程序受益于每个新设备驱动程序带来的编译器改进。正如应用程序兼容性中详细说明的那样,这也是应用程序能在编译时尚未存在的设备上运行的唯一方式。

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

环境变量可用于控制即时编译,具体说明请参阅CUDA环境变量

作为使用nvcc编译CUDA C++设备代码的替代方案,可以利用NVRTC在运行时将CUDA C++设备代码编译为PTX。NVRTC是一个用于CUDA C++的运行时编译库;更多信息请参阅NVRTC用户指南。

3.1.2. 二进制兼容性

二进制代码是与架构相关的。通过编译器选项-code生成cubin对象时,需要指定目标架构:例如,使用-code=sm_80编译会生成针对计算能力8.0设备的二进制代码。二进制兼容性保证在次版本号之间向前兼容,但不能向后兼容或跨主版本兼容。换句话说,为计算能力X.y生成的cubin对象只能在计算能力为X.z(其中z≥y)的设备上执行。

注意

二进制兼容性仅支持桌面平台,不支持Tegra平台。此外,桌面平台与Tegra平台之间的二进制兼容性也不支持。

3.1.3. PTX兼容性

某些PTX指令仅支持在更高计算能力的设备上运行。例如,Warp Shuffle Functions仅支持计算能力5.0及以上的设备。-arch编译器选项指定了将C++编译为PTX代码时假设的计算能力。因此,例如包含warp shuffle的代码必须使用-arch=compute_50(或更高版本)进行编译。

PTX 代码针对特定计算能力生成后,始终可以编译为计算能力相当或更高的二进制代码。需要注意的是,从较早PTX版本编译的二进制文件可能无法利用某些硬件特性。例如,针对计算能力7.0(Volta)设备、从计算能力6.0(Pascal)生成的PTX编译的二进制文件将无法使用Tensor Core指令,因为这些指令在Pascal架构上不可用。因此,最终二进制文件的性能可能不如使用最新PTX版本生成的二进制文件。

PTX 代码编译为针对架构条件特性时,仅能在完全相同的物理架构上运行,无法在其他架构上执行。具有架构条件的PTX代码不具备向前或向后兼容性。 例如,使用sm_90acompute_90a编译的示例代码只能在计算能力为9.0的设备上运行,既不向后兼容也不向前兼容。

3.1.4. 应用兼容性

要在特定计算能力的设备上执行代码,应用程序必须加载与该计算能力兼容的二进制或PTX代码,如二进制兼容性PTX兼容性中所述。特别是,为了能够在具有更高计算能力的未来架构上执行代码(目前还无法生成二进制代码),应用程序必须加载将被这些设备即时编译的PTX代码(参见即时编译)。

在CUDA C++应用程序中嵌入哪些PTX和二进制代码,是由-arch-code编译器选项或-gencode编译器选项控制的,具体细节请参阅nvcc用户手册。例如,

nvcc x.cu
        -gencode arch=compute_50,code=sm_50
        -gencode arch=compute_60,code=sm_60
        -gencode arch=compute_70,code=\"compute_70,sm_70\"

嵌入与计算能力5.0和6.0兼容的二进制代码(第一和第二个-gencode选项),以及兼容计算能力7.0的PTX和二进制代码(第三个-gencode选项)。

主机代码会在运行时自动选择加载和执行最合适的代码,在上述示例中,生成的代码将是:

  • 适用于计算能力5.0和5.2设备的5.0二进制代码

  • 适用于计算能力6.0和6.1设备的6.0二进制代码

  • 适用于计算能力7.0和7.5设备的7.0二进制代码

  • PTX代码会在运行时被编译为二进制代码,适用于计算能力8.0和8.6的设备。

x.cu 可以包含使用warp规约操作等优化代码路径,这些功能仅在计算能力8.0及以上的设备中支持。__CUDA_ARCH__宏可用于根据计算能力区分不同的代码路径。该宏仅在设备代码中定义。例如当使用-arch=compute_80编译时,__CUDA_ARCH__的值等于800

如果x.cu是针对架构条件特性编译的,例如使用sm_90acompute_90a,则该代码只能在具有计算能力9.0的设备上运行。

使用驱动API的应用程序必须将代码编译为单独的文件,并在运行时显式加载和执行最合适的文件。

Volta架构引入了独立线程调度技术,这改变了GPU上的线程调度方式。对于依赖之前架构中SIMT调度特定行为的代码,独立线程调度可能会改变参与线程的集合,从而导致错误结果。为帮助开发者在实施独立线程调度中详述的纠正措施时进行迁移,Volta开发者可以通过编译器选项组合-arch=compute_60 -code=sm_70选择启用Pascal架构的线程调度机制。

nvcc用户手册列出了-arch-code-gencode编译器选项的各种简写形式。例如,-arch=sm_70-arch=compute_70 -code=compute_70,sm_70的简写(等同于-gencode arch=compute_70,code=\"compute_70,sm_70\")。

3.1.5. C++ 兼容性

编译器前端根据C++语法规则处理CUDA源文件。主机代码完全支持标准C++,但设备代码仅支持部分C++功能,具体描述见C++语言支持

3.1.6. 64位兼容性

64位版本的nvcc会以64位模式编译设备代码(即指针为64位)。以64位模式编译的设备代码仅支持与以64位模式编译的主机代码配合使用。

3.2. CUDA运行时

运行时实现于cudart库中,该库通过cudart.liblibcudart.a静态链接,或通过cudart.dlllibcudart.so动态链接到应用程序。需要cudart.dll和/或cudart.so进行动态链接的应用程序通常将它们作为应用安装包的一部分。只有在链接到相同CUDA运行时实例的组件之间传递CUDA运行时符号地址才是安全的。

所有入口点都以cuda为前缀。

异构编程中所述,CUDA编程模型假设系统由主机和设备组成,各自拥有独立的内存。设备内存概述了用于管理设备内存的运行时函数。

共享内存展示了如何利用线程层次结构中介绍的共享内存来最大化性能。

页锁定主机内存介绍了页锁定主机内存,这是实现内核执行与主机和设备内存之间数据传输重叠的必要条件。

异步并发执行 描述了在系统各个层级实现异步并发执行所使用的概念和API。

多设备系统展示了编程模型如何扩展到连接同一主机的多设备系统。

错误检查 描述了如何正确检查运行时生成的错误。

调用栈 提到用于管理CUDA C++调用栈的运行时函数。

纹理与表面内存介绍了纹理和表面内存空间,它们提供了访问设备内存的另一种方式;同时还暴露了GPU纹理硬件的一个子集功能。

图形互操作性介绍了运行时提供的各种功能,用于与两种主要图形API(OpenGL和Direct3D)进行互操作。

3.2.1. 初始化

从CUDA 12.0开始,cudaInitDevice()cudaSetDevice()调用会初始化运行时以及与指定设备关联的主上下文。如果没有这些调用,运行时将隐式使用设备0,并根据需要自动初始化以处理其他运行时API请求。在计时运行时函数调用以及解释首次调用运行时的错误代码时,需要记住这一点。在12.0之前,cudaSetDevice()不会初始化运行时,应用程序通常会使用无操作的运行时调用cudaFree(0)来将运行时初始化与其他API活动隔离(既为了计时也为了错误处理)。

运行时为系统中的每个设备创建一个CUDA上下文(有关CUDA上下文的更多详情,请参阅上下文)。该上下文是该设备的主上下文,并在首次调用需要该设备上活动上下文的运行时函数时初始化。它由应用程序的所有主机线程共享。作为上下文创建的一部分,设备代码会在必要时即时编译(参见即时编译)并加载到设备内存中。这一切都是透明完成的。如果需要(例如为了实现驱动API互操作性),可以通过驱动API访问设备的主上下文,具体方法如运行时与驱动API的互操作性所述。

当主机线程调用cudaDeviceReset()时,这会销毁该主机线程当前操作的设备的主上下文(即设备选择中定义的当前设备)。任何将此设备设为当前设备的主机线程所进行的下一次运行时函数调用,都将为该设备创建一个新的主上下文。

注意

CUDA接口使用全局状态,该状态在主机程序初始化期间创建并在程序终止时销毁。CUDA运行时和驱动程序无法检测此状态是否有效,因此在程序初始化期间或main函数之后的终止阶段使用这些接口(无论是隐式还是显式)将导致未定义行为。

从CUDA 12.0开始,cudaSetDevice()在为主机线程切换当前设备后,现在会显式初始化运行时环境。在之前的CUDA版本中,新设备上的运行时初始化会延迟到cudaSetDevice()之后的第一个运行时调用才执行。这一变更意味着现在检查cudaSetDevice()返回值以捕获初始化错误变得非常重要。

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

3.2.2. 设备内存

异构编程中所述,CUDA编程模型假设系统由主机和设备组成,各自拥有独立的内存。内核在设备内存之外运行,因此运行时提供了分配、释放和复制设备内存的函数,以及在主机内存和设备内存之间传输数据的功能。

设备内存可以分配为线性内存CUDA数组

CUDA数组是针对纹理获取优化的不透明内存布局。详细描述请参阅纹理与表面内存

线性内存在一个统一地址空间中分配,这意味着单独分配的实体可以通过指针相互引用,例如在二叉树或链表中。地址空间的大小取决于主机系统(CPU)和所用GPU的计算能力:

表 1 线性内存地址空间

x86_64 (AMD64)

POWER (ppc64le)

ARM64

最高支持计算能力5.3(麦克斯韦架构)

40bit

40bit

40bit

计算能力6.0(Pascal)或更新版本

最高47位

最高49位

最高48位

注意

在计算能力5.3(Maxwell)及更早版本的设备上,CUDA驱动程序会创建一个未提交的40位虚拟地址预留空间,以确保内存分配(指针)落在支持的范围内。该预留空间显示为保留的虚拟内存,但在程序实际分配内存之前不会占用任何物理内存。

线性内存通常使用cudaMalloc()分配,并通过cudaFree()释放,主机内存与设备内存之间的数据传输通常使用cudaMemcpy()完成。在Kernels的向量加法代码示例中,需要将向量从主机内存复制到设备内存:

// Device code
__global__ void VecAdd(float* A, float* B, float* C, int N)
{
    int i = blockDim.x * blockIdx.x + threadIdx.x;
    if (i < N)
        C[i] = A[i] + B[i];
}

// Host code
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);
    float* h_C = (float*)malloc(size);

    // Initialize input vectors
    ...

    // Allocate vectors in device memory
    float* d_A;
    cudaMalloc(&d_A, size);
    float* d_B;
    cudaMalloc(&d_B, size);
    float* d_C;
    cudaMalloc(&d_C, size);

    // Copy vectors from host memory to device memory
    cudaMemcpy(d_A, h_A, size, cudaMemcpyHostToDevice);
    cudaMemcpy(d_B, h_B, size, cudaMemcpyHostToDevice);

    // Invoke kernel
    int threadsPerBlock = 256;
    int blocksPerGrid =
            (N + threadsPerBlock - 1) / threadsPerBlock;
    VecAdd<<<blocksPerGrid, threadsPerBlock>>>(d_A, d_B, d_C, N);

    // Copy result from device memory to host memory
    // h_C contains the result in host memory
    cudaMemcpy(h_C, d_C, size, cudaMemcpyDeviceToHost);

    // Free device memory
    cudaFree(d_A);
    cudaFree(d_B);
    cudaFree(d_C);

    // Free host memory
    ...
}

线性内存也可以通过cudaMallocPitch()cudaMalloc3D()进行分配。这些函数特别推荐用于分配2D或3D数组,因为它们能确保分配的内存经过适当填充以满足设备内存访问中描述的对齐要求,从而在访问行地址或在2D数组与设备内存其他区域之间执行复制操作时(使用cudaMemcpy2D()cudaMemcpy3D()函数)获得最佳性能。返回的间距(或步长)必须用于访问数组元素。以下代码示例分配了一个width x height的二维浮点值数组,并展示了如何在设备代码中遍历数组元素:

// Host code
int width = 64, height = 64;
float* devPtr;
size_t pitch;
cudaMallocPitch(&devPtr, &pitch,
                width * sizeof(float), height);
MyKernel<<<100, 512>>>(devPtr, pitch, width, height);

// Device code
__global__ void MyKernel(float* devPtr,
                         size_t pitch, int width, int height)
{
    for (int r = 0; r < height; ++r) {
        float* row = (float*)((char*)devPtr + r * pitch);
        for (int c = 0; c < width; ++c) {
            float element = row[c];
        }
    }
}

以下代码示例分配了一个width x height x depth的三维浮点值数组,并展示了如何在设备代码中遍历数组元素:

// Host code
int width = 64, height = 64, depth = 64;
cudaExtent extent = make_cudaExtent(width * sizeof(float),
                                    height, depth);
cudaPitchedPtr devPitchedPtr;
cudaMalloc3D(&devPitchedPtr, extent);
MyKernel<<<100, 512>>>(devPitchedPtr, width, height, depth);

// Device code
__global__ void MyKernel(cudaPitchedPtr devPitchedPtr,
                         int width, int height, int depth)
{
    char* devPtr = devPitchedPtr.ptr;
    size_t pitch = devPitchedPtr.pitch;
    size_t slicePitch = pitch * height;
    for (int z = 0; z < depth; ++z) {
        char* slice = devPtr + z * slicePitch;
        for (int y = 0; y < height; ++y) {
            float* row = (float*)(slice + y * pitch);
            for (int x = 0; x < width; ++x) {
                float element = row[x];
            }
        }
    }
}

注意

为避免分配过多内存从而影响系统整体性能,应根据问题规模向用户请求分配参数。如果分配失败,可以回退到其他较慢的内存类型(cudaMallocHost()cudaHostRegister()等),或返回错误告知用户被拒绝的内存需求大小。如果您的应用因某些原因无法请求分配参数,我们建议在支持的平台上使用cudaMallocManaged()

参考手册列出了用于在以下内存之间复制数据的各种函数:使用cudaMalloc()分配的线性内存、使用cudaMallocPitch()cudaMalloc3D()分配的线性内存、CUDA数组,以及为全局或常量内存空间中声明的变量分配的内存。

以下代码示例展示了通过运行时API访问全局变量的多种方式:

__constant__ float constData[256];
float data[256];
cudaMemcpyToSymbol(constData, data, sizeof(data));
cudaMemcpyFromSymbol(data, constData, sizeof(data));

__device__ float devData;
float value = 3.14f;
cudaMemcpyToSymbol(devData, &value, sizeof(float));

__device__ float* devPointer;
float* ptr;
cudaMalloc(&ptr, 256 * sizeof(float));
cudaMemcpyToSymbol(devPointer, &ptr, sizeof(ptr));

cudaGetSymbolAddress() 用于获取指向全局内存空间中声明变量所分配内存的地址。分配内存的大小可通过 cudaGetSymbolSize() 获取。

3.2.3. 设备内存L2访问管理

当CUDA内核重复访问全局内存中的数据区域时,此类数据访问可被视为持久性访问。另一方面,如果数据仅被访问一次,则此类数据访问可被视为流式访问。

从CUDA 11.0开始,计算能力8.0及以上的设备能够影响L2缓存中数据的持久性,可能为全局内存提供更高的带宽和更低的访问延迟。

3.2.3.1. 为持久访问预留的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)时,L2缓存预留大小无法通过cudaDeviceSetLimit修改。相反,预留大小只能通过在启动MPS服务器时通过环境变量CUDA_DEVICE_DEFAULT_PERSISTING_L2_CACHE_PERCENTAGE_LIMIT来指定。

3.2.3.2. 持久化访问的L2策略

访问策略窗口定义了全局内存中的一个连续区域,并为该区域内的访问指定了L2缓存中的持久化属性。

以下代码示例展示了如何使用CUDA Stream设置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);

当内核随后在CUDA stream中执行时,对全局内存范围[ptr..ptr+num_bytes)的内存访问相比其他全局内存位置的访问更有可能持久保留在L2缓存中。

L2持久化也可以为CUDA图内核节点设置,如下例所示:

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缓存的数据量。

hitRatio值低于1.0时,可用于手动控制来自并发CUDA流的不同accessPolicyWindow在L2缓存中的数据量。例如,假设L2预留缓存大小为16KB;两个并发内核运行在不同的CUDA流中,每个流具有16KB的accessPolicyWindow,且两者的hitRatio值均为1.0时,在竞争共享的L2资源时可能会相互逐出对方的缓存行。然而,如果两个accessPolicyWindows的hitRatio值均为0.5,则它们逐出自身或对方持久缓存行的可能性会降低。

3.2.3.3. L2访问属性

针对不同的全局内存数据访问定义了三种访问属性:

  1. cudaAccessPropertyStreaming: 具有流属性的内存访问不太可能在L2缓存中持久存在,因为这些访问会被优先淘汰。

  2. cudaAccessPropertyPersisting: 具有持久属性的内存访问更有可能保留在L2缓存中,因为这些访问会优先保留在L2缓存的预留部分。

  3. cudaAccessPropertyNormal: 该访问属性会强制将先前应用的持久化访问属性重置为正常状态。具有持久化属性的内存访问可能会在预期使用期过后长期保留在L2缓存中。这种使用后的持久性会减少后续不使用持久化属性的内核可用的L2缓存量。使用cudaAccessPropertyNormal属性重置访问属性窗口会移除先前访问的持久化(优先保留)状态,就像先前的访问从未设置过访问属性一样。

3.2.3.4. L2持久化示例

以下示例展示了如何为持久访问预留L2缓存,通过CUDA Stream在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

3.2.3.5. 重置L2访问为正常模式

来自先前CUDA内核的持久L2缓存行可能在很久之后仍保留在L2中。因此,将L2缓存重置为正常状态对于流式或常规内存访问以正常优先级利用L2缓存非常重要。有三种方法可以将持久访问重置为正常状态。

  1. 将之前持久化的内存区域重置为具有访问属性cudaAccessPropertyNormal

  2. 通过调用cudaCtxResetPersistingL2Cache()将所有持久化的L2缓存行重置为正常状态。

  3. 最终未被修改的行会自动重置为正常状态。强烈不建议依赖自动重置,因为自动重置所需的时间长度无法确定。

3.2.3.6. 管理L2预留缓存的利用率

在不同CUDA流中并发执行的多个CUDA内核可能被分配了不同的访问策略窗口。然而,L2预留缓存部分是在所有这些并发CUDA内核之间共享的。因此,这部分预留缓存的净利用率是所有并发内核各自使用量的总和。当持久化访问量超过预留L2缓存容量时,将内存访问指定为持久化的优势就会减弱。

为了管理预留L2缓存部分的使用率,应用程序必须考虑以下因素:

  • L2预留缓存的大小。

  • 可能并发执行的CUDA内核。

  • 所有可能并发执行的CUDA内核的访问策略窗口。

  • 何时以及如何需要重置L2缓存,以使常规或流式访问能够以同等优先级利用先前预留的L2缓存。

3.2.3.7. 查询L2缓存属性

与L2缓存相关的属性是cudaDeviceProp结构体的一部分,可以通过CUDA运行时API cudaGetDeviceProperties进行查询

CUDA设备属性包括:

  • l2CacheSize: GPU上可用的L2缓存容量。

  • persistingL2CacheMaxSize: 可为持久内存访问预留的最大L2缓存容量。

  • accessPolicyMaxWindowSize: 访问策略窗口的最大尺寸。

3.2.3.8. 控制L2缓存预留大小以支持持久内存访问

用于持久内存访问的L2预留缓存大小可通过CUDA运行时API cudaDeviceGetLimit查询,并通过CUDA运行时API cudaDeviceSetLimit设置为cudaLimit类型。设置此限制的最大值为cudaDeviceProp::persistingL2CacheMaxSize

enum cudaLimit {
    /* other fields not shown */
    cudaLimitPersistingL2CacheSize
};

3.2.4. 共享内存

变量内存空间说明符中详细描述的,共享内存是使用__shared__内存空间说明符分配的。

线程层次结构所述并在共享内存中详细说明,共享内存预计比全局内存快得多。它可以作为暂存内存(或软件管理的缓存)使用,以最小化CUDA块对全局内存的访问,如下面的矩阵乘法示例所示。

以下代码示例是一个简单的矩阵乘法实现,未利用共享内存。如图8所示,每个线程读取矩阵A的一行和矩阵B的一列,并计算矩阵C的对应元素。因此,矩阵A会从全局内存中被读取B.width次,而矩阵B会被读取A.height次。

// Matrices are stored in row-major order:
// M(row, col) = *(M.elements + row * M.width + col)
typedef struct {
    int width;
    int height;
    float* elements;
} Matrix;

// Thread block size
#define BLOCK_SIZE 16

// Forward declaration of the matrix multiplication kernel
__global__ void MatMulKernel(const Matrix, const Matrix, Matrix);

// Matrix multiplication - Host code
// Matrix dimensions are assumed to be multiples of BLOCK_SIZE
void MatMul(const Matrix A, const Matrix B, Matrix C)
{
    // Load A and B to device memory
    Matrix d_A;
    d_A.width = A.width; d_A.height = A.height;
    size_t size = A.width * A.height * sizeof(float);
    cudaMalloc(&d_A.elements, size);
    cudaMemcpy(d_A.elements, A.elements, size,
               cudaMemcpyHostToDevice);
    Matrix d_B;
    d_B.width = B.width; d_B.height = B.height;
    size = B.width * B.height * sizeof(float);
    cudaMalloc(&d_B.elements, size);
    cudaMemcpy(d_B.elements, B.elements, size,
               cudaMemcpyHostToDevice);

    // Allocate C in device memory
    Matrix d_C;
    d_C.width = C.width; d_C.height = C.height;
    size = C.width * C.height * sizeof(float);
    cudaMalloc(&d_C.elements, size);

    // Invoke kernel
    dim3 dimBlock(BLOCK_SIZE, BLOCK_SIZE);
    dim3 dimGrid(B.width / dimBlock.x, A.height / dimBlock.y);
    MatMulKernel<<<dimGrid, dimBlock>>>(d_A, d_B, d_C);

    // Read C from device memory
    cudaMemcpy(C.elements, d_C.elements, size,
               cudaMemcpyDeviceToHost);

    // Free device memory
    cudaFree(d_A.elements);
    cudaFree(d_B.elements);
    cudaFree(d_C.elements);
}

// Matrix multiplication kernel called by MatMul()
__global__ void MatMulKernel(Matrix A, Matrix B, Matrix C)
{
    // Each thread computes one element of C
    // by accumulating results into Cvalue
    float Cvalue = 0;
    int row = blockIdx.y * blockDim.y + threadIdx.y;
    int col = blockIdx.x * blockDim.x + threadIdx.x;
    for (int e = 0; e < A.width; ++e)
        Cvalue += A.elements[row * A.width + e]
                * B.elements[e * B.width + col];
    C.elements[row * C.width + col] = Cvalue;
}
_images/matrix-multiplication-without-shared-memory.png

图8 未使用共享内存的矩阵乘法

以下代码示例实现了一个利用共享内存的矩阵乘法。在这个实现中,每个线程块负责计算矩阵C的一个方形子矩阵Csub,而块内的每个线程负责计算Csub的一个元素。如图9所示,Csub等于两个矩形矩阵的乘积:与Csub具有相同行索引的维度为(A.width, block_size)的A子矩阵,以及与Csub具有相同列索引的维度为(block_size, A.width)的B子矩阵。为了适配设备资源,这两个矩形矩阵被划分为多个维度为block_size的方形矩阵,Csub则作为这些方形矩阵乘积的和来计算。每个乘积的计算过程是:首先将两个对应的方形矩阵从全局内存加载到共享内存(每个线程加载每个矩阵的一个元素),然后让每个线程计算乘积的一个元素。每个线程将这些乘积的结果累加到一个寄存器中,计算完成后将结果写入全局内存。

通过这种分块计算的方式,我们能够利用快速的共享内存,并节省大量全局内存带宽,因为A只需从全局内存中读取(B.width / block_size)次,而B只需读取(A.height / block_size)次。

前一个代码示例中的Matrix类型增加了stride字段,这样可以用相同的类型高效表示子矩阵。__device__函数用于获取和设置元素,以及从矩阵构建任何子矩阵。

// Matrices are stored in row-major order:
// M(row, col) = *(M.elements + row * M.stride + col)
typedef struct {
    int width;
    int height;
    int stride;
    float* elements;
} Matrix;
// Get a matrix element
__device__ float GetElement(const Matrix A, int row, int col)
{
    return A.elements[row * A.stride + col];
}
// Set a matrix element
__device__ void SetElement(Matrix A, int row, int col,
                           float value)
{
    A.elements[row * A.stride + col] = value;
}
// Get the BLOCK_SIZExBLOCK_SIZE sub-matrix Asub of A that is
// located col sub-matrices to the right and row sub-matrices down
// from the upper-left corner of A
 __device__ Matrix GetSubMatrix(Matrix A, int row, int col)
{
    Matrix Asub;
    Asub.width    = BLOCK_SIZE;
    Asub.height   = BLOCK_SIZE;
    Asub.stride   = A.stride;
    Asub.elements = &A.elements[A.stride * BLOCK_SIZE * row
                                         + BLOCK_SIZE * col];
    return Asub;
}
// Thread block size
#define BLOCK_SIZE 16
// Forward declaration of the matrix multiplication kernel
__global__ void MatMulKernel(const Matrix, const Matrix, Matrix);
// Matrix multiplication - Host code
// Matrix dimensions are assumed to be multiples of BLOCK_SIZE
void MatMul(const Matrix A, const Matrix B, Matrix C)
{
    // Load A and B to device memory
    Matrix d_A;
    d_A.width = d_A.stride = A.width; d_A.height = A.height;
    size_t size = A.width * A.height * sizeof(float);
    cudaMalloc(&d_A.elements, size);
    cudaMemcpy(d_A.elements, A.elements, size,
               cudaMemcpyHostToDevice);
    Matrix d_B;
    d_B.width = d_B.stride = B.width; d_B.height = B.height;
    size = B.width * B.height * sizeof(float);
    cudaMalloc(&d_B.elements, size);
    cudaMemcpy(d_B.elements, B.elements, size,
    cudaMemcpyHostToDevice);
    // Allocate C in device memory
    Matrix d_C;
    d_C.width = d_C.stride = C.width; d_C.height = C.height;
    size = C.width * C.height * sizeof(float);
    cudaMalloc(&d_C.elements, size);
    // Invoke kernel
    dim3 dimBlock(BLOCK_SIZE, BLOCK_SIZE);
    dim3 dimGrid(B.width / dimBlock.x, A.height / dimBlock.y);
    MatMulKernel<<<dimGrid, dimBlock>>>(d_A, d_B, d_C);
    // Read C from device memory
    cudaMemcpy(C.elements, d_C.elements, size,
               cudaMemcpyDeviceToHost);
    // Free device memory
    cudaFree(d_A.elements);
    cudaFree(d_B.elements);
    cudaFree(d_C.elements);
}
// Matrix multiplication kernel called by MatMul()
 __global__ void MatMulKernel(Matrix A, Matrix B, Matrix C)
{
    // Block row and column
    int blockRow = blockIdx.y;
    int blockCol = blockIdx.x;
    // Each thread block computes one sub-matrix Csub of C
    Matrix Csub = GetSubMatrix(C, blockRow, blockCol);
    // Each thread computes one element of Csub
    // by accumulating results into Cvalue
    float Cvalue = 0;
    // Thread row and column within Csub
    int row = threadIdx.y;
    int col = threadIdx.x;
    // Loop over all the sub-matrices of A and B that are
    // required to compute Csub
    // Multiply each pair of sub-matrices together
    // and accumulate the results
    for (int m = 0; m < (A.width / BLOCK_SIZE); ++m) {
        // Get sub-matrix Asub of A
        Matrix Asub = GetSubMatrix(A, blockRow, m);
        // Get sub-matrix Bsub of B
        Matrix Bsub = GetSubMatrix(B, m, blockCol);
        // Shared memory used to store Asub and Bsub respectively
        __shared__ float As[BLOCK_SIZE][BLOCK_SIZE];
        __shared__ float Bs[BLOCK_SIZE][BLOCK_SIZE];
        // Load Asub and Bsub from device memory to shared memory
        // Each thread loads one element of each sub-matrix
        As[row][col] = GetElement(Asub, row, col);
        Bs[row][col] = GetElement(Bsub, row, col);
        // Synchronize to make sure the sub-matrices are loaded
        // before starting the computation
        __syncthreads();
        // Multiply Asub and Bsub together
        for (int e = 0; e < BLOCK_SIZE; ++e)
            Cvalue += As[row][e] * Bs[e][col];
        // Synchronize to make sure that the preceding
        // computation is done before loading two new
        // sub-matrices of A and B in the next iteration
        __syncthreads();
    }
    // Write Csub to device memory
    // Each thread writes one element
    SetElement(Csub, row, col, Cvalue);
}
_images/matrix-multiplication-with-shared-memory.png

图9 使用共享内存的矩阵乘法

3.2.5. 分布式共享内存

在计算能力9.0中引入的线程块集群功能,使得集群内的线程可以访问该集群中所有参与线程块的共享内存。这种分区式共享内存被称为分布式共享内存,对应的地址空间称为分布式共享内存地址空间。属于线程块集群的线程可以在分布式地址空间中进行读取、写入或原子操作,无论该地址属于本地线程块还是远程线程块。无论内核是否使用分布式共享内存,共享内存大小的规格(静态或动态)仍以每个线程块为单位。分布式共享内存的大小仅是每个集群的线程块数量乘以每个线程块的共享内存大小。

访问分布式共享内存中的数据需要确保所有线程块都存在。用户可以通过Cluster Group API中的cluster.sync()来保证所有线程块都已开始执行。 用户还需要确保所有分布式共享内存操作在线程块退出前完成,例如,如果远程线程块试图读取某个线程块的共享内存,用户必须确保远程线程块完成共享内存读取后才能退出。

CUDA提供了一种访问分布式共享内存的机制,应用程序可以通过利用其功能获得优势。让我们来看一个简单的直方图计算示例,以及如何使用线程块集群在GPU上优化它。计算直方图的标准方法是在每个线程块的共享内存中进行计算,然后执行全局内存原子操作。这种方法的一个限制是共享内存容量。一旦直方图条柱无法放入共享内存,用户就需要直接在全局内存中计算直方图并执行原子操作。通过分布式共享内存,CUDA提供了一个中间步骤,根据直方图条柱的大小,可以选择在共享内存、分布式共享内存或直接全局内存中计算直方图。

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

#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);
}

3.2.6. 页锁定主机内存

运行时提供了允许使用页锁定(也称为固定)主机内存的函数(与通过malloc()分配的常规可分页主机内存相对):

  • cudaHostAlloc()cudaFreeHost() 用于分配和释放页锁定的主机内存;

  • cudaHostRegister()malloc()分配的内存范围锁定在页面上(有关限制请参阅参考手册)。

使用页锁定主机内存具有以下优势:

  • 异步并发执行中所述,对于某些设备,可以在内核执行的同时并发执行页锁定主机内存与设备内存之间的拷贝操作。

  • 在某些设备上,页面锁定的主机内存可以映射到设备的地址空间中,这样就无需像映射内存中描述的那样在设备内存之间来回拷贝数据。

  • 在带有前端总线的系统上,如果主机内存分配为页锁定内存,主机内存与设备内存之间的带宽会更高;如果进一步分配为写合并内存(如写合并内存中所述),带宽还会进一步提升。

注意

在非I/O一致的Tegra设备上,页锁定的主机内存不会被缓存。此外,cudaHostRegister()在非I/O一致的Tegra设备上也不受支持。

简单的零拷贝CUDA示例附带了一份关于页锁定内存API的详细文档。

3.2.6.1. 可移植内存

一块页锁定内存可以与系统中的任何设备一起使用(有关多设备系统的更多详情,请参阅多设备系统),但默认情况下,上述使用页锁定内存的优势仅适用于分配内存块时当前的设备(以及与所有共享相同统一地址空间的设备,如统一虚拟地址空间中所述)。要使这些优势对所有设备可用,需要通过向cudaHostAlloc()传递标志cudaHostAllocPortable来分配内存块,或通过向cudaHostRegister()传递标志cudaHostRegisterPortable来锁定页面。

3.2.6.2. 写合并内存

默认情况下,页面锁定的主机内存被分配为可缓存的。也可以通过向cudaHostAlloc()传递标志cudaHostAllocWriteCombined来将其分配为写合并内存。写合并内存释放了主机的L1和L2缓存资源,使应用程序的其他部分可以使用更多缓存。此外,在通过PCI Express总线传输时不会侦测写合并内存,这可以将传输性能提升高达40%。

从主机读取写入组合内存的速度极其缓慢,因此写入组合内存通常应仅用于主机只写入的内存。

应避免在WC内存上使用CPU原子指令,因为并非所有CPU实现都能保证该功能。

3.2.6.3. 映射内存

通过向cudaHostAlloc()传递cudaHostAllocMapped标志,或向cudaHostRegister()传递cudaHostRegisterMapped标志,也可以将页锁定的主机内存块映射到设备的地址空间中。因此,这样的内存块通常有两个地址:一个是由cudaHostAlloc()malloc()返回的主机内存地址,另一个是设备内存地址,可通过cudaHostGetDevicePointer()获取并在内核中用于访问该内存块。唯一的例外是使用cudaHostAlloc()分配的指针,以及如Unified Virtual Address Space中所述,当主机和设备使用统一地址空间时的情况。

直接从内核访问主机内存无法提供与设备内存相同的带宽,但确实具有一些优势:

  • 无需在设备内存中分配块并在该块与主机内存中的块之间复制数据;内核会根据需要隐式执行数据传输;

  • 无需使用流(参见并发数据传输)来实现数据传输与内核执行的重叠;由内核发起的数据传输会自动与内核执行重叠。

由于映射的页锁定内存是在主机和设备之间共享的,因此应用程序必须使用流或事件(参见异步并发执行)来同步内存访问,以避免任何潜在的读后写、写后读或写后写风险。

要能够获取任何映射的页锁定内存的设备指针,必须在执行任何其他CUDA调用之前,通过使用cudaDeviceMapHost标志调用cudaSetDeviceFlags()来启用页锁定内存映射。否则,cudaHostGetDevicePointer()将返回错误。

cudaHostGetDevicePointer() 如果设备不支持映射的页锁定主机内存,也会返回错误。应用程序可以通过检查canMapHostMemory设备属性(参见设备枚举)来查询此功能,对于支持映射页锁定主机内存的设备,该属性值为1。

请注意,在映射的页锁定内存上操作的原子函数(参见原子函数)从主机或其他设备的角度来看并不是原子的。

还需注意,CUDA运行时要求从设备发起的对主机内存的1字节、2字节、4字节、8字节和16字节自然对齐加载和存储操作,在主机和其他设备看来应保持为单次访问。 在某些平台上,硬件可能会将原子内存操作分解为单独的加载和存储操作。 这些分解后的加载和存储操作对保持自然对齐访问有相同要求。 CUDA运行时不支持PCI Express总线拓扑结构中PCI Express桥拆分8字节自然对齐操作的情况,且NVIDIA未发现任何会拆分16字节自然对齐操作的拓扑结构。

3.2.7. 内存同步域

3.2.7.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可见的其他线程的写入也可见。这被称为累积性。由于GPU在执行时无法知道哪些写入在源代码级别已被保证可见,哪些只是由于偶然的时间安排而可见,因此必须对正在执行的内存操作采取保守的广泛覆盖策略。

这有时会导致干扰:由于GPU在等待内存操作,而这些操作在源代码层面并非必需,因此栅栏/刷新操作可能比实际需要的时间更长。

请注意,栅栏(fences)可能以显式方式出现在代码中,如示例中的内部函数(intrinsics)或原子操作(atomics),也可能以隐式方式出现在任务边界处,用于实现同步关系(synchronizes-with)

一个常见的例子是,当一个内核在本地GPU内存中执行计算,而另一个并行内核(例如来自NCCL)正在与对等节点进行通信时。完成后,本地内核将隐式刷新其写入,以满足与下游工作之间的任何同步关系。这可能会不必要地完全或部分等待来自通信内核的较慢的nvlink或PCIe写入。

3.2.7.2. 使用域隔离流量

从Hopper架构GPU和CUDA 12.0开始,内存同步域功能提供了一种减轻此类干扰的方法。通过代码显式协助的交换,GPU可以减少栅栏操作带来的开销。每个内核启动都会被分配一个域ID。写入和栅栏操作都会标记该ID,且栅栏只会对与其域匹配的写入进行排序。在并发计算与通信的示例中,通信内核可以被放置在不同的域中。

在使用域时,代码必须遵守以下规则:同一GPU上不同域之间的排序或同步需要系统范围围栏。在域内部,设备范围围栏仍然足够。这对于累积性是必要的,因为一个内核的写入操作不会被另一个域中的内核发出的围栏所包含。本质上,通过确保跨域流量提前刷新到系统范围,可以满足累积性要求。

请注意,这修改了thread_scope_device的定义。但由于内核将默认使用域0(如下所述),因此保持了向后兼容性。

3.2.7.3. 在CUDA中使用域

可以通过新的启动属性cudaLaunchAttributeMemSyncDomaincudaLaunchAttributeMemSyncDomainMap访问域。前者在逻辑域cudaLaunchMemSyncDomainDefaultcudaLaunchMemSyncDomainRemote之间进行选择,后者提供从逻辑域到物理域的映射。远程域专为执行远程内存访问的内核设计,以将其内存流量与本地内核隔离。但请注意,选择特定域不会影响内核可以合法执行的内存访问。

可以通过设备属性cudaDevAttrMemSyncDomainCount查询域计数。Hopper架构有4个域。为了便于编写可移植代码,所有设备都可以使用域功能,在Hopper之前的架构上CUDA会报告计数为1。

拥有逻辑域可以简化应用程序的组合。在堆栈底层(例如NCCL)的单个内核启动时,可以选择语义逻辑域而无需考虑周围的应用程序架构。更高层级可以通过映射来引导逻辑域。如果未设置逻辑域,其默认值为默认域,而默认映射是将默认域映射到0,远程域映射到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 Hopper 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图中的内核节点上统一公开。典型用法如上述所述,会在流级别设置映射,在启动级别(或限定流使用部分)设置逻辑域。

在流捕获过程中,这两个属性都会被复制到图节点中。图会从节点本身获取这两个属性,这本质上是指定物理域的一种间接方式。在启动图的流上设置的与域相关的属性不会在图执行过程中被使用。

3.2.8. 异步并发执行

CUDA 将以下操作作为可以相互并发执行的独立任务公开:

  • 在主机上进行计算;

  • 在设备上进行计算;

  • 从主机到设备的内存传输;

  • 从设备到主机的内存传输;

  • 在给定设备的内存内部进行数据传输;

  • 设备间的内存传输。

这些操作之间实现的并发级别将取决于设备的特性集和计算能力,如下所述。

3.2.8.1. 主机与设备之间的并发执行

通过异步库函数实现主机并发执行,这些函数在设备完成请求任务之前就将控制权返回给主机线程。使用异步调用时,许多设备操作可以一起排队,当适当的设备资源可用时由CUDA驱动程序执行。这大大减轻了主机线程管理设备的责任,使其可以自由执行其他任务。以下设备操作相对于主机是异步的:

  • 内核启动;

  • 单个设备内存内的内存拷贝;

  • 从主机到设备的内存拷贝,内存块大小为64 KB或更小;

  • 由带有Async后缀的函数执行的内存拷贝操作;

  • 内存设置函数调用。

程序员可以通过将环境变量CUDA_LAUNCH_BLOCKING设置为1,来全局禁用系统上所有CUDA应用程序的内核启动异步性。此功能仅用于调试目的,不应作为使生产软件可靠运行的方法。

如果通过性能分析工具(Nsight、Visual Profiler)收集硬件计数器,则内核启动是同步的,除非启用了并发内核分析。Async内存拷贝如果涉及未锁页的主机内存,也可能变为同步操作。

3.2.8.2. 并发内核执行

部分计算能力2.x及以上的设备可以同时执行多个内核。应用程序可以通过检查concurrentKernels设备属性来查询此功能(参见设备枚举),对于支持该功能的设备,该属性值为1。

设备可以并发执行的最大内核启动数量取决于其计算能力,具体数值列于表21中。

来自一个CUDA上下文的kernel无法与另一个CUDA上下文的kernel并发执行。GPU可能会通过时间分片来为每个上下文提供执行进度。如果用户想要在SM上同时运行来自多个进程的kernel,则必须启用MPS。

使用大量纹理或大量本地内存的内核不太可能与其他内核同时执行。

3.2.8.3. 数据传输与内核执行的重叠

某些设备可以在内核执行的同时,异步地将内存复制到GPU或从GPU复制出来。应用程序可以通过检查asyncEngineCount设备属性来查询此功能(参见设备枚举),对于支持此功能的设备,该属性值大于零。如果复制操作涉及主机内存,则该内存必须被页锁定。

还可以在内核执行的同时(在支持concurrentKernels设备属性的设备上)和/或与设备之间的拷贝操作(对于支持asyncEngineCount属性的设备)同时执行设备内拷贝。设备内拷贝是使用标准内存拷贝函数发起的,目标地址和源地址位于同一设备上。

3.2.8.4. 并发数据传输

部分计算能力2.x及以上的设备可以重叠执行设备间的数据拷贝操作。应用程序可通过检查asyncEngineCount设备属性(参见Device Enumeration)来查询此功能,支持该功能的设备该属性值为2。要实现重叠传输,传输过程中涉及的任何主机内存都必须是页锁定内存。

3.2.8.5.

应用程序通过来管理上述并发操作。流是按顺序执行的一系列命令(可能由不同的主机线程发出)。而不同的流之间,它们的命令执行顺序可能是乱序的或并发的;这种行为无法保证,因此不应依赖它来确保正确性(例如,内核间通信是未定义的)。当命令的所有依赖项都满足时,流上发出的命令才会执行。这些依赖项可以是同一流上先前启动的命令,也可以是来自其他流的依赖项。同步调用的成功完成保证了所有已启动的命令都已完成。

3.2.8.5.1. 流的创建与销毁

流是通过创建一个流对象并将其指定为一系列内核启动和主机<->设备内存拷贝操作的流参数来定义的。以下代码示例创建了两个流,并在页锁定内存中分配了一个float类型的数组hostPtr

cudaStream_t stream[2];
for (int i = 0; i < 2; ++i)
    cudaStreamCreate(&stream[i]);
float* hostPtr;
cudaMallocHost(&hostPtr, 2 * size);

每个流都由以下代码示例定义,包含从主机到设备的一次内存拷贝、一次内核启动以及从设备到主机的一次内存拷贝序列:

for (int i = 0; i < 2; ++i) {
    cudaMemcpyAsync(inputDevPtr + i * size, hostPtr + i * size,
                    size, cudaMemcpyHostToDevice, stream[i]);
    MyKernel <<<100, 512, 0, stream[i]>>>
          (outputDevPtr + i * size, inputDevPtr + i * size, size);
    cudaMemcpyAsync(hostPtr + i * size, outputDevPtr + i * size,
                    size, cudaMemcpyDeviceToHost, stream[i]);
}

每个流将其输入数组hostPtr的部分数据复制到设备内存中的数组inputDevPtr,通过调用MyKernel()在设备上处理inputDevPtr,并将结果outputDevPtr复制回hostPtr的相同部分。Overlapping Behavior描述了在此示例中流如何根据设备能力重叠执行。请注意,hostPtr必须指向页锁定的主机内存才能实现任何重叠操作。

通过调用cudaStreamDestroy()来释放流。

for (int i = 0; i < 2; ++i)
    cudaStreamDestroy(stream[i]);

如果在调用cudaStreamDestroy()时设备仍在流中执行工作,该函数将立即返回,且一旦设备完成流中的所有工作,与流相关的资源将自动释放。

3.2.8.5.2. 默认流

未指定任何流参数或等效地将流参数设置为零的内核启动和主机<->设备内存拷贝操作会被提交到默认流。因此它们是按顺序执行的。

对于使用--default-stream per-thread编译标志编译的代码(或在包含CUDA头文件(cuda.hcuda_runtime.h)之前定义了CUDA_API_PER_THREAD_DEFAULT_STREAM宏的代码),默认流是一个常规流,每个主机线程都有自己的默认流。

注意

#define CUDA_API_PER_THREAD_DEFAULT_STREAM 1 cannot be used to enable this behavior when the code is compiled by nvcc as nvcc implicitly includes cuda_runtime.h at the top of the translation unit. In this case the --default-stream per-thread compilation flag needs to be used or the CUDA_API_PER_THREAD_DEFAULT_STREAM macro needs to be defined with the -DCUDA_API_PER_THREAD_DEFAULT_STREAM=1 compiler flag.

对于使用--default-stream legacy编译标志编译的代码,默认流是一个称为NULL流的特殊流,每个设备都有一个NULL流供所有主机线程使用。NULL流很特殊,因为它会导致隐式同步,如隐式同步中所述。

对于未指定--default-stream编译标志的代码,默认采用--default-stream legacy作为默认值。

3.2.8.5.3. 显式同步

有多种方式可以显式地同步各个流。

cudaDeviceSynchronize() 会等待所有主机线程的所有流中的所有前置命令执行完成。

cudaStreamSynchronize()接收一个流作为参数,并等待给定流中所有先前的命令完成。它可用于将主机与特定流同步,同时允许其他流在设备上继续执行。

cudaStreamWaitEvent()接收一个流和一个事件作为参数(关于事件的描述请参阅Events),并使得在调用cudaStreamWaitEvent()之后添加到给定流中的所有命令延迟执行,直到给定事件完成。

cudaStreamQuery()为应用程序提供了一种检查流中所有前置命令是否已完成的方法。

3.2.8.5.4. 隐式同步

如果在这两个操作之间提交了任何针对NULL流的CUDA操作,那么来自不同流的两个操作将无法并发运行,除非这些流是非阻塞流(使用cudaStreamNonBlocking标志创建)。

应用程序应遵循以下准则以提高并发内核执行的潜力:

  • 所有独立操作应在依赖操作之前发出,

  • 应尽可能延迟任何类型的同步操作。

3.2.8.5.5. 重叠行为

两个流之间的执行重叠量取决于命令被发往每个流的顺序,以及设备是否支持数据传输与内核执行的重叠(参见数据传输与内核执行的重叠)、并发内核执行(参见并发内核执行)和/或并发数据传输(参见并发数据传输)。

例如,在不支持并发数据传输的设备上,流的创建与销毁代码示例中的两个流完全不会重叠。因为从主机到设备的内存拷贝是在流[1]上发起的,而该操作只有在流[0]上发起的从设备到主机的内存拷贝完成后才能开始。如果将代码改写为以下形式(假设设备支持数据传输与内核执行的重叠)

for (int i = 0; i < 2; ++i)
    cudaMemcpyAsync(inputDevPtr + i * size, hostPtr + i * size,
                    size, cudaMemcpyHostToDevice, stream[i]);
for (int i = 0; i < 2; ++i)
    MyKernel<<<100, 512, 0, stream[i]>>>
          (outputDevPtr + i * size, inputDevPtr + i * size, size);
for (int i = 0; i < 2; ++i)
    cudaMemcpyAsync(hostPtr + i * size, outputDevPtr + i * size,
                    size, cudaMemcpyDeviceToHost, stream[i]);

那么从主机到设备的内存拷贝操作在流[1]中发出,与在流[0]中发出的内核启动操作会重叠执行。

在支持并发数据传输的设备上,流的创建与销毁代码示例中的两个流确实会重叠:发往stream[1]的从主机到设备的内存拷贝操作,与发往stream[0]的从设备到主机的内存拷贝操作相互重叠,甚至还会与发往stream[0]的内核启动操作重叠(假设设备支持数据传输与内核执行的并发重叠)。

3.2.8.5.6. 主机函数(回调)

运行时提供了一种方式,可以通过cudaLaunchHostFunc()在任何点向流中插入一个CPU函数调用。当回调之前发送到流的所有命令都完成后,所提供的函数将在主机上执行。

以下代码示例在向两个流分别发出主机到设备内存复制、内核启动和设备到主机内存复制操作后,将主机函数MyCallback添加到每个流中。该函数将在每个设备到主机内存复制完成后开始在主机上执行。

void CUDART_CB MyCallback(void *data){
    printf("Inside callback %d\n", (size_t)data);
}
...
for (size_t i = 0; i < 2; ++i) {
    cudaMemcpyAsync(devPtrIn[i], hostPtr[i], size, cudaMemcpyHostToDevice, stream[i]);
    MyKernel<<<100, 512, 0, stream[i]>>>(devPtrOut[i], devPtrIn[i], size);
    cudaMemcpyAsync(hostPtr[i], devPtrOut[i], size, cudaMemcpyDeviceToHost, stream[i]);
    cudaLaunchHostFunc(stream[i], MyCallback, (void*)i);
}

在主机函数之后流中发出的命令,在函数完成之前不会开始执行。

在流中排队的宿主函数不得调用CUDA API(直接或间接),因为如果进行此类调用可能会导致自身等待,从而引发死锁。

3.2.8.5.7. 流优先级

流的相对优先级可以在创建时使用cudaStreamCreateWithPriority()指定。允许的优先级范围(按[最高优先级, 最低优先级]排序)可通过cudaDeviceGetStreamPriorityRange()函数获取。在运行时,GPU调度器利用流优先级来确定任务执行顺序,但这些优先级仅作为提示而非保证。在选择要启动的工作时,高优先级流中的待处理任务优先于低优先级流中的任务。高优先级任务不会抢占正在运行的低优先级任务。GPU在任务执行期间不会重新评估工作队列,提高流的优先级也不会中断正在进行的工作。流优先级会影响任务执行但不会强制执行严格顺序,因此用户可以利用流优先级来影响任务执行,而无需依赖严格的顺序保证。

以下代码示例获取当前设备允许的优先级范围,并创建具有最高和最低可用优先级的流。

// 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.2.8.6. 程序化依赖启动与同步

编程式依赖启动机制允许一个依赖的次级内核在其依赖的同一CUDA流中的内核完成执行之前就启动。该技术从计算能力9.0及以上的设备开始支持,当次级内核能完成大量不依赖于内核结果的工作时,可带来性能优势。

3.2.8.6.1. 背景

CUDA应用程序通过在GPU上启动和执行多个内核来利用其计算能力。 典型的GPU活动时间线如图10所示。

GPU activity timeline

图10 GPU活动时间线

在这里,secondary_kernel会在primary_kernel完成执行后启动。这种串行执行通常是必要的,因为secondary_kernel依赖于primary_kernel生成的结果数据。如果secondary_kernel不依赖于primary_kernel,则可以通过使用Streams来同时启动它们。即使secondary_kernel依赖于primary_kernel,也存在一定的并行执行潜力。例如,几乎所有内核都有某种前导部分,在此期间会执行诸如清零缓冲区或加载常量值等任务。

Preamble section of ``secondary_kernel``

图11 secondary_kernel的前导部分

图11展示了secondary_kernel中可以并行执行而不影响应用程序的部分。 需要注意的是,并发启动还允许我们将secondary_kernel的启动延迟隐藏在primary_kernel的执行过程中。

Concurrent execution of ``primary_kernel`` and ``secondary_kernel``

图12 primary_kernelsecondary_kernel的并发执行

图12中展示的secondary_kernel并发启动和执行可以通过编程依赖启动实现。

编程式依赖启动对CUDA内核启动API进行了更改,如下节所述。 这些API至少需要计算能力9.0才能提供重叠执行功能。

3.2.8.6.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或其他方式来验证主内核的结果数据是否可用。

请注意,这些方法为主核和次核提供了并发执行的机会,但 这种行为是机会性的,并不能保证一定会实现内核并发执行。 依赖这种方式实现并发执行是不安全的,可能导致死锁。

3.2.8.6.3. 在CUDA Graphs中的使用

编程式依赖启动可通过流捕获或直接通过边缘数据CUDA Graphs中使用。要在带有边缘数据的CUDA Graph中编程实现此功能,需在连接两个内核节点的边上使用cudaGraphDependencyType值为cudaGraphDependencyTypeProgrammatic。这种边类型使得上游内核对下游内核中的cudaGridDependencySynchronize()可见。此类型必须与cudaGraphKernelNodePortLaunchCompletioncudaGraphKernelNodePortProgrammatic的输出端口配合使用。

流捕获对应的结果图如下所示:

流代码(简写)

生成的图边

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;

3.2.8.7. CUDA Graphs

CUDA Graphs 在 CUDA 中提出了一种新的工作提交模型。图是由一系列操作(如内核启动)通过依赖关系连接而成,其定义与执行是分离的。这使得图可以一次性定义后重复启动。将图的定义与执行分离带来多项优化:首先,与流相比,CPU 启动成本降低,因为大部分设置已提前完成;其次,将整个工作流呈现给 CUDA 可以实现流的分段工作提交机制可能无法实现的优化。

要了解使用图(graph)可以实现的优化,让我们看看流(stream)中的情况:当您将内核(kernel)放入流中时,主机驱动程序会执行一系列操作来准备在GPU上执行该内核。这些用于设置和启动内核的必要操作是每次发出内核时都必须支付的开销成本。对于一个执行时间较短的GPU内核来说,这种开销成本可能占整个端到端执行时间的很大一部分。

使用图形的工作提交分为三个不同的阶段:定义、实例化和执行。

  • 在定义阶段,程序会创建图中操作的描述以及它们之间的依赖关系。

  • 实例化过程会对图模板进行快照,验证其有效性,并完成大部分工作设置和初始化,目的是尽量减少启动时需要执行的操作。生成的实例被称为可执行图

  • 可执行图可以像其他任何CUDA工作一样在流中启动。它可以被多次启动而无需重复实例化。

3.2.8.7.1. 图结构

操作在图中形成一个节点。操作之间的依赖关系就是边。这些依赖关系约束了操作的执行顺序。

一旦某个操作所依赖的节点完成,该操作就可以随时被调度执行。具体的调度工作由CUDA系统负责。

3.2.8.7.1.1. 节点类型

图节点可以是以下类型之一:

Child Graph Example

图13 子图示例

3.2.8.7.1.2. 边缘数据

CUDA 12.3 引入了CUDA Graphs上的边数据功能。边数据用于修改由边指定的依赖关系,包含三个组成部分: 出端口、入端口和类型。出端口用于指定关联边的触发时机。入端口用于指定节点中哪部分依赖于关联边。 类型则用于修改端点之间的关系。

端口值特定于节点类型和方向,且边类型可能仅限于特定节点类型。在所有情况下,零初始化的边数据表示默认行为。出端口0等待整个任务,入端口0阻塞整个任务,边类型0与具有内存同步行为的完整依赖相关联。

在各种图API中,边缘数据可以通过与关联节点并行的数组进行可选指定。如果作为输入参数被省略,则使用零初始化的数据。如果作为输出(查询)参数被省略,当被忽略的边缘数据均为零初始化时,API会接受此情况;若调用会丢弃信息,则返回cudaErrorLossyQuery

部分流捕获API中也提供边缘数据支持:cudaStreamBeginCaptureToGraph()cudaStreamGetCaptureInfo()cudaStreamUpdateCaptureDependencies()。在这些情况下,尚不存在下游节点。这些数据关联着悬空边缘(半边缘),这些边缘要么会连接到未来捕获的节点,要么会在流捕获终止时被丢弃。需要注意的是,某些边缘类型不会等待上游节点完全执行完毕。在判断流捕获是否已完全重新接入原始流时,这些边缘将被忽略,并且无法在捕获结束时被丢弃。详见Creating a Graph Using Stream Capture

目前,没有节点类型定义额外的输入端口,只有内核节点定义了额外的输出端口。存在一种非默认的依赖类型cudaGraphDependencyTypeProgrammatic,它支持在两个内核节点之间实现Programmatic Dependent Launch

3.2.8.7.2. 使用Graph API创建图

图形可以通过两种机制创建:显式API和流捕获。以下是创建并执行下方图形的示例。

Creating a Graph Using Graph APIs Example

图14 使用Graph API创建图的示例

// Create the graph - it starts out empty
cudaGraphCreate(&graph, 0);

// For the purpose of this example, we'll create
// the nodes separately from the dependencies to
// demonstrate that it can be done in two stages.
// Note that dependencies can also be specified
// at node creation.
cudaGraphAddKernelNode(&a, graph, NULL, 0, &nodeParams);
cudaGraphAddKernelNode(&b, graph, NULL, 0, &nodeParams);
cudaGraphAddKernelNode(&c, graph, NULL, 0, &nodeParams);
cudaGraphAddKernelNode(&d, graph, NULL, 0, &nodeParams);

// Now set up dependencies on each node
cudaGraphAddDependencies(graph, &a, &b, 1);     // A->B
cudaGraphAddDependencies(graph, &a, &c, 1);     // A->C
cudaGraphAddDependencies(graph, &b, &d, 1);     // B->D
cudaGraphAddDependencies(graph, &c, &d, 1);     // C->D
3.2.8.7.3. 使用流捕获创建图形

流捕获提供了一种机制,可以从现有的基于流的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为每线程流而不改变功能。参见Default Stream

可以通过cudaStreamIsCapturing()查询流是否正在被捕获。

工作可以通过cudaStreamBeginCaptureToGraph()捕获到现有图中。与捕获到内部图不同,这里的工作会被捕获到用户提供的图中。

3.2.8.7.3.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

上述代码返回的图形显示在图14中。

注意

当流退出捕获模式时,流中下一个未被捕获的项(如果有)仍将依赖于最近的前一个未被捕获的项,尽管中间的项已被移除。

3.2.8.7.3.2. 禁止及未处理的操作

对正在捕获的流或已捕获的事件进行同步或查询执行状态是无效的,因为它们不代表计划执行的项目。同样,查询包含活动流捕获的更广泛句柄(例如当任何关联流处于捕获模式时的设备或上下文句柄)的执行状态或进行同步也是无效的。

当同一上下文中的任何流正在被捕获,且该流不是使用cudaStreamNonBlocking创建时,任何尝试使用传统流的操作都是无效的。这是因为传统流句柄始终包含这些其他流;向传统流排队将创建对被捕获流的依赖,查询或同步它将查询或同步正在被捕获的流。

因此在这种情况下调用同步API也是无效的。同步API(例如cudaMemcpy())会将工作加入传统流队列,并在返回前对其进行同步。

注意

作为一般规则,当依赖关系试图将已捕获的内容与未被捕获而排队执行的内容连接时,CUDA倾向于返回错误而非忽略该依赖。但有一个例外情况:当流进入或退出捕获模式时,这会切断模式转换前后添加到流中的项目之间的依赖关系。

通过等待来自正在被捕获且与事件关联的捕获图不同的流中的捕获事件来合并两个独立的捕获图是无效的。在不指定cudaEventWaitExternal标志的情况下,等待来自正在被捕获的流中的非捕获事件也是无效的。

目前图形中不支持少量将异步操作排入流的API,如果使用正在捕获的流调用这些API(例如cudaStreamAttachMemAsync()),将返回错误。

3.2.8.7.3.3. 失效

当在流捕获期间尝试无效操作时,任何关联的捕获图都将失效。当捕获图失效后,继续使用任何正在被捕获的流或与该图关联的已捕获事件都是无效的,并将返回错误,直到通过cudaStreamEndCapture()结束流捕获。此调用将使关联的流退出捕获模式,但也会返回错误值和NULL图。

3.2.8.7.4. CUDA用户对象

CUDA用户对象可用于帮助管理CUDA异步工作中使用的资源生命周期。该特性尤其适用于CUDA Graphsstream capture

各种资源管理方案与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);
    cudaStreamAddCallback(
        stream,
        [](cudaStream_t, cudaError_t, 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, nullptr, nullptr, 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.

子图节点中图所拥有的引用关联到子图,而非父图。如果子图被更新或删除,引用会相应变化。如果可执行图或子图通过cudaGraphExecUpdatecudaGraphExecChildGraphNodeSetParams进行更新,新源图中的引用会被克隆并替换目标图中的引用。无论哪种情况,如果之前的启动操作尚未同步,所有将被释放的引用都会保持到这些启动操作执行完毕。

目前没有通过CUDA API等待用户对象析构函数的机制。用户可以从析构函数代码中手动发出同步对象信号。此外,从析构函数调用CUDA API是不合法的,类似于对cudaLaunchHostFunc的限制。这是为了避免阻塞CUDA内部共享线程并阻碍工作进展。如果依赖关系是单向的,并且执行调用的线程不会阻塞CUDA工作的进展,那么向另一个线程发出信号以执行API调用是合法的。

用户对象通过cudaUserObjectCreate创建,这是浏览相关API的良好起点。

3.2.8.7.5. 更新实例化图

使用图形的工作提交分为三个不同的阶段:定义、实例化和执行。在工作流程不变的情况下,定义和实例化的开销可以通过多次执行来分摊,与流相比,图形具有明显的优势。

图是工作流的快照,包含内核、参数和依赖关系,以便尽可能快速高效地重放。当工作流发生变化时,图会变得过时,必须进行修改。对图结构的重大更改(如拓扑或节点类型)将需要重新实例化源图,因为必须重新应用各种与拓扑相关的优化技术。

重复实例化的成本可能会降低图执行的整体性能优势,但通常只有节点参数(例如内核参数和cudaMemcpy地址)会发生变化,而图拓扑结构保持不变。针对这种情况,CUDA提供了一种称为"图更新"的轻量级机制,允许直接修改某些节点参数而无需重建整个图。这比重新实例化要高效得多。

更新将在下次启动图形时生效,因此不会影响之前的图形启动,即使它们在更新时正在运行。图形可以反复更新和重新启动,因此可以在流上排队多个更新/启动。

CUDA提供了两种更新实例化图形参数的机制:整体图形更新和单个节点更新。整体图形更新允许用户提供一个拓扑结构相同的cudaGraph_t对象,其节点包含更新后的参数。单个节点更新则允许用户显式地更新各个节点的参数。当需要更新大量节点,或者调用者不了解图形拓扑结构时(例如由库函数调用流捕获生成的图形),使用更新后的cudaGraph_t更为便捷。当变更数量较少且用户持有需要更新节点的句柄时,推荐使用单个节点更新方式。单个节点更新会跳过未变更节点的拓扑检查与比较,因此在多数情况下效率更高。

CUDA还提供了一种机制,可以在不影响当前参数的情况下启用或禁用单个节点。

以下部分将更详细地解释每种方法。

3.2.8.7.5.1. 图更新限制

内核节点:

  • 函数的所属上下文无法更改。

  • 原本未使用CUDA动态并行功能的节点无法更新为使用CUDA动态并行的函数。

cudaMemsetcudaMemcpy 节点:

  • 操作数分配/映射到的CUDA设备不能更改。

  • 源/目标内存必须从与原始源/目标内存相同的上下文中分配。

  • 仅支持修改一维的cudaMemset/cudaMemcpy节点。

额外的内存拷贝节点限制:

  • 不支持更改源或目标内存类型(即cudaPitchedPtrcudaArray_t等),也不支持更改传输类型(即cudaMemcpyKind)。

外部信号量等待节点和记录节点:

  • 不支持更改信号量的数量。

条件节点:

  • 句柄创建和赋值的顺序必须在图之间保持一致。

  • 不支持更改节点参数(例如条件中的图形数量、节点上下文等)。

  • 在条件体图中更改节点参数需遵循上述规则。

对主机节点、事件记录节点或事件等待节点的更新没有限制。

3.2.8.7.5.2. 全图更新

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, NULL, NULL, 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会更高效。

条件处理标志和默认值会作为图更新的一部分进行更新。

有关使用方法和当前限制的更多信息,请参阅Graph API

3.2.8.7.5.3. 单节点更新

可以直接更新已实例化的图节点参数。这消除了实例化的开销以及创建新cudaGraph_t的开销。如果需要更新的节点数量相对于图中节点总数较少,最好单独更新各个节点。以下方法可用于更新cudaGraphExec_t节点:

  • cudaGraphExecKernelNodeSetParams()

  • cudaGraphExecMemcpyNodeSetParams()

  • cudaGraphExecMemsetNodeSetParams()

  • cudaGraphExecHostNodeSetParams()

  • cudaGraphExecChildGraphNodeSetParams()

  • cudaGraphExecEventRecordNodeSetEvent()

  • cudaGraphExecEventWaitNodeSetEvent()

  • cudaGraphExecExternalSemaphoresSignalNodeSetParams()

  • cudaGraphExecExternalSemaphoresWaitNodeSetParams()

有关使用方法和当前限制的更多信息,请参阅Graph API

3.2.8.7.5.4. 独立节点启用

在实例化图中,可以使用cudaGraphNodeSetEnabled() API来启用或禁用Kernel、memset和memcpy节点。这允许创建一个包含所需功能超集的图,该图可以针对每次启动进行定制。节点的启用状态可以通过cudaGraphNodeGetEnabled() API进行查询。

禁用节点在功能上等同于空节点,直到重新启用为止。启用/禁用节点不会影响节点参数。启用状态不受单个节点更新或使用cudaGraphExecUpdate()进行整个图更新的影响。节点禁用期间的参数更新将在节点重新启用时生效。

以下方法可用于启用/禁用cudaGraphExec_t节点,以及查询它们的状态:

  • cudaGraphNodeSetEnabled()

  • cudaGraphNodeGetEnabled()

有关使用方法和当前限制的更多信息,请参阅Graph API

3.2.8.7.6. 使用图形API

cudaGraph_t 对象不是线程安全的。用户有责任确保多个线程不会同时访问同一个 cudaGraph_t

一个cudaGraphExec_t不能与自身并发运行。启动cudaGraphExec_t将在同一可执行图的上次启动之后按顺序执行。

图执行在流中完成,以便与其他异步工作进行排序。然而,流仅用于排序;它不限制图的内部并行性,也不影响图节点的执行位置。

参见 Graph API.

3.2.8.7.7. 设备图启动

许多工作流需要在运行时根据数据做出决策,并执行不同的操作。用户可能更倾向于在设备上完成这一决策过程,而不是将其卸载到主机上,后者可能需要设备与主机之间的往返通信。为此,CUDA提供了一种从设备启动图的机制。

设备图启动提供了一种便捷的方式,用于从设备端执行动态控制流,无论是简单的循环还是复杂的设备端工作调度器。此功能仅在支持统一寻址的系统上可用。

可以从设备启动的图今后将被称为设备图,而不能从设备启动的图将被称为主机图。

设备图可以从主机和设备启动,而主机图只能从主机启动。与主机启动不同,当设备图的上一次启动仍在运行时从设备再次启动会导致错误,返回cudaErrorInvalidValue;因此,设备图不能同时从设备启动两次。同时从主机和设备启动设备图将导致未定义行为。

3.2.8.7.7.1. 设备图创建

为了让图形能够从设备启动,必须明确实例化为设备启动。这是通过向cudaGraphInstantiate()调用传递cudaGraphInstantiateFlagDeviceLaunch标志来实现的。与主机图形的情况一样,设备图形的结构在实例化时是固定的,如果不重新实例化就无法更新,而且实例化只能在主机上执行。为了使图形能够实例化为设备启动,它必须符合各种要求。

3.2.8.7.7.1.1.Device Graph Requirements

通用要求:

  • 图的所有节点必须位于同一设备上。

  • 该图只能包含内核节点、内存拷贝节点、内存设置节点和子图节点。

内核节点:

  • 图中内核不允许使用CUDA动态并行功能。

  • 只要未使用MPS,就允许协作启动。

内存拷贝节点:

  • 仅允许涉及设备内存和/或固定设备映射主机内存的复制操作。

  • 不允许涉及CUDA数组的复制操作。

  • 两个操作数在实例化时必须可从当前设备访问。请注意,复制操作将从图形所在的设备执行,即使其目标是另一设备上的内存。

3.2.8.7.7.1.2. Device Graph Upload

为了在设备上启动图形,必须首先将其上传到设备以填充必要的设备资源。这可以通过以下两种方式之一实现。

首先,可以通过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);
3.2.8.7.7.1.3.Device Graph Update

设备图只能从主机端更新,且必须在可执行图更新时重新上传到设备端才能使更改生效。这可以通过前文所述的相同方法实现。与主机图不同,在应用更新期间从设备端启动设备图将导致未定义行为。

3.2.8.7.7.2. 设备启动

设备图可以通过cudaGraphLaunch()从主机和设备端启动,该函数在设备端和主机端具有相同的签名。设备图在主机和设备端使用相同的句柄启动。当从设备端启动时,设备图必须从另一个图中启动。

设备端图启动是每个线程独立的,不同线程可能同时发起多次启动,因此用户需要选择一个特定线程来启动给定的图。

3.2.8.7.7.2.1.Device Launch Modes

与主机启动不同,设备图无法在常规CUDA流中启动,只能启动到特定的命名流中,每个命名流代表一种特定的启动模式:

表 2 仅限设备的图启动流

启动模式

cudaStreamGraphFireAndForget

启动后无需等待

cudaStreamGraphTailLaunch

尾部启动

cudaStreamGraphFireAndForgetAsSibling

同级启动

3.2.8.7.7.2.1.1. Fire and Forget Launch

顾名思义,fire and forget(发射后不管)启动会立即提交到GPU,并且独立于发起调用的图运行。在这种场景下,发起调用的图是父图,被调用的图是子图。

_images/fire-and-forget-simple.png

图15 即发即弃启动模式

上图可以通过以下示例代码生成:

__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);

    // Launch the host graph, which will in turn launch the device graph.
    cudaGraphLaunch(gExec1, stream);
}

在执行过程中,一个图最多可以包含120个无需等待结果的"fire-and-forget"图。这个总数会在同一个父图的不同启动之间重置。

3.2.8.7.7.2.1.2. Graph Execution Environments

为了充分理解设备端同步模型,首先需要理解执行环境的概念。

当从设备启动一个图形时,它会被加载到自己的执行环境中。给定图形的执行环境封装了图形中的所有工作以及所有生成的即发即忘工作。当图形完成执行并且所有生成的子工作完成时,可以认为该图形已完成。

下图展示了上一节中fire-and-forget示例代码将生成的环境封装结构。

_images/fire-and-forget-environments.png

图16 带执行环境的即发即弃启动方式

这些环境也是分层的,因此一个图形环境可以包含来自即发即弃启动的多层子环境。

_images/fire-and-forget-nested-environments.png

图17 嵌套式即发即弃环境

当从主机启动一个图形时,会存在一个流环境,该环境作为启动图形的执行环境的父环境。流环境封装了作为整体启动一部分生成的所有工作。当整个流环境标记为完成时,流启动即完成(即下游依赖的工作现在可以运行)。

_images/device-graph-stream-environment.png

图18 可视化流环境

3.2.8.7.7.2.1.3.Tail Launch

与主机端不同,无法通过传统方法(如cudaDeviceSynchronize()cudaStreamSynchronize())从GPU同步设备图。相反,为了支持串行工作依赖关系,CUDA提供了一种不同的启动模式——尾部启动,以实现类似功能。

尾部启动会在图的运行环境被视为完成时执行——即当该图及其所有子图都完成时。当一个图完成时,尾部启动列表中的下一个图的运行环境将作为父环境的子环境替换已完成的运行环境。与即发即弃启动类似,一个图可以排队多个图进行尾部启动。

_images/tail-launch-simple.png

图19 一个简单的尾部发射

上述执行流程可以通过以下代码生成:

__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);

    // Launch the host graph, which will in turn launch the device graph.
    cudaGraphLaunch(gExec1, stream);
}

由给定图形排队的尾部启动将按照它们被排队的顺序一次执行一个。因此,第一个排队的图形将首先运行,然后是第二个,依此类推。

_images/tail-launch-ordering-simple.png

图20 尾部启动顺序

由尾部图排队的尾部启动将在尾部启动列表中先前图形排队的尾部启动之前执行。这些新的尾部启动将按照它们被排队的顺序执行。

_images/tail-launch-ordering-complex.png

图21 从多个图入队时的尾部启动顺序

一个图形最多可以有255个待处理的尾部启动。

3.2.8.7.7.2.1.3.1. Tail Self-launch

设备图可以自行排队进行尾部启动,但一个给定的图在同一时间只能有一个自启动排队。为了查询当前运行的设备图以便重新启动,新增了一个设备端函数:

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++;
    }
}
3.2.8.7.7.2.1.4.Sibling Launch

同级启动是"发射后不管"启动的一种变体,在这种模式下,图形不是作为启动图形执行环境的子进程启动,而是作为启动图形父环境的子进程启动。同级启动等同于从启动图形的父环境进行"发射后不管"启动。

_images/sibling-launch-simple.png

图22 一个简单的兄弟节点启动示例

上图可以通过以下示例代码生成:

__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);

    // Launch the host graph, which will in turn launch the device graph.
    cudaGraphLaunch(gExec1, stream);
}

由于同级启动不会进入启动图的执行环境,因此它们不会阻塞由启动图排队的尾部启动。

3.2.8.7.8. 条件图节点

条件节点允许对包含在其中的子图进行条件执行和循环。这使得动态和迭代的工作流可以完全在图中表示,同时释放主机CPU以并行执行其他任务。

当条件节点的依赖项满足时,将在设备上评估条件值。条件节点可以是以下类型之一:

  • 条件性 IF节点 在执行时,如果条件值非零,则执行其主体图一次。

  • 条件性 WHILE节点 在执行时如果条件值非零,则会执行其主体图,并将持续执行主体图直到条件值为零。

条件值通过条件句柄访问,该句柄必须在节点创建前生成。设备代码可使用cudaGraphSetConditional()设置条件值。在创建句柄时,还可以指定每次图启动时应用的默认值。

创建条件节点时,会生成一个空图并将句柄返回给用户以便填充该图。这个条件体图可以通过graph APIscudaStreamBeginCaptureToGraph()进行填充。

条件节点可以嵌套。

3.2.8.7.8.1. 条件句柄

条件值由cudaGraphConditionalHandle表示,并通过cudaGraphConditionalHandleCreate()创建。

该句柄必须与单个条件节点相关联。句柄无法被销毁。

如果在创建句柄时指定了cudaGraphCondAssignDefault,条件值将在每次图执行开始时初始化为指定的默认值。如果未提供此标志,条件值在每次图执行开始时是未定义的,代码不应假设条件值在执行之间保持不变。

与句柄关联的默认值和标志将在全图更新期间更新。

3.2.8.7.8.2. 条件节点主体图要求

通用要求:

  • 图的所有节点必须位于同一设备上。

  • 该图只能包含内核节点、空节点、内存拷贝节点、内存设置节点、子图节点和条件节点。

内核节点:

  • 图中内核不允许使用CUDA动态并行功能。

  • 只要未使用MPS,就允许协作启动。

Memcpy/Memset节点:

  • 仅允许涉及设备内存和/或固定设备映射主机内存的复制/内存设置操作。

  • 不允许涉及CUDA数组的复制/内存设置操作。

  • 两个操作数在实例化时必须可从当前设备访问。请注意,复制操作将从图形所在的设备执行,即使其目标是另一设备上的内存。

3.2.8.7.8.3. 条件IF节点

如果节点执行时条件不为零,IF节点的主体图将执行一次。下图展示了一个包含3个节点的图,其中中间的节点B是一个条件节点:

_images/conditional-if-node.png

图23 条件IF节点

以下代码演示了如何创建一个包含IF条件节点的图。条件的默认值通过上游内核设置。条件的主体部分使用graph API进行填充。

__global__ void setHandle(cudaGraphConditionalHandle handle)
{
    ...
    cudaGraphSetConditional(handle, value);
    ...
}

void graphSetup() {
    cudaGraph_t graph;
    cudaGraphExec_t graphExec;
    cudaGraphNode_t node;
    void *kernelArgs[1];
    int value = 1;

    cudaGraphCreate(&graph, 0);

    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;
    cudaGraphAddNode(&node, graph, NULL, 0, &params);

    cudaGraphNodeParams cParams = { cudaGraphNodeTypeConditional };
    cParams.conditional.handle = handle;
    cParams.conditional.type   = cudaGraphCondTypeIf;
    cParams.conditional.size   = 1;
    cudaGraphAddNode(&node, graph, &node, 1, &cParams);

    cudaGraph_t bodyGraph = cParams.conditional.phGraph_out[0];

    // Populate the body of the conditional node
    ...
    cudaGraphAddNode(&node, bodyGraph, NULL, 0, &params);

    cudaGraphInstantiate(&graphExec, graph, NULL, NULL, 0);
    cudaGraphLaunch(graphExec, 0);
    cudaDeviceSynchronize();

    cudaGraphExecDestroy(graphExec);
    cudaGraphDestroy(graph);
}
3.2.8.7.8.4. 条件WHILE节点

WHILE节点的循环体会一直执行,直到条件判断为非零值。该条件会在节点执行时以及循环体执行完成后进行评估。下图展示了一个包含3个节点的图,其中中间的节点B是条件节点:

_images/conditional-while-node.png

图24 条件WHILE节点

以下代码展示了如何创建一个包含WHILE条件节点的图。通过使用cudaGraphCondAssignDefault创建句柄,可以避免使用上游内核。条件体的内容则通过graph API填充。

__global__ void loopKernel(cudaGraphConditionalHandle handle)
{
    static int count = 10;
    cudaGraphSetConditional(handle, --count ? 1 : 0);
}

void graphSetup() {
    cudaGraph_t graph;
    cudaGraphExec_t graphExec;
    cudaGraphNode_t node;
    void *kernelArgs[1];

    cuGraphCreate(&graph, 0);

    cudaGraphConditionalHandle handle;
    cudaGraphConditionalHandleCreate(&handle, graph, 1, cudaGraphCondAssignDefault);

    cudaGraphNodeParams cParams = { cudaGraphNodeTypeConditional };
    cParams.conditional.handle = handle;
    cParams.conditional.type   = cudaGraphCondTypeWhile;
    cParams.conditional.size   = 1;
    cudaGraphAddNode(&node, graph, NULL, 0, &cParams);

    cudaGraph_t bodyGraph = cParams.conditional.phGraph_out[0];

    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;
    cudaGraphAddNode(&node, bodyGraph, NULL, 0, &params);

    cudaGraphInstantiate(&graphExec, graph, NULL, NULL, 0);
    cudaGraphLaunch(graphExec, 0);
    cudaDeviceSynchronize();

    cudaGraphExecDestroy(graphExec);
    cudaGraphDestroy(graph);
}

3.2.8.8. 事件

运行时还提供了一种方式来密切监控设备的进度,并进行精确计时,它允许应用程序在程序中的任意点异步记录事件,并查询这些事件何时完成。当事件之前的所有任务(或可选地,给定流中的所有命令)都完成时,该事件即被视为完成。流零中的事件将在所有流中所有前置任务和命令完成后完成。

3.2.8.8.1. 事件的创建与销毁

以下代码示例创建了两个事件:

cudaEvent_t start, stop;
cudaEventCreate(&start);
cudaEventCreate(&stop);

它们是这样被销毁的:

cudaEventDestroy(start);
cudaEventDestroy(stop);
3.2.8.8.2. 运行时间

事件的创建与销毁中创建的事件可用于以下方式计时流的创建与销毁中的代码示例:

cudaEventRecord(start, 0);
for (int i = 0; i < 2; ++i) {
    cudaMemcpyAsync(inputDev + i * size, inputHost + i * size,
                    size, cudaMemcpyHostToDevice, stream[i]);
    MyKernel<<<100, 512, 0, stream[i]>>>
               (outputDev + i * size, inputDev + i * size, size);
    cudaMemcpyAsync(outputHost + i * size, outputDev + i * size,
                    size, cudaMemcpyDeviceToHost, stream[i]);
}
cudaEventRecord(stop, 0);
cudaEventSynchronize(stop);
float elapsedTime;
cudaEventElapsedTime(&elapsedTime, start, stop);

3.2.8.9. 同步调用

当调用同步函数时,在设备完成请求任务之前,控制权不会返回给主机线程。主机线程随后是让出、阻塞还是自旋,可以通过在主机线程执行任何其他CUDA调用之前,使用特定标志调用cudaSetDeviceFlags()来指定(详见参考手册)。

3.2.9. 多设备系统

3.2.9.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.2.9.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.2.9.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:
MyKernel<<<100, 64, 0, s0>>>(); // Launch kernel on device 1 in s0

即使内存复制操作被发送到与当前设备无关的流中,该操作仍会成功执行。

cudaEventRecord() 如果输入事件和输入流关联到不同设备将会失败。

cudaEventElapsedTime() 如果两个输入事件关联到不同设备将会失败。

cudaEventSynchronize()cudaEventQuery() 即使输入事件关联的设备与当前设备不同,也会执行成功。

cudaStreamWaitEvent() 即使输入流和输入事件关联到不同设备也能成功执行。cudaStreamWaitEvent() 因此可用于实现多设备间的同步。

每个设备都有自己的默认流(参见默认流),因此发往设备默认流的命令可能会乱序执行,或与发往其他任何设备默认流的命令并发执行。

3.2.9.4. 点对点内存访问

根据系统属性,特别是PCIe和/或NVLINK拓扑结构,设备能够访问彼此的内存(即在一个设备上执行的内核可以解引用指向另一个设备内存的指针)。如果cudaDeviceCanAccessPeer()对这两个设备返回true,则支持它们之间的点对点内存访问功能。

点对点内存访问仅在64位应用程序中受支持,并且必须通过调用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);
3.2.9.4.1. Linux上的IOMMU

仅在Linux系统上,CUDA和显示驱动程序不支持启用IOMMU的裸机PCIe点对点内存复制。不过,CUDA和显示驱动程序确实支持通过虚拟机透传方式使用IOMMU。因此,Linux用户在原生裸机系统上运行时应当禁用IOMMU。当需要为虚拟机提供PCIe透传功能时,则应启用IOMMU并使用VFIO驱动程序。

在Windows系统上不存在上述限制。

另请参阅在64位平台上分配DMA缓冲区

3.2.9.5. 点对点内存复制

可以在两个不同设备的存储器之间执行内存拷贝。

当为两个设备使用统一地址空间时(参见统一虚拟地址空间),这是通过设备内存中提到的常规内存复制函数完成的。

否则,将使用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流中):

  • 在所有先前发送到任一设备的命令完成之前不会开始

  • 在复制到任一设备后发出的任何命令(参见异步并发执行)开始之前,运行会完成。

与流的正常行为一致,两个设备内存之间的异步复制可能与另一个流中的复制或内核重叠。

请注意,如果通过cudaDeviceEnablePeerAccess()在两个设备间启用了点对点访问(如Peer-to-Peer Memory Access中所述),这两个设备之间的点对点内存复制就不再需要通过主机进行中转,因此速度更快。

3.2.10. 统一虚拟地址空间

当应用程序以64位进程运行时,主机和所有计算能力2.0及更高版本的设备将使用单一地址空间。通过CUDA API调用进行的所有主机内存分配以及支持设备上的所有设备内存分配都在此虚拟地址范围内。因此:

  • 通过CUDA分配的主机上或使用统一地址空间的任何设备上的内存位置,都可以通过cudaPointerGetAttributes()函数从指针值中确定。

  • 当复制到或从使用统一地址空间的任何设备内存时,可以将cudaMemcpy*()cudaMemcpyKind参数设置为cudaMemcpyDefault以根据指针确定位置。只要当前设备使用统一寻址,这也适用于未通过CUDA分配的主机指针。

  • 通过cudaHostAlloc()分配的存储器在所有使用统一地址空间的设备上自动具有可移植性(参见可移植存储器),并且cudaHostAlloc()返回的指针可以直接在这些设备上运行的内核中使用(即无需像映射存储器中描述的那样通过cudaHostGetDevicePointer()获取设备指针)。

应用程序可以通过检查unifiedAddressing设备属性(参见Device Enumeration)是否等于1,来查询特定设备是否使用了统一地址空间。

3.2.11. 进程间通信

由主机线程创建的任何设备内存指针或事件句柄都可以被同一进程内的任何其他线程直接引用。然而,在此进程之外它是无效的,因此不能被属于不同进程的线程直接引用。

要在不同进程间共享设备内存指针和事件,应用程序必须使用进程间通信(IPC)API,该API在参考手册中有详细说明。IPC API仅支持Linux系统上的64位进程,且要求设备计算能力为2.0或更高版本。请注意,cudaMallocManaged分配的内存不支持IPC API。

通过此API,应用程序可以使用cudaIpcGetMemHandle()获取给定设备内存指针的IPC句柄,通过标准IPC机制(例如进程间共享内存或文件)将其传递给另一个进程,并使用cudaIpcOpenMemHandle()从IPC句柄中检索设备指针,该指针在另一个进程中是有效的指针。事件句柄可以通过类似的入口点进行共享。

请注意,出于性能考虑,cudaMalloc()分配的内存可能是从更大的内存块中进行子分配的。在这种情况下,CUDA IPC API将共享整个底层内存块,这可能导致其他子分配也被共享,从而可能在进程之间造成信息泄露。为防止这种行为,建议仅共享2MiB对齐大小的分配。

使用IPC API的一个示例是,单个主进程生成一批输入数据,使多个辅助进程无需重新生成或复制即可获取这些数据。

使用CUDA IPC进行相互通信的应用程序应使用相同的CUDA驱动程序和运行时进行编译、链接和运行。

注意

自CUDA 11.5起,在计算能力7.x及更高的L4T和嵌入式Linux Tegra设备上,仅支持事件共享IPC API。内存共享IPC API在Tegra平台上仍不受支持。

3.2.12. 错误检查

所有运行时函数都会返回一个错误代码,但对于异步函数(参见异步并发执行),此错误代码无法报告设备上可能发生的任何异步错误,因为函数在设备完成任务之前就已返回;错误代码仅报告执行任务前在主机上发生的错误,通常与参数验证相关;如果发生异步错误,将由后续某个无关的运行时函数调用报告。

因此,检查某些异步函数调用后异步错误的唯一方法是:在调用后立即通过调用cudaDeviceSynchronize()(或使用Asynchronous Concurrent Execution中描述的任何其他同步机制)进行同步,并检查cudaDeviceSynchronize()返回的错误代码。

运行时为每个主机线程维护一个错误变量,该变量初始化为cudaSuccess,并在每次发生错误时(无论是参数验证错误还是异步错误)被错误代码覆盖。cudaPeekAtLastError()返回此变量。cudaGetLastError()返回此变量并将其重置为cudaSuccess

内核启动不会返回任何错误代码,因此必须在内核启动后立即调用cudaPeekAtLastError()cudaGetLastError()来获取任何启动前的错误。为确保cudaPeekAtLastError()cudaGetLastError()返回的错误不是源自内核启动之前的调用,必须确保在内核启动前将运行时错误变量设置为cudaSuccess,例如在内核启动前调用cudaGetLastError()。内核启动是异步的,因此要检查异步错误,应用程序必须在内核启动与调用cudaPeekAtLastError()cudaGetLastError()之间进行同步。

请注意,cudaStreamQuery()cudaEventQuery()可能返回的cudaErrorNotReady不被视为错误,因此不会被cudaPeekAtLastError()cudaGetLastError()报告。

3.2.13. 调用堆栈

在计算能力2.x及更高版本的设备上,可以使用cudaDeviceGetLimit()查询调用堆栈的大小,并使用cudaDeviceSetLimit()进行设置。

When the call stack overflows, the kernel call fails with a stack overflow error if the application is run via a CUDA debugger (CUDA-GDB, Nsight) or an unspecified launch error, otherwise. When the compiler cannot determine the stack size, it issues a warning saying Stack size cannot be statically determined. This is usually the case with recursive functions. Once this warning is issued, user will need to set stack size manually if default stack size is not sufficient.

3.2.14. 纹理与表面内存

CUDA支持GPU用于图形处理的部分纹理硬件功能,以访问纹理和表面内存。相较于从全局内存读取数据,从纹理或表面内存读取数据能带来多项性能优势,具体说明详见设备内存访问

3.2.14.1. 纹理内存

纹理内存通过内核使用纹理函数中描述的设备函数进行读取。调用这些函数读取纹理的过程称为纹理获取。每个纹理获取都会为纹理对象API指定一个名为纹理对象的参数。

纹理对象指定:

  • 纹理,即被获取的纹理内存片段。纹理对象在运行时创建,纹理的具体属性在创建纹理对象时指定,如Texture Object API中所述。

  • 维度决定了纹理是作为使用一个纹理坐标的一维数组、使用两个纹理坐标的二维数组,还是使用三个纹理坐标的三维数组进行寻址。数组元素称为纹素(texels),即纹理元素(texture elements)的简称。纹理宽度高度深度分别表示数组在各个维度上的大小。表21 列出了根据设备计算能力确定的最大纹理宽度、高度和深度。

  • 纹素类型,仅限于基本整数和单精度浮点类型,以及从这些基本类型派生的1、2、4分量向量类型(定义于内置向量类型中)。

  • 读取模式,可选值为cudaReadModeNormalizedFloatcudaReadModeElementType。若选择cudaReadModeNormalizedFloat且纹理元素为16位或8位整型时,纹理拾取返回的值将转换为浮点类型,无符号整型的整个取值范围映射到[0.0, 1.0],有符号整型映射到[-1.0, 1.0];例如,值为0xff的无符号8位纹理元素将读取为1。若选择cudaReadModeElementType则不进行任何转换。

  • 纹理坐标是否已归一化。默认情况下,纹理引用(通过纹理函数的功能)使用[0, N-1]范围内的浮点坐标,其中N是与坐标对应的纹理维度大小。例如,一个64x32大小的纹理在x和y维度上将分别使用[0, 63]和[0, 31]范围内的坐标进行引用。归一化纹理坐标使坐标在[0.0, 1.0-1/N]范围内指定,而不是[0, N-1],因此相同的64x32纹理将在x和y维度上通过[0, 1-1/N]范围内的归一化坐标进行寻址。如果纹理坐标需要独立于纹理大小,归一化纹理坐标自然适合某些应用程序的需求。

  • 寻址模式。使用超出范围的坐标调用B.8节的设备函数是有效的。寻址模式定义了在这种情况下会发生什么。默认的寻址模式是将坐标钳制到有效范围内:非归一化坐标为[0, N),归一化坐标为[0.0, 1.0)。如果指定了边界模式,则超出范围的纹理坐标获取将返回零。对于归一化坐标,还支持环绕模式和镜像模式。使用环绕模式时,每个坐标x会被转换为frac(x)=x - floor(x),其中floor(x)是不大于x的最大整数。使用镜像模式时,如果floor(x)是偶数,则每个坐标x转换为frac(x);如果是奇数,则转换为1-frac(x)。寻址模式被指定为一个大小为3的数组,其第一、第二和第三个元素分别指定第一、第二和第三个纹理坐标的寻址模式;可选的寻址模式包括cudaAddressModeBordercudaAddressModeClampcudaAddressModeWrapcudaAddressModeMirror;其中cudaAddressModeWrapcudaAddressModeMirror仅支持归一化纹理坐标

  • 过滤模式,指定了根据输入纹理坐标计算获取纹理时返回值的计算方式。线性纹理过滤仅适用于配置为返回浮点数据的纹理。它执行相邻纹素之间的低精度插值。启用时,会读取纹理获取位置周围的纹素,并根据纹理坐标落在纹素之间的位置对纹理获取的返回值进行插值。一维纹理执行简单的线性插值,二维纹理执行双线性插值,三维纹理执行三线性插值。Texture Fetching提供了关于纹理获取的更多细节。过滤模式等于cudaFilterModePointcudaFilterModeLinear。如果是cudaFilterModePoint,返回值是纹理坐标最接近输入纹理坐标的纹素。如果是cudaFilterModeLinear,返回值是两个(一维纹理)、四个(二维纹理)或八个(三维纹理)纹理坐标最接近输入纹理坐标的纹素的线性插值。cudaFilterModeLinear仅对浮点类型的返回值有效。

Texture Object API 介绍了纹理对象API。

16位浮点纹理 解释了如何处理16位浮点纹理。

纹理也可以按照分层纹理中描述的方式进行分层。

立方体贴图纹理分层立方体贴图纹理描述了一种特殊类型的纹理——立方体贴图纹理。

纹理聚集描述了一种特殊的纹理获取方式——纹理聚集。

3.2.14.1.1. 纹理对象API

纹理对象是通过cudaCreateTextureObject()创建的,它基于类型为struct cudaResourceDesc的资源描述(指定了纹理属性)以及如下定义的纹理描述:

struct cudaTextureDesc
{
    enum cudaTextureAddressMode addressMode[3];
    enum cudaTextureFilterMode  filterMode;
    enum cudaTextureReadMode    readMode;
    int                         sRGB;
    int                         normalizedCoords;
    unsigned int                maxAnisotropy;
    enum cudaTextureFilterMode  mipmapFilterMode;
    float                       mipmapLevelBias;
    float                       minMipmapLevelClamp;
    float                       maxMipmapLevelClamp;
};
  • addressMode 指定寻址模式;

  • filterMode 指定过滤模式;

  • readMode 指定读取模式;

  • normalizedCoords 指定纹理坐标是否已归一化;

  • 请参阅参考手册了解sRGBmaxAnisotropymipmapFilterModemipmapLevelBiasminMipmapLevelClampmaxMipmapLevelClamp

以下代码示例对一个纹理应用了一些简单的变换内核。

// Simple transformation kernel
__global__ void transformKernel(float* output,
                                cudaTextureObject_t texObj,
                                int width, int height,
                                float theta)
{
    // Calculate normalized texture coordinates
    unsigned int x = blockIdx.x * blockDim.x + threadIdx.x;
    unsigned int y = blockIdx.y * blockDim.y + threadIdx.y;

    float u = x / (float)width;
    float v = y / (float)height;

    // Transform coordinates
    u -= 0.5f;
    v -= 0.5f;
    float tu = u * cosf(theta) - v * sinf(theta) + 0.5f;
    float tv = v * cosf(theta) + u * sinf(theta) + 0.5f;

    // Read from texture and write to global memory
    output[y * width + x] = tex2D<float>(texObj, tu, tv);
}
// Host code
int main()
{
    const int height = 1024;
    const int width = 1024;
    float angle = 0.5;

    // Allocate and set some host data
    float *h_data = (float *)std::malloc(sizeof(float) * width * height);
    for (int i = 0; i < height * width; ++i)
        h_data[i] = i;

    // Allocate CUDA array in device memory
    cudaChannelFormatDesc channelDesc =
        cudaCreateChannelDesc(32, 0, 0, 0, cudaChannelFormatKindFloat);
    cudaArray_t cuArray;
    cudaMallocArray(&cuArray, &channelDesc, width, height);

    // Set pitch of the source (the width in memory in bytes of the 2D array pointed
    // to by src, including padding), we dont have any padding
    const size_t spitch = width * sizeof(float);
    // Copy data located at address h_data in host memory to device memory
    cudaMemcpy2DToArray(cuArray, 0, 0, h_data, spitch, width * sizeof(float),
                        height, cudaMemcpyHostToDevice);

    // Specify texture
    struct cudaResourceDesc resDesc;
    memset(&resDesc, 0, sizeof(resDesc));
    resDesc.resType = cudaResourceTypeArray;
    resDesc.res.array.array = cuArray;

    // Specify texture object parameters
    struct cudaTextureDesc texDesc;
    memset(&texDesc, 0, sizeof(texDesc));
    texDesc.addressMode[0] = cudaAddressModeWrap;
    texDesc.addressMode[1] = cudaAddressModeWrap;
    texDesc.filterMode = cudaFilterModeLinear;
    texDesc.readMode = cudaReadModeElementType;
    texDesc.normalizedCoords = 1;

    // Create texture object
    cudaTextureObject_t texObj = 0;
    cudaCreateTextureObject(&texObj, &resDesc, &texDesc, NULL);

    // Allocate result of transformation in device memory
    float *output;
    cudaMalloc(&output, width * height * sizeof(float));

    // Invoke kernel
    dim3 threadsperBlock(16, 16);
    dim3 numBlocks((width + threadsperBlock.x - 1) / threadsperBlock.x,
                    (height + threadsperBlock.y - 1) / threadsperBlock.y);
    transformKernel<<<numBlocks, threadsperBlock>>>(output, texObj, width, height,
                                                    angle);
    // Copy data from device back to host
    cudaMemcpy(h_data, output, width * height * sizeof(float),
                cudaMemcpyDeviceToHost);

    // Destroy texture object
    cudaDestroyTextureObject(texObj);

    // Free device memory
    cudaFreeArray(cuArray);
    cudaFree(output);

    // Free host memory
    free(h_data);

    return 0;
}
3.2.14.1.2. 16位浮点纹理

CUDA数组支持的16位浮点或半精度格式与IEEE 754-2008 binary2格式相同。

CUDA C++ 不支持匹配的数据类型,但提供了内置函数通过unsigned short类型与32位浮点格式相互转换:__float2half_rn(float)__half2float(unsigned short)。这些函数仅在设备代码中受支持。主机代码的等效函数可以在OpenEXR库中找到。

在执行任何过滤操作之前,16位浮点分量在纹理获取过程中会被提升为32位浮点。

可以通过调用cudaCreateChannelDescHalf*()函数之一来创建16位浮点格式的通道描述。

3.2.14.1.3. 分层纹理

一维或二维分层纹理(在Direct3D中也称为纹理数组,在OpenGL中称为数组纹理)是由一系列层组成的纹理,所有层都是具有相同维度、大小和数据类型的常规纹理。

一维分层纹理使用整数索引和浮点纹理坐标进行寻址;索引表示序列中的层,坐标则寻址该层内的纹素。二维分层纹理使用整数索引和两个浮点纹理坐标进行寻址;索引表示序列中的层,坐标则寻址该层内的纹素。

分层纹理只能通过调用cudaMalloc3DArray()并设置cudaArrayLayered标志(对于一维分层纹理高度需设为零)来创建为CUDA数组。

分层纹理使用设备函数tex1DLayered()tex2DLayered()进行获取。纹理过滤(参见Texture Fetching)仅在单个层内执行,不会跨层处理。

分层纹理仅在计算能力2.0及以上的设备上受支持。

3.2.14.1.4. 立方体贴图纹理

立方体贴图是一种特殊类型的二维分层纹理,它包含六个层,分别代表立方体的各个面:

  • 层的宽度等于其高度。

  • 立方体贴图使用三个纹理坐标xyz进行寻址,这些坐标被解释为从立方体中心发出的方向向量,指向立方体的一个面以及与该面对应的层中的纹理元素。更具体地说,面是通过具有最大幅值m的坐标选择的,对应的层使用坐标(s/m+1)/2(t/m+1)/2进行寻址,其中st表3中定义。

表 3 立方体贴图获取

面部

|x| > |y||x| > |z|

x ≥ 0

0

x

-z

-y

x < 0

1

-x

z

-y

|y| > |x||y| > |z|

y ≥ 0

2

y

x

z

y < 0

3

-y

x

-z

|z| > |x||z| > |y|

z ≥ 0

4

z

x

-y

z < 0

5

-z

-x

-y

立方体贴图纹理只能通过调用带有cudaArrayCubemap标志的cudaMalloc3DArray()来创建为CUDA数组。

立方体贴图纹理使用设备函数texCubemap()进行获取。

立方体贴图纹理仅在计算能力2.0及以上的设备上受支持。

3.2.14.1.5. 立方体贴图分层纹理

一个立方体贴图分层纹理是指各层均为相同尺寸立方体贴图的分层纹理。

立方体贴图分层纹理使用一个整数索引和三个浮点纹理坐标进行寻址;索引表示序列中的立方体贴图,坐标则定位该立方体贴图中的纹素。

立方体贴图分层纹理只能通过调用cudaMalloc3DArray()并设置cudaArrayLayeredcudaArrayCubemap标志来创建为CUDA数组。

立方体贴图分层纹理使用设备函数texCubemapLayered()进行获取。纹理过滤(参见纹理获取)仅在单个层内执行,不会跨层处理。

立方体贴图分层纹理仅在计算能力2.0及以上的设备上受支持。

3.2.14.1.6. 纹理采集

纹理聚集是一种特殊的纹理获取方式,仅适用于二维纹理。它通过tex2Dgather()函数执行,该函数具有与tex2D()相同的参数,外加一个额外的comp参数(取值为0、1、2或3,参见tex2Dgather())。该函数返回四个32位数值,分别对应常规纹理获取时用于双线性过滤的四个纹素中comp分量的值。例如,如果这些纹素的值为(253, 20, 31, 255)、(250, 25, 29, 254)、(249, 16, 37, 253)、(251, 22, 30, 250),且comp为2时,tex2Dgather()将返回(31, 29, 37, 30)。

请注意,纹理坐标仅以8位小数精度计算。因此,在tex2D()会对其权重之一(α或β,参见线性滤波)使用1.0的情况下,tex2Dgather()可能会返回意外结果。例如,当纹理坐标x为2.49805时:xB=x-0.5=1.99805,但xB的小数部分以8位定点格式存储。由于0.99805更接近256.f/256.f而非255.f/256.f,xB的值将为2。因此,这种情况下tex2Dgather()将在x方向上返回索引2和3,而非索引1和2。

纹理聚集仅支持使用cudaArrayTextureGather标志创建的CUDA数组,且其宽度和高度小于表21中为纹理聚集指定的最大值,该值小于常规纹理获取的尺寸限制。

纹理聚集功能仅在计算能力2.0及以上的设备上支持。

3.2.14.2. 表面内存

对于计算能力2.0及以上的设备,可以通过Cubemap Surfaces中描述的表面对象,使用Surface Functions中描述的函数来读写带有cudaArraySurfaceLoadStore标志创建的CUDA数组。

表21列出了根据设备计算能力确定的最大表面宽度、高度和深度。

3.2.14.2.1. 表面对象API

表面对象是通过cudaCreateSurfaceObject()从类型为struct cudaResourceDesc的资源描述创建的。与纹理内存不同,表面内存使用字节寻址。这意味着通过纹理函数访问纹理元素时使用的x坐标需要乘以元素的字节大小,才能通过表面函数访问相同的元素。例如,绑定到纹理对象texObj和表面对象surfObj的一维浮点CUDA数组中纹理坐标x处的元素,通过texObj使用tex1d(texObj, x)读取,而通过surfObj则使用surf1Dread(surfObj, 4*x)。类似地,绑定到纹理对象texObj和表面对象surfObj的二维浮点CUDA数组中纹理坐标x和y处的元素,通过texObj使用tex2d(texObj, x, y)访问,而通过surObj则使用surf2Dread(surfObj, 4*x, y)(y坐标的字节偏移量是根据CUDA数组的底层行间距内部计算的)。

以下代码示例对一个表面应用了一些简单的变换内核。

// Simple copy kernel
__global__ void copyKernel(cudaSurfaceObject_t inputSurfObj,
                           cudaSurfaceObject_t outputSurfObj,
                           int width, int height)
{
    // Calculate surface coordinates
    unsigned int x = blockIdx.x * blockDim.x + threadIdx.x;
    unsigned int y = blockIdx.y * blockDim.y + threadIdx.y;
    if (x < width && y < height) {
        uchar4 data;
        // Read from input surface
        surf2Dread(&data,  inputSurfObj, x * 4, y);
        // Write to output surface
        surf2Dwrite(data, outputSurfObj, x * 4, y);
    }
}

// Host code
int main()
{
    const int height = 1024;
    const int width = 1024;

    // Allocate and set some host data
    unsigned char *h_data =
        (unsigned char *)std::malloc(sizeof(unsigned char) * width * height * 4);
    for (int i = 0; i < height * width * 4; ++i)
        h_data[i] = i;

    // Allocate CUDA arrays in device memory
    cudaChannelFormatDesc channelDesc =
        cudaCreateChannelDesc(8, 8, 8, 8, cudaChannelFormatKindUnsigned);
    cudaArray_t cuInputArray;
    cudaMallocArray(&cuInputArray, &channelDesc, width, height,
                    cudaArraySurfaceLoadStore);
    cudaArray_t cuOutputArray;
    cudaMallocArray(&cuOutputArray, &channelDesc, width, height,
                    cudaArraySurfaceLoadStore);

    // Set pitch of the source (the width in memory in bytes of the 2D array
    // pointed to by src, including padding), we dont have any padding
    const size_t spitch = 4 * width * sizeof(unsigned char);
    // Copy data located at address h_data in host memory to device memory
    cudaMemcpy2DToArray(cuInputArray, 0, 0, h_data, spitch,
                        4 * width * sizeof(unsigned char), height,
                        cudaMemcpyHostToDevice);

    // Specify surface
    struct cudaResourceDesc resDesc;
    memset(&resDesc, 0, sizeof(resDesc));
    resDesc.resType = cudaResourceTypeArray;

    // Create the surface objects
    resDesc.res.array.array = cuInputArray;
    cudaSurfaceObject_t inputSurfObj = 0;
    cudaCreateSurfaceObject(&inputSurfObj, &resDesc);
    resDesc.res.array.array = cuOutputArray;
    cudaSurfaceObject_t outputSurfObj = 0;
    cudaCreateSurfaceObject(&outputSurfObj, &resDesc);

    // Invoke kernel
    dim3 threadsperBlock(16, 16);
    dim3 numBlocks((width + threadsperBlock.x - 1) / threadsperBlock.x,
                    (height + threadsperBlock.y - 1) / threadsperBlock.y);
    copyKernel<<<numBlocks, threadsperBlock>>>(inputSurfObj, outputSurfObj, width,
                                                height);

    // Copy data from device back to host
    cudaMemcpy2DFromArray(h_data, spitch, cuOutputArray, 0, 0,
                            4 * width * sizeof(unsigned char), height,
                            cudaMemcpyDeviceToHost);

    // Destroy surface objects
    cudaDestroySurfaceObject(inputSurfObj);
    cudaDestroySurfaceObject(outputSurfObj);

    // Free device memory
    cudaFreeArray(cuInputArray);
    cudaFreeArray(cuOutputArray);

    // Free host memory
    free(h_data);

  return 0;
}
3.2.14.2.2. 立方体贴图表面

立方体贴图表面使用surfCubemapread()surfCubemapwrite()(surfCubemapread()surfCubemapwrite())作为二维分层表面进行访问,即使用表示面的整数索引和两个浮点纹理坐标来寻址对应此面的层中的纹素。面的顺序如表3所示。

3.2.14.2.3. 立方体贴图分层表面

立方体贴图层叠表面通过surfCubemapLayeredread()surfCubemapLayeredwrite()(surfCubemapLayeredread()surfCubemapLayeredwrite())作为二维层叠表面进行访问,即使用一个表示立方体贴图某个面的整型索引,以及两个浮点纹理坐标来寻址该面对应层中的纹素。面的顺序如表3所示,例如索引((2 * 6) + 3)表示访问第三个立方体贴图的第四个面。

3.2.14.3. CUDA数组

CUDA数组是为纹理获取优化的不透明内存布局。它们可以是一维、二维或三维的,由元素组成,每个元素包含1、2或4个分量,这些分量可以是带符号或无符号的8位、16位或32位整数,16位浮点数或32位浮点数。CUDA数组只能通过内核进行访问,如纹理内存中描述的纹理获取,或如表面内存中描述的表面读写操作。

3.2.14.4. 读写一致性

纹理和表面内存是缓存的(参见设备内存访问),在同一内核调用期间,缓存不会与全局内存写入和表面内存写入保持一致性。因此,任何对已被同一内核调用中的全局写入或表面写入修改过的地址进行纹理获取或表面读取操作,都将返回未定义数据。换句话说,线程可以安全地读取某些纹理或表面内存位置,仅当该内存位置是由先前内核调用或内存复制更新的,而不能是由同一线程或同一内核调用中的其他线程先前更新的。

3.2.15. 图形互操作性

来自OpenGL和Direct3D的部分资源可以被映射到CUDA的地址空间中,这样既能让CUDA读取由OpenGL或Direct3D写入的数据,也能让CUDA写入数据供OpenGL或Direct3D使用。

在使用OpenGL互操作性Direct3D互操作性中提到的函数进行映射之前,必须先将资源注册到CUDA。这些函数会返回一个指向struct cudaGraphicsResource类型的CUDA图形资源指针。注册资源可能开销较大,因此通常每个资源只需调用一次。取消注册CUDA图形资源使用cudaGraphicsUnregisterResource()函数。每个打算使用该资源的CUDA上下文都需要单独进行注册。

一旦资源注册到CUDA后,就可以使用cudaGraphicsMapResources()cudaGraphicsUnmapResources()进行多次映射和解映射操作。可以调用cudaGraphicsResourceSetMapFlags()来指定使用提示(只写、只读),CUDA驱动程序可以利用这些提示来优化资源管理。

内核可以通过cudaGraphicsResourceGetMappedPointer()返回的设备内存地址读取或写入映射的缓冲区资源,对于CUDA数组则使用cudaGraphicsSubResourceGetMappedArray()返回的地址。

当资源被映射时,通过OpenGL、Direct3D或其他CUDA上下文访问该资源会产生未定义的结果。OpenGL互操作性Direct3D互操作性提供了每个图形API的具体细节和一些代码示例。SLI互操作性则说明了系统处于SLI模式时的具体情况。

3.2.15.1. OpenGL互操作性

可以映射到CUDA地址空间的OpenGL资源包括OpenGL缓冲区、纹理和渲染缓冲区对象。

使用cudaGraphicsGLRegisterBuffer()注册缓冲区对象。在CUDA中,它会显示为设备指针,因此可以通过内核或cudaMemcpy()调用来进行读写操作。

纹理或渲染缓冲对象通过cudaGraphicsGLRegisterImage()进行注册。在CUDA中,它会显示为一个CUDA数组。内核程序可以通过将其绑定到纹理或表面引用来读取该数组。如果资源已使用cudaGraphicsRegisterFlagsSurfaceLoadStore标志注册,内核还可以通过表面写入函数向其写入数据。该数组也可以通过cudaMemcpy2D()调用来读写。cudaGraphicsGLRegisterImage()支持所有具有1、2或4个分量且内部类型为浮点型(例如GL_RGBA_FLOAT32)、归一化整型(例如GL_RGBA8, GL_INTENSITY16)以及非归一化整型(例如GL_RGBA8UI)的纹理格式(请注意,由于非归一化整型格式需要OpenGL 3.0,它们只能由着色器写入,而不能通过固定功能管线写入)。

共享资源的OpenGL上下文必须对进行任何OpenGL互操作性API调用的主机线程保持当前状态。

请注意:当OpenGL纹理变为无绑定时(例如通过使用glGetTextureHandle*/glGetImageHandle* API请求图像或纹理句柄时),它无法在CUDA中注册。应用程序需要在请求图像或纹理句柄之前先注册该纹理以实现互操作。

以下代码示例使用内核动态修改存储在顶点缓冲对象中的2D width x height顶点网格:

GLuint positionsVBO;
struct cudaGraphicsResource* positionsVBO_CUDA;

int main()
{
    // Initialize OpenGL and GLUT for device 0
    // and make the OpenGL context current
    ...
    glutDisplayFunc(display);

    // Explicitly set device 0
    cudaSetDevice(0);

    // Create buffer object and register it with CUDA
    glGenBuffers(1, &positionsVBO);
    glBindBuffer(GL_ARRAY_BUFFER, positionsVBO);
    unsigned int size = width * height * 4 * sizeof(float);
    glBufferData(GL_ARRAY_BUFFER, size, 0, GL_DYNAMIC_DRAW);
    glBindBuffer(GL_ARRAY_BUFFER, 0);
    cudaGraphicsGLRegisterBuffer(&positionsVBO_CUDA,
                                 positionsVBO,
                                 cudaGraphicsMapFlagsWriteDiscard);

    // Launch rendering loop
    glutMainLoop();

    ...
}

void display()
{
    // Map buffer object for writing from CUDA
    float4* positions;
    cudaGraphicsMapResources(1, &positionsVBO_CUDA, 0);
    size_t num_bytes;
    cudaGraphicsResourceGetMappedPointer((void**)&positions,
                                         &num_bytes,
                                         positionsVBO_CUDA));

    // Execute kernel
    dim3 dimBlock(16, 16, 1);
    dim3 dimGrid(width / dimBlock.x, height / dimBlock.y, 1);
    createVertices<<<dimGrid, dimBlock>>>(positions, time,
                                          width, height);

    // Unmap buffer object
    cudaGraphicsUnmapResources(1, &positionsVBO_CUDA, 0);

    // Render from buffer object
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    glBindBuffer(GL_ARRAY_BUFFER, positionsVBO);
    glVertexPointer(4, GL_FLOAT, 0, 0);
    glEnableClientState(GL_VERTEX_ARRAY);
    glDrawArrays(GL_POINTS, 0, width * height);
    glDisableClientState(GL_VERTEX_ARRAY);

    // Swap buffers
    glutSwapBuffers();
    glutPostRedisplay();
}
void deleteVBO()
{
    cudaGraphicsUnregisterResource(positionsVBO_CUDA);
    glDeleteBuffers(1, &positionsVBO);
}

__global__ void createVertices(float4* positions, float time,
                               unsigned int width, unsigned int height)
{
    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 positions
    positions[y * width + x] = make_float4(u, w, v, 1.0f);
}

在Windows系统和Quadro GPU上,cudaWGLGetDevice()可用于检索与wglEnumGpusNV()返回的句柄相关联的CUDA设备。在多GPU配置中,当OpenGL渲染在Quadro GPU上执行而CUDA计算在系统中的其他GPU上执行时,Quadro GPU提供比GeForce和Tesla GPU更高的OpenGL互操作性性能。

3.2.15.2. Direct3D 互操作性

Direct3D互操作性支持Direct3D 9Ex、Direct3D 10和Direct3D 11。

CUDA上下文只能与满足以下条件的Direct3D设备互操作:Direct3D 9Ex设备创建时必须将DeviceType设置为D3DDEVTYPE_HAL,且BehaviorFlags需包含D3DCREATE_HARDWARE_VERTEXPROCESSING标志;Direct3D 10和Direct3D 11设备创建时必须将DriverType设置为D3D_DRIVER_TYPE_HARDWARE

可以被映射到CUDA地址空间的Direct3D资源包括Direct3D缓冲区、纹理和表面。这些资源通过cudaGraphicsD3D9RegisterResource()cudaGraphicsD3D10RegisterResource()cudaGraphicsD3D11RegisterResource()进行注册。

以下代码示例使用内核动态修改存储在顶点缓冲对象中的2D width x height顶点网格。

3.2.15.2.1. Direct3D 9 版本
IDirect3D9* D3D;
IDirect3DDevice9* device;
struct CUSTOMVERTEX {
    FLOAT x, y, z;
    DWORD color;
};
IDirect3DVertexBuffer9* positionsVB;
struct cudaGraphicsResource* positionsVB_CUDA;

int main()
{
    int dev;
    // Initialize Direct3D
    D3D = Direct3DCreate9Ex(D3D_SDK_VERSION);

    // Get a CUDA-enabled adapter
    unsigned int adapter = 0;
    for (; adapter < g_pD3D->GetAdapterCount(); adapter++) {
        D3DADAPTER_IDENTIFIER9 adapterId;
        g_pD3D->GetAdapterIdentifier(adapter, 0, &adapterId);
        if (cudaD3D9GetDevice(&dev, adapterId.DeviceName)
            == cudaSuccess)
            break;
    }

     // Create device
    ...
    D3D->CreateDeviceEx(adapter, D3DDEVTYPE_HAL, hWnd,
                        D3DCREATE_HARDWARE_VERTEXPROCESSING,
                        &params, NULL, &device);

    // Use the same device
    cudaSetDevice(dev);

    // Create vertex buffer and register it with CUDA
    unsigned int size = width * height * sizeof(CUSTOMVERTEX);
    device->CreateVertexBuffer(size, 0, D3DFVF_CUSTOMVERTEX,
                               D3DPOOL_DEFAULT, &positionsVB, 0);
    cudaGraphicsD3D9RegisterResource(&positionsVB_CUDA,
                                     positionsVB,
                                     cudaGraphicsRegisterFlagsNone);
    cudaGraphicsResourceSetMapFlags(positionsVB_CUDA,
                                    cudaGraphicsMapFlagsWriteDiscard);

    // Launch rendering loop
    while (...) {
        ...
        Render();
        ...
    }
    ...
}
void Render()
{
    // Map vertex buffer for writing from CUDA
    float4* positions;
    cudaGraphicsMapResources(1, &positionsVB_CUDA, 0);
    size_t num_bytes;
    cudaGraphicsResourceGetMappedPointer((void**)&positions,
                                         &num_bytes,
                                         positionsVB_CUDA));

    // Execute kernel
    dim3 dimBlock(16, 16, 1);
    dim3 dimGrid(width / dimBlock.x, height / dimBlock.y, 1);
    createVertices<<<dimGrid, dimBlock>>>(positions, time,
                                          width, height);

    // Unmap vertex buffer
    cudaGraphicsUnmapResources(1, &positionsVB_CUDA, 0);

    // Draw and present
    ...
}

void releaseVB()
{
    cudaGraphicsUnregisterResource(positionsVB_CUDA);
    positionsVB->Release();
}

__global__ void createVertices(float4* positions, float time,
                               unsigned int width, unsigned int height)
{
    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 positions
    positions[y * width + x] =
                make_float4(u, w, v, __int_as_float(0xff00ff00));
}
3.2.15.2.2. Direct3D 10版本
ID3D10Device* device;
struct CUSTOMVERTEX {
    FLOAT x, y, z;
    DWORD color;
};
ID3D10Buffer* positionsVB;
struct cudaGraphicsResource* positionsVB_CUDA;

int main()
{
    int dev;
    // Get a CUDA-enabled adapter
    IDXGIFactory* factory;
    CreateDXGIFactory(__uuidof(IDXGIFactory), (void**)&factory);
    IDXGIAdapter* adapter = 0;
    for (unsigned int i = 0; !adapter; ++i) {
        if (FAILED(factory->EnumAdapters(i, &adapter))
            break;
        if (cudaD3D10GetDevice(&dev, adapter) == cudaSuccess)
            break;
        adapter->Release();
    }
    factory->Release();

    // Create swap chain and device
    ...
    D3D10CreateDeviceAndSwapChain(adapter,
                                  D3D10_DRIVER_TYPE_HARDWARE, 0,
                                  D3D10_CREATE_DEVICE_DEBUG,
                                  D3D10_SDK_VERSION,
                                  &swapChainDesc, &swapChain,
                                  &device);
    adapter->Release();

    // Use the same device
    cudaSetDevice(dev);

    // Create vertex buffer and register it with CUDA
    unsigned int size = width * height * sizeof(CUSTOMVERTEX);
    D3D10_BUFFER_DESC bufferDesc;
    bufferDesc.Usage          = D3D10_USAGE_DEFAULT;
    bufferDesc.ByteWidth      = size;
    bufferDesc.BindFlags      = D3D10_BIND_VERTEX_BUFFER;
    bufferDesc.CPUAccessFlags = 0;
    bufferDesc.MiscFlags      = 0;
    device->CreateBuffer(&bufferDesc, 0, &positionsVB);
    cudaGraphicsD3D10RegisterResource(&positionsVB_CUDA,
                                      positionsVB,
                                      cudaGraphicsRegisterFlagsNone);
                                      cudaGraphicsResourceSetMapFlags(positionsVB_CUDA,
                                      cudaGraphicsMapFlagsWriteDiscard);

    // Launch rendering loop
    while (...) {
        ...
        Render();
        ...
    }
    ...
}
void Render()
{
    // Map vertex buffer for writing from CUDA
    float4* positions;
    cudaGraphicsMapResources(1, &positionsVB_CUDA, 0);
    size_t num_bytes;
    cudaGraphicsResourceGetMappedPointer((void**)&positions,
                                         &num_bytes,
                                         positionsVB_CUDA));

    // Execute kernel
    dim3 dimBlock(16, 16, 1);
    dim3 dimGrid(width / dimBlock.x, height / dimBlock.y, 1);
    createVertices<<<dimGrid, dimBlock>>>(positions, time,
                                          width, height);

    // Unmap vertex buffer
    cudaGraphicsUnmapResources(1, &positionsVB_CUDA, 0);

    // Draw and present
    ...
}

void releaseVB()
{
    cudaGraphicsUnregisterResource(positionsVB_CUDA);
    positionsVB->Release();
}

__global__ void createVertices(float4* positions, float time,
                               unsigned int width, unsigned int height)
{
    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 positions
    positions[y * width + x] =
                make_float4(u, w, v, __int_as_float(0xff00ff00));
}
3.2.15.2.3. Direct3D 11 版本
ID3D11Device* device;
struct CUSTOMVERTEX {
    FLOAT x, y, z;
    DWORD color;
};
ID3D11Buffer* positionsVB;
struct cudaGraphicsResource* positionsVB_CUDA;

int main()
{
    int dev;
    // Get a CUDA-enabled adapter
    IDXGIFactory* factory;
    CreateDXGIFactory(__uuidof(IDXGIFactory), (void**)&factory);
    IDXGIAdapter* adapter = 0;
    for (unsigned int i = 0; !adapter; ++i) {
        if (FAILED(factory->EnumAdapters(i, &adapter))
            break;
        if (cudaD3D11GetDevice(&dev, adapter) == cudaSuccess)
            break;
        adapter->Release();
    }
    factory->Release();

    // Create swap chain and device
    ...
    sFnPtr_D3D11CreateDeviceAndSwapChain(adapter,
                                         D3D11_DRIVER_TYPE_HARDWARE,
                                         0,
                                         D3D11_CREATE_DEVICE_DEBUG,
                                         featureLevels, 3,
                                         D3D11_SDK_VERSION,
                                         &swapChainDesc, &swapChain,
                                         &device,
                                         &featureLevel,
                                         &deviceContext);
    adapter->Release();

    // Use the same device
    cudaSetDevice(dev);

    // Create vertex buffer and register it with CUDA
    unsigned int size = width * height * sizeof(CUSTOMVERTEX);
    D3D11_BUFFER_DESC bufferDesc;
    bufferDesc.Usage          = D3D11_USAGE_DEFAULT;
    bufferDesc.ByteWidth      = size;
    bufferDesc.BindFlags      = D3D11_BIND_VERTEX_BUFFER;
    bufferDesc.CPUAccessFlags = 0;
    bufferDesc.MiscFlags      = 0;
    device->CreateBuffer(&bufferDesc, 0, &positionsVB);
    cudaGraphicsD3D11RegisterResource(&positionsVB_CUDA,
                                      positionsVB,
                                      cudaGraphicsRegisterFlagsNone);
    cudaGraphicsResourceSetMapFlags(positionsVB_CUDA,
                                    cudaGraphicsMapFlagsWriteDiscard);

    // Launch rendering loop
    while (...) {
        ...
        Render();
        ...
    }
    ...
}
void Render()
{
    // Map vertex buffer for writing from CUDA
    float4* positions;
    cudaGraphicsMapResources(1, &positionsVB_CUDA, 0);
    size_t num_bytes;
    cudaGraphicsResourceGetMappedPointer((void**)&positions,
                                         &num_bytes,
                                         positionsVB_CUDA));

    // Execute kernel
    dim3 dimBlock(16, 16, 1);
    dim3 dimGrid(width / dimBlock.x, height / dimBlock.y, 1);
    createVertices<<<dimGrid, dimBlock>>>(positions, time,
                                          width, height);

    // Unmap vertex buffer
    cudaGraphicsUnmapResources(1, &positionsVB_CUDA, 0);

    // Draw and present
    ...
}

void releaseVB()
{
    cudaGraphicsUnregisterResource(positionsVB_CUDA);
    positionsVB->Release();
}

    __global__ void createVertices(float4* positions, float time,
                          unsigned int width, unsigned int height)
{
    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 positions
    positions[y * width + x] =
                make_float4(u, w, v, __int_as_float(0xff00ff00));
}

3.2.15.3. SLI互操作性

在配备多块GPU的系统中,所有支持CUDA的GPU均可通过CUDA驱动程序和运行时作为独立设备进行访问。但当系统处于SLI模式时,需特别注意以下事项。

首先,在单个GPU上的一个CUDA设备中进行内存分配时,会消耗属于Direct3D或OpenGL设备SLI配置中其他GPU的内存。因此,内存分配可能会比预期更早失败。

其次,应用程序应创建多个CUDA上下文,SLI配置中的每个GPU对应一个。虽然这不是严格要求,但可以避免设备间不必要的数据传输。应用程序可以使用cudaD3D[9|10|11]GetDevices()(针对Direct3D)和cudaGLGetDevices()(针对OpenGL)系列调用来识别当前帧和下一帧执行渲染的设备的CUDA设备句柄。根据这些信息,应用程序通常会选择合适的设备,并将Direct3D或OpenGL资源映射到由cudaD3D[9|10|11]GetDevices()cudaGLGetDevices()返回的CUDA设备上(当deviceList参数设置为cudaD3D[9|10|11]DeviceListCurrentFramecudaGLDeviceListCurrentFrame时)。

请注意,从cudaGraphicsD9D[9|10|11]RegisterResourcecudaGraphicsGLRegister[Buffer|Image]返回的资源只能在注册发生的设备上使用。因此在SLI配置下,当不同帧的数据在不同CUDA设备上计算时,必须为每个设备单独注册资源。

有关CUDA运行时如何分别与Direct3D和OpenGL互操作的详细信息,请参阅Direct3D互操作性OpenGL互操作性

3.2.16. 外部资源互操作性

外部资源互操作性允许CUDA导入由其他API显式导出的特定资源。这些对象通常由其他API使用操作系统原生句柄导出,例如Linux上的文件描述符或Windows上的NT句柄。它们也可以使用其他统一接口(如NVIDIA软件通信接口)导出。可导入的资源类型有两种:内存对象和同步对象。

内存对象可以通过cudaImportExternalMemory()导入到CUDA中。导入的内存对象可以通过cudaExternalMemoryGetMappedBuffer()映射的设备指针或通过cudaExternalMemoryGetMappedMipmappedArray()映射的CUDA mipmapped数组在内核中访问。根据内存对象的类型,可能可以在单个内存对象上设置多个映射。这些映射必须与导出API中设置的映射相匹配。任何不匹配的映射都会导致未定义行为。导入的内存对象必须使用cudaDestroyExternalMemory()释放。释放内存对象不会释放对该对象的任何映射。因此,映射到该对象上的任何设备指针必须使用cudaFree()显式释放,映射到该对象上的任何CUDA mipmapped数组必须使用cudaFreeMipmappedArray()显式释放。在对象被销毁后访问其映射是非法的。

可以使用cudaImportExternalSemaphore()将同步对象导入到CUDA中。导入的同步对象可以通过cudaSignalExternalSemaphoresAsync()发出信号,并通过cudaWaitExternalSemaphoresAsync()进行等待。在发出相应信号之前发出等待操作是非法的。此外,根据导入的同步对象类型,可能对其信号发出和等待方式有额外限制,如后续章节所述。必须使用cudaDestroyExternalSemaphore()释放导入的信号量对象。在销毁信号量对象之前,所有未完成的信号和等待操作都必须已完成。

3.2.16.1. Vulkan互操作性

3.2.16.1.1. 匹配设备UUID

当导入由Vulkan导出的内存和同步对象时,必须在创建它们的同一设备上进行导入和映射。可以通过比较CUDA设备的UUID与Vulkan物理设备的UUID来确定对应的CUDA设备,如下面的代码示例所示。请注意,Vulkan物理设备不应属于包含多个Vulkan物理设备的设备组。包含给定Vulkan物理设备的设备组(由vkEnumeratePhysicalDeviceGroups返回)必须具有物理设备计数为1。

int getCudaDeviceForVulkanPhysicalDevice(VkPhysicalDevice vkPhysicalDevice) {
    VkPhysicalDeviceIDProperties vkPhysicalDeviceIDProperties = {};
    vkPhysicalDeviceIDProperties.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_ID_PROPERTIES;
    vkPhysicalDeviceIDProperties.pNext = NULL;

    VkPhysicalDeviceProperties2 vkPhysicalDeviceProperties2 = {};
    vkPhysicalDeviceProperties2.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2;
    vkPhysicalDeviceProperties2.pNext = &vkPhysicalDeviceIDProperties;

    vkGetPhysicalDeviceProperties2(vkPhysicalDevice, &vkPhysicalDeviceProperties2);

    int cudaDeviceCount;
    cudaGetDeviceCount(&cudaDeviceCount);

    for (int cudaDevice = 0; cudaDevice < cudaDeviceCount; cudaDevice++) {
        cudaDeviceProp deviceProp;
        cudaGetDeviceProperties(&deviceProp, cudaDevice);
        if (!memcmp(&deviceProp.uuid, vkPhysicalDeviceIDProperties.deviceUUID, VK_UUID_SIZE)) {
            return cudaDevice;
        }
    }
    return cudaInvalidDeviceId;
}
3.2.16.1.2. 导入内存对象

在Linux和Windows 10系统上,Vulkan导出的专用和非专用内存对象都可以导入到CUDA中。而在Windows 7系统上,只能导入专用内存对象。当导入Vulkan专用内存对象时,必须设置cudaExternalMemoryDedicated标志。

一个使用VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT导出的Vulkan内存对象,可以通过关联的文件描述符导入到CUDA中,如下所示。请注意,一旦导入成功,CUDA将取得该文件描述符的所有权。在成功导入后继续使用该文件描述符会导致未定义行为。

cudaExternalMemory_t importVulkanMemoryObjectFromFileDescriptor(int fd, unsigned long long size, bool isDedicated) {
    cudaExternalMemory_t extMem = NULL;
    cudaExternalMemoryHandleDesc desc = {};

    memset(&desc, 0, sizeof(desc));

    desc.type = cudaExternalMemoryHandleTypeOpaqueFd;
    desc.handle.fd = fd;
    desc.size = size;
    if (isDedicated) {
        desc.flags |= cudaExternalMemoryDedicated;
    }

    cudaImportExternalMemory(&extMem, &desc);

    // Input parameter 'fd' should not be used beyond this point as CUDA has assumed ownership of it

    return extMem;
}

一个使用VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_WIN32_BIT导出的Vulkan内存对象,可以通过与该对象关联的NT句柄导入到CUDA中,如下所示。请注意,CUDA不会获取该NT句柄的所有权,应用程序有责任在不再需要时关闭该句柄。NT句柄持有对资源的引用,因此必须显式释放该句柄,才能释放底层内存。

cudaExternalMemory_t importVulkanMemoryObjectFromNTHandle(HANDLE handle, unsigned long long size, bool isDedicated) {
    cudaExternalMemory_t extMem = NULL;
    cudaExternalMemoryHandleDesc desc = {};

    memset(&desc, 0, sizeof(desc));

    desc.type = cudaExternalMemoryHandleTypeOpaqueWin32;
    desc.handle.win32.handle = handle;
    desc.size = size;
    if (isDedicated) {
        desc.flags |= cudaExternalMemoryDedicated;
    }

    cudaImportExternalMemory(&extMem, &desc);

    // Input parameter 'handle' should be closed if it's not needed anymore
    CloseHandle(handle);

    return extMem;
}

使用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;
}

使用VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_WIN32_KMT_BIT导出的Vulkan内存对象可以通过与该对象关联的全局共享D3DKMT句柄导入到CUDA中,如下所示。由于全局共享的D3DKMT句柄不持有对底层内存的引用,当资源的所有其他引用被销毁时,该句柄会自动销毁。

cudaExternalMemory_t importVulkanMemoryObjectFromKMTHandle(HANDLE handle, unsigned long long size, bool isDedicated) {
    cudaExternalMemory_t extMem = NULL;
    cudaExternalMemoryHandleDesc desc = {};

    memset(&desc, 0, sizeof(desc));

    desc.type = cudaExternalMemoryHandleTypeOpaqueWin32Kmt;
    desc.handle.win32.handle = (void *)handle;
    desc.size = size;
    if (isDedicated) {
        desc.flags |= cudaExternalMemoryDedicated;
    }

    cudaImportExternalMemory(&extMem, &desc);

    return extMem;
}
3.2.16.1.3. 将缓冲区映射到导入的内存对象

设备指针可以映射到导入的内存对象上,如下所示。映射的偏移量和大小必须与使用相应Vulkan 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;

}
3.2.16.1.4. 将Mipmapped数组映射到导入的内存对象

可以将CUDA mipmapped数组映射到导入的内存对象上,如下所示。偏移量、维度、格式和mip级别数量必须与使用相应Vulkan API创建映射时指定的内容匹配。此外,如果mipmapped数组在Vulkan中绑定为颜色目标,则必须设置标志cudaArrayColorAttachment。所有映射的mipmapped数组都必须使用cudaFreeMipmappedArray()释放。以下代码示例展示了在将mipmapped数组映射到导入的内存对象时,如何将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;
}

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;
}

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;
}

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;
}
3.2.16.1.5. 导入同步对象

一个使用VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_FD_BIT导出的Vulkan信号量对象,可以通过关联的文件描述符导入到CUDA中,如下所示。请注意,一旦导入后,CUDA将取得该文件描述符的所有权。在成功导入后继续使用该文件描述符会导致未定义行为。

cudaExternalSemaphore_t importVulkanSemaphoreObjectFromFileDescriptor(int fd) {
    cudaExternalSemaphore_t extSem = NULL;
    cudaExternalSemaphoreHandleDesc desc = {};

    memset(&desc, 0, sizeof(desc));

    desc.type = cudaExternalSemaphoreHandleTypeOpaqueFd;
    desc.handle.fd = fd;

    cudaImportExternalSemaphore(&extSem, &desc);

    // Input parameter 'fd' should not be used beyond this point as CUDA has assumed ownership of it

    return extSem;
}

一个使用VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_WIN32_BIT导出的Vulkan信号量对象,可以通过与该对象关联的NT句柄导入到CUDA中,如下所示。请注意,CUDA不会获取该NT句柄的所有权,应用程序有责任在不再需要时关闭该句柄。NT句柄持有对资源的引用,因此必须显式释放该句柄,才能释放底层的信号量。

cudaExternalSemaphore_t importVulkanSemaphoreObjectFromNTHandle(HANDLE handle) {
    cudaExternalSemaphore_t extSem = NULL;
    cudaExternalSemaphoreHandleDesc desc = {};

    memset(&desc, 0, sizeof(desc));

    desc.type = cudaExternalSemaphoreHandleTypeOpaqueWin32;
    desc.handle.win32.handle = handle;

    cudaImportExternalSemaphore(&extSem, &desc);

    // Input parameter 'handle' should be closed if it's not needed anymore
    CloseHandle(handle);

    return extSem;
}

一个使用VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_WIN32_BIT导出的Vulkan信号量对象,如果存在命名句柄,也可以按如下所示方式导入。

cudaExternalSemaphore_t importVulkanSemaphoreObjectFromNamedNTHandle(LPCWSTR name) {
    cudaExternalSemaphore_t extSem = NULL;
    cudaExternalSemaphoreHandleDesc desc = {};

    memset(&desc, 0, sizeof(desc));

    desc.type = cudaExternalSemaphoreHandleTypeOpaqueWin32;
    desc.handle.win32.name = (void *)name;

    cudaImportExternalSemaphore(&extSem, &desc);

    return extSem;
}

一个使用VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_WIN32_KMT_BIT导出的Vulkan信号量对象,可以通过与该对象关联的全局共享D3DKMT句柄导入到CUDA中,如下所示。由于全局共享的D3DKMT句柄并不持有对底层信号量的引用,当该资源的所有其他引用被销毁时,该句柄会自动销毁。

cudaExternalSemaphore_t importVulkanSemaphoreObjectFromKMTHandle(HANDLE handle) {
    cudaExternalSemaphore_t extSem = NULL;
    cudaExternalSemaphoreHandleDesc desc = {};

    memset(&desc, 0, sizeof(desc));

    desc.type = cudaExternalSemaphoreHandleTypeOpaqueWin32Kmt;
    desc.handle.win32.handle = (void *)handle;

    cudaImportExternalSemaphore(&extSem, &desc);

    return extSem;
}
3.2.16.1.6. 导入同步对象的信号通知/等待

导入的Vulkan信号量对象可以按如下方式发出信号。对此类信号量对象发出信号会将其设置为已信号状态。对应的等待此信号的等待操作必须在Vulkan中发出。此外,等待此信号的等待操作必须在此信号发出之后才能发出。

void signalExternalSemaphore(cudaExternalSemaphore_t extSem, cudaStream_t stream) {
    cudaExternalSemaphoreSignalParams params = {};

    memset(&params, 0, sizeof(params));

    cudaSignalExternalSemaphoresAsync(&extSem, &params, 1, stream);
}

导入的Vulkan信号量对象可以如下所示进行等待。等待此类信号量对象会一直阻塞,直到其进入已触发状态,然后将其重置回未触发状态。此等待操作所对应的触发信号必须在Vulkan中发出。此外,该触发信号必须在此等待操作发出之前完成。

void waitExternalSemaphore(cudaExternalSemaphore_t extSem, cudaStream_t stream) {
    cudaExternalSemaphoreWaitParams params = {};

    memset(&params, 0, sizeof(params));

    cudaWaitExternalSemaphoresAsync(&extSem, &params, 1, stream);
}

3.2.16.2. OpenGL互操作性

传统的OpenGL-CUDA互操作技术,如OpenGL互操作性中所述,是通过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

3.2.16.3. Direct3D 12 互操作性

3.2.16.3.1. 匹配设备LUID

当导入由Direct3D 12导出的内存和同步对象时,必须在创建它们的同一设备上进行导入和映射。可以通过比较CUDA设备的LUID与Direct3D 12设备的LUID来确定创建这些对象的对应CUDA设备,如下列代码示例所示。请注意,Direct3D 12设备不能在链接节点适配器上创建。也就是说,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;
}
3.2.16.3.2. 导入内存对象

一个可共享的Direct3D 12堆内存对象,通过在调用ID3D12Device::CreateHeap时设置D3D12_HEAP_FLAG_SHARED标志创建,可以使用与该对象关联的NT句柄导入到CUDA中,如下所示。请注意,应用程序有责任在不再需要时关闭NT句柄。NT句柄持有对资源的引用,因此必须显式释放它才能释放底层内存。

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;
}

如果存在命名句柄,也可以使用它导入可共享的Direct3D 12堆内存对象,如下所示。

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标志创建的可共享Direct3D 12提交资源,可以使用与该对象关联的NT句柄导入到CUDA中,如下所示。导入Direct3D 12提交资源时,必须设置cudaExternalMemoryDedicated标志。请注意,应用程序有责任在不再需要时关闭NT句柄。NT句柄持有对该资源的引用,因此必须显式释放它才能释放底层内存。

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;
}

如果存在命名句柄,也可以使用它来导入可共享的Direct3D 12提交资源,如下所示。

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;
}
3.2.16.3.3. 将缓冲区映射到导入的内存对象

设备指针可以映射到导入的内存对象上,如下所示。映射的偏移量和大小必须与使用相应的Direct3D 12 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;
}
3.2.16.3.4. 将Mipmapped数组映射到导入的内存对象

可以将CUDA mipmapped数组映射到导入的内存对象上,如下所示。偏移量、维度、格式和mip级别数量必须与使用相应Direct3D 12 API创建映射时指定的内容匹配。此外,如果mipmapped数组可以在Direct3D 12中绑定为渲染目标,则必须设置标志cudaArrayColorAttachment。所有映射的mipmapped数组都必须使用cudaFreeMipmappedArray()释放。以下代码示例展示了在将mipmapped数组映射到导入的内存对象时,如何将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;
}

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;
}
3.2.16.3.5. 导入同步对象

一个可共享的Direct3D 12围栏对象,通过在调用ID3D12Device::CreateFence时设置D3D12_FENCE_FLAG_SHARED标志创建,可以使用与该对象关联的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;
}

如果存在命名句柄,也可以使用它导入可共享的Direct3D 12围栏对象,如下所示。

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;
}
3.2.16.3.6. 导入同步对象的信号通知/等待

导入的Direct3D 12围栏对象可以按如下方式发出信号。对此类围栏对象发出信号会将其值设置为指定值。等待此信号的相应等待操作必须在Direct3D 12中发出。此外,等待此信号的等待操作必须在此信号发出之后才能发出。

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);
}

导入的Direct3D 12围栏对象可按如下方式等待。等待此类围栏对象会一直阻塞,直到其值大于或等于指定值。此等待操作所对应的信号必须在Direct3D 12中发出。此外,必须先发出信号才能执行此等待操作。

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);
}

3.2.16.4. Direct3D 11 互操作性

3.2.16.4.1. 匹配设备LUID

当导入由Direct3D 11导出的内存和同步对象时,必须在创建它们的同一设备上进行导入和映射。可以通过比较CUDA设备的LUID与Direct3D 11设备的LUID来确定创建这些对象对应的CUDA设备,如下面的代码示例所示。

int getCudaDeviceForD3D11Device(ID3D11Device *d3d11Device) {
    IDXGIDevice *dxgiDevice;
    d3d11Device->QueryInterface(__uuidof(IDXGIDevice), (void **)&dxgiDevice);

    IDXGIAdapter *dxgiAdapter;
    dxgiDevice->GetAdapter(&dxgiAdapter);

    DXGI_ADAPTER_DESC dxgiAdapterDesc;
    dxgiAdapter->GetDesc(&dxgiAdapterDesc);

    LUID d3d11Luid = dxgiAdapterDesc.AdapterLuid;

    int cudaDeviceCount;
    cudaGetDeviceCount(&cudaDeviceCount);

    for (int cudaDevice = 0; cudaDevice < cudaDeviceCount; cudaDevice++) {
        cudaDeviceProp deviceProp;
        cudaGetDeviceProperties(&deviceProp, cudaDevice);
        char *cudaLuid = deviceProp.luid;

        if (!memcmp(&d3d11Luid.LowPart, cudaLuid, sizeof(d3d11Luid.LowPart)) &&
            !memcmp(&d3d11Luid.HighPart, cudaLuid + sizeof(d3d11Luid.LowPart), sizeof(d3d11Luid.HighPart))) {
            return cudaDevice;
        }
    }
    return cudaInvalidDeviceId;
}
3.2.16.4.2. 导入内存对象

可通过在调用ID3D11Device:CreateTexture1DID3D11Device:CreateTexture2DID3D11Device:CreateTexture3D时设置D3D11_RESOURCE_MISC_SHAREDD3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX(Windows 7)或D3D11_RESOURCE_MISC_SHARED_NTHANDLE(Windows 10)标志来创建可共享的Direct3D 11纹理资源,即ID3D11Texture1DID3D11Texture2DID3D11Texture3D。类似地,在调用ID3D11Device::CreateBuffer时指定上述任一标志可创建可共享的Direct3D 11缓冲区资源ID3D11Buffer。通过指定D3D11_RESOURCE_MISC_SHARED_NTHANDLE创建的可共享资源,可使用与该对象关联的NT句柄导入到CUDA中(如下所示)。注意:应用程序需在不再需要时负责关闭NT句柄。由于NT句柄持有对资源的引用,必须显式释放该句柄才能释放底层内存。导入Direct3D 11资源时,必须设置cudaExternalMemoryDedicated标志。

cudaExternalMemory_t importD3D11ResourceFromNTHandle(HANDLE handle, unsigned long long size) {
    cudaExternalMemory_t extMem = NULL;
    cudaExternalMemoryHandleDesc desc = {};

    memset(&desc, 0, sizeof(desc));

    desc.type = cudaExternalMemoryHandleTypeD3D11Resource;
    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;
}

如果存在命名句柄,也可以使用它导入可共享的Direct3D 11资源,如下所示。

cudaExternalMemory_t importD3D11ResourceFromNamedNTHandle(LPCWSTR name, unsigned long long size) {
    cudaExternalMemory_t extMem = NULL;
    cudaExternalMemoryHandleDesc desc = {};

    memset(&desc, 0, sizeof(desc));

    desc.type = cudaExternalMemoryHandleTypeD3D11Resource;
    desc.handle.win32.name = (void *)name;
    desc.size = size;
    desc.flags |= cudaExternalMemoryDedicated;

    cudaImportExternalMemory(&extMem, &desc);

    return extMem;
}

通过指定D3D11_RESOURCE_MISC_SHAREDD3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX创建的可共享Direct3D 11资源,可以使用与该对象关联的全局共享D3DKMT句柄导入到CUDA中,如下所示。由于全局共享D3DKMT句柄不持有对底层内存的引用,当资源的所有其他引用被销毁时,该句柄会自动销毁。

cudaExternalMemory_t importD3D11ResourceFromKMTHandle(HANDLE handle, unsigned long long size) {
    cudaExternalMemory_t extMem = NULL;
    cudaExternalMemoryHandleDesc desc = {};

    memset(&desc, 0, sizeof(desc));

    desc.type = cudaExternalMemoryHandleTypeD3D11ResourceKmt;
    desc.handle.win32.handle = (void *)handle;
    desc.size = size;
    desc.flags |= cudaExternalMemoryDedicated;

    cudaImportExternalMemory(&extMem, &desc);

    return extMem;
}
3.2.16.4.3. 将缓冲区映射到导入的内存对象

设备指针可以映射到导入的内存对象上,如下所示。映射的偏移量和大小必须与使用对应的Direct3D 11 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;
}
3.2.16.4.4. 将Mipmapped数组映射到导入的内存对象

可以将CUDA mipmapped数组映射到导入的内存对象上,如下所示。偏移量、维度、格式和mip级别数量必须与使用相应Direct3D 11 API创建映射时指定的内容匹配。此外,如果mipmapped数组可以在Direct3D 12中作为渲染目标绑定,则必须设置标志cudaArrayColorAttachment。所有映射的mipmapped数组都必须使用cudaFreeMipmappedArray()释放。以下代码示例展示了在将mipmapped数组映射到导入的内存对象时,如何将Direct3D 11参数转换为相应的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 getCudaExtentForD3D11Extent(UINT64 width, UINT height, UINT16 depthOrArraySize, D3D12_SRV_DIMENSION d3d11SRVDimension) {
    cudaExtent e = { 0, 0, 0 };

    switch (d3d11SRVDimension) {
    case D3D11_SRV_DIMENSION_TEXTURE1D:        e.width = width; e.height = 0;      e.depth = 0;                break;
    case D3D11_SRV_DIMENSION_TEXTURE2D:        e.width = width; e.height = height; e.depth = 0;                break;
    case D3D11_SRV_DIMENSION_TEXTURE3D:        e.width = width; e.height = height; e.depth = depthOrArraySize; break;
    case D3D11_SRV_DIMENSION_TEXTURECUBE:      e.width = width; e.height = height; e.depth = depthOrArraySize; break;
    case D3D11_SRV_DIMENSION_TEXTURE1DARRAY:   e.width = width; e.height = 0;      e.depth = depthOrArraySize; break;
    case D3D11_SRV_DIMENSION_TEXTURE2DARRAY:   e.width = width; e.height = height; e.depth = depthOrArraySize; break;
    case D3D11_SRV_DIMENSION_TEXTURECUBEARRAY: e.width = width; e.height = height; e.depth = depthOrArraySize; break;
    default: assert(0);
    }
    return e;
}

unsigned int getCudaMipmappedArrayFlagsForD3D12Resource(D3D11_SRV_DIMENSION d3d11SRVDimension, D3D11_BIND_FLAG d3d11BindFlags, bool allowSurfaceLoadStore) {
    unsigned int flags = 0;

    switch (d3d11SRVDimension) {
    case D3D11_SRV_DIMENSION_TEXTURECUBE:      flags |= cudaArrayCubemap;                    break;
    case D3D11_SRV_DIMENSION_TEXTURECUBEARRAY: flags |= cudaArrayCubemap | cudaArrayLayered; break;
    case D3D11_SRV_DIMENSION_TEXTURE1DARRAY:   flags |= cudaArrayLayered;                    break;
    case D3D11_SRV_DIMENSION_TEXTURE2DARRAY:   flags |= cudaArrayLayered;                    break;
    default: break;
    }

    if (d3d11BindFlags & D3D11_BIND_RENDER_TARGET) {
        flags |= cudaArrayColorAttachment;
    }

    if (allowSurfaceLoadStore) {
        flags |= cudaArraySurfaceLoadStore;
    }

    return flags;
}
3.2.16.4.5. 导入同步对象

一个可共享的Direct3D 11围栏对象,通过在调用ID3D11Device5::CreateFence时设置D3D11_FENCE_FLAG_SHARED标志创建,可以使用与该对象关联的NT句柄导入到CUDA中,如下所示。请注意,应用程序有责任在不再需要时关闭该句柄。NT句柄持有对资源的引用,因此必须显式释放它,底层信号量才能被释放。

cudaExternalSemaphore_t importD3D11FenceFromNTHandle(HANDLE handle) {
    cudaExternalSemaphore_t extSem = NULL;
    cudaExternalSemaphoreHandleDesc desc = {};

    memset(&desc, 0, sizeof(desc));

    desc.type = cudaExternalSemaphoreHandleTypeD3D11Fence;
    desc.handle.win32.handle = handle;

    cudaImportExternalSemaphore(&extSem, &desc);

    // Input parameter 'handle' should be closed if it's not needed anymore
    CloseHandle(handle);

    return extSem;
}

如果存在命名句柄,也可以使用它导入可共享的Direct3D 11围栏对象,如下所示。

cudaExternalSemaphore_t importD3D11FenceFromNamedNTHandle(LPCWSTR name) {
    cudaExternalSemaphore_t extSem = NULL;
    cudaExternalSemaphoreHandleDesc desc = {};

    memset(&desc, 0, sizeof(desc));

    desc.type = cudaExternalSemaphoreHandleTypeD3D11Fence;
    desc.handle.win32.name = (void *)name;

    cudaImportExternalSemaphore(&extSem, &desc);

    return extSem;
}

与可共享Direct3D 11资源关联的可共享Direct3D 11键控互斥对象(即IDXGIKeyedMutex),通过设置标志D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX创建,可以使用与该对象关联的NT句柄导入到CUDA中,如下所示。请注意,应用程序有责任在不再需要时关闭该句柄。NT句柄持有对资源的引用,因此必须显式释放该句柄,才能释放底层信号量。

cudaExternalSemaphore_t importD3D11KeyedMutexFromNTHandle(HANDLE handle) {
    cudaExternalSemaphore_t extSem = NULL;
    cudaExternalSemaphoreHandleDesc desc = {};

    memset(&desc, 0, sizeof(desc));

    desc.type = cudaExternalSemaphoreHandleTypeKeyedMutex;
    desc.handle.win32.handle = handle;

    cudaImportExternalSemaphore(&extSem, &desc);

    // Input parameter 'handle' should be closed if it's not needed anymore
    CloseHandle(handle);

    return extSem;
}

如果存在命名句柄,也可以使用它导入可共享的Direct3D 11键控互斥对象,如下所示。

cudaExternalSemaphore_t importD3D11KeyedMutexFromNamedNTHandle(LPCWSTR name) {
    cudaExternalSemaphore_t extSem = NULL;
    cudaExternalSemaphoreHandleDesc desc = {};

    memset(&desc, 0, sizeof(desc));

    desc.type = cudaExternalSemaphoreHandleTypeKeyedMutex;
    desc.handle.win32.name = (void *)name;

    cudaImportExternalSemaphore(&extSem, &desc);

    return extSem;
}

可以使用与该对象关联的全局共享D3DKMT句柄将可共享的Direct3D 11键控互斥对象导入CUDA,如下所示。由于全局共享D3DKMT句柄不持有对底层内存的引用,当资源的所有其他引用被销毁时,它会自动销毁。

cudaExternalSemaphore_t importD3D11FenceFromKMTHandle(HANDLE handle) {
    cudaExternalSemaphore_t extSem = NULL;
    cudaExternalSemaphoreHandleDesc desc = {};

    memset(&desc, 0, sizeof(desc));

    desc.type = cudaExternalSemaphoreHandleTypeKeyedMutexKmt;
    desc.handle.win32.handle = handle;

    cudaImportExternalSemaphore(&extSem, &desc);

    // Input parameter 'handle' should be closed if it's not needed anymore
    CloseHandle(handle);

    return extSem;
}
3.2.16.4.6. 导入同步对象的信号通知/等待

导入的Direct3D 11围栏对象可按如下方式发出信号。对此类围栏对象发出信号会将其值设置为指定值。等待此信号的对应等待操作必须在Direct3D 11中发出。此外,等待此信号的操作必须在该信号发出后才能发出。

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);
}

导入的Direct3D 11围栏对象可按如下方式等待。等待此类围栏对象会一直阻塞,直到其值大于或等于指定值。该等待操作所对应的信号必须在Direct3D 11中发出。此外,必须先发出信号才能执行此等待操作。

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);
}

导入的Direct3D 11键控互斥对象可以按如下方式发出信号。通过指定键值对此类键控互斥对象发出信号,将释放该键值的键控互斥锁。对应的等待此信号的操作必须在Direct3D 11中使用相同的键值发出。此外,Direct3D 11的等待操作必须在此信号发出之后进行。

void signalExternalSemaphore(cudaExternalSemaphore_t extSem, unsigned long long key, cudaStream_t stream) {
    cudaExternalSemaphoreSignalParams params = {};

    memset(&params, 0, sizeof(params));

    params.params.keyedmutex.key = key;

    cudaSignalExternalSemaphoresAsync(&extSem, &params, 1, stream);
}

可以按照如下方式等待导入的Direct3D 11键控互斥对象。等待此类键控互斥时需要以毫秒为单位指定超时值。等待操作将持续到键控互斥值等于指定键值或超时到期为止。超时间隔也可以设置为无限值。若指定无限值,则永远不会超时。必须使用Windows INFINITE宏来指定无限超时。此等待操作所对应的信号必须在Direct3D 11中发出。此外,必须先发出Direct3D 11信号,才能发出此等待操作。

void waitExternalSemaphore(cudaExternalSemaphore_t extSem, unsigned long long key, unsigned int timeoutMs, cudaStream_t stream) {
    cudaExternalSemaphoreWaitParams params = {};

    memset(&params, 0, sizeof(params));

    params.params.keyedmutex.key = key;
    params.params.keyedmutex.timeoutMs = timeoutMs;

    cudaWaitExternalSemaphoresAsync(&extSem, &params, 1, stream);
}

3.2.16.5. NVIDIA软件通信接口互操作性(NVSCI)

NvSciBuf和NvSciSync是为实现以下目的而开发的接口:

  • NvSciBuf: 允许应用程序在内存中分配和交换缓冲区

  • NvSciSync: 允许应用程序在操作边界管理同步对象

有关这些接口的更多详细信息,请访问:https://docs.nvidia.com/drive

3.2.16.5.1. 导入内存对象

要为给定CUDA设备分配兼容的NvSciBuf对象,必须按照如下方式在NvSciBuf属性列表中设置对应的GPU ID属性NvSciBufGeneralAttrKey_GpuId。应用程序还可以选择指定以下属性 -

  • NvSciBufGeneralAttrKey_NeedCpuAccess: 指定缓冲区是否需要CPU访问

  • NvSciBufRawBufferAttrKey_Align: 指定NvSciBufType_RawBuffer的对齐要求

  • NvSciBufGeneralAttrKey_RequiredPerm: 可以为每个NvSciBuf内存对象实例配置不同的UMD访问权限。例如,若需为GPU提供对该缓冲区的只读访问权限,可使用NvSciBufObjDupWithReducePerm()函数并以NvSciBufAccessPerm_Readonly作为输入参数创建重复的NvSciBuf对象。随后将新建的这个降权副本对象导入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;

已分配的NvSciBuf内存对象可以通过NvSciBufObj句柄导入到CUDA中,如下所示。应用程序应查询已分配的NvSciBufObj以获取填充CUDA外部内存描述符所需的属性。请注意,属性列表和NvSciBuf对象应由应用程序维护。如果导入到CUDA的NvSciBuf对象也被其他驱动程序映射,则根据NvSciBufGeneralAttrKey_GpuSwNeedCacheCoherency输出属性值,应用程序必须使用NvSciSync对象(参见Importing Synchronization Objects)作为适当的屏障,以保持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;
 }
3.2.16.5.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;
}
3.2.16.5.3. 将Mipmapped数组映射到导入的内存对象

可以将CUDA mipmapped数组映射到导入的内存对象上,如下所示。偏移量、维度和格式可以根据分配的NvSciBufObj属性进行填充。所有映射的mipmapped数组必须使用cudaFreeMipmappedArray()释放。以下代码示例展示了在将mipmapped数组映射到导入的内存对象时,如何将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;
}
3.2.16.5.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;
}

一个NvSciSync对象(如上所述创建)可以使用NvSciSyncObj句柄导入到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;
}
3.2.16.5.5. 导入同步对象的信号通知/等待

导入的NvSciSyncObj对象可按以下方式发出信号。对基于NvSciSync的信号量对象发出信号时,会初始化作为输入传递的fence参数。该fence参数将由与前述信号相对应的等待操作进行等待。此外,等待此信号的等待操作必须在该信号发出之后才能发出。如果将标志设置为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的信号量对象会阻塞,直到输入的fence参数被对应的信号发送方触发。此外,必须先发出信号才能进行等待操作。如果标志设置为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);
}

3.3. 版本控制与兼容性

开发人员在开发CUDA应用程序时需要关注两个版本号:计算能力(描述了计算设备的通用规格和特性,参见计算能力)以及CUDA驱动API版本(描述了驱动API和运行时支持的功能特性)。

驱动API的版本在驱动头文件中定义为CUDA_VERSION。它允许开发者检查其应用程序是否需要比当前安装版本更新的设备驱动。这一点很重要,因为驱动API具有向后兼容性,这意味着针对特定版本驱动API编译的应用程序、插件和库(包括CUDA运行时)将在后续设备驱动版本中继续工作,如图12所示。但驱动API不具备向前兼容性,这意味着针对特定版本驱动API编译的应用程序、插件和库(包括CUDA运行时)将无法在旧版本的设备驱动上运行。

需要注意的是,支持的版本混合与匹配存在一定限制:

  • 由于系统上同一时间只能安装一个版本的CUDA驱动程序,因此已安装的驱动版本必须等于或高于在该系统上运行的所有应用程序、插件或库所构建时针对的最大驱动程序API版本。

  • 应用程序使用的所有插件和库必须使用相同版本的CUDA Runtime,除非它们静态链接到Runtime,在这种情况下,同一进程空间中可以共存多个版本的运行时。请注意,如果使用nvcc链接应用程序,默认情况下将使用CUDA Runtime库的静态版本,并且所有CUDA Toolkit库都静态链接到CUDA Runtime。

  • 应用程序使用的所有插件和库必须使用相同版本的任何依赖运行时的库(如cuFFT、cuBLAS等),除非静态链接这些库。

The Driver API Is Backward but Not Forward Compatible

图25 Driver API向后兼容但不向前兼容

对于Tesla GPU产品,CUDA 10为用户态CUDA驱动组件引入了新的前向兼容升级路径。此功能在CUDA兼容性中有详细说明。此处描述的CUDA驱动版本要求适用于用户态组件的版本。

3.4. 计算模式

在运行Windows Server 2008及更高版本或Linux的特斯拉解决方案上,用户可以使用NVIDIA系统管理界面(nvidia-smi)将系统中的任何设备设置为以下三种模式之一,该工具作为驱动程序的一部分分发:

  • 默认计算模式:多个主机线程可以同时使用该设备(在使用运行时API时通过调用cudaSetDevice(),或在使用驱动程序API时通过使与该设备关联的上下文成为当前上下文)。

  • 独占进程计算模式:系统中所有进程在该设备上只能创建一个CUDA上下文。创建该上下文的进程内,可以任意数量的线程使用该上下文。

  • 禁止计算模式:无法在设备上创建CUDA上下文。

这意味着,特别是当设备0处于禁止模式或被其他进程独占使用时,未显式调用cudaSetDevice()的主机线程可能会关联到非0号设备。cudaSetValidDevices()可用于从设备的优先级列表中设置设备。

还需注意的是,对于采用帕斯卡架构及更高版本(计算能力主版本号为6及以上)的设备,支持计算抢占功能。该功能允许以指令级粒度抢占计算任务,而非此前麦克斯韦和开普勒GPU架构中的线程块粒度,其优势在于可防止运行长时间内核的应用程序独占系统或超时。然而,计算抢占会带来上下文切换开销,该功能在支持设备上会自动启用。通过使用属性查询函数cudaDeviceGetAttribute()配合属性cudaDevAttrComputePreemptionSupported,可判断当前设备是否支持计算抢占。若希望避免不同进程带来的上下文切换开销,用户可通过选择独占进程模式确保GPU上仅运行单一进程。

应用程序可以通过检查computeMode设备属性来查询设备的计算模式(参见Device Enumeration)。

3.5. 模式切换

带有显示输出的GPU会预留部分DRAM内存用于所谓的主表面,该内存用于刷新用户所见的显示设备输出。当用户通过更改显示分辨率或色深(使用NVIDIA控制面板或Windows上的显示控制面板)来启动显示器的模式切换时,主表面所需的内存容量会发生变化。例如,如果用户将显示分辨率从1280x1024x32位更改为1600x1200x32位,系统必须为主表面分配7.68 MB而非5.24 MB内存。(启用抗锯齿功能的全屏图形应用程序可能需要为主表面分配更多显存。)在Windows系统中,其他可能触发显示模式切换的事件包括:启动全屏DirectX应用程序、按Alt+Tab键从全屏DirectX应用程序切换任务,或按Ctrl+Alt+Del键锁定计算机。

如果模式切换增加了主表面所需的内存容量,系统可能不得不挪用原本分配给CUDA应用程序的内存分配。因此,模式切换会导致任何调用CUDA运行时的操作失败,并返回无效上下文错误。

3.6. Windows版Tesla计算集群模式

使用NVIDIA的系统管理界面(nvidia-smi),可以将Tesla和Quadro系列设备的Windows设备驱动程序设置为TCC(Tesla计算集群)模式。

TCC模式移除了对任何图形功能的支持。

4. 硬件实现

NVIDIA GPU架构围绕可扩展的多线程流式多处理器(SMs)阵列构建。当主机CPU上的CUDA程序调用内核网格时,网格的块会被枚举并分配给具有可用执行能力的多处理器。线程块的线程在一个多处理器上并发执行,多个线程块可以在一个多处理器上同时执行。当线程块终止时,新的块会在空闲的多处理器上启动。

多处理器设计用于同时执行数百个线程。为了管理如此大量的线程,它采用了一种独特的架构称为SIMT(单指令多线程),如SIMT架构中所述。指令通过流水线执行,既利用了单个线程内的指令级并行性,也通过硬件多线程中详述的同步硬件多线程实现了广泛的线程级并行性。与CPU核心不同,这些指令是按顺序发出的,且没有分支预测或推测执行。

SIMT架构硬件多线程描述了流式多处理器对所有设备通用的架构特性。计算能力5.x计算能力6.x计算能力7.x分别提供了计算能力为5.x、6.x和7.x设备的具体细节。

NVIDIA GPU架构采用小端字节序表示。

4.1. SIMT架构

多处理器以32个并行线程为一组创建、管理、调度和执行线程,这种线程组称为warp。构成warp的各个线程从相同的程序地址开始执行,但它们拥有独立的指令地址计数器和寄存器状态,因此可以自由分支并独立执行。术语warp源自纺织技术,这是最早的并行线程技术。half-warp指的是warp的前半部分或后半部分。quarter-warp则指warp的第一、第二、第三或第四部分。

当一个多处理器被分配一个或多个线程块执行时,它会将这些线程块划分为多个warp,每个warp由warp调度器安排执行。线程块被划分为warp的方式始终相同:每个warp包含连续递增的线程ID,第一个warp包含线程0。线程层次结构描述了线程ID如何与块中的线程索引相关联。

一个warp每次执行一条公共指令,因此当warp中的所有32个线程的执行路径一致时,才能实现完全效率。如果warp中的线程通过数据相关的条件分支发生分歧,则该warp会执行每个被采用的分支路径,同时禁用不在该路径上的线程。分支分歧仅发生在warp内部;不同的warp相互独立执行,无论它们执行的是相同还是不同的代码路径。

SIMT架构类似于SIMD(单指令多数据)向量组织,因为单个指令控制多个处理单元。一个关键区别在于,SIMD向量组织向软件暴露了SIMD宽度,而SIMT指令规定了单个线程的执行和分支行为。与SIMD向量机相比,SIMT使程序员能够为独立的标量线程编写线程级并行代码,也能为协作线程编写数据并行代码。就正确性而言,程序员基本上可以忽略SIMT行为;但通过确保代码很少需要让线程束中的线程分叉,可以实现显著的性能提升。实际上,这类似于传统代码中缓存行的作用:在设计正确性时可以安全地忽略缓存行大小,但在设计峰值性能时必须考虑代码结构。另一方面,向量架构要求软件将加载合并为向量并手动管理分叉。

在NVIDIA Volta架构之前,warp使用一个由warp内所有32个线程共享的程序计数器,以及一个指定warp中活动线程的活动掩码。因此,来自同一warp但处于不同分支区域或不同执行状态的线程无法相互发送信号或交换数据,而需要基于锁或互斥量进行细粒度数据共享的算法很容易导致死锁,具体取决于竞争线程来自哪个warp。

从NVIDIA Volta架构开始,独立线程调度允许线程之间实现完全并发,不受束(warp)的限制。通过独立线程调度,GPU能够维护每个线程的执行状态,包括程序计数器和调用栈,并且可以以线程级粒度暂停执行,这既有助于更好地利用执行资源,也能让一个线程等待另一个线程生成数据。调度优化器会决定如何将同一束中的活动线程分组到SIMT单元中。这保留了与之前NVIDIA GPU相同的SIMT执行高吞吐量特性,但提供了更大的灵活性:现在线程可以在子束粒度上分叉并重新汇合。

如果开发者基于之前硬件架构的warp同步性2做出假设,独立线程调度可能导致实际参与执行的线程组与预期存在较大差异。特别是,所有warp同步代码(例如无需同步的warp内部规约操作)都应重新检查,以确保与NVIDIA Volta及后续架构的兼容性。详见计算能力7.x章节获取更多细节。

注意

参与当前指令的warp线程被称为活动线程,而未执行当前指令的线程则被称为非活动线程(已禁用)。线程可能因多种原因处于非活动状态,包括比其他同warp线程提前退出、选择了与warp当前执行路径不同的分支路径,或是属于线程数不是warp大小整数倍的块中的末尾线程。

如果一个线程束执行的非原子指令向全局内存或共享内存中的同一位置写入,且该线程束中有多个线程执行此操作,那么该位置发生的序列化写入次数会根据设备的计算能力而变化(参见计算能力5.x计算能力6.x计算能力7.x),并且最终由哪个线程执行写入操作是未定义的。

如果一个由线程束执行的原子指令对全局内存中同一位置进行读取、修改和写入,且涉及该线程束中多个线程,那么对该位置的每次读取/修改/写入都会发生,并且它们都是串行化的,但发生的顺序是未定义的。

4.2. 硬件多线程

多处理器处理的每个warp的执行上下文(程序计数器、寄存器等)在warp的整个生命周期内都保持在芯片上。因此,从一个执行上下文切换到另一个执行上下文没有开销,并且在每条指令发出时,warp调度器会选择一个准备好执行下一条指令的warp(该warp的活动线程),并向这些线程发出指令。

具体来说,每个多处理器都拥有一组32位寄存器,这些寄存器在warp之间进行分配,同时还有一个并行数据缓存共享内存,在线程块之间进行分配。

对于给定的内核,可以在多处理器上同时驻留和处理的计算块和线程束数量取决于该内核使用的寄存器数量、共享内存大小,以及多处理器上可用的寄存器数量和共享内存容量。此外,每个多处理器还存在最大驻留块数量和最大驻留线程束数量的限制。这些限制以及多处理器上可用的寄存器数量和共享内存容量取决于设备的计算能力,具体数值详见计算能力章节。如果多处理器上没有足够的寄存器或共享内存来至少处理一个计算块,则该内核将无法启动。

一个块中的warp总数量如下:

\(\text{ceil}\left( \frac{T}{W_{size}},1 \right)\)

  • T 是每个线程块中的线程数量,

  • Wsize 是线程束大小,等于32,

  • ceil(x, y) 等于将x向上取整到最接近y的倍数。

每个块分配的寄存器总数和共享内存总量记录在CUDA工具包提供的CUDA占用率计算器中。

2

术语warp-synchronous指的是隐含假设同一warp中的线程在每条指令处都保持同步的代码。

5. 性能优化指南

5.1. 整体性能优化策略

性能优化围绕四个基本策略展开:

  • 最大化并行执行以实现最高利用率;

  • 优化内存使用以实现最大内存吞吐量;

  • 优化指令使用以实现最大指令吞吐量;

  • 最小化内存抖动。

针对应用程序特定部分采取何种策略能获得最佳性能提升,取决于该部分的性能瓶颈。例如,对一个主要受限于内存访问的内核进行指令使用优化,将无法带来显著的性能提升。因此,应通过测量和监控性能瓶颈(例如使用CUDA分析器)来持续指导优化工作。此外,将特定内核的浮点运算吞吐量或内存吞吐量(视何者更相关)与设备的相应峰值理论吞吐量进行比较,可以表明该内核还存在多大的优化空间。

5.2. 最大化利用率

为了最大化利用率,应用程序应以一种能够暴露尽可能多的并行性并高效地将这种并行性映射到系统各个组件的方式构建,以使它们在大部分时间保持忙碌状态。

5.2.1. 应用层面

从高层次来看,应用程序应通过使用异步并发执行中描述的异步函数调用和流,最大化主机、设备以及连接主机与设备的总线之间的并行执行。它应该为每个处理器分配最适合的工作类型:串行工作负载给主机;并行工作负载给设备。

对于并行工作负载,在算法中某些线程需要同步以相互共享数据而导致并行性中断的点上,存在两种情况:要么这些线程属于同一个块,此时它们应使用__syncthreads()并通过同一内核调用中的共享内存来共享数据;要么它们属于不同块,此时必须通过全局内存使用两个独立的内核调用来共享数据,一个用于写入全局内存,另一个用于从全局内存读取。第二种情况的效率要低得多,因为它增加了额外内核调用和全局内存流量的开销。因此,应通过将算法映射到CUDA编程模型来尽量减少这种情况的发生,使得需要线程间通信的计算尽可能在单个线程块内完成。

5.2.2. 设备层级

在更底层,应用程序应最大化设备上多处理器之间的并行执行。

多个内核可以在设备上并发执行,因此通过使用流来启用足够多的内核并发执行,也能实现最大利用率,如异步并发执行中所述。

5.2.3. 多处理器层级

在更底层级别,应用程序应最大化多处理器内各功能单元之间的并行执行。

硬件多线程所述,GPU多处理器主要依赖线程级并行性来最大化其功能单元的利用率。因此,利用率与驻留线程束的数量直接相关。在每条指令发射时,线程束调度器会选择一条准备就绪的可执行指令。该指令可以是同一线程束的另一条独立指令(利用指令级并行性),或者更常见的是另一线程束的指令(利用线程级并行性)。如果选中一条准备就绪的可执行指令,则会将其发射到该线程束的活动线程。线程束准备好执行下一条指令所需的时钟周期数称为延迟,当所有线程束调度器在该延迟期间的每个时钟周期都能为某些线程束发射指令时(换句话说,当延迟被完全"隐藏"时),就实现了完全利用率。隐藏L个时钟周期延迟所需的指令数量取决于这些指令各自的吞吐量(各种算术指令的吞吐量参见算术指令)。如果我们假设指令具有最大吞吐量,则该值等于:

  • 对于计算能力5.x、6.1、6.2、7.x和8.x的设备使用4L,因为这些设备的每个多处理器在每个时钟周期会同时为四个线程束各发出一条指令,如计算能力部分所述。

  • 对于计算能力6.0的设备使用2L,因为这些设备每个周期发出的两条指令是针对两个不同线程束的指令。

warp未准备好执行下一条指令的最常见原因是该指令的输入操作数尚未就绪。

如果所有输入操作数都是寄存器,延迟是由寄存器依赖引起的,即某些输入操作数由尚未执行完成的先前指令写入。在这种情况下,延迟等于前一条指令的执行时间,而warp调度器在此期间必须调度其他warp的指令。执行时间因指令而异。在计算能力7.x的设备上,大多数算术指令通常需要4个时钟周期。这意味着每个多处理器需要16个活动warp(4个周期,4个warp调度器)才能隐藏算术指令延迟(假设warp以最大吞吐量执行指令,否则需要更少的warp)。如果单个warp表现出指令级并行性,即其指令流中有多个独立指令,则需要更少的warp,因为来自单个warp的多个独立指令可以背靠背地发出。

如果某些输入操作数位于片外存储器中,延迟会显著增加:通常需要数百个时钟周期。在这种高延迟期间,要保持线程束调度器忙碌所需的线程束数量取决于内核代码及其指令级并行程度。一般而言,当无片外存储器操作数的指令数量(大多数情况下是算术指令)与有片外存储器操作数的指令数量之比较低时(该比值通常称为程序的算术强度),就需要更多的线程束。

另一个导致warp无法执行下一条指令的原因是它正在等待某个内存栅栏(Memory Fence Functions)或同步点(Synchronization Functions)。同步点会迫使多处理器空闲,因为越来越多的warp需要等待同一块中的其他warp完成同步点之前的指令执行。在这种情况下,每个多处理器拥有多个驻留块有助于减少空闲时间,因为来自不同块的warp在同步点不需要互相等待。

对于给定的内核调用,驻留在每个多处理器上的块和线程束数量取决于调用的执行配置(Execution Configuration)、多处理器的内存资源以及内核的资源需求,如Hardware Multithreading中所述。寄存器与共享内存使用情况会在使用--ptxas-options=-v选项编译时由编译器报告。

一个块所需的共享内存总量等于静态分配的共享内存量与动态分配的共享内存量之和。

内核使用的寄存器数量对驻留线程束(warp)数量有显著影响。例如,对于计算能力6.x的设备,如果一个内核使用64个寄存器且每个块包含512个线程,同时只需要极少的共享内存,那么两个块(即32个线程束)可以驻留在多处理器上,因为它们需要2x512x64个寄存器,正好匹配多处理器上可用的寄存器总数。但是一旦内核多使用一个寄存器,就只能驻留一个块(即16个线程束),因为两个块将需要2x512x65个寄存器,这超出了多处理器的寄存器容量。因此,编译器会尽量优化寄存器使用,同时将寄存器溢出(参见设备内存访问)和指令数量控制在最低水平。寄存器使用量可以通过maxrregcount编译器选项、启动边界中描述的__launch_bounds__()限定符,或每个线程的最大寄存器数中描述的__maxnreg__()限定符进行控制。

寄存器文件由32位寄存器组成。因此,存储在寄存器中的每个变量至少需要一个32位寄存器,例如,double类型的变量会占用两个32位寄存器。

执行配置对特定内核调用性能的影响通常取决于内核代码。因此建议进行实验测试。应用程序还可以根据寄存器文件大小和共享内存大小来参数化执行配置,这些参数取决于设备的计算能力、多处理器数量以及设备内存带宽,所有这些都可以通过运行时查询(参见参考手册)。

每个块的线程数应选择为warp大小的倍数,以尽可能避免因warp未充分利用而浪费计算资源。

5.2.3.1. 占用率计算器

提供多个API函数来帮助程序员根据寄存器和共享内存需求选择线程块大小和集群大小。

  • 占用率计算器API cudaOccupancyMaxActiveBlocksPerMultiprocessor 可以根据内核的块大小和共享内存使用情况提供占用率预测。此函数以每个多处理器上并发线程块的数量来报告占用率。

    • 注意,该值可以转换为其他指标。乘以每个块中的线程束数量,可以得到每个多处理器上的并发线程束数量;再将并发线程束数量除以每个多处理器的最大线程束数量,即可得到以百分比表示的占用率。

  • 基于占用率的启动配置器API cudaOccupancyMaxPotentialBlockSizecudaOccupancyMaxPotentialBlockSizeVariableSMem,通过启发式算法计算能实现多处理器级别最大占用率的执行配置。

  • 占用率计算器API cudaOccupancyMaxActiveClusters 可以根据内核的簇大小、块大小和共享内存使用情况提供占用率预测。此函数以系统中GPU上给定大小的最大活跃簇数量来报告占用率。

以下代码示例计算了MyKernel的占用率。然后通过并发warp数与每个多处理器最大warp数的比值来报告占用级别。

// Device code
__global__ void MyKernel(int *d, int *a, int *b)
{
    int idx = threadIdx.x + blockIdx.x * blockDim.x;
    d[idx] = a[idx] * b[idx];
}

// Host code
int main()
{
    int numBlocks;        // Occupancy in terms of active blocks
    int blockSize = 32;

    // These variables are used to convert occupancy to warps
    int device;
    cudaDeviceProp prop;
    int activeWarps;
    int maxWarps;

    cudaGetDevice(&device);
    cudaGetDeviceProperties(&prop, device);

    cudaOccupancyMaxActiveBlocksPerMultiprocessor(
        &numBlocks,
        MyKernel,
        blockSize,
        0);

    activeWarps = numBlocks * blockSize / prop.warpSize;
    maxWarps = prop.maxThreadsPerMultiProcessor / prop.warpSize;

    std::cout << "Occupancy: " << (double)activeWarps / maxWarps * 100 << "%" << std::endl;

    return 0;
}

以下代码示例根据用户输入配置基于占用率的MyKernel内核启动。

// Device code
__global__ void MyKernel(int *array, int arrayCount)
{
    int idx = threadIdx.x + blockIdx.x * blockDim.x;
    if (idx < arrayCount) {
        array[idx] *= array[idx];
    }
}

// Host code
int launchMyKernel(int *array, int arrayCount)
{
    int blockSize;      // The launch configurator returned block size
    int minGridSize;    // The minimum grid size needed to achieve the
                        // maximum occupancy for a full device
                        // launch
    int gridSize;       // The actual grid size needed, based on input
                        // size

    cudaOccupancyMaxPotentialBlockSize(
        &minGridSize,
        &blockSize,
        (void*)MyKernel,
        0,
        arrayCount);

    // Round up according to array size
    gridSize = (arrayCount + blockSize - 1) / blockSize;

    MyKernel<<<gridSize, blockSize>>>(array, arrayCount);
    cudaDeviceSynchronize();

    // If interested, the occupancy can be calculated with
    // cudaOccupancyMaxActiveBlocksPerMultiprocessor

    return 0;
}

以下代码示例展示了如何使用集群占用率API查找给定大小的最大活跃集群数量。下方示例代码计算了每块128线程、大小为2的集群占用率。

从计算能力9.0开始,集群大小为8是向前兼容的,但在GPU硬件或MIG配置过小无法支持8个多处理器的情况下,最大集群大小将会降低。建议用户在启动集群内核前查询最大集群大小。可通过cudaOccupancyMaxPotentialClusterSize API查询最大集群大小。

{
  cudaLaunchConfig_t config = {0};
  config.gridDim = number_of_blocks;
  config.blockDim = 128; // threads_per_block = 128
  config.dynamicSmemBytes = dynamic_shared_memory_size;

  cudaLaunchAttribute attribute[1];
  attribute[0].id = cudaLaunchAttributeClusterDimension;
  attribute[0].val.clusterDim.x = 2; // cluster_size = 2
  attribute[0].val.clusterDim.y = 1;
  attribute[0].val.clusterDim.z = 1;
  config.attrs = attribute;
  config.numAttrs = 1;

  int max_cluster_size = 0;
  cudaOccupancyMaxPotentialClusterSize(&max_cluster_size, (void *)kernel, &config);

  int max_active_clusters = 0;
  cudaOccupancyMaxActiveClusters(&max_active_clusters, (void *)kernel, &config);

  std::cout << "Max Active Clusters of size 2: " << max_active_clusters << std::endl;
}

CUDA Nsight Compute用户界面还在/include/cuda_occupancy.h中提供了一个独立的占用率计算器和启动配置器实现,适用于任何不依赖CUDA软件堆栈的用例。Nsight Compute版本的占用率计算器特别适合作为学习工具,它能可视化展示影响占用率的参数(块大小、每个线程的寄存器数量和每个线程的共享内存)变化所产生的影响。

5.3. 最大化内存吞吐量

优化应用程序整体内存吞吐量的第一步是尽量减少低带宽的数据传输。

这意味着需要尽量减少主机与设备之间的数据传输,如主机与设备间数据传输中所述,因为这些传输的带宽远低于全局内存与设备之间的数据传输。

这也意味着通过最大化利用片上内存(即共享内存和缓存)来最小化全局内存与设备之间的数据传输:共享内存和缓存(即计算能力2.x及更高版本的设备上可用的L1缓存和L2缓存,所有设备上可用的纹理缓存和常量缓存)。

共享内存相当于一个用户管理的缓存:应用程序显式分配和访问它。如CUDA Runtime所示,典型的编程模式是将来自设备内存的数据暂存到共享内存中;换句话说,让一个块的每个线程:

  • 将数据从设备内存加载到共享内存,

  • 与块内的所有其他线程同步,确保每个线程可以安全地读取由不同线程填充的共享内存位置。

  • 在共享内存中处理数据,

  • 如有必要再次同步,以确保共享内存已更新为最新结果,

  • 将结果写回设备内存。

对于某些应用(例如那些全局内存访问模式依赖于数据的场景),传统的硬件管理缓存更适合用于利用数据局部性。如计算能力7.x计算能力8.x计算能力9.0中所述,对于计算能力为7.x、8.x和9.0的设备,相同的片上内存被同时用于L1缓存和共享内存,且每次内核调用时可以配置其中多少比例专用于L1缓存而非共享内存。

内核的内存访问吞吐量会根据每种内存类型的访问模式而有数量级的变化。因此,最大化内存吞吐量的下一步是根据设备内存访问中描述的最佳内存访问模式,尽可能优化地组织内存访问。这一优化对于全局内存访问尤为重要,因为与可用的片上带宽和算术指令吞吐量相比,全局内存带宽较低,因此非最优的全局内存访问通常会对性能产生重大影响。

5.3.1. 主机与设备之间的数据传输

应用程序应尽量减少主机与设备之间的数据传输。实现这一目标的一种方法是将更多代码从主机迁移到设备上运行,即使这意味着运行的内核无法充分暴露并行性以在设备上高效执行。中间数据结构可以在设备内存中创建、由设备操作,并在无需主机映射或复制到主机内存的情况下销毁。

此外,由于每次数据传输都会产生额外开销,将多个小批量传输合并为单个大批量传输的性能始终优于单独进行每次传输。

在带有前端总线的系统上,通过使用页锁定主机内存中描述的页锁定主机内存,可以实现主机与设备之间数据传输的更高性能。

此外,在使用映射页锁定内存(Mapped Memory)时,无需分配任何设备内存,也无需在设备与主机内存之间显式复制数据。每次内核访问映射内存时,数据传输都会隐式执行。为了获得最佳性能,这些内存访问必须像访问全局内存一样进行合并(参见Device Memory Accesses)。假设满足这些条件且映射内存仅被读写一次,使用映射页锁定内存替代设备与主机内存间的显式复制可以提升性能。

在设备内存和主机内存物理上相同的集成系统中,主机和设备内存之间的任何拷贝都是多余的,应改用映射的页锁定内存。应用程序可以通过检查集成设备属性(参见设备枚举)是否等于1来查询设备是否为integrated

5.3.2. 设备内存访问

一条访问可寻址内存(如全局内存、局部内存、共享内存、常量内存或纹理内存)的指令可能需要根据线程束内线程间内存地址的分布情况被多次重新执行。这种分布方式对指令吞吐量的影响因内存类型而异,具体描述将在后续章节中展开。例如,对于全局内存而言,通常地址分布越分散,吞吐量下降就越显著。

全局内存

全局内存位于设备内存中,设备内存通过32字节、64字节或128字节的内存事务进行访问。这些内存事务必须自然对齐:只有与自身大小对齐(即首地址为其大小的整数倍)的32字节、64字节或128字节设备内存段才能被内存事务读取或写入。

当一个线程束(warp)执行访问全局内存的指令时,它会根据每个线程访问的字(word)大小以及内存地址在线程间的分布情况,将线程束内各线程的内存访问合并成一个或多个内存事务。一般来说,所需的事务越多,除了线程实际访问的字之外,传输的未使用字也越多,从而相应降低指令吞吐量。例如,如果为每个线程的4字节访问生成32字节的内存事务,吞吐量将降至原来的1/8。

所需的交易数量以及最终影响的吞吐量会随设备的计算能力而变化。Compute Capability 5.xCompute Capability 6.xCompute Capability 7.xCompute Capability 8.xCompute Capability 9.0提供了关于不同计算能力下全局内存访问处理方式的更多细节。

为了最大化全局内存吞吐量,因此需要通过以下方式最大化合并访问:

大小与对齐要求

全局内存指令支持读取或写入大小为1、2、4、8或16字节的字。当且仅当数据类型的大小为1、2、4、8或16字节且数据是自然对齐的(即其地址是该大小的倍数)时,对驻留在全局内存中的数据的任何访问(通过变量或指针)都会编译为单个全局内存指令。

如果未满足此大小和对齐要求,访问将编译为多条指令,这些指令的交错访问模式会阻碍它们完全合并。因此,建议对驻留在全局内存中的数据使用符合此要求的类型。

对于内置向量类型,对齐要求会自动满足。

对于结构体,编译器可以使用对齐说明符__align__(8) __align__(16)来强制执行大小和对齐要求,例如

struct __align__(8) {
    float x;
    float y;
};

struct __align__(16) {
    float x;
    float y;
    float z;
};

任何位于全局内存中的变量地址,或由驱动程序或运行时API的内存分配例程返回的地址,始终至少对齐256字节。

读取非自然对齐的8字节或16字节字会产生错误结果(偏差几个字),因此必须特别注意保持这些类型的任何值或值数组的起始地址对齐。一个容易被忽视的典型情况是使用某些自定义的全局内存分配方案时,即通过分配单个大内存块(替换多次调用cudaMalloc()cuMemAlloc())并将其分割为多个数组,这种情况下每个数组的起始地址会偏离内存块的起始地址。

二维数组

一个常见的全局内存访问模式是,当索引为(tx,ty)的每个线程使用以下地址访问宽度为width的二维数组中的一个元素时,该数组位于地址BaseAddress处,类型为type*(其中type满足Maximize Utilization中描述的要求):

BaseAddress + width * ty + tx

为了使这些访问完全合并,线程块的宽度和数组的宽度都必须是warp大小的倍数。

具体来说,这意味着如果一个数组的宽度不是该大小的整数倍,那么实际分配时将宽度向上取整至最接近的整数倍并相应填充行数据,访问效率会显著提高。参考手册中描述的cudaMallocPitch()cuMemAllocPitch()函数及相关内存拷贝函数,使程序员能够编写不依赖硬件的代码来分配符合这些约束条件的数组。

本地内存

局部内存访问仅针对某些自动变量发生,如变量内存空间说明符中所述。编译器可能放置在局部内存中的自动变量包括:

  • 无法确定是否使用常量索引的数组

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

  • 任何变量,如果内核使用的寄存器数量超过可用数量(这也被称为寄存器溢出)。

检查PTX汇编代码(通过使用-ptx-keep选项编译获得)可以判断变量是否在初始编译阶段被放置在本地内存中,因为这类变量会使用.local助记符声明,并通过ld.localst.local助记符访问。即使初始阶段未放置,后续编译阶段仍可能因发现该变量占用过多目标架构的寄存器空间而改变决定:使用cuobjdump检查cubin对象可确认此情况。此外,当使用--ptxas-options=-v选项编译时,编译器会报告每个内核的本地内存总使用量(lmem)。请注意,某些数学函数的实现路径可能会访问本地内存。

本地内存空间位于设备内存中,因此本地内存访问具有与全局内存访问相同的高延迟和低带宽特性,并且需要满足设备内存访问中描述的相同内存合并要求。不过,本地内存的组织方式使得连续的32位字由连续的线程ID访问。因此,只要一个线程束中的所有线程访问相同的相对地址(例如,数组变量中的相同索引,结构变量中的相同成员),访问就是完全合并的。

在计算能力5.x及以上的设备上,本地内存访问总是以与全局内存访问相同的方式缓存在L2中(参见计算能力5.x计算能力6.x)。

共享内存

由于位于芯片上,共享内存相比本地或全局内存具有更高的带宽和更低的延迟。

为了实现高带宽,共享内存被划分为大小相等的内存模块,称为存储体(banks),这些模块可以同时被访问。因此,任何由落在n个不同存储体中的n个地址组成的内存读取或写入请求都可以被同时处理,从而产生比单个模块高n倍的总体带宽。

然而,如果内存请求的两个地址落在同一个存储体中,就会发生存储体冲突,访问必须串行化处理。硬件会将存在存储体冲突的内存请求拆分成多个独立的无冲突请求,吞吐量会降低为拆分后请求数量的倒数。如果拆分后的独立内存请求数量为n,则称初始内存请求引发了n路存储体冲突。

为了获得最佳性能,理解内存地址如何映射到内存存储体非常重要,这样才能合理安排内存请求,最小化存储体冲突。具体内容请参阅计算能力5.x计算能力6.x计算能力7.x计算能力8.x计算能力9.0中针对计算能力分别为5.x、6.x、7.x、8.x和9.0设备的说明。

常量内存

常量内存空间位于设备内存中,并被缓存在常量缓存中。

随后,一个请求会被拆分成与初始请求中不同内存地址数量相等的多个独立请求,吞吐量将下降为独立请求数量的倒数倍。

如果缓存命中,则生成的请求将以恒定缓存的吞吐量提供服务;否则将以设备内存的吞吐量提供服务。

纹理与表面内存

纹理和表面内存空间位于设备内存中,并缓存在纹理缓存中,因此纹理获取或表面读取仅在缓存未命中时需从设备内存读取一次,否则只需从纹理缓存读取一次。纹理缓存针对二维空间局部性进行了优化,因此同一线程束中读取二维空间上相邻纹理或表面地址的线程将获得最佳性能。此外,该缓存设计用于恒定延迟的流式获取:缓存命中可降低DRAM带宽需求,但不会减少获取延迟。

通过纹理或表面获取读取设备内存具有一些优势,使其成为从全局或常量内存读取设备内存的有益替代方案:

  • 如果内存读取不符合全局或常量内存读取必须遵循的高性能访问模式,只要纹理提取或表面读取存在局部性,就能实现更高的带宽;

  • 寻址计算由专用单元在内核外部执行;

  • 打包的数据可以通过单次操作广播到单独的变量中;

  • 8位和16位整数输入数据可选择性地转换为范围在[0.0, 1.0]或[-1.0, 1.0]内的32位浮点值(参见纹理内存)。

5.4. 最大化指令吞吐量

为了最大化指令吞吐量,应用程序应:

  • 尽量减少使用低吞吐量的算术指令;这包括在不影响最终结果的情况下,用精度换取速度,例如使用内置函数而非常规函数(内置函数列表见Intrinsic Functions)、使用单精度而非双精度、或将非规格化数值刷新为零;

  • 尽量减少控制流指令导致的线程束分化,详见控制流指令

  • 减少指令数量,例如通过尽可能优化掉同步点(如Synchronization Instruction中所述)或使用受限指针(如__restrict__中所述)。

在本节中,吞吐量以每个时钟周期每个多处理器的操作数量表示。对于32的线程束大小,一条指令对应32次操作,因此如果N是每个时钟周期的操作数量,则指令吞吐量为每个时钟周期N/32条指令。

所有吞吐量数据均针对单个多处理器。需要将其乘以设备中的多处理器数量才能获得整个设备的吞吐量。

5.4.1. 算术指令

下表列出了不同计算能力设备原生支持的算术指令吞吐量。

表 4 原生算术指令的吞吐量。(每个时钟周期每个多处理器生成的结果数量)

计算能力

5.0, 5.2

5.3

6.0

6.1

6.2

7.x

8.0

8.6

8.9

9.0

16位浮点数加法、乘法、乘加运算

N/A

256

128

2

256

128

2563

128

256

32位浮点数加法、乘法、乘加运算

128

64

128

64

128

64位浮点数加法、乘法、乘加运算

4

32

4

325

32

2

64

32位浮点数倒数、倒数平方根、以2为底的对数(__log2f)、以2为底的指数(exp2f)、正弦(__sinf)、余弦(__cosf)

32

16

32

16

32位整数加法、扩展精度加法、减法、扩展精度减法

128

64

128

64

32位整数乘法、乘加运算、扩展精度乘加运算

多条指令

646

24位整数乘法 (__[u]mul24)

多重指令。

32位整数位移

64

32

64

比较、最小值、最大值

64

32

64

32位整数位反转

64

32

64

16

位字段提取/插入

64

32

64

多指令

64

32位按位与、或、异或运算

128

64

128

64

前导零计数,最高有效非符号位

32

16

32

16

人口计数

32

16

32

16

warp shuffle

32

328

32

warp reduce

多条指令

16

线程束投票

64

绝对差之和

64

32

64

SIMD视频指令 vabsdiff2

多重指令。

SIMD视频指令 vabsdiff4

多重指令。

64

所有其他SIMD视频指令

多条指令

从8位和16位整数到32位整数类型的类型转换

32

16

32

64

64位类型之间的类型转换

4

16

4

1610

16

2

2

16

所有其他类型转换

32

16

32

16

16位DPX

多指令

128

32位DPX

多指令

64

其他指令和功能是在原生指令基础上实现的。具体实现可能因设备计算能力不同而有所差异,且编译后的原生指令数量会随每个编译器版本而变化。对于复杂功能,可能会根据输入存在多条代码路径。可使用cuobjdump工具来检查cubin对象中的特定实现。

部分函数的实现可直接在CUDA头文件中找到(math_functions.h, device_functions.h, …)。

通常来说,使用-ftz=true(非规格化数被刷新为零)编译的代码往往比使用-ftz=false编译的代码具有更高的性能。类似地,使用-prec-div=false(较低精度的除法)编译的代码往往比使用-prec-div=true编译的代码性能更高,而使用-prec-sqrt=false(较低精度的平方根)编译的代码往往比使用-prec-sqrt=true编译的代码性能更高。nvcc用户手册对这些编译标志有更详细的描述。

单精度浮点数除法

__fdividef(x, y) (参见Intrinsic Functions) 相比除法运算符能提供更快的单精度浮点数除法运算。

单精度浮点数倒数平方根

为了保持IEEE-754语义,编译器只能在倒数和平方根都是近似计算时(即使用-prec-div=false-prec-sqrt=false参数),将1.0/sqrtf()优化为rsqrtf()。因此建议在需要时直接调用rsqrtf()函数。

单精度浮点数平方根

单精度浮点平方根通过倒数平方根再取倒数的方式实现,而非倒数平方根后接乘法,以确保对0和无穷大给出正确结果。

正弦与余弦

sinf(x), cosf(x), tanf(x), sincosf(x)以及对应的双精度指令计算代价更高,当参数x的绝对值较大时尤其明显。

更准确地说,参数缩减代码(实现细节请参阅数学函数)包含两条代码路径,分别称为快速路径和慢速路径。

快速路径适用于幅度足够小的参数,本质上由几次乘加运算组成。慢速路径适用于幅度较大的参数,包含为确保在整个参数范围内获得正确结果所需的冗长计算。

目前,三角函数参数缩减代码针对单精度函数选择绝对值小于105615.0f的参数采用快速路径,双精度函数则针对小于2147483648.0的参数采用快速路径。

由于慢速路径比快速路径需要更多的寄存器,我们尝试通过在本地内存中存储一些中间变量来降低慢速路径的寄存器压力,这可能会因为本地内存的高延迟和带宽而影响性能(参见设备内存访问)。目前,单精度函数使用了28字节的本地内存,双精度函数使用了44字节。不过,具体用量可能会有所变化。

由于慢速路径中的冗长计算和本地内存使用,当需要慢速路径归约而非快速路径归约时,这些三角函数的吞吐量会降低一个数量级。

整数运算

整数除法和取模运算成本较高,因为它们会编译成多达20条指令。在某些情况下可以用位运算替代:如果n是2的幂次方,那么(i/n)等价于(i>>log2(n)),而(i%n)等价于(i&(n-1));当n是字面量时,编译器会自动执行这些转换。

__brev__popc 映射为单条指令,而 __brevll__popcll 则映射为几条指令。

__[u]mul24 是遗留的内置函数,已无任何使用理由。

半精度算术

为了在16位精度浮点数加法、乘法或乘加运算中获得良好性能,建议对half精度使用half2数据类型,对__nv_bfloat16精度使用__nv_bfloat162。然后可以利用向量内联函数(例如__hadd2__hsub2__hmul2__hfma2)在单条指令中执行两次操作。使用half2__nv_bfloat162替代两次half__nv_bfloat16调用,还可能提升其他内联函数(如warp shuffles)的性能。

内置的__halves2half2函数用于将两个half精度值转换为half2数据类型。

内置的__halves2bfloat162用于将两个__nv_bfloat精度值转换为__nv_bfloat162数据类型。

类型转换

有时,编译器必须插入转换指令,这会引入额外的执行周期。这种情况适用于:

  • charshort类型变量进行操作的函数,其操作数通常需要转换为int类型。

  • 用作单精度浮点计算输入的双精度浮点常量(即那些未定义任何类型后缀的常量,符合C/C++标准要求)。

最后这种情况可以通过使用单精度浮点常量来避免,这些常量用f后缀定义,例如3.141592653589793f1.0f0.5f

5.4.2. 控制流指令

任何流程控制指令(if, switch, do, for, while)都可能通过导致同一warp中的线程发散(即遵循不同的执行路径)而显著影响有效指令吞吐量。如果发生这种情况,不同的执行路径必须被串行化,从而增加了该warp执行的总指令数。

为了在线程ID控制流程的情况下获得最佳性能,控制条件的编写应尽量减少发散warp的数量。这是可行的,因为如SIMT架构中所述,warp在块中的分布是确定性的。一个简单的例子是当控制条件仅取决于(threadIdx / warpSize),其中warpSize表示warp大小。在这种情况下,由于控制条件与warp完美对齐,因此不会出现warp发散。

有时,编译器可能会展开循环,或者通过使用分支预测来优化掉短的ifswitch代码块,如下所述。在这些情况下,任何线程束都不会发生分支发散。程序员也可以使用#pragma unroll指令来控制循环展开(参见#pragma unroll)。

在使用分支预测时,所有执行依赖于控制条件的指令都不会被跳过。相反,每条指令都与一个每线程条件码(或谓词)相关联,该谓词会根据控制条件设置为真或假。尽管这些指令都会被调度执行,但只有谓词为真的指令才会实际执行。谓词为假的指令不会写入结果,也不会计算地址或读取操作数。

5.4.3. 同步指令

对于计算能力6.0的设备,__syncthreads()的吞吐量为每时钟周期32次操作;计算能力7.x和8.x的设备为每时钟周期16次操作;而计算能力5.x、6.1和6.2的设备则为每时钟周期64次操作。

请注意,__syncthreads()可能会影响性能,因为它会强制多处理器空闲,具体细节请参阅Device Memory Accesses

5.5. 最小化内存抖动

频繁分配和释放内存的应用程序可能会发现,随着时间的推移,内存分配调用的速度会逐渐变慢,直至达到一个极限。这通常是由于将内存释放回操作系统供其自身使用的特性所导致的。为了在这方面获得最佳性能,我们建议采取以下措施:

  • 尝试根据当前问题的大小来分配内存。不要试图使用cudaMalloc/cudaMallocHost/cuMemCreate分配所有可用内存,因为这会强制内存立即驻留,并阻止其他应用程序使用该内存。这会给操作系统调度程序带来更大压力,或者完全阻止其他使用相同GPU的应用程序运行。

  • 尽量在应用程序早期以适当大小的分配方式申请内存,仅在应用程序不再使用时才释放。减少应用程序中cudaMalloc+cudaFree的调用次数,特别是在性能关键区域。

  • 如果应用程序无法分配足够的设备内存,可以考虑回退到其他内存类型,例如cudaMallocHostcudaMallocManaged,这些类型可能性能不如前者,但能让应用程序继续运行。

  • 对于支持该功能的平台,cudaMallocManaged允许超额订阅,并且在启用正确的cudaMemAdvise策略后,应用程序可以保留cudaMalloc的大部分(如果不是全部)性能。cudaMallocManaged也不会强制分配常驻内存,直到需要或预取时才驻留,从而减少操作系统调度程序的整体压力,并更好地支持多租户用例。

3

128 对应 __nv_bfloat16

4

GeForce GPU(除Titan GPU外)为8

5

适用于计算能力7.5的GPU

6

32 用于扩展精度

7

GeForce GPU为32,Titan GPU除外

8

适用于计算能力7.5的GPU

9

GeForce GPU(除Titan GPU外)为8

10

针对计算能力7.5的GPU设置为2

6. 支持CUDA的GPU

https://developer.nvidia.com/cuda-gpus 列出了所有支持CUDA的设备及其计算能力。

可以使用运行时查询计算能力、多处理器数量、时钟频率、设备内存总量以及其他属性(参见参考手册)。

7. C++ 语言扩展

7.1. 函数执行空间指定符

函数执行空间限定符用于指定函数是在主机上执行还是在设备上执行,以及是否可以从主机或设备调用。

7.1.1. __global__

__global__执行空间说明符将函数声明为内核。此类函数具有以下特性:

  • 在设备上执行,

  • 可从主机调用,

  • 对于计算能力5.0或更高的设备可从设备端调用(详见CUDA动态并行)。

一个 __global__ 函数必须具有 void 返回类型,并且不能是类的成员。

任何对__global__函数的调用都必须按照执行配置中所述指定其执行配置。

调用__global__函数是异步的,这意味着它在设备完成执行之前就会返回。

7.1.2. __device__

__device__执行空间说明符用于声明一个函数,该函数具有以下特性:

  • 在设备上执行,

  • 仅可从设备端调用。

__global____device__ 执行空间说明符不能同时使用。

7.1.3. __host__

__host__执行空间说明符声明了一个函数,该函数是:

  • 在主机上执行,

  • 仅可从主机端调用。

这相当于声明一个仅带有__host__执行空间说明符的函数,或者声明一个不包含任何__host____device____global__执行空间说明符的函数;无论哪种情况,该函数都仅为主机编译。

__global____host__ 执行空间说明符不能同时使用。

__device____host__ 执行空间说明符可以同时使用,在这种情况下,函数会同时为主机和设备编译。应用兼容性中介绍的 __CUDA_ARCH__ 宏可用于区分主机和设备之间的代码路径:

__host__ __device__ func()
{
#if __CUDA_ARCH__ >= 800
   // Device code path for compute capability 8.x
#elif __CUDA_ARCH__ >= 700
   // Device code path for compute capability 7.x
#elif __CUDA_ARCH__ >= 600
   // Device code path for compute capability 6.x
#elif __CUDA_ARCH__ >= 500
   // Device code path for compute capability 5.x
#elif !defined(__CUDA_ARCH__)
   // Host code path
#endif
}

7.1.4. 未定义行为

当出现以下情况时,"跨执行空间"调用具有未定义行为:

  • __CUDA_ARCH__ 已定义的情况下,从 __global____device____host__ __device__ 函数内部调用 __host__ 函数。

  • __CUDA_ARCH__ 未定义,这是从 __host__ 函数内部调用 __device__ 函数的情况。9

7.1.5. __noinline__ 和 __forceinline__

编译器会在认为适当时内联任何__device__函数。

__noinline__ 函数限定符可用作提示编译器尽可能不要内联该函数。

__forceinline__ 函数限定符可用于强制编译器内联该函数。

__noinline____forceinline__ 函数限定符不能同时使用,且这两个限定符都不能应用于内联函数。

7.1.6. __inline_hint__

__inline_hint__限定符使编译器能够进行更激进的函数内联优化。与__forceinline__不同,它并不强制要求函数必须内联。在使用LTO(链接时优化)时,该限定符可用于提升跨模块的内联优化效果。

__noinline____forceinline__ 函数限定符都不能与 __inline_hint__ 函数限定符一起使用。

7.2. 变量内存空间指定符

变量内存空间指定符表示设备上变量的内存位置。

在设备代码中声明的自动变量,如果未使用本节描述的__device____shared____constant__内存空间限定符,通常存放在寄存器中。但在某些情况下,编译器可能会选择将其放置在本地内存中,这可能会对性能产生不利影响,具体细节请参阅设备内存访问

7.2.1. __device__

__device__ 内存空间说明符用于声明一个驻留在设备上的变量。

最多可以使用接下来三节中定义的其他内存空间说明符中的一个与__device__一起使用,以进一步指明变量所属的内存空间。如果未指定任何说明符,则该变量:

  • 驻留在全局内存空间中,

  • 其生命周期与创建它的CUDA上下文相同,

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

  • 可通过运行时库(cudaGetSymbolAddress() / cudaGetSymbolSize() / cudaMemcpyToSymbol() / cudaMemcpyFromSymbol()从网格内的所有线程以及主机访问。

7.2.2. __constant__

__constant__ 内存空间限定符,可选择与 __device__ 一起使用,用于声明一个变量,该变量:

  • 驻留在常量内存空间中,

  • 其生命周期与创建它的CUDA上下文相同,

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

  • 可以通过运行时库(cudaGetSymbolAddress() / cudaGetSymbolSize() / cudaMemcpyToSymbol() / cudaMemcpyFromSymbol())从网格内的所有线程以及主机访问。

在存在并发网格访问该常量的情况下,从主机端修改该常量的行为(在该网格生命周期的任何时刻)是未定义的。

7.2.3. __shared__

__shared__内存空间限定符(可选择与__device__一起使用)用于声明具有以下特性的变量:

  • 驻留在线程块的共享内存空间中

  • 生命周期与代码块相同,

  • 每个块都有一个独立的对象,

  • 仅可从块内的所有线程访问,

  • 没有固定地址。

当在共享内存中声明一个变量作为外部数组时,例如

extern __shared__ float shared[];

数组的大小在启动时确定(参见执行配置)。以这种方式声明的所有变量在内存中起始地址相同,因此必须通过偏移量显式管理数组中变量的布局。例如,如果想要实现等同于

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

在动态分配的共享内存中,可以通过以下方式声明和初始化数组:

extern __shared__ float array[];
__device__ void func()      // __device__ or __global__ function
{
    short* array0 = (short*)array;
    float* array1 = (float*)&array0[128];
    int*   array2 =   (int*)&array1[64];
}

请注意,指针需要与其指向的类型对齐,因此例如以下代码将无法工作,因为array1未按4字节对齐。

extern __shared__ float array[];
__device__ void func()      // __device__ or __global__ function
{
    short* array0 = (short*)array;
    float* array1 = (float*)&array0[127];
}

内置向量类型的对齐要求列于表5中。

7.2.4. __grid_constant__

对于计算架构大于或等于7.0的情况,__grid_constant__注解用于标注一个非引用类型的const限定__global__函数参数,该参数满足以下条件:

  • 生命周期与网格相同,

  • 对网格是私有的,即主机线程和其他网格(包括子网格)的线程无法访问该对象。

  • 每个网格拥有独立的对象,即网格中的所有线程都访问相同的地址,

  • 是只读的,即修改__grid_constant__对象或其任何子对象属于未定义行为,包括mutable成员。

要求:

  • 使用__grid_constant__标注的内核参数必须具有const限定的非引用类型。

  • 所有函数声明必须在任何__grid_constant_参数方面保持一致。

  • 函数模板特化必须与主模板声明在__grid_constant__参数方面保持一致。

  • 函数模板实例化指令必须与主模板声明在__grid_constant__参数方面保持一致。

如果获取了__global__函数参数的地址,编译器通常会在线程本地内存中创建内核参数的副本,并使用该副本的地址,以部分支持C++语义(允许每个线程修改其自身的函数参数本地副本)。通过使用__grid_constant__注解__global__函数参数,可确保编译器不会在线程本地内存中创建内核参数的副本,而是直接使用参数本身的通用地址。避免本地副本可能带来性能提升。

__device__ void unknown_function(S const&);
__global__ void kernel(const __grid_constant__ S s) {
   s.x += threadIdx.x;  // Undefined Behavior: tried to modify read-only memory

   // Compiler will _not_ create a per-thread thread local copy of "s":
   unknown_function(s);
}

7.2.5. __managed__

__managed__内存空间说明符(可选择与__device__一起使用),用于声明一个具有以下特性的变量:

  • 可以从设备和主机代码中引用,例如,可以获取其地址,或者可以直接从设备或主机函数中读取或写入。

  • 生命周期与应用程序相同。

更多详情请参阅__managed__ Memory Space Specifier

7.2.6. __restrict__

nvcc 通过 __restrict__ 关键字支持受限指针。

C99中引入了受限指针(restricted pointers),旨在缓解C类语言中存在的别名问题,该问题会阻碍从代码重排序到公共子表达式消除等各种优化。

以下是一个存在别名问题的示例,其中使用受限指针可以帮助编译器减少指令数量:

void foo(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];
    ...
}

在C类语言中,指针abc可能存在别名关系,因此通过c的任何写入都可能修改ab的元素。这意味着为了保证功能正确性,编译器不能将a[0]b[0]加载到寄存器中相乘,然后将结果同时存储到c[0]c[1],因为如果a[0]实际上与c[0]是同一内存位置,结果将与抽象执行模型不符。因此编译器无法利用这个公共子表达式。同样地,编译器也不能简单地将c[4]的计算重新排序到靠近c[0]c[1]计算的位置,因为对c[3]的前置写入可能会改变c[4]计算的输入。

通过将abc声明为受限指针,程序员向编译器声明这些指针实际上不存在别名问题,这意味着通过c的写入永远不会覆盖ab的元素。这将函数原型修改如下:

void foo(const float* __restrict__ a,
         const float* __restrict__ b,
         float* __restrict__ c);

请注意,所有指针参数都需要设置为受限(restricted),以便编译器优化器能够从中获益。添加__restrict__关键字后,编译器现在可以自由地进行重排序和公共子表达式消除,同时保持与抽象执行模型完全相同的功能:

void foo(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;
    ...
}

此处的影响是减少了内存访问次数和计算量。但由于"缓存"加载和公共子表达式导致寄存器压力增加,这之间需要取得平衡。

由于寄存器压力是许多CUDA代码中的关键问题,使用受限指针可能会对CUDA代码产生负面性能影响,因为会降低占用率。

7.3. 内置向量类型

7.3.1. 字符型(char)、短整型(short)、整型(int)、长整型(long)、长长整型(longlong)、单精度浮点型(float)、双精度浮点型(double)

这些是从基本整数和浮点类型派生的向量类型。它们是结构体,其第1、第2、第3和第4个分量分别可以通过字段xyzw访问。它们都带有形式为make_ name>的构造函数;例如,

int2 make_int2(int x, int y);

创建一个类型为int2的向量,其值为(x, y)

向量类型的对齐要求详见下表

表5 对齐要求

类型

对齐方式

char1, uchar1

1

char2, uchar2

2

char3, uchar3

1

char4, uchar4

4

short1, ushort1

2

short2, ushort2

4

short3, ushort3

2

short4, ushort4

8

int1, uint1

4

int2, uint2

8

int3, uint3

4

int4, uint4

16

long1, ulong1

如果sizeof(long)等于sizeof(int)则为4,否则为8

long2, ulong2

如果sizeof(long)等于sizeof(int)则为8,否则为16

long3, ulong3

如果sizeof(long)等于sizeof(int)则为4,否则为8

long4, ulong4

16

longlong1, ulonglong1

8

longlong2, ulonglong2

16

longlong3, ulonglong3

8

longlong4, ulonglong4

16

float1

4

float2

8

float3

4

float4

16

双精度浮点数1

8

double2

16

双精度3

8

双精度4

16

7.3.2. dim3

此类型是基于uint3的整数向量类型,用于指定维度。当定义dim3类型的变量时,任何未指定的分量都会初始化为1。

7.4. 内置变量

内置变量用于指定网格和块的维度以及块和线程的索引。这些变量仅在设备上执行的函数内有效。

7.4.1. gridDim

该变量类型为dim3(参见dim3),包含网格的维度。

7.4.2. blockIdx

该变量类型为uint3(参见char, short, int, long, longlong, float, double),包含网格内的块索引。

7.4.3. blockDim

该变量类型为dim3(参见dim3),包含块的维度信息。

7.4.4. threadIdx

该变量类型为uint3(参见char, short, int, long, longlong, float, double),包含线程在块内的索引。

7.4.5. warpSize

该变量类型为int,包含以线程为单位的warp大小(关于warp的定义请参阅SIMT Architecture)。

7.5. 内存栅栏函数

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;
}

两个线程同时从相同的内存位置XY进行读写操作。任何数据竞争都属于未定义行为,没有明确的语义。最终AB的结果值可能是任意的。

内存栅栏函数可用于强制对内存访问施加顺序一致性排序。不同内存栅栏函数的区别在于其强制排序的作用域范围,但它们都与所访问的内存空间类型无关(共享内存、全局内存、页锁定主机内存以及对等设备的内存)。

void __threadfence_block();

等同于 cuda::atomic_thread_fence(cuda::memory_order_seq_cst, cuda::thread_scope_block) 并确保:

  • 在调用__threadfence_block()之前,调用线程对所有内存的写入操作,将被调用线程所在块内的所有线程视为发生在调用__threadfence_block()之后调用线程对所有内存的写入操作之前;

  • 调用线程在调用__threadfence_block()之前对所有内存的所有读取操作,都将在调用__threadfence_block()之后对所有内存的所有读取操作之前完成排序。

void __threadfence();

等同于 cuda::atomic_thread_fence(cuda::memory_order_seq_cst, cuda::thread_scope_device),并确保调用线程在调用__threadfence()之后对所有内存的写入操作,不会被设备中的任何线程观察到发生在调用__threadfence()之前该线程对所有内存的写入操作之前。

void __threadfence_system();

等同于 cuda::atomic_thread_fence(cuda::memory_order_seq_cst, cuda::thread_scope_system),并确保调用线程在调用__threadfence_system()之前对所有内存的写入操作,会被设备中的所有线程、主机线程以及对等设备中的所有线程观察到,且这些写入操作都发生在调用线程在调用__threadfence_system()之后对所有内存的写入操作之前。

__threadfence_system() 仅在计算能力2.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,

  • A 等于 10 且 B 等于 2,

  • A 等于 10 且 B 等于 20。

第四种结果是不可能的,因为第一次写入必须在第二次写入之前可见。如果线程1和2属于同一个块,使用__threadfence_block()就足够了。如果线程1和2不属于同一个块,当它们是来自同一设备的CUDA线程时必须使用__threadfence(),当它们是来自两个不同设备的CUDA线程时必须使用__threadfence_system()

一个常见的使用场景是线程消费由其他线程产生的数据,如下面的内核代码示例所示,该内核在一次调用中计算包含N个数字的数组总和。每个线程块首先对数组的子集进行求和,并将结果存储在全局内存中。当所有线程块完成后,最后一个完成的线程块从全局内存中读取这些部分和并进行求和以获得最终结果。为了确定哪个线程块最后完成,每个线程块原子地递增一个计数器以表示它已完成计算并存储了其部分和(关于原子函数,请参阅Atomic Functions)。最后一个线程块是接收到计数器值等于gridDim.x-1的那个。如果在存储部分和与递增计数器之间没有设置内存屏障,计数器可能在部分和被存储之前递增,因此可能达到gridDim.x-1,导致最后一个线程块在实际更新内存中的部分和之前就开始读取它们。

内存栅栏函数仅影响线程对内存操作的顺序;它们本身并不确保这些内存操作对其他线程可见(就像__syncthreads()对块内线程所做的那样;参见Synchronization Functions)。在下面的代码示例中,通过将result变量声明为volatile(参见Volatile Qualifier),确保了对其内存操作的可见性。

__device__ unsigned int count = 0;
__shared__ bool isLastBlockDone;
__global__ void sum(const float* array, unsigned int N,
                    volatile float* result)
{
    // 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 incrementing
        // of the "count" variable is only performed after
        // the partial sum has been written to global memory.
        __threadfence();

        // Thread 0 signals that it is done.
        unsigned int value = atomicInc(&count, gridDim.x);

        // Thread 0 determines if its block is the last
        // block to be done.
        isLastBlockDone = (value == (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;
        }
    }
}

7.6. 同步函数

void __syncthreads();

等待线程块中的所有线程都到达此点,并且这些线程在__syncthreads()之前对全局和共享内存的所有访问操作对块内所有线程可见。

__syncthreads() 用于协调同一线程块内各线程之间的通信。当块内的某些线程访问共享内存或全局内存中的相同地址时,这些内存访问可能存在读后写、写后读或写后写的风险。通过在访问之间同步线程,可以避免这些数据风险。

__syncthreads() 允许在条件代码中使用,但前提是该条件在整个线程块中的评估结果必须完全一致,否则代码执行可能会挂起或产生意外的副作用。

计算能力2.x及以上的设备支持以下三种__syncthreads()变体。

int __syncthreads_count(int predicate);

__syncthreads()相同,但额外具有评估块内所有线程的谓词功能,并返回谓词评估为非零的线程数量。

int __syncthreads_and(int predicate);

__syncthreads()相同,但额外具备一个特性:它会评估块内所有线程的谓词条件,当且仅当所有线程的谓词评估结果均为非零值时,该函数才会返回非零值。

int __syncthreads_or(int predicate);

__syncthreads()相同,但额外具有一个特性:它会评估块中所有线程的谓词条件,当且仅当任意线程的谓词评估结果非零时返回非零值。

void __syncwarp(unsigned mask=0xffffffff);

将导致执行线程等待,直到掩码(mask)中指定的所有warp通道都执行了__syncwarp()(使用相同的掩码)后才会继续执行。每个调用线程必须在掩码中设置自己的位,并且掩码中指定的所有未退出线程都必须使用相同的掩码执行相应的__syncwarp(),否则结果将是未定义的。

执行__syncwarp()可确保参与屏障的线程之间的内存顺序。因此,warp内希望通过内存通信的线程可以先存储到内存,执行__syncwarp(),然后安全地读取warp中其他线程存储的值。

注意

对于.target sm_6x或更低版本,掩码中的所有线程必须在收敛时执行相同的__syncwarp(),且掩码中所有值的并集必须等于活动掩码。否则,行为将是未定义的。

7.7. 数学函数

参考手册列出了设备代码中支持的所有C/C++标准库数学函数,以及仅在设备代码中支持的所有内置函数。

数学函数在相关情况下会提供这些函数的精度信息。

7.8. 纹理函数

纹理对象在Texture Object API中有详细描述。

纹理获取在Texture Fetching中有详细描述。

7.8.1. 纹理对象API

7.8.1.1. tex1Dfetch()

template<class T>
T tex1Dfetch(cudaTextureObject_t texObj, int x);

从由一维纹理对象texObj指定的线性内存区域中获取数据,使用整数纹理坐标xtex1Dfetch()仅适用于非归一化坐标,因此仅支持边界和钳位寻址模式。它不执行任何纹理过滤。对于整数类型,可以选择将整数提升为单精度浮点数。

7.8.1.2. tex1D()

template<class T>
T tex1D(cudaTextureObject_t texObj, float x);

从一维纹理对象texObj指定的CUDA数组中,使用纹理坐标x进行获取。

7.8.1.3. tex1DLod()

template<class T>
T tex1DLod(cudaTextureObject_t texObj, float x, float level);

从一维纹理对象texObj指定的CUDA数组中,使用细节层次level下的纹理坐标x进行数据获取。

7.8.1.4. tex1DGrad()

template<class T>
T tex1DGrad(cudaTextureObject_t texObj, float x, float dx, float dy);

从一维纹理对象texObj指定的CUDA数组中获取数据,使用纹理坐标x。细节级别由X梯度dx和Y梯度dy推导得出。

7.8.1.5. tex2D()

template<class T>
T tex2D(cudaTextureObject_t texObj, float x, float y);

从CUDA数组或由二维纹理对象texObj指定的线性内存区域中获取数据,使用纹理坐标(x,y)

7.8.1.6. 稀疏CUDA数组的tex2D()函数

                template<class T>
T tex2D(cudaTextureObject_t texObj, float x, float y, bool* isResident);

从由二维纹理对象texObj指定的CUDA数组中获取数据,使用纹理坐标(x,y)。同时通过isResident指针返回纹理元素是否驻留在内存中。如果没有驻留,获取的值将为零。

7.8.1.7. tex2Dgather()

template<class T>
T tex2Dgather(cudaTextureObject_t texObj,
              float x, float y, int comp = 0);

从由2D纹理对象texObj指定的CUDA数组中获取数据,使用纹理坐标xy以及comp参数,具体描述见Texture Gather

7.8.1.8. 稀疏CUDA数组的tex2Dgather()函数

                template<class T>
T tex2Dgather(cudaTextureObject_t texObj,
            float x, float y, bool* isResident, int comp = 0);

从由2D纹理对象texObj指定的CUDA数组中获取数据,使用纹理坐标xy以及Texture Gather中描述的comp参数。同时通过isResident指针返回纹理元素是否驻留在内存中。如果不驻留,获取的值将为零。

7.8.1.9. tex2DGrad()

template<class T>
T tex2DGrad(cudaTextureObject_t texObj, float x, float y,
            float2 dx, float2 dy);

从由二维纹理对象texObj指定的CUDA数组中获取数据,使用纹理坐标(x,y)。细节级别由梯度dxdy推导得出。

7.8.1.10. 稀疏CUDA数组的tex2DGrad()函数

                template<class T>
T tex2DGrad(cudaTextureObject_t texObj, float x, float y,
        float2 dx, float2 dy, bool* isResident);

从由二维纹理对象texObj指定的CUDA数组中获取数据,使用纹理坐标(x,y)。细节级别由梯度dxdy推导得出。同时通过isResident指针返回纹理元素是否驻留在内存中。如果没有驻留,获取的值将为零。

7.8.1.11. tex2DLod()

template<class T>
tex2DLod(cudaTextureObject_t texObj, float x, float y, float level);

从CUDA数组或由二维纹理对象texObj指定的线性内存区域中,使用细节层次level处的纹理坐标(x,y)进行获取。

7.8.1.12. 稀疏CUDA数组的tex2DLod()函数

        template<class T>
tex2DLod(cudaTextureObject_t texObj, float x, float y, float level, bool* isResident);

从由二维纹理对象texObj指定的CUDA数组中,使用细节层次level下的纹理坐标(x,y)进行获取。同时通过isResident指针返回纹素是否驻留在内存中。如果没有驻留,获取的值将为零。

7.8.1.13. tex3D()

template<class T>
T tex3D(cudaTextureObject_t texObj, float x, float y, float z);

从三维纹理对象texObj指定的CUDA数组中获取数据,使用纹理坐标(x,y,z)

7.8.1.14. 稀疏CUDA数组的tex3D()函数

                template<class T>
T tex3D(cudaTextureObject_t texObj, float x, float y, float z, bool* isResident);

从三维纹理对象texObj指定的CUDA数组中获取数据,使用纹理坐标(x,y,z)。同时通过isResident指针返回该纹理元素是否驻留在内存中。如果未驻留,获取的值将为零。

7.8.1.15. tex3DLod()

template<class T>
T tex3DLod(cudaTextureObject_t texObj, float x, float y, float z, float level);

从CUDA数组或由三维纹理对象texObj指定的线性内存区域中获取数据,使用细节级别level处的纹理坐标(x,y,z)

7.8.1.16. 针对稀疏CUDA数组的tex3DLod()函数

                template<class T>
T tex3DLod(cudaTextureObject_t texObj, float x, float y, float z, float level, bool* isResident);

从CUDA数组或由三维纹理对象texObj指定的线性内存区域中,使用细节层次level下的纹理坐标(x,y,z)进行获取。同时通过isResident指针返回纹理元素是否驻留在内存中。如果未驻留,获取的值将为零。

7.8.1.17. tex3DGrad()

template<class T>
T tex3DGrad(cudaTextureObject_t texObj, float x, float y, float z,
            float4 dx, float4 dy);

从由三维纹理对象texObj指定的CUDA数组中获取数据,使用纹理坐标(x,y,z),并根据X和Y梯度dxdy导出的细节层次进行采样。

7.8.1.18. 稀疏CUDA数组的tex3DGrad()函数

                template<class T>
T tex3DGrad(cudaTextureObject_t texObj, float x, float y, float z,
        float4 dx, float4 dy, bool* isResident);

从三维纹理对象texObj指定的CUDA数组中获取数据,使用纹理坐标(x,y,z),并根据X和Y梯度dxdy计算细节层次。同时通过isResident指针返回纹理元素是否驻留在内存中。如果未驻留,获取的值将为零。

7.8.1.19. tex1DLayered()

template<class T>
T tex1DLayered(cudaTextureObject_t texObj, float x, int layer);

从一维纹理对象texObj指定的CUDA数组中获取数据,使用纹理坐标x和索引layer,如Layered Textures中所述。

7.8.1.20. tex1DLayeredLod()

template<class T>
T tex1DLayeredLod(cudaTextureObject_t texObj, float x, int layer, float level);

从由一维分层纹理指定的CUDA数组中获取,使用纹理坐标x和细节级别level在层级layer处进行采样。

7.8.1.21. tex1DLayeredGrad()

template<class T>
T tex1DLayeredGrad(cudaTextureObject_t texObj, float x, int layer,
                   float dx, float dy);

从由一维分层纹理指定的CUDA数组中获取,使用纹理坐标x以及从dxdy梯度导出的细节层次,在层layer处。

7.8.1.22. tex2DLayered()

template<class T>
T tex2DLayered(cudaTextureObject_t texObj,
               float x, float y, int layer);

从由二维纹理对象texObj指定的CUDA数组中获取数据,使用纹理坐标(x,y)和索引layer,如Layered Textures中所述。

7.8.1.23. 用于稀疏CUDA数组的tex2DLayered()函数

                template<class T>
T tex2DLayered(cudaTextureObject_t texObj,
            float x, float y, int layer, bool* isResident);

从由二维纹理对象texObj指定的CUDA数组中获取数据,使用纹理坐标(x,y)和索引layer,如Layered Textures中所述。同时通过isResident指针返回纹理元素是否驻留在内存中。如果没有驻留,获取的值将为零。

7.8.1.24. tex2DLayeredLod()

template<class T>
T tex2DLayeredLod(cudaTextureObject_t texObj, float x, float y, int layer,
                  float level);

从由二维分层纹理指定的CUDA数组中,使用纹理坐标(x,y)在第layer层进行获取。

7.8.1.25. 用于稀疏CUDA数组的tex2DLayeredLod()函数

                template<class T>
T tex2DLayeredLod(cudaTextureObject_t texObj, float x, float y, int layer,
                float level, bool* isResident);

从由二维分层纹理指定的CUDA数组中,使用纹理坐标(x,y)layer层获取数据。同时通过isResident指针返回该纹理元素是否驻留在内存中。如果未驻留,则获取的值将为零。

7.8.1.26. tex2DLayeredGrad()

template<class T>
T tex2DLayeredGrad(cudaTextureObject_t texObj, float x, float y, int layer,
                   float2 dx, float2 dy);

从由二维分层纹理指定的CUDA数组中获取,使用纹理坐标(x,y)以及从dxdy梯度导出的细节层次,在层layer处进行。

7.8.1.27. 针对稀疏CUDA数组的tex2DLayeredGrad()函数

                template<class T>
T tex2DLayeredGrad(cudaTextureObject_t texObj, float x, float y, int layer,
                float2 dx, float2 dy, bool* isResident);

从由二维分层纹理指定的CUDA数组中,使用纹理坐标(x,y)和从dxdy梯度导出的细节层次,在layer层获取数据。同时通过isResident指针返回纹理元素是否驻留在内存中。如果未驻留,获取的值将为零。

7.8.1.28. texCubemap()

template<class T>
T texCubemap(cudaTextureObject_t texObj, float x, float y, float z);

通过纹理坐标(x,y,z)获取由立方体贴图纹理对象texObj指定的CUDA数组,具体说明见Cubemap Textures

7.8.1.29. texCubemapGrad()

template<class T>
T texCubemapGrad(cudaTextureObject_t texObj, float x, float, y, float z,
                float4 dx, float4 dy);

从由立方体贴图纹理对象texObj指定的CUDA数组中获取数据,使用纹理坐标(x,y,z),如Cubemap Textures中所述。使用的细节级别由梯度dxdy推导得出。

7.8.1.30. texCubemapLod()

template<class T>
T texCubemapLod(cudaTextureObject_t texObj, float x, float, y, float z,
                float level);

从由立方体贴图纹理对象texObj指定的CUDA数组中获取数据,使用纹理坐标(x,y,z),如Cubemap Textures中所述。使用的细节级别由level给出。

7.8.1.31. texCubemapLayered()

template<class T>
T texCubemapLayered(cudaTextureObject_t texObj,
                    float x, float y, float z, int layer);

从由立方体贴图分层纹理对象texObj指定的CUDA数组中获取数据,使用纹理坐标(x,y,z)和索引layer,如Cubemap Layered Textures中所述。

7.8.1.32. texCubemapLayeredGrad()

template<class T>
T texCubemapLayeredGrad(cudaTextureObject_t texObj, float x, float y, float z,
                       int layer, float4 dx, float4 dy);

从由立方体贴图分层纹理对象texObj指定的CUDA数组中获取数据,使用纹理坐标(x,y,z)和索引layer,如Cubemap Layered Textures中所述,细节级别由梯度dxdy推导得出。

7.8.1.33. texCubemapLayeredLod()

template<class T>
T texCubemapLayeredLod(cudaTextureObject_t texObj, float x, float y, float z,
                       int layer, float level);

从由立方体贴图分层纹理对象texObj指定的CUDA数组中获取数据,使用纹理坐标(x,y,z)和索引layer,如Cubemap Layered Textures中所述,在细节级别level处。

7.9. 表面函数

表面函数仅支持计算能力2.0及以上的设备。

曲面对象的描述请参见Surface Object API

在以下章节中,boundaryMode指定了边界模式,即如何处理超出范围的表面坐标;它可以是cudaBoundaryModeClamp(此时超出范围的坐标会被截断至有效范围),或是cudaBoundaryModeZero(此时超出范围的读取返回零值且超出范围的写入会被忽略),亦或是cudaBoundaryModeTrap(此时超出范围的访问会导致内核执行失败)。

7.9.1. 表面对象API

7.9.1.1. surf1Dread()

template<class T>
T surf1Dread(cudaSurfaceObject_t surfObj, int x,
               boundaryMode = cudaBoundaryModeTrap);

使用字节坐标x读取由一维表面对象surfObj指定的CUDA数组。

7.9.1.2. surf1Dwrite

template<class T>
void surf1Dwrite(T data,
                  cudaSurfaceObject_t surfObj,
                  int x,
                  boundaryMode = cudaBoundaryModeTrap);

将值数据写入由一维表面对象surfObj在字节坐标x处指定的CUDA数组中。

7.9.1.3. surf2Dread()

template<class T>
T surf2Dread(cudaSurfaceObject_t surfObj,
              int x, int y,
              boundaryMode = cudaBoundaryModeTrap);
template<class T>
void surf2Dread(T* data,
                 cudaSurfaceObject_t surfObj,
                 int x, int y,
                 boundaryMode = cudaBoundaryModeTrap);

使用字节坐标x和y读取由二维表面对象surfObj指定的CUDA数组。

7.9.1.4. surf2Dwrite()

template<class T>
void surf2Dwrite(T data,
                  cudaSurfaceObject_t surfObj,
                  int x, int y,
                  boundaryMode = cudaBoundaryModeTrap);

将值数据写入由二维表面对象surfObj在字节坐标x和y处指定的CUDA数组。

7.9.1.5. surf3Dread()

template<class T>
T surf3Dread(cudaSurfaceObject_t surfObj,
              int x, int y, int z,
              boundaryMode = cudaBoundaryModeTrap);
template<class T>
void surf3Dread(T* data,
                 cudaSurfaceObject_t surfObj,
                 int x, int y, int z,
                 boundaryMode = cudaBoundaryModeTrap);

使用字节坐标x、y和z读取由三维表面对象surfObj指定的CUDA数组。

7.9.1.6. surf3Dwrite()

template<class T>
void surf3Dwrite(T data,
                  cudaSurfaceObject_t surfObj,
                  int x, int y, int z,
                  boundaryMode = cudaBoundaryModeTrap);

将值数据写入由三维对象surfObj指定的CUDA数组中,位置为字节坐标x、y和z。

7.9.1.7. surf1DLayeredread()

template<class T>
T surf1DLayeredread(
                 cudaSurfaceObject_t surfObj,
                 int x, int layer,
                 boundaryMode = cudaBoundaryModeTrap);
template<class T>
void surf1DLayeredread(T data,
                 cudaSurfaceObject_t surfObj,
                 int x, int layer,
                 boundaryMode = cudaBoundaryModeTrap);

通过一维分层表面对象surfObj,使用字节坐标x和索引layer读取指定的CUDA数组。

7.9.1.8. surf1DLayeredwrite()

template<class Type>
void surf1DLayeredwrite(T data,
                 cudaSurfaceObject_t surfObj,
                 int x, int layer,
                 boundaryMode = cudaBoundaryModeTrap);

将值数据写入由二维分层表面对象surfObj指定的CUDA数组中,位置为字节坐标x和索引layer

7.9.1.9. surf2DLayeredread()

template<class T>
T surf2DLayeredread(
                 cudaSurfaceObject_t surfObj,
                 int x, int y, int layer,
                 boundaryMode = cudaBoundaryModeTrap);
template<class T>
void surf2DLayeredread(T data,
                         cudaSurfaceObject_t surfObj,
                         int x, int y, int layer,
                         boundaryMode = cudaBoundaryModeTrap);

使用字节坐标x和y以及索引layer读取由二维分层表面对象surfObj指定的CUDA数组。

7.9.1.10. surf2DLayeredwrite()

template<class T>
void surf2DLayeredwrite(T data,
                          cudaSurfaceObject_t surfObj,
                          int x, int y, int layer,
                          boundaryMode = cudaBoundaryModeTrap);

将值数据写入由一维分层表面对象surfObj指定的CUDA数组中,位置为字节坐标x和y,以及索引layer

7.9.1.11. surfCubemapread()

template<class T>
T surfCubemapread(
                 cudaSurfaceObject_t surfObj,
                 int x, int y, int face,
                 boundaryMode = cudaBoundaryModeTrap);
template<class T>
void surfCubemapread(T data,
                 cudaSurfaceObject_t surfObj,
                 int x, int y, int face,
                 boundaryMode = cudaBoundaryModeTrap);

通过立方体贴图表面对象surfObj读取指定的CUDA数组,使用字节坐标x和y以及面索引face。

7.9.1.12. surfCubemapwrite()

template<class T>
void surfCubemapwrite(T data,
                 cudaSurfaceObject_t surfObj,
                 int x, int y, int face,
                 boundaryMode = cudaBoundaryModeTrap);

将值数据写入由立方体贴图对象surfObj指定的CUDA数组,位置为字节坐标x和y,以及面索引face。

7.9.1.13. surfCubemapLayeredread()

template<class T>
T surfCubemapLayeredread(
             cudaSurfaceObject_t surfObj,
             int x, int y, int layerFace,
             boundaryMode = cudaBoundaryModeTrap);
template<class T>
void surfCubemapLayeredread(T data,
             cudaSurfaceObject_t surfObj,
             int x, int y, int layerFace,
             boundaryMode = cudaBoundaryModeTrap);

通过字节坐标x和y以及索引layerFace读取由立方体贴图分层表面对象surfObj指定的CUDA数组。

7.9.1.14. surfCubemapLayeredwrite()

template<class T>
void surfCubemapLayeredwrite(T data,
             cudaSurfaceObject_t surfObj,
             int x, int y, int layerFace,
             boundaryMode = cudaBoundaryModeTrap);

将值数据写入由立方体贴图分层对象surfObj指定的CUDA数组中,位置为字节坐标xy,索引为layerFace

7.10. 只读数据缓存加载函数

只读数据缓存加载功能仅支持计算能力5.0及更高版本的设备。

T __ldg(const T* address);

返回位于地址address的类型为T的数据,其中T可以是charsigned charshortintlonglong longunsigned charunsigned shortunsigned intunsigned longunsigned long longchar2char4short2short4int2int4longlong2uchar2uchar4ushort2ushort4uint2uint4ulonglong2floatfloat2float4doubledouble2。当包含cuda_fp16.h头文件时,T可以是__half__half2。类似地,当包含cuda_bf16.h头文件时,T也可以是__nv_bfloat16__nv_bfloat162。该操作会被缓存在只读数据缓存中(参见Global Memory)。

7.11. 使用缓存提示加载函数

这些加载函数仅支持计算能力5.0及以上的设备。

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);

返回位于地址address的类型为T的数据,其中T可以是charsigned charshortintlonglong longunsigned charunsigned shortunsigned intunsigned longunsigned long longchar2char4short2short4int2int4longlong2uchar2uchar4ushort2ushort4uint2uint4ulonglong2floatfloat2float4doubledouble2。当包含cuda_fp16.h头文件时,T可以是__half__half2。类似地,当包含cuda_bf16.h头文件时,T也可以是__nv_bfloat16__nv_bfloat162。该操作使用相应的缓存运算符(参见PTX ISA

7.12. 使用缓存提示存储函数

这些存储函数仅支持计算能力5.0及以上的设备。

void __stwb(T* address, T value);
void __stcg(T* address, T value);
void __stcs(T* address, T value);
void __stwt(T* address, T value);

将类型为Tvalue参数值存储到地址address指定的位置,其中T可以是charsigned charshortintlonglong longunsigned charunsigned shortunsigned intunsigned longunsigned long longchar2char4short2short4int2int4longlong2uchar2uchar4ushort2ushort4uint2uint4ulonglong2floatfloat2float4doubledouble2。当包含cuda_fp16.h头文件时,T也可以是__half__half2。类似地,当包含cuda_bf16.h头文件时,T还可以是__nv_bfloat16__nv_bfloat162。该操作使用相应的缓存运算符(参见PTX ISA

7.13. 时间函数

clock_t clock();
long long int clock64();

在设备代码中执行时,返回一个每时钟周期递增的多处理器计数器值。在内核开始和结束时采样该计数器,取两个样本的差值,并记录每个线程的结果,可以衡量设备完全执行该线程所需的时钟周期数,但不能反映设备实际执行线程指令的时钟周期数。由于线程是分时执行的,前者数值会大于后者。

7.14. 原子函数

原子函数对位于全局内存或共享内存中的一个32位、64位或128位字执行读-修改-写原子操作。对于float2float4类型,该读-修改-写操作会作用于全局内存中向量的每个元素。例如,atomicAdd()会读取全局内存或共享内存中某个地址的字,对其加上一个数值,然后将结果写回同一地址。原子函数只能在设备函数中使用。

本节描述的原子函数具有cuda::memory_order_relaxed排序语义,且仅在特定scope范围内保持原子性:

  • 带有_system后缀的原子API(例如:atomicAdd_system)在满足特定条件时,其作用域为cuda::thread_scope_system

  • 不带后缀的原子API(例如:atomicAdd)的作用域是cuda::thread_scope_device级别的原子操作。

  • 带有_block后缀的原子API(例如:atomicAdd_block)的作用域是cuda::thread_scope_block级别的原子操作。

在以下示例中,CPU和GPU都会原子性地更新地址addr处的整数值:

__global__ void mykernel(int *addr) {
  atomicAdd_system(addr, 10);       // only available on devices with compute capability 6.x
}

void foo() {
  int *addr;
  cudaMallocManaged(&addr, 4);
  *addr = 0;

   mykernel<<<...>>>(addr);
   __sync_fetch_and_add(addr, 10);  // CPU atomic operation
}

请注意,任何原子操作都可以基于atomicCAS()(比较并交换)来实现。例如,在计算能力低于6.0的设备上不支持双精度浮点数的atomicAdd()操作,但可以通过以下方式实现:

#if __CUDA_ARCH__ < 600
__device__ double atomicAdd(double* address, double val)
{
    unsigned long long int* address_as_ull =
                              (unsigned long long int*)address;
    unsigned long long int old = *address_as_ull, assumed;

    do {
        assumed = old;
        old = atomicCAS(address_as_ull, assumed,
                        __double_as_longlong(val +
                               __longlong_as_double(assumed)));

    // Note: uses integer comparison to avoid hang in case of NaN (since NaN != NaN)
    } while (assumed != old);

    return __longlong_as_double(old);
}
#endif

以下设备级原子API存在系统级和块级变体,但有以下例外情况:

  • 计算能力低于6.0的设备仅支持设备范围的原子操作,

  • 计算能力低于7.2的Tegra设备不支持系统级原子操作。

CUDA 12.8及更高版本支持带有内存顺序和线程作用域的原子操作的CUDA编译器内置函数。我们遵循GNU原子内置函数签名,并额外增加了一个线程作用域参数。 我们使用以下原子操作内存顺序和线程作用域:

enum {
   __NV_ATOMIC_RELAXED,
   __NV_ATOMIC_CONSUME,
   __NV_ATOMIC_ACQUIRE,
   __NV_ATOMIC_RELEASE,
   __NV_ATOMIC_ACQ_REL,
   __NV_ATOMIC_SEQ_CST
};

enum {
   __NV_THREAD_SCOPE_THREAD,
   __NV_THREAD_SCOPE_BLOCK,
   __NV_THREAD_SCOPE_CLUSTER,
   __NV_THREAD_SCOPE_DEVICE,
   __NV_THREAD_SCOPE_SYSTEM
};

示例:

__device__ T __nv_atomic_load_n(T* ptr, int order, int scope);

T可以是1、2、4、8和16字节大小的任何整数类型。

这些函数只能在__device__函数的块作用域内使用。例如:

__device__ void foo() {
   __shared__ unsigned int u1 = 1;
   __shared__ unsigned int u2 = 2;
   __nv_atomic_load(&u1, &u2, __NV_ATOMIC_RELAXED, __NV_THREAD_SCOPE_SYSTEM);
}

并且这些函数的地址无法被获取。以下是三个不支持的示例:

// Not permitted to be used in a host function
__host__ void bar() {
   __shared__ unsigned int u1 = 1;
   __shared__ unsigned int u2 = 2;
   __nv_atomic_load(&u1, &u2, __NV_ATOMIC_RELAXED, __NV_THREAD_SCOPE_SYSTEM);
}

// Not permitted to be used as a template default argument.
// The function address cannot be taken.
template<void *F = __nv_atomic_load_n>
class X {
   void *f = F;
};

// Not permitted to be called in a constructor initialization list.
int b = 1;
class Y {
   int a;
   Y(): a(__nv_atomic_load_n(&b))
};

内存顺序对应于C++标准原子操作的内存顺序。对于线程作用域,我们遵循cuda::thread_scope的定义。 关于支持的数据类型,请参阅不同原子操作对应的章节。

7.14.1. 算术函数

7.14.1.1. atomicAdd()

int atomicAdd(int* address, int val);
unsigned int atomicAdd(unsigned int* address,
                       unsigned int val);
unsigned long long int atomicAdd(unsigned long long int* address,
                                 unsigned long long int val);
float atomicAdd(float* address, float val);
double atomicAdd(double* address, double val);
__half2 atomicAdd(__half2 *address, __half2 val);
__half atomicAdd(__half *address, __half val);
__nv_bfloat162 atomicAdd(__nv_bfloat162 *address, __nv_bfloat162 val);
__nv_bfloat16 atomicAdd(__nv_bfloat16 *address, __nv_bfloat16 val);
float2 atomicAdd(float2* address, float2 val);
float4 atomicAdd(float4* address, float4 val);

读取位于全局或共享内存中地址address处的16位、32位或64位old值,计算(old + val),并将结果存储回同一内存地址。这三个操作在一个原子事务中完成。该函数返回old

32位浮点版本的atomicAdd()仅支持计算能力2.x及以上的设备。

64位浮点版本的atomicAdd()仅支持计算能力6.x及以上的设备。

32位__half2浮点版本的atomicAdd()仅支持计算能力6.x及以上的设备。__half2__nv_bfloat162加法操作的原子性分别针对两个__half__nv_bfloat16元素单独保证;整个__half2__nv_bfloat162并不保证作为单个32位访问具有原子性。

float2float4 浮点向量版本的 atomicAdd() 仅支持计算能力为 9.x 及更高的设备。float2float4 加法操作的原子性分别针对两个或四个 float 元素中的每一个单独保证;整个 float2float4 并不保证作为单个 64 位或 128 位访问是原子性的。

16位浮点类型__half版本的atomicAdd()函数仅支持计算能力7.x及以上的设备。

16位__nv_bfloat16浮点版本的atomicAdd()仅支持计算能力8.x及以上的设备。

设备仅在计算能力9.x及更高版本支持atomicAdd()的浮点向量版本float2float4

float2float4 浮点向量版本的 atomicAdd() 仅支持全局内存地址。

7.14.1.2. atomicSub()

int atomicSub(int* address, int val);
unsigned int atomicSub(unsigned int* address,
                       unsigned int val);

读取位于全局或共享内存中地址address处的32位字old,计算(old - val),并将结果存储回同一内存地址。这三个操作在一个原子事务中完成。该函数返回old

7.14.1.3. atomicExch()

int atomicExch(int* address, int val);
unsigned int atomicExch(unsigned int* address,
                        unsigned int val);
unsigned long long int atomicExch(unsigned long long int* address,
                                  unsigned long long int val);
float atomicExch(float* address, float val);

读取位于全局或共享内存中地址address处的32位或64位字old,并将val存储回同一地址。这两个操作在一个原子事务中完成。该函数返回old

template<typename T> T atomicExch(T* address, T val);

读取位于全局或共享内存中地址address处的128位字old,并将val存储回同一地址。这两个操作在一个原子事务中完成。该函数返回old。类型T必须满足以下要求:

sizeof(T) == 16
alignof(T) >= 16
std::is_trivially_copyable<T>::value == true
// for C++03 and older
std::is_default_constructible<T>::value == true

因此,T 必须是128位且正确对齐的,可平凡复制的,在C++03或更早版本中,它还必须具有默认构造函数。

128位的atomicExch()仅支持计算能力9.x及以上的设备。

7.14.1.4. atomicMin()

int atomicMin(int* address, int val);
unsigned int atomicMin(unsigned int* address,
                       unsigned int val);
unsigned long long int atomicMin(unsigned long long int* address,
                                 unsigned long long int val);
long long int atomicMin(long long int* address,
                                long long int val);

读取位于全局或共享内存中地址address处的32位或64位字old,计算oldval的最小值,并将结果存储回同一地址。这三个操作在一个原子事务中完成。该函数返回old

64位版本的atomicMin()仅支持计算能力5.0及以上的设备。

7.14.1.5. atomicMax()

int atomicMax(int* address, int val);
unsigned int atomicMax(unsigned int* address,
                       unsigned int val);
unsigned long long int atomicMax(unsigned long long int* address,
                                 unsigned long long int val);
long long int atomicMax(long long int* address,
                                 long long int val);

读取位于全局或共享内存中地址address处的32位或64位字old,计算oldval的最大值,并将结果存储回同一地址。这三个操作在一个原子事务中完成。该函数返回old

64位版本的atomicMax()仅支持计算能力5.0及以上的设备。

7.14.1.6. atomicInc()

unsigned int atomicInc(unsigned int* address,
                       unsigned int val);

读取位于全局或共享内存中地址address处的32位字old,计算((old >= val) ? 0 : (old+1)),并将结果存储回同一内存地址。这三个操作在一个原子事务中完成。该函数返回old

7.14.1.7. atomicDec()

unsigned int atomicDec(unsigned int* address,
                       unsigned int val);

读取位于全局或共享内存中地址address处的32位字old,计算(((old == 0) || (old > val)) ? val : (old-1),并将结果存储回同一内存地址。这三个操作在一个原子事务中完成。该函数返回old

7.14.1.8. atomicCAS()

int atomicCAS(int* address, int compare, int val);
unsigned int atomicCAS(unsigned int* address,
                       unsigned int compare,
                       unsigned int val);
unsigned long long int atomicCAS(unsigned long long int* address,
                                 unsigned long long int compare,
                                 unsigned long long int val);
unsigned short int atomicCAS(unsigned short int *address,
                             unsigned short int compare,
                             unsigned short int val);

读取位于全局或共享内存中地址address处的16位、32位或64位字old,计算(old == compare ? val : old),并将结果存储回同一内存地址。这三个操作在一个原子事务中完成。该函数返回old(比较并交换)。

template<typename T> T atomicCAS(T* address, T compare, T val);

读取位于全局或共享内存中地址address处的128位字old,计算(old == compare ? val : old),并将结果存储回同一内存地址。这三个操作在一个原子事务中完成。该函数返回old(比较并交换)。类型T必须满足以下要求:

sizeof(T) == 16
alignof(T) >= 16
std::is_trivially_copyable<T>::value == true
// for C++03 and older
std::is_default_constructible<T>::value == true

因此,T必须是128位且正确对齐,可简单复制,在C++03或更早版本中,它还必须具有默认构造函数。

128位的atomicCAS()仅支持计算能力9.x及以上的设备。

7.14.1.9. __nv_atomic_exchange()

__device__ void __nv_atomic_exchange(T* ptr, T* val, T *ret, int order, int scope);

该原子函数在CUDA 12.8中引入。它会读取ptr指针指向的值并存储到ret指针指向的位置。同时它会读取val指针指向的值并存储到ptr指针指向的位置。

这是一个通用的原子交换操作,意味着T可以是任何大小为4、8或16字节的数据类型。

支持内存顺序和线程范围的原子操作在架构sm_60及以上版本中可用。

16字节数据类型在架构sm_90及以上版本中支持。

cluster的线程作用域在架构sm_90及更高版本上受支持。

参数 orderscope 必须是整数字面量,即参数不能是变量。

7.14.1.10. __nv_atomic_exchange_n()

__device__ T __nv_atomic_exchange_n(T* ptr, T val, int order, int scope);

该原子函数在CUDA 12.8中引入。它会读取ptr指针所指向的值作为返回值,同时将val存储到ptr指针所指向的位置。

这是一个非泛型原子交换操作,意味着T只能是大小为4、8或16字节的整型类型。

支持内存顺序和线程范围的原子操作在架构sm_60及以上版本中可用。

16字节数据类型在架构sm_90及以上版本中受支持。

cluster的线程范围在架构sm_90及更高版本上受支持。

参数 orderscope 必须是整数字面量,即参数不能是变量。

7.14.1.11. __nv_atomic_compare_exchange()

__device__ bool __nv_atomic_compare_exchange (T* ptr, T* expected, T* desired, bool weak, int success_order, int failure_order, int scope);

该原子函数在CUDA 12.8中引入。它会读取ptr指针指向的值,并与expected指针指向的值进行比较。如果两者相等,则返回值为true,并将desired指针指向的值存储到ptr指针指向的位置。否则返回false,并将ptr指针指向的值存储到expected指针指向的位置。参数weak会被忽略,函数会选择success_orderfailure_order中更强的内存顺序来执行比较交换操作。

这是一个通用的原子比较交换操作,意味着T可以是任何大小为2、4、8或16字节的数据类型。

支持内存顺序和线程范围的原子操作在架构sm_60及以上版本中可用。

16字节数据类型在架构sm_90及以上版本中支持。

2字节数据类型在架构sm_70及以上版本中受支持。

cluster的线程作用域在架构sm_90及更高版本上受支持。

参数 orderscope 必须是整数字面量,即参数不能是变量。

7.14.1.12. __nv_atomic_compare_exchange_n()

__device__ bool __nv_atomic_compare_exchange_n (T* ptr, T* expected, T desired, bool weak, int success_order, int failure_order, int scope);

该原子函数在CUDA 12.8中引入。它会读取ptr指针指向的值,并与expected指针指向的值进行比较。如果两者相等,则返回值为true,并将desired存储到ptr指向的位置。否则返回false,并将ptr指向的值存储到expected指向的位置。参数weak会被忽略,函数会选择success_orderfailure_order中更强的内存顺序来执行比较交换操作。

这是一个非泛型的原子比较交换操作,意味着T只能是大小为2、4、8或16字节的整型类型。

支持内存顺序和线程范围的原子操作在架构sm_60及以上版本中可用。

16字节数据类型在架构sm_90及以上版本中受支持。

2字节数据类型在架构sm_70及以上版本中受支持。

cluster的线程作用域在架构sm_90及更高版本上受支持。

参数 orderscope 必须是整数字面量,即参数不能是变量。

7.14.1.13. __nv_atomic_fetch_add() 和 __nv_atomic_add()

__device__ T __nv_atomic_fetch_add (T* ptr, T val, int order, int scope);
__device__ void __nv_atomic_add (T* ptr, T val, int order, int scope);

这两个原子函数是在CUDA 12.8中引入的。它会读取ptr指针指向的值,与val相加,然后将结果存回ptr指向的位置。__nv_atomic_fetch_add会返回ptr指针原先指向的值,而__nv_atomic_add则没有返回值。

T 只能是 unsigned intintunsigned long longfloatdouble

支持内存顺序和线程范围的原子操作在架构sm_60及以上版本中可用。

cluster的线程作用域在架构sm_90及更高版本上受支持。

参数 orderscope 必须是整数字面量,即参数不能是变量。

7.14.1.14. __nv_atomic_fetch_sub() 和 __nv_atomic_sub()

__device__ T __nv_atomic_fetch_sub (T* ptr, T val, int order, int scope);
__device__ void __nv_atomic_sub (T* ptr, T val, int order, int scope);

这两个原子函数在CUDA 12.8中引入。它会读取ptr指针指向的值,与val进行减法运算,然后将结果存回ptr指向的位置。__nv_atomic_fetch_sub会返回ptr指针原先指向的值。而__nv_atomic_sub则没有返回值。

T 只能是 unsigned intintunsigned long longfloatdouble

支持内存顺序和线程范围的原子操作在架构sm_60及以上版本中可用。

cluster的线程作用域在架构sm_90及更高版本上受支持。

参数 orderscope 必须是整数字面量,即参数不能是变量。

7.14.1.15. __nv_atomic_fetch_min() 和 __nv_atomic_min()

__device__ T __nv_atomic_fetch_min (T* ptr, T val, int order, int scope);
__device__ void __nv_atomic_min (T* ptr, T val, int order, int scope);

这两个原子函数是在CUDA 12.8中引入的。它会读取ptr指针指向的值,与val进行比较,并将较小的值存储回ptr指向的位置。__nv_atomic_fetch_min会返回ptr指针原先指向的值。而__nv_atomic_min则没有返回值。

T 只能是 unsigned intintunsigned long longlong long

支持内存顺序和线程范围的原子操作在架构sm_60及以上版本中可用。

cluster的线程作用域在架构sm_90及更高版本上受支持。

参数 orderscope 必须是整数字面量,即参数不能是变量。

7.14.1.16. __nv_atomic_fetch_max() 和 __nv_atomic_max()

__device__ T __nv_atomic_fetch_max (T* ptr, T val, int order, int scope);
__device__ void __nv_atomic_max (T* ptr, T val, int order, int scope);

这两个原子函数在CUDA 12.8中引入。它会读取ptr指针指向的值,与val进行比较,并将较大的值存回ptr指向的位置。__nv_atomic_fetch_max会返回ptr指针原先指向的值。而__nv_atomic_max则没有返回值。

T 只能是 unsigned intintunsigned long longlong long

支持内存顺序和线程范围的原子操作在架构sm_60及以上版本中可用。

cluster的线程作用域在架构sm_90及更高版本上受支持。

参数 orderscope 必须是整数字面量,即参数不能是变量。

7.14.2. 位运算函数

7.14.2.1. atomicAnd()

int atomicAnd(int* address, int val);
unsigned int atomicAnd(unsigned int* address,
                       unsigned int val);
unsigned long long int atomicAnd(unsigned long long int* address,
                                 unsigned long long int val);

读取位于全局或共享内存中地址address处的32位或64位字old,计算(old & val),并将结果存储回同一地址。这三个操作在一个原子事务中完成。该函数返回old

64位版本的atomicAnd()仅支持计算能力5.0及以上的设备。

7.14.2.2. atomicOr()

int atomicOr(int* address, int val);
unsigned int atomicOr(unsigned int* address,
                      unsigned int val);
unsigned long long int atomicOr(unsigned long long int* address,
                                unsigned long long int val);

读取位于全局或共享内存中地址address处的32位或64位字old,计算(old | val),并将结果存储回同一内存地址。这三个操作在一个原子事务中完成。该函数返回old

64位版本的atomicOr()仅支持计算能力5.0及以上的设备。

7.14.2.3. atomicXor()

int atomicXor(int* address, int val);
unsigned int atomicXor(unsigned int* address,
                       unsigned int val);
unsigned long long int atomicXor(unsigned long long int* address,
                                 unsigned long long int val);

读取位于全局或共享内存中地址address处的32位或64位字old,计算(old ^ val),并将结果存储回同一地址。这三个操作在一个原子事务中完成。该函数返回old

64位版本的atomicXor()仅支持计算能力5.0及以上的设备。

7.14.2.4. __nv_atomic_fetch_or() 和 __nv_atomic_or()

__device__ T __nv_atomic_fetch_or (T* ptr, T val, int order, int scope);
__device__ void __nv_atomic_or (T* ptr, T val, int order, int scope);

这两个原子函数在CUDA 12.8中引入。它会读取ptr指针指向的值,与val进行或运算,然后将结果存回ptr指向的位置。__nv_atomic_fetch_or会返回ptr指针原先指向的值。__nv_atomic_or则没有返回值。

T 只能是大小为4或8字节的整数类型。

支持内存顺序和线程范围的原子操作在架构sm_60及以上版本中可用。

cluster的线程范围在架构sm_90及更高版本上受支持。

参数 orderscope 必须是整数字面量,即参数不能是变量。

7.14.2.5. __nv_atomic_fetch_xor() 和 __nv_atomic_xor()

__device__ T __nv_atomic_fetch_xor (T* ptr, T val, int order, int scope);
__device__ void __nv_atomic_xor (T* ptr, T val, int order, int scope);

这两个原子函数在CUDA 12.8中引入。它会读取ptr指针指向的值,与val进行异或运算,并将结果存回ptr指向的位置。__nv_atomic_fetch_xor会返回ptr指针原先指向的值。而__nv_atomic_xor则没有返回值。

T 只能是大小为4或8字节的整数类型。

支持内存顺序和线程范围的原子操作在架构sm_60及以上版本中可用。

cluster的线程范围在架构sm_90及更高版本上受支持。

参数 orderscope 必须是整数字面量,即参数不能是变量。

7.14.2.6. __nv_atomic_fetch_and() 和 __nv_atomic_and()

__device__ T __nv_atomic_fetch_and (T* ptr, T val, int order, int scope);
__device__ void __nv_atomic_and (T* ptr, T val, int order, int scope);

这两个原子函数是在CUDA 12.8中引入的。它会读取ptr指针指向的值,与val进行与运算,并将结果存回ptr指向的位置。__nv_atomic_fetch_and会返回ptr指针指向的旧值。__nv_atomic_and则没有返回值。

T 只能是大小为4或8字节的整数类型。

支持内存顺序和线程范围的原子操作在架构sm_60及以上版本中可用。

cluster的线程范围在架构sm_90及更高版本上受支持。

参数 orderscope 必须是整数字面量,即参数不能是变量。

7.14.3. 其他原子函数

7.14.3.1. __nv_atomic_load()

__device__ void __nv_atomic_load(T* ptr, T* ret, int order, int scope);

此原子函数在CUDA 12.8中引入。它会加载ptr指针所指向的值,并将该值写入ret指针所指向的位置。

这是一个通用的原子加载操作,意味着T可以是任何大小为1、2、4、8或16字节的数据类型。

支持内存顺序和线程范围的原子操作在架构sm_60及以上版本中可用。

16字节数据类型在架构sm_70及以上版本中受支持。

cluster的线程范围在架构sm_90及更高版本上受支持。

参数 orderscope 必须是整数字面量,即参数不能是变量。

7.14.3.2. __nv_atomic_load_n()

__device__ T __nv_atomic_load_n(T* ptr, int order, int scope);

此原子函数在CUDA 12.8中引入。它会加载ptr指针所指向的值并返回该值。

这是一个非泛型原子加载操作,意味着T只能是大小为1、2、4、8或16字节的整型类型。

支持内存顺序和线程范围的原子操作在架构sm_60及以上版本中可用。

16字节数据类型在架构sm_70及以上版本中受支持。

cluster的线程范围在架构sm_90及更高版本上受支持。

参数 orderscope 必须是整数字面量,即参数不能是变量。

7.14.3.3. __nv_atomic_store()

__device__ void __nv_atomic_store(T* ptr, T* val, int order, int scope);

此原子函数在CUDA 12.8中引入。它会读取val指向的值,并存储到ptr指向的位置。

这是一个通用的原子加载操作,意味着T可以是1、2、4、8或16字节大小的任何数据类型。

支持内存顺序和线程范围的原子操作在架构sm_60及以上版本中可用。

16字节数据类型在架构sm_70及以上版本中支持。

cluster的线程范围在架构sm_90及更高版本上受支持。

参数 orderscope 必须是整数字面量,即参数不能是变量。

7.14.3.4. __nv_atomic_store_n()

__device__ void __nv_atomic_store_n(T* ptr, T val, int order, int scope);

此原子函数在CUDA 12.8中引入。它将val存储到ptr所指向的位置。

这是一个非泛型原子加载操作,意味着T只能是大小为1、2、4、8或16字节的整型类型。

支持内存顺序和线程范围的原子操作在架构sm_60及以上版本中可用。

16字节数据类型在架构sm_70及以上版本中受支持。

cluster的线程范围在架构sm_90及更高版本上受支持。

参数 orderscope 必须是整数字面量,即参数不能是变量。

7.14.3.5. __nv_atomic_thread_fence()

__device__ void __nv_atomic_thread_fence (int order, int scope);

此原子函数根据指定的内存顺序,在当前线程请求的内存访问之间建立顺序。线程范围参数指定了可能观察到该操作排序效果的一组线程。

cluster的线程范围在架构sm_90及更高版本上受支持。

参数 orderscope 必须是整数字面量,即参数不能是变量。

7.15. 地址空间谓词函数

如果参数是空指针,本节描述的函数行为未定义。

7.15.1. __isGlobal()

__device__ unsigned int __isGlobal(const void *ptr);

如果ptr包含全局内存空间中对象的通用地址,则返回1,否则返回0。

7.15.2. __isShared()

__device__ unsigned int __isShared(const void *ptr);

如果ptr包含共享内存空间中对象的通用地址则返回1,否则返回0。

7.15.3. __isConstant()

__device__ unsigned int __isConstant(const void *ptr);

如果ptr包含常量内存空间中对象的通用地址则返回1,否则返回0。

7.15.4. __isGridConstant()

__device__ unsigned int __isGridConstant(const void *ptr);

如果ptr包含使用__grid_constant__注解的内核参数的通用地址,则返回1,否则返回0。仅支持计算架构大于或等于7.x或更高版本。

7.15.5. __isLocal()

__device__ unsigned int __isLocal(const void *ptr);

如果ptr包含本地内存空间中对象的通用地址,则返回1,否则返回0。

7.16. 地址空间转换函数

7.16.1. __cvta_generic_to_global()

__device__ size_t __cvta_generic_to_global(const void *ptr);

返回对由ptr表示的通用地址执行PTXcvta.to.global指令的结果。

7.16.2. __cvta_generic_to_shared()

__device__ size_t __cvta_generic_to_shared(const void *ptr);

返回对由ptr表示的通用地址执行PTXcvta.to.shared指令的结果。

7.16.3. __cvta_generic_to_constant()

__device__ size_t __cvta_generic_to_constant(const void *ptr);

返回对由ptr表示的通用地址执行PTXcvta.to.const指令的结果。

7.16.4. __cvta_generic_to_local()

__device__ size_t __cvta_generic_to_local(const void *ptr);

返回对由ptr表示的通用地址执行PTXcvta.to.local指令的结果。

7.16.5. __cvta_global_to_generic()

__device__ void * __cvta_global_to_generic(size_t rawbits);

返回通过执行PTXcvta.global指令对rawbits提供的值所获得的通用指针。

7.16.6. __cvta_shared_to_generic()

__device__ void * __cvta_shared_to_generic(size_t rawbits);

返回通过执行PTXcvta.shared指令对rawbits提供的值所获得的通用指针。

7.16.7. __cvta_constant_to_generic()

__device__ void * __cvta_constant_to_generic(size_t rawbits);

返回通过执行PTXcvta.const指令对rawbits提供的值所获得的通用指针。

7.16.8. __cvta_local_to_generic()

__device__ void * __cvta_local_to_generic(size_t rawbits);

返回通过执行PTXcvta.local指令对rawbits提供的值所获得的通用指针。

7.17. Alloca 函数

7.17.1. 概述

__host__ __device__ void * alloca(size_t size);

7.17.2. 描述

alloca()函数在调用者的栈帧中分配size字节的内存。返回值为指向已分配内存的指针,当从设备代码调用该函数时,内存起始地址为16字节对齐。当alloca()的调用者返回时,分配的内存会自动释放。

注意

在Windows平台上,使用alloca()前必须包含头文件。使用alloca()可能导致栈溢出,用户需要相应调整栈大小。

支持计算能力5.2或更高版本。

7.17.3. 示例

__device__ void foo(unsigned int num) {
    int4 *ptr = (int4 *)alloca(num * sizeof(int4));
    // use of ptr
    ...
}

7.18. 编译器优化提示函数

本节描述的函数可用于向编译器优化器提供额外信息。

7.18.1. __builtin_assume_aligned()

void * __builtin_assume_aligned (const void *exp, size_t align)

允许编译器假设参数指针至少对齐到align字节,并返回该参数指针。

示例:

void *res = __builtin_assume_aligned(ptr, 32); // compiler can assume 'res' is
                                               // at least 32-byte aligned

三参数版本:

void * __builtin_assume_aligned (const void *exp, size_t align,
                                 <integral type> offset)

允许编译器假设(char *)exp - offset至少对齐到align字节,并返回参数指针。

示例:

void *res = __builtin_assume_aligned(ptr, 32, 8); // compiler can assume
                                                  // '(char *)res - 8' is
                                                  // at least 32-byte aligned.

7.18.2. __builtin_assume()

void __builtin_assume(bool exp)

允许编译器假设布尔参数为真。如果在运行时该参数不为真,则行为未定义。请注意,如果该参数有副作用,则行为未指定。

示例:

 __device__ int get(int *ptr, int idx) {
   __builtin_assume(idx <= 2);
   return ptr[idx];
}

7.18.3. __assume()

void __assume(bool exp)

允许编译器假设布尔参数为真。如果在运行时该参数不为真,则行为未定义。请注意,如果该参数有副作用,则行为未指定。

示例:

 __device__ int get(int *ptr, int idx) {
   __assume(idx <= 2);
   return ptr[idx];
}

7.18.4. __builtin_expect()

long __builtin_expect (long exp, long c)

向编译器表明预期exp == c成立,并返回exp的值。通常用于向编译器提供分支预测信息。

示例:

// indicate to the compiler that likely "var == 0",
// so the body of the if-block is unlikely to be
// executed at run time.
if (__builtin_expect (var, 0))
  doit ();

7.18.5. __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();
}

7.18.6. 限制条件

__assume() 仅在使用了 cl.exe 主机编译器时才受支持。其他函数在所有平台上都受支持,但需遵守以下限制:

  • If the host compiler supports the function, the function can be invoked from anywhere in translation unit.

  • 否则,该函数必须从__device__/__global__函数体内调用,或者仅在定义了__CUDA_ARCH__宏时才能调用12

7.19. Warp投票函数

int __all_sync(unsigned mask, int predicate);
int __any_sync(unsigned mask, int predicate);
unsigned __ballot_sync(unsigned mask, int predicate);
unsigned __activemask();

弃用通知:__any__all__ballot已在CUDA 9.0中对所有设备弃用。

移除通知:当目标设备的计算能力为7.x或更高时,__any__all__ballot将不再可用,应改用它们的同步变体。

warp投票函数允许给定warp中的线程执行归约-广播操作。这些函数接收warp中每个线程的整数predicate作为输入,并将这些值与零进行比较。比较结果通过以下方式之一在warp的active线程间进行组合(归约),并将单个返回值广播给每个参与的线程:

__all_sync(unsigned mask, predicate):

mask中所有未退出的线程评估predicate,当且仅当所有线程的predicate评估结果均为非零时返回非零值。

__any_sync(unsigned mask, predicate):

mask中所有未退出的线程评估predicate,当且仅当任意线程的predicate评估结果非零时返回非零值。

__ballot_sync(unsigned mask, predicate):

mask中所有未退出的线程评估predicate,并返回一个整数值。当且仅当warp中第N个线程的predicate评估结果非零且该线程处于活动状态时,该整数值的第N位将被置位。

__activemask():

返回调用线程束中当前所有活动线程的32位整数掩码。当调用__activemask()时,如果线程束中的第N条通道处于活动状态,则设置第N位。非活动线程在返回的掩码中用0表示。已退出程序的线程始终标记为非活动状态。请注意,在__activemask()调用处收敛的线程不能保证在后续指令中保持收敛,除非这些指令是同步的线程束内置函数。

对于__all_sync__any_sync__ballot_sync,必须传入一个指定参与调用的线程掩码。每个参与线程必须设置代表其通道ID的位,以确保在硬件执行该内置函数前这些线程能正确汇聚。每个调用线程必须在掩码中设置自己的位,且掩码中指定的所有未退出线程必须使用相同的掩码执行相同的内置函数,否则结果将是未定义的。

这些内部函数不隐含内存屏障。它们不保证任何内存排序。

7.20. Warp匹配函数

__match_any_sync__match_all_syncwarp内的线程之间执行变量的广播-比较操作。

支持计算能力7.x或更高的设备。

7.20.1. 概述

unsigned int __match_any_sync(unsigned mask, T value);
unsigned int __match_all_sync(unsigned mask, T value, int *pred);

T 可以是 intunsigned intlongunsigned longlong longunsigned long longfloatdouble

7.20.2. 描述

__match_sync() 内部函数允许在对 mask 中指定的线程进行同步后,在线程束(warp)中的线程之间广播并比较值 value

__match_any_sync

返回mask中与value值相同的线程掩码

__match_all_sync

如果mask中所有线程的value值相同,则返回mask;否则返回0。如果mask中所有线程的value值相同,则谓词pred设为true;否则谓词设为false。

新的*_sync匹配内置函数接收一个掩码,指示参与调用的线程。每个参与线程必须设置一个代表其通道ID的位,以确保在硬件执行内置函数之前它们能正确汇聚。每个调用线程必须在掩码中设置自己的位,且掩码中指定的所有未退出线程必须使用相同的掩码执行相同的内置函数,否则结果将是未定义的。

这些内部函数不隐含内存屏障。它们不保证任何内存排序。

7.21. Warp归约函数

__reduce_sync(unsigned mask, T value) 内部函数在同步mask中指定的线程后,对value中提供的数据执行归约操作。对于{add, min, max}操作,T可以是有符号或无符号类型;而对于{and, or, xor}操作,T只能是无符号类型。

支持计算能力为8.x或更高的设备。

7.21.1. 概述

// add/min/max
unsigned __reduce_add_sync(unsigned mask, unsigned value);
unsigned __reduce_min_sync(unsigned mask, unsigned value);
unsigned __reduce_max_sync(unsigned mask, unsigned value);
int __reduce_add_sync(unsigned mask, int value);
int __reduce_min_sync(unsigned mask, int value);
int __reduce_max_sync(unsigned mask, int value);

// and/or/xor
unsigned __reduce_and_sync(unsigned mask, unsigned value);
unsigned __reduce_or_sync(unsigned mask, unsigned value);
unsigned __reduce_xor_sync(unsigned mask, unsigned value);

7.21.2. 描述

__reduce_add_sync, __reduce_min_sync, __reduce_max_sync

返回对mask中指定的每个线程提供的value值进行算术加法、最小值或最大值归约操作的结果。

__reduce_and_sync, __reduce_or_sync, __reduce_xor_sync

返回对mask中指定的每个线程提供的value值应用逻辑AND、OR或XOR归约操作的结果。

mask 表示参与调用的线程。每一位代表线程的车道ID,必须为每个参与的线程设置相应的位,以确保它们在硬件执行该内部函数之前正确汇聚。每个调用线程必须在掩码中设置自己的位,且掩码中指定的所有未退出线程必须使用相同的掩码执行相同的内部函数,否则结果将是未定义的。

这些内部函数不隐含内存屏障。它们不保证任何内存排序。

7.22. Warp Shuffle 函数

__shfl_sync, __shfl_up_sync, __shfl_down_sync__shfl_xor_sync用于在warp内的线程之间交换变量。

支持计算能力5.0或更高的设备。

弃用通知:__shfl__shfl_up__shfl_down__shfl_xor已在CUDA 9.0中对所有设备弃用。

移除通知:当面向计算能力7.x或更高版本的设备时,__shfl__shfl_up__shfl_down__shfl_xor将不再可用,应改用它们的同步变体。

7.22.1. 概述

T __shfl_sync(unsigned mask, T var, int srcLane, int width=warpSize);
T __shfl_up_sync(unsigned mask, T var, unsigned int delta, int width=warpSize);
T __shfl_down_sync(unsigned mask, T var, unsigned int delta, int width=warpSize);
T __shfl_xor_sync(unsigned mask, T var, int laneMask, int width=warpSize);

T 可以是 intunsigned intlongunsigned longlong longunsigned long longfloatdouble。包含 cuda_fp16.h 头文件后,T 还可以是 __half__half2。同样地,包含 cuda_bf16.h 头文件后,T 也可以是 __nv_bfloat16__nv_bfloat162

7.22.2. 描述

__shfl_sync() 内部函数允许在同一个warp内的线程之间交换变量,而无需使用共享内存。该交换操作对warp内所有活跃线程(且在mask中指定的线程)同时进行,根据数据类型的不同,每个线程可移动4或8字节的数据。

warp内的线程被称为lanes,其索引范围可以是0到warpSize-1(包含边界值)。支持四种源lane寻址模式:

__shfl_sync()

从索引通道直接复制

__shfl_up_sync()

从相对于调用者ID较低的车道复制

__shfl_down_sync()

从相对于调用者ID更高的通道复制

__shfl_xor_sync()

基于自身通道ID的按位异或操作从通道复制

线程只能从正在主动参与__shfl_sync()指令的其他线程读取数据。如果目标线程处于非活动状态,则获取的值是未定义的。

所有__shfl_sync()内部函数都接受一个可选的width参数,该参数会改变内部函数的行为。width必须是一个在[1, warpSize]范围内(即1、2、4、8、16或32)的2的幂次方值。对于其他值,结果是未定义的。

__shfl_sync() 返回由srcLane指定ID的线程所持有的var值。如果宽度小于warpSize,则warp的每个子部分将作为独立实体运行,起始逻辑通道ID为0。若srcLane超出范围[0:width-1],返回的值对应于srcLane modulo width(即同一子部分内)所持有的var值。

__shfl_up_sync() 通过从调用者的通道ID中减去delta来计算源通道ID。返回由结果通道ID持有的var值:实际上,var会在warp中向上移动delta个通道。如果width小于warpSize,则warp的每个子部分将作为一个独立的实体运行,起始逻辑通道ID为0。源通道索引不会环绕width的值,因此实际上较低的delta个通道将保持不变。

__shfl_down_sync() 通过将delta与调用者的通道ID相加来计算源通道ID。返回由结果通道ID持有的var值:这会产生将var沿着warp向下移动delta个通道的效果。如果宽度小于warpSize,则warp的每个子部分将作为一个独立实体运行,起始逻辑通道ID为0。与__shfl_up_sync()类似,源通道的ID号不会环绕宽度值,因此上部的delta个通道将保持不变。

__shfl_xor_sync() 通过执行调用者通道ID与laneMask的按位异或运算来计算源行ID:返回由结果通道ID持有的var值。如果width小于warpSize,则每组连续的width个线程能够访问前面线程组的元素,但如果它们尝试访问后面线程组的元素,则会返回它们自己的var值。此模式实现了诸如树形归约和广播中使用的蝶形寻址模式。

新的*_sync shfl内部函数接收一个掩码,用于指示参与调用的线程。每个参与线程必须设置一个代表其通道ID的位,以确保在硬件执行该内部函数前它们能正确汇聚。每个调用线程必须在掩码中设置自己的位,且掩码中指定的所有未退出线程必须使用相同的掩码执行相同的内部函数,否则结果将是未定义的。

线程只能从另一个正在主动参与__shfl_sync()命令的线程中读取数据。如果目标线程处于非活动状态,则获取的值将是未定义的。

这些内部函数不隐含内存屏障。它们不保证任何内存排序。

7.22.3. 示例

7.22.3.1. 在warp中广播单个值

#include <stdio.h>

__global__ void bcast(int arg) {
    int laneId = threadIdx.x & 0x1f;
    int value;
    if (laneId == 0)        // Note unused variable for
        value = arg;        // all threads except lane 0
    value = __shfl_sync(0xffffffff, value, 0);   // Synchronize all threads in warp, and get "value" from lane 0
    if (value != arg)
        printf("Thread %d failed.\n", threadIdx.x);
}

int main() {
    bcast<<< 1, 32 >>>(1234);
    cudaDeviceSynchronize();

    return 0;
}

7.22.3.2. 跨8线程子分区的包含式加扫描

#include <stdio.h>

__global__ void scan4() {
    int laneId = threadIdx.x & 0x1f;
    // Seed sample starting value (inverse of lane ID)
    int value = 31 - laneId;

    // Loop to accumulate scan within my partition.
    // Scan requires log2(n) == 3 steps for 8 threads
    // It works by an accumulated sum up the warp
    // by 1, 2, 4, 8 etc. steps.
    for (int i=1; i<=4; i*=2) {
        // We do the __shfl_sync unconditionally so that we
        // can read even from threads which won't do a
        // sum, and then conditionally assign the result.
        int n = __shfl_up_sync(0xffffffff, value, i, 8);
        if ((laneId & 7) >= i)
            value += n;
    }

    printf("Thread %d final value = %d\n", threadIdx.x, value);
}

int main() {
    scan4<<< 1, 32 >>>();
    cudaDeviceSynchronize();

    return 0;
}

7.22.3.3. 跨warp归约

#include <stdio.h>

__global__ void warpReduce() {
    int laneId = threadIdx.x & 0x1f;
    // Seed starting value as inverse lane ID
    int value = 31 - laneId;

    // Use XOR mode to perform butterfly reduction
    for (int i=16; i>=1; i/=2)
        value += __shfl_xor_sync(0xffffffff, value, i, 32);

    // "value" now contains the sum across all threads
    printf("Thread %d final value = %d\n", threadIdx.x, value);
}

int main() {
    warpReduce<<< 1, 32 >>>();
    cudaDeviceSynchronize();

    return 0;
}

7.23. 纳秒休眠函数

7.23.1. 概述

void __nanosleep(unsigned ns);

7.23.2. 描述

__nanosleep(ns) 将线程挂起约 ns 纳秒的休眠时间。最大休眠时长约为1毫秒。

支持计算能力7.0或更高版本。

7.23.3. 示例

以下代码实现了带有指数退避的互斥锁。

__device__ void mutex_lock(unsigned int *mutex) {
    unsigned int ns = 8;
    while (atomicCAS(mutex, 0, 1) == 1) {
        __nanosleep(ns);
        if (ns < 256) {
            ns *= 2;
        }
    }
}

__device__ void mutex_unlock(unsigned int *mutex) {
    atomicExch(mutex, 0);
}

7.24. Warp矩阵函数

C++ warp矩阵运算利用Tensor Core加速形式为D=A*B+C的矩阵问题。这些操作支持计算能力7.0及以上设备的混合精度浮点数据。这需要warp中所有线程的协同工作。此外,这些操作仅当条件在整个warp中评估结果完全一致时才允许在条件代码中使用,否则代码执行可能会挂起。

7.24.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

一个重载类,包含分布在warp内所有线程中的矩阵片段。矩阵元素映射到fragment内部存储的方式未作规定,可能会在未来架构中更改。

仅允许特定的模板参数组合。第一个模板参数指定片段如何参与矩阵运算。Use可接受的值为:

  • matrix_a 当片段作为第一个乘数 A 时,

  • matrix_b 当片段用作第二个乘数 B 时,或

  • accumulator 当片段被用作源或目标累加器时(分别为CD)。

    mnk尺寸描述了参与乘累加操作的warp级矩阵块的形状。每个块的维度取决于其角色。对于matrix_a,块的维度为m x k;对于matrix_b,维度为k x n,而accumulator块的维度为m x n

    数据类型T可以是doublefloat__half__nv_bfloat16charunsigned char(用于乘数),以及doublefloatint__half(用于累加器)。如Element Types and Matrix Sizes文档所述,仅支持有限的累加器与乘数类型组合。必须为matrix_amatrix_b片段指定Layout参数。row_majorcol_major分别表示矩阵行或列中的元素在内存中是连续的。accumulator矩阵的Layout参数应保留默认值void。仅当如下所述加载或存储累加器时,才需要指定行或列布局。

load_matrix_sync

等待所有warp通道都到达load_matrix_sync后,从内存加载矩阵片段a。mptr必须是一个256位对齐的指针,指向内存中矩阵的第一个元素。ldm描述连续行(行主序布局)或列(列主序布局)之间的元素步长,对于__half元素类型必须是8的倍数,对于float元素类型必须是4的倍数(即两种情况都必须是16字节的倍数)。如果片段是accumulator,则必须将layout参数指定为mem_row_majormem_col_major。对于matrix_amatrix_b片段,布局从片段的layout参数推断。mptrldmlayout以及a的所有模板参数的值对于warp中的所有线程必须相同。此函数必须由warp中的所有线程调用,否则结果未定义。

store_matrix_sync

等待所有warp通道都到达store_matrix_sync后,将矩阵片段a存储到内存中。mptr必须是一个256位对齐的指针,指向内存中矩阵的第一个元素。ldm描述连续行(对于行主序布局)或列(对于列主序布局)之间的元素步幅,对于__half元素类型必须是8的倍数,对于float元素类型必须是4的倍数(即两种情况都必须是16字节的倍数)。输出矩阵的布局必须指定为mem_row_majormem_col_major。对于warp中的所有线程,mptrldmlayout以及a的所有模板参数值必须相同。

fill_fragment

用常量值v填充矩阵片段。由于矩阵元素到每个片段的映射未指定,该函数通常由warp中的所有线程调用,并使用相同的v值。

mma_sync

等待所有warp通道都到达mma_sync后,执行warp同步矩阵乘加操作D=A*B+C。同时也支持原地操作C=A*B+C。warp中所有线程的satf值和每个矩阵片段的模板参数必须相同。此外,片段ABCD之间的模板参数mnk必须匹配。此函数必须由warp中的所有线程调用,否则结果将未定义。

如果satf(饱和至有限值)模式为true,则目标累加器适用以下附加数值属性:

  • 如果元素结果为+Infinity,对应的累加器将包含+MAX_NORM

  • 如果某个元素的结果是-Infinity,对应的累加器将包含-MAX_NORM

  • 如果元素结果为NaN,对应的累加器将包含+0

由于矩阵元素到每个线程fragment的映射关系未指定,在调用store_matrix_sync后,必须从内存(共享或全局)中访问各个矩阵元素。在特殊情况下,当warp中的所有线程将对所有fragment元素统一应用逐元素操作时,可以使用以下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;

7.24.2. 替代浮点

在计算能力8.0及以上的设备上,Tensor Cores支持替代类型的浮点运算。

__nv_bfloat16

这种数据格式是一种替代的fp16格式,其数值范围与f32相同但精度降低(7位)。您可以直接使用cuda_bf16.h中提供的__nv_bfloat16类型来处理此数据格式。使用__nv_bfloat16数据类型的矩阵片段需要与float类型的累加器组合使用。支持的形状和操作与__half相同。

tf32

该数据格式是Tensor Core支持的一种特殊浮点格式,其数值范围与f32相同但精度降低(≥10位)。此格式的内部布局由具体实现定义。要在WMMA运算中使用此浮点格式,必须手动将输入矩阵转换为tf32精度。

为便于转换,提供了一个新的内置函数__float_to_tf32。虽然该内置函数的输入和输出参数都是float类型,但输出数值上将是tf32。这种新精度设计仅与Tensor Core一起使用,如果与其他float类型操作混合使用,结果的精度和范围将是未定义的。

当输入矩阵(matrix_amatrix_b)转换为tf32精度后,将fragmentprecision::tf32精度结合,并使用float数据类型传递给load_matrix_sync时,将能够利用这一新功能。两个累加器片段都必须具有float数据类型。唯一支持的矩阵尺寸为16x16x8(m-n-k)。

片段元素以float形式表示,因此从element_typestorage_element_type的映射关系为:

precision::tf32 -> float

7.24.3. 双精度

Tensor Cores支持在计算能力8.0及以上的设备上进行双精度浮点运算。要使用这一新功能,必须使用带有double类型的fragmentmma_sync操作将使用.rn(四舍五入到最接近的偶数)舍入修饰符执行。

7.24.4. 子字节操作

子字节WMMA操作提供了一种访问Tensor Core低精度能力的方式。它们被视为预览功能,即其数据结构和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::u4experimental::precision::s4作为片段数据类型。由于片段的元素被打包在一起,num_storage_elements将小于该片段的num_elements。因此,对于子字节片段,num_elements变量返回子字节类型element_type的元素数量。对于单比特精度也是如此,在这种情况下,从element_typestorage_element_type的映射如下:

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

对于子字节操作,当元素类型为experimental::precision::u4experimental::precision::s4时,load_matrix_sync中的ldm值应为32的倍数;当元素类型为experimental::precision::b1时,该值应为128的倍数(即两种情况都必须是16字节的倍数)。

注意

对MMA指令的以下变体的支持已被弃用,并将在sm_90中移除:

  • experimental::precision::u4

  • experimental::precision::s4

  • experimental::precision::b1 并将 bmmaBitOp 设置为 bmmaBitOpXOR

bmma_sync

等待所有warp通道执行完bmma_sync后,执行warp同步的位矩阵乘加操作D = (A op B) + C,其中op由逻辑运算bmmaBitOp和累加运算bmmaAccumulateOp组成。可用的操作包括:

bmmaBitOpXOR,对matrix_a中的一行与matrix_b的128位列进行按位异或运算

bmmaBitOpAND,对matrix_a中的一行与matrix_b的128位列执行128位AND运算,该功能在计算能力8.0及以上的设备上可用。

accumulate操作始终是bmmaAccumulateOpPOPC,用于计算置位比特的数量。

7.24.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, …);),然后可以安全地以指针类型[例如float *dst]传递给bar()

请注意,由于sm_70可以在sm_75上运行,上述示例中的sm_75代码可以更改为sm_70,并能在sm_75上正确工作。但是,当与其他单独编译的sm_75二进制文件链接时,建议在应用程序中包含原生的sm_75代码。

7.24.6. 元素类型与矩阵尺寸

Tensor Core支持多种元素类型和矩阵尺寸。下表展示了matrix_amatrix_baccumulator矩阵支持的各种组合:

矩阵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

无符号字符

无符号字符

整型

16x16x16

unsigned char

unsigned char

int

32x8x16

unsigned char

unsigned char

int

8x32x16

有符号字符型

有符号字符型

整型

16x16x16

有符号字符

有符号字符

整型

32x8x16

有符号字符型

有符号字符型

整型

8x32x16

替代浮点数支持:

矩阵A

矩阵B

累加器

矩阵尺寸(m-n-k)

__nv_bfloat16

__nv_bfloat16

float

16x16x16

__nv_bfloat16

__nv_bfloat16

float

32x8x16

__nv_bfloat16

__nv_bfloat16

float

8x32x16

精度::tf32

精度::tf32

浮点数

16x16x8

双精度支持:

矩阵A

矩阵B

累加器

矩阵尺寸(m-n-k)

double

double

double

8x8x4

实验性支持亚字节操作:

矩阵A

矩阵B

累加器

矩阵尺寸(m-n-k)

precision::u4

precision::u4

int

8x8x32

精度::s4

精度::s4

int

8x8x32

precision::b1

precision::b1

int

8x8x128

7.24.7. 示例

以下代码在单个warp中实现了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);
}

7.25. DPX

DPX是一组函数,能够查找最多三个16位和32位有符号或无符号整数参数的最小值和最大值,以及融合加法和最小/最大值操作,并可选ReLU(钳制到零):

  • 三个参数:__vimax3_s32, __vimax3_s16x2, __vimax3_u32, __vimax3_u16x2, __vimin3_s32, __vimin3_s16x2, __vimin3_u32, __vimin3_u16x2

  • 两个参数,带ReLU激活函数: __vimax_s32_relu, __vimax_s16x2_relu, __vimin_s32_relu, __vimin_s16x2_relu

  • 三个参数,带ReLU激活函数:__vimax3_s32_relu, __vimax3_s16x2_relu, __vimin3_s32_relu, __vimin3_s16x2_relu

  • 两个参数,同时返回哪个参数更小/更大:__vibmax_s32, __vibmax_u32, __vibmin_s32, __vibmin_u32, __vibmax_s16x2, __vibmax_u16x2, __vibmin_s16x2, __vibmin_u16x2

  • 三个参数,比较(第一个 + 第二个)与第三个参数的关系: __viaddmax_s32, __viaddmax_s16x2, __viaddmax_u32, __viaddmax_u16x2, __viaddmin_s32, __viaddmin_s16x2, __viaddmin_u32, __viaddmin_u16x2

  • 三个参数,使用ReLU激活函数,比较(第一个+第二个)与第三个参数和零的关系: __viaddmax_s32_relu, __viaddmax_s16x2_relu, __viaddmin_s32_relu, __viaddmin_s16x2_relu

这些指令在计算能力为9及以上的设备上通过硬件加速执行,在较旧设备上则通过软件模拟运行。

完整API可在CUDA Math API文档中查阅。

DPX 在实现动态规划算法时特别有用,例如基因组学中的 Smith-Waterman 或 Needleman-Wunsch 算法,以及路径优化中的 Floyd-Warshall 算法。

7.25.1. 示例

三个带符号32位整数的最大值,使用ReLU激活函数

const int a = -15;
const int b = 8;
const int c = 5;
int max_value_0 = __vimax3_s32_relu(a, b, c); // max(-15, 8, 5, 0) = 8
const int d = -2;
const int e = -4;
int max_value_1 = __vimax3_s32_relu(a, d, e); // max(-15, -2, -4, 0) = 0

两个32位有符号整数之和的最小值,另一个32位有符号整数和零(ReLU)

const int a = -5;
const int b = 6;
const int c = -2;
int max_value_0 = __viaddmax_s32_relu(a, b, c); // max(-5 + 6, -2, 0) = max(1, -2, 0) = 1
const int d = 4;
int max_value_1 = __viaddmax_s32_relu(a, d, c); // max(-5 + 4, -2, 0) = max(-1, -2, 0) = 0

两个无符号32位整数的最小值及确定哪个值更小

const unsigned int a = 9;
const unsigned int b = 6;
bool smaller_value;
unsigned int min_value = __vibmin_u32(a, b, &smaller_value); // min_value is 6, smaller_value is true

三对无符号16位整数的最大值

const unsigned a = 0x00050002;
const unsigned b = 0x00070004;
const unsigned c = 0x00020006;
unsigned int max_value = __vimax3_u16x2(a, b, c); // max(5, 7, 2) and max(2, 4, 6), so max_value is 0x00070006

7.26. 异步屏障

NVIDIA C++标准库引入了std::barrier的GPU实现。除了std::barrier的实现外,该库还提供了扩展功能,允许用户指定屏障对象的作用域。屏障API作用域的相关文档请参阅Thread Scopes。计算能力8.0或更高的设备为屏障操作提供了硬件加速,并将这些屏障与memcpy_async功能集成。对于计算能力低于8.0但7.0及以上的设备,这些屏障功能仍然可用,但不具备硬件加速。

nvcuda::experimental::awbarrier 已被弃用,建议改用 cuda::barrier

7.26.1. 简单同步模式

如果没有到达/等待屏障,同步是通过使用__syncthreads()(用于同步块中的所有线程)或在使用Cooperative Groups时使用group.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 */
        block.sync(); /* wait for all threads to arrive here */
        /* code after wait */
    }
}

线程会在同步点(block.sync())处被阻塞,直到所有线程都到达该同步点。此外,同步点之前发生的内存更新保证在该同步点之后对块内所有线程可见,即相当于atomic_thread_fence(memory_order_seq_cst, thread_scope_block)以及sync操作。

该模式包含三个阶段:

  • 同步之前的代码执行内存更新,这些更新将在同步之后被读取。

  • 同步点

  • 在同步点之后的代码,能够看到同步点之前发生的内存更新。

7.26.2. 时间分割与同步的五个阶段

使用std::barrier的时间分割同步模式如下所示。

#include <cuda/barrier>
#include <cooperative_groups.h>

__device__ void compute(float* data, int curr_iteration);

__global__ void split_arrive_wait(int iteration_count, float *data) {
    using barrier = cuda::barrier<cuda::thread_scope_block>;
    __shared__  barrier bar;
    auto block = cooperative_groups::this_thread_block();

    if (block.thread_rank() == 0) {
        init(&bar, block.size()); // Initialize the barrier with expected arrival count
    }
    block.sync();

    for (int curr_iter = 0; curr_iter < iteration_count; ++curr_iter) {
        /* code before arrive */
       barrier::arrival_token token = bar.arrive(); /* this thread arrives. Arrival does not block a thread */
       compute(data, curr_iter);
       bar.wait(std::move(token)); /* wait for all threads participating in the barrier to complete bar.arrive()*/
        /* code after wait */
    }
}

在这种模式中,同步点(block.sync())被拆分为到达点(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()之前发生的内存更新的工作。

到达后等待模式包含五个阶段,这些阶段可以迭代重复:

  • 在到达之前的代码执行内存更新,这些更新将在等待之后被读取。

  • 到达点带有隐式内存栅栏(即等同于atomic_thread_fence(memory_order_seq_cst, thread_scope_block))。

  • 在到达和等待之间的代码。

  • 等待点。

  • 在等待之后执行的代码,能够看到在到达之前执行的更新。

7.26.3. 引导初始化、预期到达计数与参与度

初始化必须在任何线程开始参与cuda::barrier之前完成。

#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) {
        init(&bar, block.size()); // Single thread initializes the total expected arrival count.
    }
    block.sync();
}

在任何线程参与cuda::barrier之前,必须使用init()初始化屏障,并指定预期到达计数(本例中为block.size())。初始化必须在任何线程调用bar.arrive()之前完成。这带来了一个引导难题:线程在参与cuda::barrier之前需要同步,但线程创建cuda::barrier的目的正是为了实现同步。在本示例中,参与线程属于协作组,并使用block.sync()来完成初始化引导。由于本示例中整个线程块都参与初始化,因此也可以使用__syncthreads()

init()的第二个参数是预期到达计数,即在参与线程从bar.wait(std::move(token))调用解除阻塞之前,参与线程将调用bar.arrive()的次数。在前面的示例中,cuda::barrier使用线程块中的线程数(即cooperative_groups::this_thread_block().size())进行初始化,并且线程块中的所有线程都参与屏障同步。

cuda::barrier 在指定线程参与方式(分离到达/等待)和参与线程方面具有灵活性。相比之下,协作组中的 this_thread_block.sync()__syncthreads() 适用于整个线程块,而 __syncwarp(mask) 则是针对warp的特定子集。如果用户意图同步整个线程块或整个warp,出于性能考虑,我们建议分别使用 __syncthreads()__syncwarp(mask)

7.26.4. 屏障的阶段:到达、倒计时、完成与重置

当参与线程调用bar.arrive()时,cuda::barrier会从预期到达计数递减至零。当倒计时归零时,表示当前阶段的cuda::barrier已完成。当最后一次调用bar.arrive()使倒计时归零时,计数会自动且原子性地重置。重置操作会将倒计时恢复为预期到达计数值,并将cuda::barrier推进到下一阶段。

一个token对象,属于cuda::barrier::arrival_token类,由token=bar.arrive()返回,它与屏障的当前阶段相关联。调用bar.wait(std::move(token))会阻塞调用线程,只要cuda::barrier处于当前阶段,即与该token关联的阶段与cuda::barrier的阶段匹配。如果在调用bar.wait(std::move(token))之前阶段已经推进(因为倒计时归零),则线程不会被阻塞;如果线程在bar.wait(std::move(token))中被阻塞时阶段推进,线程将被解除阻塞。

了解何时可能或不可能发生重置至关重要,尤其是在复杂的到达/等待同步模式中。

  • 线程对token=bar.arrive()bar.wait(std::move(token))的调用必须按顺序进行,使得token=bar.arrive()发生在cuda::barrier的当前阶段,而bar.wait(std::move(token))发生在同一阶段或下一阶段。

  • 线程调用bar.arrive()时,屏障的计数器必须为非零值。屏障初始化后,如果线程调用bar.arrive()导致倒计时归零,则必须先调用bar.wait(std::move(token)),屏障才能被重新用于后续的bar.arrive()调用。

  • bar.wait() 必须仅使用当前阶段或紧邻前一阶段的token对象调用。对于token对象的任何其他值,其行为是未定义的。

对于简单的到达/等待同步模式,遵守这些使用规则非常简单。

7.26.5. 空间分区(也称为Warp专业化)

线程块可以进行空间分区,使得warp专门用于执行独立计算。空间分区用于生产者或消费者模式,其中一个线程子集生成的数据会被另一个(不相交的)线程子集同时消费。

生产者/消费者空间分区模式需要两个单向同步来管理生产者和消费者之间的数据缓冲区。

生产者

消费者

等待缓冲区准备就绪以填充

发出信号表示缓冲区已准备好填充

生成数据并填充缓冲区

信号缓冲区已满

等待缓冲区填充

消耗填充缓冲区中的数据

生产者线程等待消费者线程发出缓冲区可以填充的信号;然而,消费者线程并不需要等待这个信号。消费者线程等待生产者线程发出缓冲区已填充的信号;然而,生产者线程也不需要等待这个信号。为了实现完全的生产者/消费者并发,这种模式至少需要双缓冲,其中每个缓冲区需要两个cuda::barrier

#include <cuda/barrier>
#include <cooperative_groups.h>

using barrier = cuda::barrier<cuda::thread_scope_block>;

__device__ void producer(barrier ready[], barrier filled[], float* buffer, float* in, int N, int buffer_len)
{
    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::arrival_token token = filled[i%2].arrive(); /* buffer_(i%2) is filled */
    }
}

__device__ void consumer(barrier ready[], barrier filled[], float* buffer, float* out, int N, int buffer_len)
{
    barrier::arrival_token token1 = ready[0].arrive(); /* buffer_0 is ready for initial fill */
    barrier::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::arrival_token token = ready[i%2].arrive(); /* buffer_(i%2) is ready to be re-filled */
    }
}

//N is the total number of float elements in arrays in and out
__global__ void producer_consumer_pattern(int N, int buffer_len, float* in, float* out) {

    // 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__ barrier bar[4];


    auto block = cooperative_groups::this_thread_block();
    if (block.thread_rank() < 4)
        init(bar + block.thread_rank(), block.size());
    block.sync();

    if (block.thread_rank() < warpSize)
        producer(bar, bar+2, buffer, in, N, buffer_len);
    else
        consumer(bar, bar+2, buffer, out, N, buffer_len);
}

在这个示例中,第一个warp被专门化为生产者,其余warps被专门化为消费者。所有生产者和消费者线程都参与了四个cuda::barrier中的每一个(调用bar.arrive()bar.arrive_and_wait()),因此预期到达计数等于block.size()

生产者线程等待消费者线程发出信号,表示可以填充共享内存缓冲区。为了等待cuda::barrier,生产者线程必须首先到达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(),然后等待下一次迭代的缓冲区被填充。

7.26.6. 提前退出(放弃参与)

当一个线程参与一系列同步操作时需要提前退出该序列时,该线程必须在退出前显式声明退出参与。其余参与的线程可以正常继续后续的cuda::barrier到达和等待操作。

#include <cuda/barrier>
#include <cooperative_groups.h>

__device__ bool condition_check();

__global__ void early_exit_kernel(int N) {
    using barrier = cuda::barrier<cuda::thread_scope_block>;
    __shared__ barrier 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 */
        barrier::arrival_token token = bar.arrive();
        /* code between arrive and wait */
        bar.wait(std::move(token)); /* wait for all threads to arrive */
        /* code after wait */
    }
}

该操作到达cuda::barrier以履行参与线程在当前阶段到达的义务,然后减少下一阶段的预期到达计数,使得该线程不再需要在屏障上到达。

7.26.7. 完成函数

CompletionFunction 作为 cuda::barrier 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 % 128 == 0);

  auto completion_fn = [&] {
    int sum = 0;
    for (int i = 0; i < 128; ++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 t = bar->arrive();
    // We can do independent computation here
    bar->wait(std::move(t));
    // 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
  }
}

7.26.8. 内存屏障原语接口

内存屏障原语是类似C语言的接口,用于访问cuda::barrier功能。这些原语可通过包含头文件来使用。

7.26.8.1. 数据类型

typedef /* implementation defined */ __mbarrier_t;
typedef /* implementation defined */ __mbarrier_token_t;

7.26.8.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 必须是指向共享内存中mbarrier对象的指针。

  • 在重新利用对应的共享内存之前,需要先使*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 必须与 *this 的紧邻前阶段或当前阶段相关联。

  • 如果token*bar的紧邻前阶段相关联,则返回true,否则返回false

//Note: This API has been deprecated in CUDA 11.1
uint32_t __mbarrier_pending_count(__mbarrier_token_t token);

7.27. 异步数据拷贝

CUDA 11 引入了通过 memcpy_async API 实现的异步数据操作功能,允许设备代码显式管理数据的异步拷贝。memcpy_async 特性使 CUDA 内核能够实现计算与数据传输的重叠执行。

7.27.1. memcpy_async API

memcpy_async API接口在cuda/barriercuda/pipelinecooperative_groups/memcpy_async.h头文件中提供。

cuda::memcpy_async API 与 cuda::barriercuda::pipeline 同步原语配合使用,而 cooperative_groups::memcpy_async 则通过 coopertive_groups::wait 实现同步。

这些API具有非常相似的语义:将对象从src复制到dst,就像由另一个线程执行一样,在复制完成后,可以通过cuda::pipelinecuda::barriercooperative_groups::wait进行同步。

关于cuda::barriercuda::pipelinecuda::memcpy_async重载函数的完整API文档,请参阅libcudacxx API文档,其中还提供了一些示例。

cooperative_groups::memcpy_async 的API文档可在 Cooperative Groups 章节中找到。

使用cuda::barriercuda::pipelinememcpy_async API需要计算能力7.0或更高版本。在计算能力8.0或更高的设备上,从全局内存到共享内存的memcpy_async操作可以利用硬件加速。

7.27.2. 复制与计算模式 - 通过共享内存暂存数据

CUDA应用程序通常采用一种复制与计算的模式,该模式:

  • 从全局内存中获取数据

  • 将数据存储到共享内存中,并且

  • 对共享内存中的数据进行计算,并可能将结果写回全局内存。

以下部分展示了如何在不使用和使用memcpy_async功能的情况下表达这种模式:

7.27.3. 不使用 memcpy_async

如果没有memcpy_async复制计算模式中的复制阶段会表示为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_memcpy_async(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];

    block.sync(); // Wait for all copies to complete

    compute(global_out + block_batch_idx, shared); // Compute and write result to global memory

    block.sync(); // Wait for compute using shared memory to finish
  }
}

7.27.4. 使用 memcpy_async

通过memcpy_async,实现从全局内存到共享内存的赋值

shared[local_idx] = global_in[global_idx];

被替换为从协作组的异步复制操作

cooperative_groups::memcpy_async(group, shared, global_in + batch_idx, sizeof(int) * block.size());

cooperative_groups::memcpy_async API 从全局内存中复制 sizeof(int) * block.size() 字节的数据,起始地址为 global_in + batch_idx,目标地址为 shared 数据。该操作由另一个线程异步执行,在复制完成后会与当前线程调用 cooperative_groups::wait 进行同步。在复制操作完成前,修改全局数据或读写共享数据会导致数据竞争。

在计算能力8.0或更高的设备上,memcpy_async从全局内存到共享内存的传输可以利用硬件加速,从而避免通过中间寄存器传输数据。

#include <cooperative_groups.h>
#include <cooperative_groups/memcpy_async.h>

__device__ void compute(int* global_out, int const* shared_in);

__global__ void with_memcpy_async(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

  for (size_t batch = 0; batch < batch_sz; ++batch) {
    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, sizeof(int) * block.size());

    cooperative_groups::wait(block); // Joins all threads, waits for all copies to complete

    compute(global_out + block_batch_idx, shared);

    block.sync();
  }
}}

7.27.5. 使用cuda::barrier进行异步数据拷贝

针对cuda::barriercuda::memcpy_async重载功能,允许使用barrier来同步异步数据传输。该重载通过以下方式执行复制操作,就像由绑定到屏障的另一线程执行:在创建时增加当前阶段的预期计数,并在复制操作完成后减少该计数,从而确保只有当参与屏障的所有线程都已到达,并且绑定到屏障当前阶段的所有memcpy_async操作都已完成时,屏障的阶段才会推进。以下示例使用了一个块级barrier(所有块线程均参与),并将等待操作替换为屏障的arrive_and_wait,同时提供了与前一个示例相同的功能:

#include <cooperative_groups.h>
#include <cuda/barrier>
__device__ void compute(int* global_out, int const* shared_in);

__global__ void with_barrier(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()); // Assume input size fits batch_sz * grid_size

  extern __shared__ int shared[]; // block.size() * sizeof(int) bytes

  // Create a synchronization object (C++20 barrier)
  __shared__ cuda::barrier<cuda::thread_scope::thread_scope_block> barrier;
  if (block.thread_rank() == 0) {
    init(&barrier, block.size()); // Friend function initializes barrier
  }
  block.sync();

  for (size_t batch = 0; batch < batch_sz; ++batch) {
    size_t block_batch_idx = block.group_index().x * block.size() + grid.size() * batch;
    cuda::memcpy_async(block, shared, global_in + block_batch_idx, sizeof(int) * block.size(), barrier);

    barrier.arrive_and_wait(); // Waits for all copies to complete

    compute(global_out + block_batch_idx, shared);

    block.sync();
  }
}

7.27.6. memcpy_async性能优化指南

对于计算能力8.x的设备,流水线机制在同一条CUDA warp中的线程间共享。这种共享会导致memcpy_async操作批次在warp内相互纠缠,在某些情况下可能影响性能。

本节重点介绍warp-entanglement效应对commitwaitarrive操作的影响。有关各个操作的概述,请参阅Pipeline InterfacePipeline Primitives Interface

7.27.6.1. 对齐

在计算能力8.0的设备上,cp.async指令系列支持从全局内存到共享内存的异步数据拷贝。这些指令每次可拷贝4、8或16字节数据。如果提供给memcpy_async的大小是4、8或16的倍数,并且传递给memcpy_async的两个指针都按4、8或16字节对齐,那么memcpy_async可以完全通过异步内存操作来实现。

此外,为了在使用memcpy_async API时获得最佳性能,共享内存和全局内存都需要128字节的对齐。

对于指向对齐要求为1或2的类型值的指针,通常无法证明这些指针始终对齐到更高的对齐边界。确定cp.async指令是否可用必须延迟到运行时。执行此类运行时对齐检查会增加代码大小并带来运行时开销。

cuda::aligned_size_t(size_t size)Shape 可用于提供证明,通过将其作为参数传递给需要 Shapememcpy_async API,表明传递给 memcpy_async 的两个指针都对齐到 Align 对齐边界,并且 sizeAlign 的倍数:

cuda::memcpy_async(group, dst, src, cuda::aligned_size_t<16>(N * block.size()), pipeline);

如果证明不正确,则行为未定义。

7.27.6.2. 可平凡复制

在计算能力8.0的设备上,cp.async指令系列允许异步将数据从全局内存复制到共享内存。如果传递给memcpy_async的指针类型不指向TriviallyCopyable类型,则需要调用每个输出元素的拷贝构造函数,这些指令无法用于加速memcpy_async

7.27.6.3. 线程束纠缠 - 提交

memcpy_async的批处理序列在warp内共享。提交操作会被合并,因此对于所有调用提交操作的收敛线程,序列只会递增一次。如果warp完全收敛,序列递增1;如果warp完全发散,序列递增32。

  • PB 为warp共享流水线的实际批次序列。

    PB = {BP0, BP1, BP2, …, BPL}

  • TB为一个线程感知到的批次序列,就像该序列仅由该线程调用提交操作而递增。

    TB = {BT0, BT1, BT2, …, BTL}

    pipeline::producer_commit()的返回值来自该线程感知到的批次序列。

  • 线程感知序列中的索引总是与实际warp共享序列中相等或更大的索引对齐。只有当所有提交操作都由收敛线程调用时,这两个序列才会相等。

    BTn BPm 其中 n <= m

例如,当一个warp完全发散时:

  • warp共享管道的实际序列将是:PB = {0, 1, 2, 3, ..., 31} (PL=31)。

  • 该warp中每个线程感知到的执行顺序将是:

    • 线程 0: TB = {0} (TL=0)

    • 线程1: TB = {0} (TL=0)

    • 线程 31: TB = {0} (TL=0)

7.27.6.4. 线程束纠缠 - 等待

CUDA线程通过调用pipeline_consumer_wait_prior()pipeline::consumer_wait()来等待感知序列TB中的批次完成。请注意pipeline::consumer_wait()等同于pipeline_consumer_wait_prior(),其中N =                                        PL

pipeline_consumer_wait_prior() 函数会等待实际序列中至少到并包括 PL-N 的批次。由于 TL <= PL,等待到并包括 PL-N 的批次也意味着会等待 TL-N 批次。因此,当 TL < PL 时,线程会无意中等待更多、更新的批次。

在上述极端完全发散warp的示例中,每个线程可能需要等待全部32个批次。

7.27.6.5. 线程束纠缠 - 到达同步

Warp-divergence(线程束发散)会影响arrive_on(bar)操作更新屏障的次数。如果调用线程束完全收敛,则屏障会更新一次。如果调用线程束完全发散,则会对屏障执行32次独立更新。

7.27.6.6. 保持提交与到达操作同步

建议由聚合线程执行commit和arrive-on调用:

  • 避免过度等待,通过保持线程感知的批次顺序与实际顺序一致,以及

  • 尽量减少对屏障对象的更新。

当这些操作之前的代码导致线程发散时,应在调用提交(commit)或到达(arrive-on)操作之前,通过__syncwarp重新收敛warp。

7.28. 使用cuda::pipeline进行异步数据拷贝

CUDA提供了cuda::pipeline同步对象来管理和重叠异步数据传输与计算。

cuda::pipeline的API文档可在libcudacxx API中查阅。管道对象是一个双端N级队列,具有头部尾部,用于按照先进先出(FIFO)顺序处理任务。该管道对象提供以下成员函数来管理管道的各个阶段。

Pipeline 类成员函数

描述

producer_acquire

获取管道内部队列中可用的阶段。

producer_commit

提交在当前获取的流水线阶段上,调用producer_acquire后发出的异步操作。

consumer_wait

等待流水线最旧阶段的所有异步操作完成。

consumer_release

将流水线中最旧的阶段释放回流水线对象以供重用。释放的阶段随后可以被生产者获取。

7.28.1. 使用cuda::pipeline进行单阶段异步数据拷贝

在之前的示例中,我们展示了如何使用cooperative_groupscuda::barrier进行异步数据传输。本节中,我们将使用cuda::pipeline API的单级版本来调度异步拷贝操作。随后我们将扩展这个示例,展示多级重叠计算与拷贝的实现。

#include <cooperative_groups/memcpy_async.h>
#include <cuda/pipeline>

__device__ void compute(int* global_out, int const* shared_in);
__global__ void with_single_stage(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()); // Assume input size fits batch_sz * grid_size

    constexpr size_t stages_count = 1; // Pipeline with one stage
    // One batch must fit in shared memory:
    extern __shared__ int shared[];  // block.size() * sizeof(int) bytes

    // Allocate shared storage for a single stage cuda::pipeline:
    __shared__ cuda::pipeline_shared_state<
        cuda::thread_scope::thread_scope_block,
        stages_count
    > shared_state;
    auto pipeline = cuda::make_pipeline(block, &shared_state);

    // Each thread processes `batch_sz` elements.
    // Compute offset of the batch `batch` of this thread block in global memory:
    auto block_batch = [&](size_t batch) -> int {
      return block.group_index().x * block.size() + grid.size() * batch;
    };

    for (size_t batch = 0; batch < batch_sz; ++batch) {
        size_t global_idx = block_batch(batch);

        // Collectively acquire the pipeline head stage from all producer threads:
        pipeline.producer_acquire();

        // Submit async copies to the pipeline's head stage to be
        // computed in the next loop iteration
        cuda::memcpy_async(block, shared, global_in + global_idx, sizeof(int) * block.size(), pipeline);
        // Collectively commit (advance) the pipeline's head stage
        pipeline.producer_commit();

        // Collectively wait for the operations committed to the
        // previous `compute` stage to complete:
        pipeline.consumer_wait();

        // Computation overlapped with the memcpy_async of the "copy" stage:
        compute(global_out + global_idx, shared);

        // Collectively release the stage resources
        pipeline.consumer_release();
    }
}

7.28.2. 使用cuda::pipeline实现多阶段异步数据拷贝

在之前使用cooperative_groups::waitcuda::barrier的示例中,内核线程会立即等待共享内存的数据传输完成。这避免了从全局内存到寄存器的数据传输,但并没有通过重叠计算来隐藏memcpy_async操作的延迟。

为此我们在以下示例中使用CUDA的pipeline功能。它提供了一种管理memcpy_async批处理序列的机制,使CUDA内核能够将内存传输与计算重叠执行。以下示例实现了一个两阶段流水线,将数据传输与计算重叠执行。它:

  • 初始化管道共享状态(更多详情见下文)

  • 通过为第一批数据调度memcpy_async来启动流水线。

  • 循环遍历所有批次:它为下一个批次调度memcpy_async,阻塞所有线程直到前一个批次的memcpy_async完成,然后将前一个批次的计算与下一个批次内存的异步拷贝重叠执行。

  • 最后,通过对最后一批数据进行计算来排空流水线。

请注意,为了与cuda::pipeline保持互操作性,此处使用了来自cuda/pipeline头文件的cuda::memcpy_async函数。

#include <cooperative_groups/memcpy_async.h>
#include <cuda/pipeline>

__device__ void compute(int* global_out, int const* shared_in);
__global__ void with_staging(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()); // Assume input size fits batch_sz * grid_size

    constexpr size_t stages_count = 2; // Pipeline with two stages
    // Two batches must fit in shared memory:
    extern __shared__ int shared[];  // stages_count * block.size() * sizeof(int) bytes
    size_t shared_offset[stages_count] = { 0, block.size() }; // Offsets to each batch

    // Allocate shared storage for a two-stage cuda::pipeline:
    __shared__ cuda::pipeline_shared_state<
        cuda::thread_scope::thread_scope_block,
        stages_count
    > shared_state;
    auto pipeline = cuda::make_pipeline(block, &shared_state);

    // Each thread processes `batch_sz` elements.
    // Compute offset of the batch `batch` of this thread block in global memory:
    auto block_batch = [&](size_t batch) -> int {
      return block.group_index().x * block.size() + grid.size() * batch;
    };

    // Initialize first pipeline stage by submitting a `memcpy_async` to fetch a whole batch for the block:
    if (batch_sz == 0) return;
    pipeline.producer_acquire();
    cuda::memcpy_async(block, shared + shared_offset[0], global_in + block_batch(0), sizeof(int) * block.size(), pipeline);
    pipeline.producer_commit();

    // Pipelined copy/compute:
    for (size_t batch = 1; batch < batch_sz; ++batch) {
        // Stage indices for the compute and copy stages:
        size_t compute_stage_idx = (batch - 1) % 2;
        size_t copy_stage_idx = batch % 2;

        size_t global_idx = block_batch(batch);

        // Collectively acquire the pipeline head stage from all producer threads:
        pipeline.producer_acquire();

        // Submit async copies to the pipeline's head stage to be
        // computed in the next loop iteration
        cuda::memcpy_async(block, shared + shared_offset[copy_stage_idx], global_in + global_idx, sizeof(int) * block.size(), pipeline);
        // Collectively commit (advance) the pipeline's head stage
        pipeline.producer_commit();

        // Collectively wait for the operations commited to the
        // previous `compute` stage to complete:
        pipeline.consumer_wait();

        // Computation overlapped with the memcpy_async of the "copy" stage:
        compute(global_out + global_idx, shared + shared_offset[compute_stage_idx]);

        // Collectively release the stage resources
        pipeline.consumer_release();
    }

    // Compute the data fetch by the last iteration
    pipeline.consumer_wait();
    compute(global_out + block_batch(batch_sz-1), shared + shared_offset[(batch_sz - 1) % 2]);
    pipeline.consumer_release();
}

pipeline对象是一个具有头部尾部的双端队列,用于按照先进先出(FIFO)顺序处理工作。生产者线程将工作提交到pipeline的头部,而消费者线程从pipeline的尾部提取工作。在上面的示例中,所有线程既是生产者线程又是消费者线程。线程首先提交memcpy_async操作来获取下一批数据,同时它们等待前一批memcpy_async操作完成。

  • 将工作提交到流水线阶段涉及以下步骤:

    • 通过使用pipeline.producer_acquire()从一组生产者线程集体获取管道的头部

    • 向流水线头部提交memcpy_async操作。

    • 通过pipeline.producer_commit()集体提交(推进)流水线头部。

  • 使用之前提交的阶段涉及以下步骤:

    • 集体等待阶段完成,例如使用pipeline.consumer_wait()来等待尾部(最老的)阶段。

    • 使用pipeline.consumer_release()集体释放该阶段。

cuda::pipeline_shared_state count>封装了有限资源,这些资源允许流水线处理最多count个并发阶段。如果所有资源都在使用中,pipeline.producer_acquire()会阻塞生产者线程,直到消费者线程释放下一个流水线阶段的资源。

这个示例可以通过将循环的前导和结尾部分与循环本身合并来更简洁地编写,如下所示:

template <size_t stages_count = 2 /* Pipeline with stages_count stages */>
__global__ void with_staging_unified(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()); // Assume input size fits batch_sz * grid_size

    extern __shared__ int shared[]; // stages_count * block.size() * sizeof(int) bytes
    size_t shared_offset[stages_count];
    for (int s = 0; s < stages_count; ++s) shared_offset[s] = s * block.size();

    __shared__ cuda::pipeline_shared_state<
        cuda::thread_scope::thread_scope_block,
        stages_count
    > shared_state;
    auto pipeline = cuda::make_pipeline(block, &shared_state);

    auto block_batch = [&](size_t batch) -> int {
        return block.group_index().x * block.size() + grid.size() * batch;
    };

    // compute_batch: next batch to process
    // fetch_batch:  next batch to fetch from global memory
    for (size_t compute_batch = 0, fetch_batch = 0; compute_batch < batch_sz; ++compute_batch) {
        // The outer loop iterates over the computation of the batches
        for (; fetch_batch < batch_sz && fetch_batch < (compute_batch + stages_count); ++fetch_batch) {
            // This inner loop iterates over the memory transfers, making sure that the pipeline is always full
            pipeline.producer_acquire();
            size_t shared_idx = fetch_batch % stages_count;
            size_t batch_idx = fetch_batch;
            size_t block_batch_idx = block_batch(batch_idx);
            cuda::memcpy_async(block, shared + shared_offset[shared_idx], global_in + block_batch_idx, sizeof(int) * block.size(), pipeline);
            pipeline.producer_commit();
        }
        pipeline.consumer_wait();
        int shared_idx = compute_batch % stages_count;
        int batch_idx = compute_batch;
        compute(global_out + block_batch(batch_idx), shared + shared_offset[shared_idx]);
        pipeline.consumer_release();
    }
}

上面使用的pipeline原语非常灵活,支持我们之前的示例未使用的两个特性:块中的任意线程子集都可以参与pipeline,且参与线程中的任意子集可以成为生产者、消费者或两者兼具。在以下示例中,线程等级为"偶数"的线程作为生产者,其他线程则作为消费者:

__device__ void compute(int* global_out, int shared_in);

template <size_t stages_count = 2>
__global__ void with_specialized_staging_unified(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();

    // In this example, threads with "even" thread rank are producers, while threads with "odd" thread rank are consumers:
    const cuda::pipeline_role thread_role
      = block.thread_rank() % 2 == 0? cuda::pipeline_role::producer : cuda::pipeline_role::consumer;

    // Each thread block only has half of its threads as producers:
    auto producer_threads = block.size() / 2;

    // Map adjacent even and odd threads to the same id:
    const int thread_idx = block.thread_rank() / 2;

    auto elements_per_batch = size / batch_sz;
    auto elements_per_batch_per_block = elements_per_batch / grid.group_dim().x;

    extern __shared__ int shared[]; // stages_count * elements_per_batch_per_block * sizeof(int) bytes
    size_t shared_offset[stages_count];
    for (int s = 0; s < stages_count; ++s) shared_offset[s] = s * elements_per_batch_per_block;

    __shared__ cuda::pipeline_shared_state<
        cuda::thread_scope::thread_scope_block,
        stages_count
    > shared_state;
    cuda::pipeline pipeline = cuda::make_pipeline(block, &shared_state, thread_role);

    // Each thread block processes `batch_sz` batches.
    // Compute offset of the batch `batch` of this thread block in global memory:
    auto block_batch = [&](size_t batch) -> int {
      return elements_per_batch * batch + elements_per_batch_per_block * blockIdx.x;
    };

    for (size_t compute_batch = 0, fetch_batch = 0; compute_batch < batch_sz; ++compute_batch) {
        // The outer loop iterates over the computation of the batches
        for (; fetch_batch < batch_sz && fetch_batch < (compute_batch + stages_count); ++fetch_batch) {
            // This inner loop iterates over the memory transfers, making sure that the pipeline is always full
            if (thread_role == cuda::pipeline_role::producer) {
                // Only the producer threads schedule asynchronous memcpys:
                pipeline.producer_acquire();
                size_t shared_idx = fetch_batch % stages_count;
                size_t batch_idx = fetch_batch;
                size_t global_batch_idx = block_batch(batch_idx) + thread_idx;
                size_t shared_batch_idx = shared_offset[shared_idx] + thread_idx;
                cuda::memcpy_async(shared + shared_batch_idx, global_in + global_batch_idx, sizeof(int), pipeline);
                pipeline.producer_commit();
            }
        }
        if (thread_role == cuda::pipeline_role::consumer) {
            // Only the consumer threads compute:
            pipeline.consumer_wait();
            size_t shared_idx = compute_batch % stages_count;
            size_t global_batch_idx = block_batch(compute_batch) + thread_idx;
            size_t shared_batch_idx = shared_offset[shared_idx] + thread_idx;
            compute(global_out + global_batch_idx, *(shared + shared_batch_idx));
            pipeline.consumer_release();
        }
    }
}

pipeline会执行一些优化,例如当所有线程同时是生产者和消费者时,但通常来说,支持所有这些功能的成本无法完全消除。例如,pipeline会在共享内存中存储并使用一组屏障进行同步,如果块中的所有线程都参与管道,这实际上是不必要的。

对于块中所有线程都参与pipeline的特殊情况,我们可以通过使用pipeline结合__syncthreads()来实现比pipeline更好的效果:

template<size_t stages_count>
__global__ void with_staging_scope_thread(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();
    auto thread = cooperative_groups::this_thread();
    assert(size == batch_sz * grid.size()); // Assume input size fits batch_sz * grid_size

    extern __shared__ int shared[]; // stages_count * block.size() * sizeof(int) bytes
    size_t shared_offset[stages_count];
    for (int s = 0; s < stages_count; ++s) shared_offset[s] = s * block.size();

    // No pipeline::shared_state needed
    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;
    };

    for (size_t compute_batch = 0, fetch_batch = 0; compute_batch < batch_sz; ++compute_batch) {
        for (; fetch_batch < batch_sz && fetch_batch < (compute_batch + stages_count); ++fetch_batch) {
            pipeline.producer_acquire();
            size_t shared_idx = fetch_batch % stages_count;
            size_t batch_idx = fetch_batch;
            // Each thread fetches its own data:
            size_t thread_batch_idx = block_batch(batch_idx) + threadIdx.x;
            // The copy is performed by a single `thread` and the size of the batch is now that of a single element:
            cuda::memcpy_async(thread, shared + shared_offset[shared_idx] + threadIdx.x, global_in + thread_batch_idx, sizeof(int), pipeline);
            pipeline.producer_commit();
        }
        pipeline.consumer_wait();
        block.sync(); // __syncthreads: All memcpy_async of all threads in the block for this stage have completed here
        int shared_idx = compute_batch % stages_count;
        int batch_idx = compute_batch;
        compute(global_out + block_batch(batch_idx), shared + shared_offset[shared_idx]);
        pipeline.consumer_release();
    }
}

如果compute操作仅读取由当前线程所在warp中其他线程写入的共享内存,那么使用__syncwarp()就足够了。

7.28.3. 管道接口

cuda::memcpy_async 的完整API文档可在libcudacxx API文档中查看,其中还包含一些示例。

pipeline 接口需要

  • 至少需要 CUDA 11.0 版本

  • 至少兼容ISO C++ 2011标准,例如可以使用-std=c++11进行编译,以及

  • #include .

对于类C接口,在不兼容ISO C++ 2011标准的情况下编译时,请参阅Pipeline Primitives Interface

7.28.4. 流水线原语接口

流水线原语是一种类似C语言的接口,用于实现memcpy_async功能。通过包含头文件即可使用流水线原语接口。若在不支持ISO C++ 2011兼容性的环境下编译,则需要包含头文件。

7.28.4.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]; /* 复制 */
    for (; i < size_and_align; ++i) ((char*)dst_shared)[i] = 0; /* 零填充 */
    
  • 要求:

    • dst_shared 必须是指向 memcpy_async 共享内存目标的指针。

    • src_global 必须是指向memcpy_async操作的全局内存源的指针。

    • size_and_align 必须为4、8或16。

    • zfill <= size_and_align.

    • size_and_align 必须是 dst_sharedsrc_global 的对齐方式。

  • 在任何线程等待memcpy_async操作完成之前修改源内存或观察目标内存都会导致竞态条件。在提交memcpy_async操作和等待其完成之间,以下任何操作都会引入竞态条件:

    • dst_shared 加载。

    • 存储到 dst_sharedsrc_global

    • dst_sharedsrc_global 应用原子更新。

7.28.4.2. 提交原语

void __pipeline_commit();
  • 将提交的memcpy_async作为当前批次加入流水线。

7.28.4.3. 等待原语

void __pipeline_wait_prior(size_t N);
  • {0, 1, 2, ..., L}为给定线程调用__pipeline_commit()时关联的索引序列。

  • 等待批次完成至少达到并包括L-N

7.28.4.4. 到达屏障原语

void __pipeline_arrive_on(__mbarrier_t* bar);
  • bar 指向共享内存中的一个屏障。

  • 将屏障到达计数增加一,当此调用之前排序的所有memcpy_async操作完成后,到达计数会减少一,因此对到达计数的净影响为零。用户需确保到达计数的增量不超过__mbarrier_maximum_count()

7.29. 使用张量内存加速器(TMA)进行异步数据拷贝

许多应用需要在全局内存中大量数据的输入输出。通常,这些数据在全局内存中以多维数组形式存储,具有非连续的数据访问模式。为减少全局内存使用,这类数组的子区块会在计算前被复制到共享内存中。加载和存储过程涉及地址计算,这些计算容易出错且重复性高。为减轻这些计算负担,计算能力9.0版本引入了张量内存加速器(TMA)。TMA的主要目标是针对多维数组,提供从全局内存到共享内存的高效数据传输机制。

命名。张量内存加速器(TMA)是一个广义术语,用于指代本节描述的功能。为了保持向前兼容性并减少与PTX ISA的差异,本节文本根据所使用的具体复制类型,将TMA操作称为批量异步复制或批量张量异步复制。使用"批量"一词是为了将这些操作与前面章节描述的异步内存操作区分开来。

维度。TMA支持复制一维和多维数组(最高5维)。批量异步拷贝一维连续数组的编程模型与批量张量异步拷贝多维数组的编程模型不同。要执行多维数组的批量张量异步拷贝,硬件需要一个张量映射。该对象描述了多维数组在全局内存和共享内存中的布局。张量映射通常使用cuTensorMapEncode API在主机上创建,然后作为带有__grid_constant__注解的const内核参数从主机传输到设备。张量映射作为带有__grid_constant__注解的const内核参数从主机传输到设备,并可在设备上用于在共享内存和全局内存之间拷贝数据块。相比之下,执行连续一维数组的批量异步拷贝不需要张量映射:可以在设备上使用指针和大小参数完成。

源与目标。批量异步拷贝操作的源地址和目标地址可以位于共享内存或全局内存中。这些操作可以从全局内存读取数据到共享内存,将数据从共享内存写入全局内存,还可以将共享内存数据拷贝至同一集群中另一个块的分布式共享内存。此外,在集群环境下,批量异步操作可被指定为多播模式。这种情况下,数据可以从全局内存传输到集群内多个块的共享内存中。多播特性针对目标架构sm_90a进行了优化,在其他目标架构上可能性能显著下降。因此建议与计算架构sm_90a配合使用。

异步性。使用TMA进行的数据传输是异步的。这使得发起线程可以继续计算,而硬件则异步地复制数据。 实际数据传输是否异步进行取决于硬件实现,未来可能会发生变化。 有几种完成机制可用于批量异步操作以发出完成信号。 当操作从全局内存读取数据到共享内存时,块中的任何线程都可以通过等待共享内存屏障来等待数据在共享内存中可读。当批量异步操作将数据从共享内存写入全局或分布式共享内存时,只有发起线程可以等待操作完成。 这是通过基于批量异步组的完成机制实现的。描述这些完成机制的表格可在下方及PTX ISA中找到。

表 6 支持可能的内存空间源和目的地以及完成机制的异步拷贝。空白单元格表示不支持该源-目的地组合。

方向

完成机制

目标位置

源位置

异步拷贝

批量异步拷贝 (TMA)

全局

全局

全局

Shared::cta

批量异步组

Shared::cta

全局

异步组, mbarrier

Mbarrier

Shared::cluster

全局

M屏障(组播)

Shared::cta

Shared::cluster

Mbarrier

Shared::cta

Shared::cta

7.29.1. 使用TMA传输一维数组

本节演示如何编写一个简单的内核,该内核使用TMA对一维数组进行读取-修改-写入操作。这展示了如何通过批量异步拷贝来加载和存储数据,以及如何使执行线程与这些拷贝操作同步。

内核的代码包含在下方。某些功能需要内联PTX汇编,目前通过libcu++提供支持。 可以通过以下代码检查这些封装器的可用性:

#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__

内核经历以下阶段:

  1. 初始化共享内存屏障。

  2. 启动从全局内存到共享内存的批量异步内存块复制。

  3. 到达并等待共享内存屏障。

  4. 增加共享内存缓冲区的值。

  5. 等待共享内存写入对后续批量异步拷贝可见,即在async proxy中排序共享内存写入操作,确保其在下个步骤之前完成。

  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;
__global__ void add_one_kernel(int* data, size_t offset)
{
  // Shared memory buffer. The destination shared memory buffer of
  // a bulk operations should be 16 byte aligned.
  __shared__ alignas(16) int smem_data[buf_len];

  // 1. a) Initialize shared memory barrier with the number of threads participating in the barrier.
  //    b) Make initialized barrier visible in async proxy.
  #pragma nv_diag_suppress static_var_with_dynamic_init
  __shared__ barrier bar;
  if (threadIdx.x == 0) { 
    init(&bar, blockDim.x);                      // a)
    ptx::fence_proxy_async(ptx::space_shared);   // b)
  }
  __syncthreads();

  // 2. Initiate TMA transfer to copy global to shared memory.
  if (threadIdx.x == 0) {
    // 3a. cuda::memcpy_async arrives on the barrier and communicates
    //     how many bytes are expected to come in (the transaction count)
    cuda::memcpy_async(
        smem_data, 
        data + offset, 
        cuda::aligned_size_t<16>(sizeof(smem_data)),
        bar
    );
  }
  // 3b. All threads arrive on the barrier
  barrier::arrival_token token = bar.arrive();
  
  // 3c. 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);   // b)
  __syncthreads();
  // After syncthreads, writes by all threads are visible to TMA engine.

  // 6. Initiate TMA transfer to copy shared memory to global memory
  if (threadIdx.x == 0) {
    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>());
  }
}

屏障初始化。屏障初始化时会设置参与该块的线程数量。因此,只有当所有线程都到达该屏障时,屏障才会翻转。共享内存屏障的详细说明可参阅使用cuda::barrier进行异步数据拷贝。为了使初始化的屏障对后续批量异步拷贝操作可见,这里使用了fence.proxy.async.shared::cta指令。该指令确保后续批量异步拷贝操作能作用于已初始化的屏障。

TMA读取。批量异步拷贝指令指示硬件将一大块数据复制到共享内存中,并在完成读取后更新共享内存屏障的事务计数。通常来说,以尽可能大的尺寸执行尽可能少的批量拷贝能获得最佳性能。由于拷贝操作可由硬件异步执行,因此无需将拷贝分割成更小的块。

发起批量异步拷贝操作的线程通过mbarrier.expect_tx到达屏障点。该操作由cuda::memcpy_async自动执行。这会告知屏障该线程已到达,并告知预期到达的字节数(tx/事务)。只需单个线程更新预期事务计数。若多个线程更新事务计数,预期事务数将是各次更新的总和。屏障仅在所有线程都到达所有字节都到达后才会翻转。屏障翻转后,这些字节就可以安全地从共享内存中读取,既可以被线程读取,也可以被后续的批量异步拷贝操作使用。更多关于屏障事务计数的信息可在PTX ISA中找到。

屏障等待。使用mbarrier.try_wait来等待屏障翻转。它可能返回true表示等待结束,或者返回false表示等待可能超时。while循环会持续等待完成,并在超时后重试。

SMEM写入与同步。缓冲区值的递增操作涉及对共享内存的读写。为确保后续批量异步拷贝能感知到这些写入,代码中使用了fence.proxy.async.shared::cta指令。该指令将共享内存的写入操作排序在后续通过异步代理执行的批量异步拷贝读取操作之前。因此每个线程首先通过fence.proxy.async.shared::cta对异步代理中共享内存对象的写入进行排序,这些由所有线程执行的操作会通过线程0中的__syncthreads()调用在异步操作前完成全局排序。

TMA写入与同步。从共享内存到全局内存的写入操作再次由单个线程发起。写入完成状态不通过共享内存屏障跟踪,而是采用线程本地机制。多个写入操作可批量归入所谓的批量异步组。随后,线程可以等待该组内所有操作完成共享内存读取(如上述代码所示)或完成全局内存写入,使发起线程能观察到写入结果。更多信息请参阅cp.async.bulk.wait_group的PTX ISA文档。 需注意批量异步与非批量异步拷贝指令具有不同的异步组:既存在cp.async.wait_group指令,也存在cp.async.bulk.wait_group指令。

批量异步指令对其源地址和目标地址有特定的对齐要求。更多信息可在下表中找到。

表 7 Compute Capability 9.0中一维批量异步操作的对齐要求。

地址/大小

对齐方式

全局内存地址

必须16字节对齐。

共享内存地址

必须16字节对齐。

共享内存屏障地址

必须8字节对齐(这一点由cuda::barrier保证)。

传输大小

必须是16字节的倍数。

7.29.2. 使用TMA传输多维数组

一维和多维情况之间的主要区别在于,必须在主机上创建张量映射并将其传递给CUDA内核。本节介绍如何使用CUDA驱动API创建张量映射、如何将其传递到设备以及在设备上如何使用它。

驱动API。张量映射是通过cuTensorMapEncodeTiled驱动API创建的。该API可以通过直接链接到驱动程序(-lcuda)或使用cudaGetDriverEntryPoint API来访问。下面我们将展示如何获取指向cuTensorMapEncodeTiled API的指针。更多信息请参阅驱动入口点访问

#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
  );

主机到设备传输。有三种方法可以让设备代码访问张量映射。推荐的做法是将张量映射作为__grid_constant__常量参数传递给内核。其他方式包括使用cudaMemcpyToSymbol将张量映射复制到设备的__constant__内存中,或通过全局内存访问它。当将张量映射作为参数传递时,某些版本的GCC C++编译器会发出警告"GCC 4.6中传递64字节对齐参数的ABI已更改"。该警告可以忽略。

#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);
}

作为__grid_constant__内核参数的替代方案,可以使用全局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的二维区块。该区块的左上角由索引xy指定。该区块会被加载到共享内存中,经过修改后再写回全局内存。

#include <cuda.h>         // CUtensormap
#include <cuda/barrier>
using barrier = cuda::barrier<cuda::thread_scope_block>;
namespace cde = cuda::device::experimental;

__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);
    // Make initialized barrier visible in async proxy.
    cde::fence_proxy_async_shared_cta();
  }
  // Syncthreads so initialized barrier is visible to all threads.
  __syncthreads();

  barrier::arrival_token token;
  if (threadIdx.x == 0) {
    // Initiate bulk tensor copy.
    cde::cp_async_bulk_tensor_2d_global_to_shared(&smem_buffer, &tensor_map, x, y, 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.
  cde::fence_proxy_async_shared_cta();
  __syncthreads();
  // After syncthreads, writes by all threads are visible to TMA engine.

  // Initiate TMA transfer to copy shared memory to global memory
  if (threadIdx.x == 0) {
    cde::cp_async_bulk_tensor_2d_shared_to_global(&tensor_map, x, y, &smem_buffer);
    // Wait for TMA transfer to have finished reading shared memory.
    // Create a "bulk async-group" out of the previous bulk copy operation.
    cde::cp_async_bulk_commit_group();
    // Wait for the group to have completed reading from shared memory.
    cde::cp_async_bulk_wait_group_read<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字节。有关对齐的更多信息,请参阅表格计算能力9.0中多维批量张量异步拷贝操作的对齐要求

表 8 Compute Capability 9.0中多维批量张量异步复制操作的对齐要求。

地址/大小

对齐方式

全局内存地址

必须16字节对齐。

全局内存大小

必须大于或等于1。不需要是16字节的倍数。

全局内存步长

必须是16字节的倍数。

共享内存地址

必须128字节对齐。

共享内存屏障地址

必须8字节对齐(这由cuda::barrier保证)。

传输大小

必须是16字节的倍数。

7.29.2.1. 多维TMA PTX封装器

以下PTX指令按上述示例代码中的使用顺序排列。

cp.async.bulk.tensor指令用于启动全局内存与共享内存之间的大规模张量异步拷贝操作。以下封装函数实现了从全局内存读取到共享内存,以及从共享内存写入到全局内存的功能。

// https://docs.nvidia.com/cuda/parallel-thread-execution/index.html#data-movement-and-conversion-instructions-cp-async-bulk-tensor
inline __device__
void cuda::device::experimental::cp_async_bulk_tensor_1d_global_to_shared(
    void *dest, const CUtensorMap *tensor_map , int c0, cuda::barrier<cuda::thread_scope_block> &bar
);

// https://docs.nvidia.com/cuda/parallel-thread-execution/index.html#data-movement-and-conversion-instructions-cp-async-bulk-tensor
inline __device__
void cuda::device::experimental::cp_async_bulk_tensor_2d_global_to_shared(
    void *dest, const CUtensorMap *tensor_map , int c0, int c1, cuda::barrier<cuda::thread_scope_block> &bar
);

// https://docs.nvidia.com/cuda/parallel-thread-execution/index.html#data-movement-and-conversion-instructions-cp-async-bulk-tensor
inline __device__
void cuda::device::experimental::cp_async_bulk_tensor_3d_global_to_shared(
    void *dest, const CUtensorMap *tensor_map, int c0, int c1, int c2, cuda::barrier<cuda::thread_scope_block> &bar
);

// https://docs.nvidia.com/cuda/parallel-thread-execution/index.html#data-movement-and-conversion-instructions-cp-async-bulk-tensor
inline __device__
void cuda::device::experimental::cp_async_bulk_tensor_4d_global_to_shared(
    void *dest, const CUtensorMap *tensor_map , int c0, int c1, int c2, int c3, cuda::barrier<cuda::thread_scope_block> &bar
);

// https://docs.nvidia.com/cuda/parallel-thread-execution/index.html#data-movement-and-conversion-instructions-cp-async-bulk-tensor
inline __device__
void cuda::device::experimental::cp_async_bulk_tensor_5d_global_to_shared(
    void *dest, const CUtensorMap *tensor_map , int c0, int c1, int c2, int c3, int c4, cuda::barrier<cuda::thread_scope_block> &bar
);
// https://docs.nvidia.com/cuda/parallel-thread-execution/index.html#data-movement-and-conversion-instructions-cp-async-bulk-tensor
inline __device__
void cuda::device::experimental::cp_async_bulk_tensor_1d_shared_to_global(
    const CUtensorMap *tensor_map, int c0, const void *src
);

// https://docs.nvidia.com/cuda/parallel-thread-execution/index.html#data-movement-and-conversion-instructions-cp-async-bulk-tensor
inline __device__
void cuda::device::experimental::cp_async_bulk_tensor_2d_shared_to_global(
    const CUtensorMap *tensor_map, int c0, int c1, const void *src
);

// https://docs.nvidia.com/cuda/parallel-thread-execution/index.html#data-movement-and-conversion-instructions-cp-async-bulk-tensor
inline __device__
void cuda::device::experimental::cp_async_bulk_tensor_3d_shared_to_global(
    const CUtensorMap *tensor_map, int c0, int c1, int c2, const void *src
);

// https://docs.nvidia.com/cuda/parallel-thread-execution/index.html#data-movement-and-conversion-instructions-cp-async-bulk-tensor
inline __device__
void cuda::device::experimental::cp_async_bulk_tensor_4d_shared_to_global(
    const CUtensorMap *tensor_map, int c0, int c1, int c2, int c3, const void *src
);

// https://docs.nvidia.com/cuda/parallel-thread-execution/index.html#data-movement-and-conversion-instructions-cp-async-bulk-tensor
inline __device__
void cuda::device::experimental::cp_async_bulk_tensor_5d_shared_to_global(
    const CUtensorMap *tensor_map, int c0, int c1, int c2, int c3, int c4, const void *src
);

7.29.3. TMA 交换模式

默认情况下,TMA引擎将数据加载到共享内存的顺序与其在全局内存中的布局相同。然而,这种布局可能不适用于某些共享内存访问模式,因为它可能导致共享内存存储体冲突。为了提高性能并减少存储体冲突,我们可以通过应用"swizzle模式"来改变共享内存的布局。

共享内存具有32个存储体,其组织方式使得连续的32位字映射到连续的存储体。每个存储体在每个时钟周期具有32位的带宽。在加载和存储共享内存时,如果在同一事务中多次使用同一存储体,就会发生存储体冲突,从而导致带宽降低。请参阅共享内存、存储体冲突。

为确保数据在共享内存中的布局方式能让用户代码避免共享内存存储体冲突,可以指示TMA引擎在将数据存储到共享内存之前对其进行"swizzle"处理,并在将数据从共享内存复制回全局内存时进行"unswizzle"处理。张量映射编码了"swizzle模式",用于指示所使用的具体swizzle模式。

7.29.3.1. 示例 '矩阵转置'

一个例子是矩阵的转置操作,其中数据从行优先访问映射到列优先访问。数据在全局内存中以行主序存储,但我们希望在共享内存中也能按列访问,这会导致存储体冲突。然而,通过使用128字节的"swizzle"模式和新共享内存索引,这些冲突就被消除了。

在本示例中,我们加载了一个8x8的int4类型矩阵,该矩阵以行主序方式存储在全局内存中,我们将其加载到共享内存。随后,每组8个线程从共享内存缓冲区加载一行数据,并将其存储到独立转置共享内存缓冲区的列中。这种存储操作会导致八路存储体冲突。最终,转置缓冲区会被写回全局内存。

为避免存储体冲突,可以使用CU_TENSOR_MAP_SWIZZLE_128B布局。该布局匹配128字节的行长度,并以一种方式改变共享内存布局,使得列式访问和行式访问都不需要在每次事务中使用相同的存储体。

说明。以下两张表格展示了8x8 int4类型矩阵及其转置矩阵的正常和交错共享内存布局。颜色表示矩阵元素映射到八个四存储体组中的哪一个,边距行和边距列列出了全局内存的行列索引。条目显示了16字节矩阵元素的共享内存索引。

image20

未经过重排的共享内存数据布局中,共享内存索引与全局内存索引相同。每条加载指令会读取一行数据并存储在转置缓冲区的列中。由于转置后矩阵列的所有元素都落在同一个存储体中,存储操作必须串行执行,导致每存储一列会产生8次存储事务,形成8路存储体冲突。

image21

采用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);
     cde::fence_proxy_async_shared_cta();
   }

   __syncthreads();

   barrier::arrival_token token;
   if (threadIdx.x == 0) {
     // Initiate bulk tensor copy from global to shared memory,
     // in the same way as without swizzle.
     cde::cp_async_bulk_tensor_2d_global_to_shared(&smem_buffer, &tensor_map, 0, 0, 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.
   cde::fence_proxy_async_shared_cta();
   __syncthreads();

// Initiate TMA transfer to copy the transposed shared memory buffer back to global memory,
// it will 'unswizzle' the data.
if (threadIdx.x == 0) {
   cde::cp_async_bulk_tensor_2d_shared_to_global(&tensor_map, 0, 0, &smem_buffer_tr);
   cde::cp_async_bulk_commit_group();
   cde::cp_async_bulk_wait_group_read<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);
 ...
}

备注。 此示例旨在展示swizzle的用法,'原样'使用既不高效也无法超出给定维度扩展。

说明。 在数据传输过程中,TMA引擎会根据以下表格描述的混洗模式对数据进行重排。这些混洗模式定义了沿混洗宽度的16字节块到四组存储体子组的映射关系。其类型为CUtensorMapSwizzle,包含四个选项:none(无)、32字节、64字节和128字节。请注意共享内存框的内部维度必须小于或等于混洗模式的跨度。

7.29.3.2. Swizzle模式

如前所述,共有四种交换模式。下图展示了不同的交换模式图案,包括新共享内存索引的对应关系。这些表格定义了沿128字节分布的16字节块到八个四存储体子组的映射关系。

image22

注意事项。 应用TMA交错模式时,必须严格遵守特定的内存要求,以避免未定义行为和错误。

  • 全局内存对齐:全局内存必须对齐到128字节。

  • 共享内存对齐:共享内存必须按照交换模式重复的字节数进行对齐。如果未保持此对齐,交换索引计算将导致未定义的映射。要处理这种情况,可以将内存对齐到128字节并在计算中添加偏移量。请参阅下面的备注。

  • 内部维度:共享内存块的内部维度必须满足表N中规定的大小要求。如果这些要求未满足,则该指令被视为无效。此外,如果交换宽度超过内部维度,请确保分配的共享内存能够容纳完整的交换宽度。

  • 粒度:swizzle映射的粒度固定为16字节。这意味着数据以16字节的块为单位组织和访问,在规划内存布局和访问模式时必须考虑这一点。

备注:基于指针的方法访问混洗数据。 当共享内存缓冲区未按照混洗模式重复的字节数对齐时,混洗模式会存在偏移量。这里我们描述如何确定该偏移量。使用TMA时,要求共享内存按128字节对齐。要计算共享内存缓冲区因此偏移了多少次,可应用以下方法:

data_t* ptr = &smem_buffer[0][0];
int offset = (reinterpret_cast<uintptr_t>(ptr) >> 0x7) & 0x7;

计算能力9的不同swizzle模式的要求和属性中,这个偏移量表示初始行偏移量,因此,在swizzle索引计算中,它会被加到行索引y上。例如,在CU_TENSOR_MAP_SWIZZLE_128B模式下,索引关系为smem[y][x] <-> smem[y][((y+offset)%8)^x]

表 9 Compute Capability 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 字节

7.30. 在设备上编码张量映射

前面的章节已经描述了如何使用CUDA驱动API在主机上创建张量映射。

本节介绍如何在设备上编码平铺类型的张量映射。这在某些情况下非常有用,例如当处理一批不同尺寸的张量时,在单个内核启动中使用常规方式传输张量映射(通过const __grid_constant__内核参数)可能不太理想。

推荐模式如下:

  1. 在主机上使用Driver 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];
};

7.30.1. 设备端张量映射的编码与修改

在全局内存中对张量映射进行编码的推荐流程如下。

  1. 向内核传递一个现有的张量映射template_tensor_map。与在cp.async.bulk.tensor指令中使用张量映射的内核不同,这可以通过任何方式完成:指向全局内存的指针、内核参数、__const___变量等。

  2. 使用template_tensor_map值在共享内存中复制初始化一个张量映射。

  3. 使用cuda::ptx::tensormap_replace函数修改共享内存中的张量映射。这些函数封装了tensormap.replace PTX指令,可用于修改平铺类型张量映射的任何字段,包括基地址、大小、步长等。

  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

注意

设备端修改仅支持平铺类型的张量映射;其他类型的张量映射无法在设备上修改。有关张量映射类型的更多信息,请参阅Driver 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);
}

7.30.2. 修改后的张量映射使用方式

与使用作为const __grid_constant__内核参数传递的张量映射不同,在全局内存中使用张量映射需要显式地在张量映射代理中建立修改张量映射的线程与使用它的线程之间的释放-获取模式。

该模式中的释放部分已在上一节展示。它是通过使用cuda::ptx::tensormap.cp_fenceproxy函数实现的。

The acquire part is accomplished using the cuda::ptx::fence_proxy_tensormap_generic function that wraps the fence.proxy.tensormap::generic.acquire instruction. If the two threads participating in the release-acquire pattern are on the same device, the .gpu scope suffices. If the threads are on different devices, the .sys scope must be used. Once a tensor map has been acquired by one thread, it can be used by other threads in the block after sufficient synchronization, for example, using __syncthreads(). The thread that uses the tensor map and the thread that performs the fence must be in the same block. That is, if the threads are in, for example, two different thread blocks of the same cluster, the same grid, or a different kernel, synchronization APIs such as cooperative_groups::cluster or grid_group::sync() or stream-order synchronization do not suffice to establish ordering for tensor map updates, that is, threads in these other thread blocks still need to acquire the tensor map proxy at the right scope before using the updated tensor map. If there are no intermediate modifications, the fence does not have to be repeated before each cp.async.bulk.tensor instruction.

以下示例展示了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);
    // Make barrier init visible in async proxy, i.e., to TMA engine
    ptx::fence_proxy_async(ptx::space_shared);
    // 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");
  }
}

7.30.3. 使用Driver 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;
}

7.31. 性能分析计数器函数

每个多处理器都配备了一组16个硬件计数器,应用程序可以通过调用__prof_trigger()函数,用一条指令来递增这些计数器。

void __prof_trigger(int counter);

每个warp将索引为counter的每多处理器硬件计数器递增1。计数器8至15为保留值,应用程序不应使用。

计数器0、1、…、7的值可以通过nvprof工具使用nvprof --events prof_trigger_0x命令获取,其中x取值为0、1、…、7。所有计数器在每次内核启动前都会重置(请注意,在收集计数器数据时,内核启动是同步进行的,如主机与设备间的并发执行中所述)。

7.32. 断言

断言功能仅支持计算能力2.x及更高版本的设备。

void assert(int expression);

如果expression等于零,则停止内核执行。如果程序在调试器中运行,这将触发断点,调试器可用于检查设备的当前状态。否则,对于expression等于零的每个线程,在通过cudaDeviceSynchronize()cudaStreamSynchronize()cudaEventSynchronize()与主机同步后,会向stderr打印一条消息。该消息的格式如下:

<filename>:<line number>:<function>:
block: [blockId.x,blockId.x,blockIdx.z],
thread: [threadIdx.x,threadIdx.y,threadIdx.z]
Assertion `<expression>` failed.

针对同一设备的任何后续主机端同步调用都将返回cudaErrorAssert。在调用cudaDeviceReset()重新初始化设备之前,无法向该设备发送更多命令。

如果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(int argc, char* argv[])
{
    testAssert<<<1,1>>>();
    cudaDeviceSynchronize();

    return 0;
}

将输出:

test.cu:19: void testAssert(): block: [0,0,0], thread: [0,0,0] Assertion `should_be_one` failed.

断言(Assertions)主要用于调试目的。它们可能会影响性能,因此建议在生产代码中禁用断言。通过在包含assert.h头文件之前定义NDEBUG预处理器宏,可以在编译时禁用断言。需要注意的是,expression不应是具有副作用的表达式(例如类似(++i > 0)的表达式),否则禁用断言会影响代码的功能。

7.33. 陷阱函数

可以通过从任意设备线程调用__trap()函数来启动陷阱操作。

void __trap();

内核执行被中止,并在主机程序中引发中断。

7.34. 断点函数

可以通过从任意设备线程调用__brkpt()函数来暂停内核函数的执行。

void __brkpt();

7.35. 格式化输出

格式化输出仅支持计算能力2.x及以上的设备。

int printf(const char *format[, arg, ...]);

将内核中的格式化输出打印到主机端输出流。

内核中的printf()函数行为与标准C库printf()函数类似,关于printf()行为的完整描述请参阅主机系统手册页。本质上,传入的format字符串会被输出到主机上的流中,每当遇到格式说明符时就会从参数列表中进行替换。以下是支持的格式说明符列表。

printf()命令与其他设备端函数一样执行:每个线程独立执行,并在调用线程的上下文中运行。对于多线程内核来说,这意味着直接调用printf()会被每个线程执行,并使用该线程指定的数据。随后在主机流中会出现多个版本的输出字符串,每个遇到printf()的线程都会产生一个输出。

如果只需要单个输出字符串,则由程序员负责将输出限制在单个线程内(参见示例以获取说明性示例)。

与C标准printf()返回打印字符数不同,CUDA的printf()返回解析的参数数量。如果格式字符串后没有参数,则返回0。如果格式字符串为NULL,则返回-1。如果发生内部错误,则返回-2。

7.35.1. 格式说明符

与标准printf()类似,格式说明符的形式为:%[flags][width][.precision][size]type

支持以下字段(完整行为描述请参阅广泛可用的文档):

  • 标志:'#' ' ' '0' '+' '-'

  • 宽度: '*' '0-9'

  • 精度: '0-9'

  • 尺寸: 'h' 'l' 'll'

  • 类型: "%cdiouxXpeEfgGaAs"

请注意,CUDA的printf()函数会接受任何标志、宽度、精度、大小和类型的组合,无论它们整体上是否构成有效的格式说明符。换句话说,"%hd"会被接受,而printf会期望在参数列表的相应位置传入一个双精度变量。

7.35.2. 限制

printf()输出的最终格式化是在主机系统上完成的。这意味着格式字符串必须能被主机系统的编译器和C库理解。我们已尽力确保CUDA的printf函数支持的格式说明符构成一个通用子集,兼容大多数常见的主机编译器,但具体行为仍取决于主机操作系统。

格式说明符中所述,printf()将接受所有有效标志和类型的组合。这是因为它无法确定在最终输出格式化的主机系统上哪些组合有效、哪些无效。这样做的后果是,如果程序输出的格式字符串包含无效组合,则输出结果可能是未定义的。

除了格式字符串外,printf()命令最多可接受32个参数。超出此数量的额外参数将被忽略,格式说明符将按原样输出。

由于long类型在64位Windows平台上的大小不同(64位Windows平台上为4字节,其他64位平台上为8字节),在非Windows 64位机器上编译但在win64机器上运行的内核,对于所有包含"%ld"的格式字符串都会看到损坏的输出。建议编译平台与执行平台相匹配以确保安全性。

printf()的输出缓冲区在内核启动前被设置为固定大小(参见Associated Host-Side API)。该缓冲区是循环的,如果内核执行期间产生的输出超过缓冲区容量,较早的输出将被覆盖。仅当执行以下任一操作时才会刷新缓冲区:

  • 通过<<<>>>cuLaunchKernel()启动内核(在启动开始时,如果CUDA_LAUNCH_BLOCKING环境变量设置为1,则在启动结束时也会触发),

  • 通过 cudaDeviceSynchronize()cuCtxSynchronize()cudaStreamSynchronize()cuStreamSynchronize()cudaEventSynchronize()cuEventSynchronize() 进行同步

  • 通过任何阻塞版本的cudaMemcpy*()cuMemcpy*()进行内存拷贝,

  • 通过cuModuleLoad()cuModuleUnload()进行模块加载/卸载,

  • 通过 cudaDeviceReset()cuCtxDestroy() 销毁上下文。

  • 在执行由cudaStreamAddCallbackcuStreamAddCallback添加的流回调之前。

请注意,程序退出时缓冲区不会自动刷新。用户必须显式调用cudaDeviceReset()cuCtxDestroy(),如下例所示。

在内部,printf()使用了一个共享数据结构,因此调用printf()可能会改变线程的执行顺序。具体来说,调用printf()的线程可能比不调用printf()的线程执行路径更长,且该路径长度取决于printf()的参数。但请注意,CUDA除了在显式的__syncthreads()屏障处外,不保证线程执行顺序,因此无法判断执行顺序的改变是由printf()还是硬件中的其他调度行为引起的。

7.35.3. 关联的主机端API

以下API函数用于获取和设置用于将printf()参数和内部元数据传输到主机的缓冲区大小(默认为1兆字节):

  • cudaDeviceGetLimit(size_t* size,cudaLimitPrintfFifoSize)

  • cudaDeviceSetLimit(cudaLimitPrintfFifoSize, size_t size)

7.35.4. 示例

以下代码示例:

#include <stdio.h>

__global__ void helloCUDA(float f)
{
    printf("Hello thread %d, f=%f\n", threadIdx.x, f);
}

int main()
{
    helloCUDA<<<1, 5>>>(1.2345f);
    cudaDeviceSynchronize();
    return 0;
}

将输出:

Hello thread 2, f=1.2345
Hello thread 1, f=1.2345
Hello thread 4, f=1.2345
Hello thread 0, f=1.2345
Hello thread 3, f=1.2345

注意每个线程都会遇到printf()命令,因此输出的行数与网格中启动的线程数相同。正如预期的那样,全局值(即float f)在所有线程之间是共享的,而局部值(即threadIdx.x)在每个线程中是独立的。

以下代码示例:

#include <stdio.h>

__global__ void helloCUDA(float f)
{
    if (threadIdx.x == 0)
        printf("Hello thread %d, f=%f\n", threadIdx.x, f) ;
}

int main()
{
    helloCUDA<<<1, 5>>>(1.2345f);
    cudaDeviceSynchronize();
    return 0;
}

将输出:

Hello thread 0, f=1.2345

显然,if()语句限制了哪些线程会调用printf,因此只会看到一行输出。

7.36. 动态全局内存分配与操作

动态全局内存分配和操作仅支持计算能力2.x及以上的设备。

__host__ __device__ void* malloc(size_t size);
__device__ void *__nv_aligned_device_malloc(size_t size, size_t align);
__host__ __device__  void free(void* ptr);

从全局内存中的固定大小堆中动态分配和释放内存。

__host__ __device__ void* memcpy(void* dest, const void* src, size_t size);

src指向的内存位置复制size字节到dest指向的内存位置。

__host__ __device__ void* memset(void* ptr, int value, size_t size);

ptr指向的内存块中的size字节设置为value(解释为无符号字符)。

The CUDA in-kernel malloc()function allocates at least size bytes from the device heap and returns a pointer to the allocated memory or NULL if insufficient memory exists to fulfill the request. The returned pointer is guaranteed to be aligned to a 16-byte boundary.

The CUDA in-kernel __nv_aligned_device_malloc() function allocates at least size bytes from the device heap and returns a pointer to the allocated memory or NULL if insufficient memory exists to fulfill the requested size or alignment. The address of the allocated memory will be a multiple of align. align must be a non-zero power of 2.

CUDA内核中的free()函数用于释放由ptr指向的内存,该指针必须是通过先前调用malloc()__nv_aligned_device_malloc()返回的。如果ptr为NULL,则对free()的调用将被忽略。重复使用相同的ptr调用free()会导致未定义行为。

由给定CUDA线程通过malloc()__nv_aligned_device_malloc()分配的内存将在CUDA上下文的整个生命周期内保持分配状态,直到通过调用free()显式释放。这些内存可以被任何其他CUDA线程使用,甚至来自后续的内核启动。任何CUDA线程都可以释放由另一个线程分配的内存,但需要注意确保同一个指针不会被多次释放。

7.36.1. 堆内存分配

设备内存堆具有固定大小,必须在任何使用malloc()__nv_aligned_device_malloc()free()的程序加载到上下文之前指定。如果任何程序使用malloc()__nv_aligned_device_malloc()而未显式指定堆大小,则会分配默认的8兆字节堆。

以下API函数用于获取和设置堆大小:

  • cudaDeviceGetLimit(size_t* size, cudaLimitMallocHeapSize)

  • cudaDeviceSetLimit(cudaLimitMallocHeapSize, size_t size)

授予的堆大小至少为size字节。cuCtxGetLimit()cudaDeviceGetLimit()返回当前请求的堆大小。

堆的实际内存分配发生在将模块加载到上下文时,无论是通过CUDA驱动API显式加载(参见Module),还是通过CUDA运行时API隐式加载(参见CUDA Runtime)。如果内存分配失败,模块加载将产生CUDA_ERROR_SHARED_OBJECT_INIT_FAILED错误。

堆大小一旦模块加载后即无法更改,且不会根据需求动态调整。

为设备堆保留的内存是额外于通过主机端CUDA API调用(如cudaMalloc())分配的内存。

7.36.2. 与主机内存API的互操作性

通过设备malloc()__nv_aligned_device_malloc()分配的内存无法使用运行时释放(即通过调用设备内存中的任何内存释放函数)。

同样地,通过运行时分配的内存(即通过调用设备内存中的任何内存分配函数)无法通过free()释放。

此外,在设备代码中通过调用malloc()__nv_aligned_device_malloc()分配的内存不能用于任何运行时或驱动API调用(例如cudaMemcpy、cudaMemset等)。

7.36.3. 示例

7.36.3.1. 每线程分配

以下代码示例:

#include <stdlib.h>
#include <stdio.h>

__global__ void mallocTest()
{
    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);
    mallocTest<<<1, 5>>>();
    cudaDeviceSynchronize();
    return 0;
}

将输出:

Thread 0 got pointer: 00057020
Thread 1 got pointer: 0005708c
Thread 2 got pointer: 000570f8
Thread 3 got pointer: 00057164
Thread 4 got pointer: 000571d0

注意每个线程如何遇到malloc()memset()命令,从而接收并初始化自己的内存分配。(具体指针值会有所不同:此处仅为示例说明。)

7.36.3.2. 每线程块分配

#include <stdlib.h>

__global__ void mallocTest()
{
    __shared__ int* data;

    // The first thread in the block does the allocation and then
    // shares the pointer with all other threads through shared memory,
    // so that access can easily be coalesced.
    // 64 bytes per thread are allocated.
    if (threadIdx.x == 0) {
        size_t size = blockDim.x * 64;
        data = (int*)malloc(size);
    }
    __syncthreads();

    // Check for failure
    if (data == NULL)
        return;

    // Threads index into the memory, ensuring coalescence
    int* ptr = data;
    for (int i = 0; i < 64; ++i)
        ptr[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);
    mallocTest<<<10, 128>>>();
    cudaDeviceSynchronize();
    return 0;
}

7.36.3.3. 内核启动间的持久化内存分配

#include <stdlib.h>
#include <stdio.h>

#define NUM_BLOCKS 20

__device__ int* dataptr[NUM_BLOCKS]; // Per-block pointer

__global__ void allocmem()
{
    // Only the first thread in the block does the allocation
    // since we want only one allocation per block.
    if (threadIdx.x == 0)
        dataptr[blockIdx.x] = (int*)malloc(blockDim.x * 4);
    __syncthreads();

    // Check for failure
    if (dataptr[blockIdx.x] == NULL)
        return;

    // Zero the data with all threads in parallel
    dataptr[blockIdx.x][threadIdx.x] = 0;
}

// Simple example: store thread ID into each element
__global__ void usemem()
{
    int* ptr = dataptr[blockIdx.x];
    if (ptr != NULL)
        ptr[threadIdx.x] += threadIdx.x;
}

// Print the content of the buffer before freeing it
__global__ void freemem()
{
    int* ptr = dataptr[blockIdx.x];
    if (ptr != NULL)
        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
    allocmem<<< NUM_BLOCKS, 10 >>>();

    // Use memory
    usemem<<< NUM_BLOCKS, 10 >>>();
    usemem<<< NUM_BLOCKS, 10 >>>();
    usemem<<< NUM_BLOCKS, 10 >>>();

    // Free memory
    freemem<<< NUM_BLOCKS, 10 >>>();

    cudaDeviceSynchronize();

    return 0;
}

7.37. 执行配置

任何对__global__函数的调用都必须指定该调用的执行配置。执行配置定义了将在设备上执行该函数时使用的网格和块的维度,以及相关的流(有关流的描述,请参见CUDA Runtime)。

执行配置通过在函数名和括号内的参数列表之间插入形如<<< Dg, Db, Ns, S >>>的表达式来指定,其中:

  • Dg 的类型是 dim3 (参见 dim3),用于指定网格的维度和大小,使得 Dg.x * Dg.y * Dg.z 等于要启动的块数量;

  • Db 的类型为 dim3 (参见 dim3),用于指定每个块的维度和大小,使得 Db.x * Db.y * Db.z 等于每个块的线程数;

  • Ns 类型为 size_t,指定了每个块为此调用在静态分配内存之外动态分配的共享内存字节数;这部分动态分配的内存可用于__shared__中提到的任何声明为外部数组的变量;Ns 是一个可选参数,默认值为0;

  • S 的类型为 cudaStream_t,用于指定关联的流;S 是一个可选参数,默认值为 0。

例如,一个声明为

__global__ void Func(float* parameter);

必须这样调用:

Func<<< Dg, Db, Ns >>>(parameter);

执行配置的参数在实际函数参数之前被评估。

如果DgDb超过设备允许的最大尺寸(如Compute Capabilities中所述),或者Ns超过设备上可用的最大共享内存量减去静态分配所需的共享内存量,则函数调用将失败。

计算能力9.0及以上版本允许用户指定编译时线程块集群维度,使内核能够在CUDA中使用集群层次结构。编译时集群维度可通过__cluster_dims__([x, [y, [z]]])指定。以下示例展示了X维度为2、Y和Z维度为1的编译时集群大小。

__global__ void __cluster_dims__(2, 1, 1) Func(float* parameter);

__cluster_dims__()的默认形式指定内核将作为集群网格启动。用户不指定集群维度时,可以在启动时自由指定维度。若在启动时未指定维度,将导致启动错误。

线程块簇的维度也可以在运行时指定,并且可以使用cudaLaunchKernelEx API启动带有簇的内核。该API接收一个类型为cudaLaunchConfig_t的配置参数、内核函数指针和内核参数。下面的示例展示了运行时内核配置。

__global__ void Func(float* parameter);


// 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 = Dg;
    config.blockDim = Db;
    config.dynamicSmemBytes = Ns;

    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* parameter;
    cudaLaunchKernelEx(&config, Func, parameter);
}

7.38. 启动边界

多处理器级别中详细讨论的那样,内核使用的寄存器越少,可能驻留在多处理器上的线程和线程块就越多,这可以提高性能。

因此,编译器会使用启发式方法来最小化寄存器使用量,同时将寄存器溢出(参见设备内存访问)和指令数保持在最低水平。应用程序可以通过以启动边界的形式向编译器提供额外信息来辅助这些启发式方法,这些启动边界是在__global__函数定义中使用__launch_bounds__()限定符指定的:

__global__ void
__launch_bounds__(maxThreadsPerBlock, minBlocksPerMultiprocessor, maxBlocksPerCluster)
MyKernel(...)
{
    ...
}
  • maxThreadsPerBlock 指定应用程序启动MyKernel()时每个块的最大线程数;它会编译为.maxntidPTX指令。

  • minBlocksPerMultiprocessor 是可选参数,用于指定每个多处理器期望的最小常驻块数;它会编译为 .minnctapersmPTX 指令。

  • maxBlocksPerCluster 是可选参数,用于指定应用程序启动MyKernel()时每个集群所需的最大线程块数;它会编译为.maxclusterrankPTX指令。

如果指定了启动边界,编译器首先从中推导出内核应使用的寄存器数量上限L,以确保minBlocksPerMultiprocessor个块(如果未指定minBlocksPerMultiprocessor则为单个块)的maxThreadsPerBlock线程可以驻留在多处理器上(有关内核使用的寄存器数量与每个块分配的寄存器数量之间的关系,请参阅硬件多线程)。然后编译器通过以下方式优化寄存器使用:

  • 如果初始寄存器使用量高于L,编译器会进一步降低使用量直至小于或等于L,这通常会以增加本地内存使用量和/或指令数量为代价;

  • 如果初始寄存器使用量低于L

    • 如果指定了maxThreadsPerBlock但未指定minBlocksPerMultiprocessor,编译器将使用maxThreadsPerBlock来确定寄存器使用阈值,用于在nn+1个驻留块之间转换(即如多处理器级别示例所示,当减少一个寄存器可为额外驻留块腾出空间时),然后应用与未指定启动边界时类似的启发式方法;

    • 如果同时指定了minBlocksPerMultiprocessormaxThreadsPerBlock,编译器可能会将寄存器使用量提高到L,以减少指令数量并更好地隐藏单线程指令延迟。

如果内核执行的每个块线程数超过其启动限制maxThreadsPerBlock,则内核将无法启动。

如果内核执行的每个集群线程块数量超过其启动限制maxBlocksPerCluster,则内核将无法启动。

CUDA内核所需的每线程资源可能会以不希望的方式限制最大块大小。为了保持对未来硬件和工具包的向前兼容性,并确保至少一个线程块可以在SM上运行,开发者应包含单参数__launch_bounds__(maxThreadsPerBlock),该参数指定内核将启动的最大块大小。若不这样做,可能会导致"请求启动的资源过多"错误。在某些情况下,提供双参数版本的__launch_bounds__(maxThreadsPerBlock,minBlocksPerMultiprocessor)可以提高性能。minBlocksPerMultiprocessor的正确值应通过详细的内核分析来确定。

对于给定的内核,最佳启动边界通常会在主要架构版本之间有所不同。以下示例代码展示了如何在设备代码中利用应用兼容性中引入的__CUDA_ARCH__宏来处理这种情况。

#define THREADS_PER_BLOCK          256
#if __CUDA_ARCH__ >= 200
    #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

// Device code
__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>>>(...);

然而这不会生效,因为如Application Compatibility中所述,__CUDA_ARCH__在主机代码中未定义,所以即使当__CUDA_ARCH__大于或等于200时,MyKernel仍将以每块256个线程启动。相反,应该确定每块的线程数:

  • 要么在编译时使用不依赖__CUDA_ARCH__的宏,例如

    // 主机代码
    MyKernel<<<blocksPerGrid, THREADS_PER_BLOCK>>>(...);
    
  • 或者根据计算能力在运行时确定

    // 主机代码
    cudaGetDeviceProperties(&deviceProp, device);
    int threadsPerBlock =
              (deviceProp.major >= 2 ?
                        2 * THREADS_PER_BLOCK : THREADS_PER_BLOCK);
    MyKernel<<<blocksPerGrid, threadsPerBlock>>>(...);
    

寄存器使用情况通过编译器选项--ptxas-options=-v进行报告。常驻块的数量可以从CUDA分析器报告的占用率中得出(关于占用率的定义,请参阅设备内存访问)。

7.39. 每个线程的最大寄存器数量

为了提供底层性能调优机制,CUDA C++提供了__maxnreg__()函数限定符,用于向后端优化编译器传递性能调优信息。__maxnreg__()限定符指定了线程块中单个线程可分配的最大寄存器数量。在__global__函数的定义中:

__global__ void
__maxnreg__(maxNumberRegistersPerThread)
MyKernel(...)
{
    ...
}
  • maxNumberRegistersPerThread 指定了内核函数MyKernel()中单个线程可分配的最大寄存器数量;该参数会编译为.maxnregPTX指令。

__launch_bounds__()__maxnreg__() 限定符不能应用于同一个内核。

也可以通过使用maxrregcount编译器选项来控制文件中所有__global__函数的寄存器使用情况。对于带有__maxnreg__限定符的函数,maxrregcount的值将被忽略。

7.40. #pragma unroll

默认情况下,编译器会自动展开具有已知循环次数的小型循环。而#pragma unroll指令可用于控制任意给定循环的展开操作。该指令必须紧贴在循环语句之前,且仅作用于当前循环。其后可选择性地跟随一个整型常量表达式(ICE)13。若未指定ICE,当循环次数为常量时将完全展开该循环;若ICE值为1,编译器将不展开该循环。当ICE值为非正整数或超过int数据类型最大可表示值时,该编译指令将被忽略。

示例:

struct S1_t { static const int value = 4; };
template <int X, typename T2>
__device__ void foo(int *p1, int *p2) {

// no argument specified, loop will be completely unrolled
#pragma unroll
for (int i = 0; i < 12; ++i)
  p1[i] += p2[i]*2;

// unroll value = 8
#pragma unroll (X+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 (T2::value)
for (int i = 0; i < 12; ++i)
  p1[i] += p2[i]*16;
}

__global__ void bar(int *p1, int *p2) {
foo<7, S1_t>(p1, p2);
}

7.41. SIMD视频指令

PTX ISA 3.0版本包含SIMD(单指令多数据)视频指令,这些指令可对16位值对和8位值四元组进行操作。这些功能在计算能力3.0的设备上可用。

SIMD视频指令包括:

  • vadd2, vadd4

  • vsub2, vsub4

  • vavrg2, vavrg4

  • vabsdiff2, vabsdiff4

  • vmin2, vmin4

  • vmax2, vmax4

  • vset2, vset4

PTX指令(例如SIMD视频指令)可以通过汇编器asm()语句包含在CUDA程序中。

asm()语句的基本语法是:

asm("template-string" : "constraint"(output) : "constraint"(input)"));

使用vabsdiff4 PTX指令的示例如下:

asm("vabsdiff4.u32.u32.u32.add" " %0, %1, %2, %3;": "=r" (result):"r" (A), "r" (B), "r" (C));

这里使用vabsdiff4指令来计算整数四字节SIMD绝对差值和。以SIMD方式对无符号整数A和B的每个字节计算绝对差值。通过指定可选的累加操作(.add)来对这些差值进行求和。

有关在代码中使用汇编语句的详细信息,请参阅文档《在CUDA中使用内联PTX汇编》。关于您所使用的PTX版本对应的PTX指令详情,请参阅PTX ISA文档(例如《并行线程执行ISA 3.0版》)。

7.42. 诊断编译指示

以下编译指示可用于控制发出特定诊断消息时使用的错误严重级别。

#pragma nv_diag_suppress
#pragma nv_diag_warning
#pragma nv_diag_error
#pragma nv_diag_default
#pragma nv_diag_once

这些编译指令的用途具有以下形式:

#pragma nv_diag_xxx error_number, error_number ...

受影响的诊断信息通过警告消息中显示的错误编号来指定。任何诊断信息都可以被强制设为错误级别,但只有警告信息可以降低其严重级别,或在被提升为错误后恢复为警告级别。nv_diag_default编译指示用于将诊断信息的严重级别恢复到发出任何编译指示之前的状态(即经过命令行选项修改后的消息默认严重级别)。以下示例抑制了foo变量声明时出现的"已声明但从未引用"警告:

#pragma nv_diag_suppress 177
void foo()
{
  int i=0;
}
#pragma nv_diag_default 177
void bar()
{
  int i=0;
}

以下编译指令可用于保存和恢复当前的诊断编译状态:

#pragma nv_diagnostic push
#pragma nv_diagnostic pop

示例:

#pragma nv_diagnostic push
#pragma nv_diag_suppress 177
void foo()
{
  int i=0;
}
#pragma nv_diagnostic pop
void bar()
{
  int i=0;
}

请注意,这些编译指示仅影响nvcc CUDA前端编译器;它们对主机编译器没有影响。

移除通知:从CUDA 12.0开始,移除了对不带nv_前缀的诊断编译指示的支持。如果这些编译指示位于设备代码中,将会发出unrecognized #pragma in device code警告;否则它们将被传递给主机编译器。如果这些编译指示是用于CUDA代码的,请改用带nv_前缀的编译指示。

11

当外围的__host__函数是模板时,nvcc当前在某些情况下可能无法发出诊断消息;此行为在未来可能会改变。

12

目的是防止主机编译器在遇到对该函数的调用时,如果主机编译器不支持该函数。

13(1,2)

请参阅C++标准中关于整型常量表达式的定义。

8. 协作组

8.1. 简介

协作组(Cooperative Groups)是CUDA编程模型的扩展功能,自CUDA 9版本引入,用于组织可相互通信的线程组。该功能使开发者能够明确线程通信的粒度层级,从而表达更丰富、更高效的并行分解方案。

从历史上看,CUDA编程模型为同步协作线程提供了单一而简单的构造:通过线程块内所有线程的屏障同步,这是通过__syncthreads()内置函数实现的。然而,程序员希望以其他粒度定义和同步线程组,从而通过"集体"式的全组函数接口实现更高性能、更灵活的设计和软件复用。为了表达更广泛的并行交互模式,许多注重性能的程序员不得不自行编写临时且不安全的原语,用于在单个线程束内或运行在单个GPU上的多个线程块之间同步线程。虽然这些性能提升往往很有价值,但这导致了脆弱代码的不断积累,这些代码的编写、调优和维护成本高昂,且难以跨GPU代际兼容。协作组(Cooperative Groups)通过提供安全且面向未来的机制来解决这个问题,从而实现高性能代码。

8.2. 协作组中的新特性

8.2.1. CUDA 12.2

8.2.2. CUDA 12.1

8.2.3. CUDA 12.0

  • 以下实验性API现已移至主命名空间:

    • 异步归约和扫描更新在CUDA 11.7中新增

    • thread_block_tile 在CUDA 11.1中增加了大于32的支持

  • 在计算能力8.0或更高版本上创建这些大型分块时,不再需要使用block_tile_memory对象来提供内存。

8.3. 编程模型概念

协作组(Cooperative Groups)编程模型描述了CUDA线程块内部及跨线程块的同步模式。它既提供了应用程序定义自定义线程组的方法,也提供了同步这些线程组的接口。该模型还提供了新的启动API,这些API强制执行特定限制,从而能确保同步机制正常工作。这些基础功能使得CUDA能够实现新型协作并行模式,包括生产者-消费者并行、机会主义并行以及跨整个网格的全局同步。

Cooperative Groups编程模型包含以下元素:

  • 用于表示协作线程组的数据类型;

  • 用于获取由CUDA启动API(例如线程块)定义的隐式组的操作;

  • 将现有组分区为新组的集合操作;

  • 用于数据移动和操作的集合算法(例如 memcpy_async、reduce、scan);

  • 一个用于同步组内所有线程的操作;

  • 用于检查群组属性的操作;

  • 提供底层、特定于群组且通常由硬件加速的集体操作。

Cooperative Groups中的核心概念是将线程集合命名为对象。通过将组作为一等程序对象来表达,可以改进软件组合,因为集合函数可以接收一个明确表示参与线程组的对象。该对象还能明确表达程序员的意图,从而消除不合理的架构假设,这些假设会导致代码脆弱、对编译器优化的不必要限制,并能更好地兼容新一代GPU。

为了编写高效代码,最好使用专门的线程组(采用通用方式会失去大量编译时优化),并通过引用将这些组对象传递给那些打算以某种协作方式使用这些线程的函数。

Cooperative Groups需要CUDA 9.0或更高版本。要使用Cooperative Groups,请包含头文件:

// Primary header is compatible with pre-C++11, collective algorithm headers require C++11
#include <cooperative_groups.h>
// Optionally include for memcpy_async() collective
#include <cooperative_groups/memcpy_async.h>
// Optionally include for reduce() collective
#include <cooperative_groups/reduce.h>
// Optionally include for inclusive_scan() and exclusive_scan() collectives
#include <cooperative_groups/scan.h>

并使用Cooperative Groups命名空间:

using namespace cooperative_groups;
// Alternatively use an alias to avoid polluting the namespace with collective algorithms
namespace cg = cooperative_groups;

代码可以使用nvcc正常编译,但如果希望使用memcpy_async、reduce或scan功能,且主机编译器的默认方言不是C++11或更高版本,则必须在命令行中添加--std=c++11

8.3.1. 组合示例

为了说明分组的概念,这个示例尝试执行一个块范围内的求和归约。之前编写这段代码时,实现上存在一些隐藏的限制条件:

__device__ int sum(int *x, int n) {
    // ...
    __syncthreads();
    return total;
}

__global__ void parallel_kernel(float *x) {
    // ...
    // Entire thread block must call sum
    sum(x, n);
}

线程块中的所有线程都必须到达__syncthreads()屏障,但这个约束对可能想使用sum(…)的开发者是隐藏的。使用Cooperative Groups时,更好的编写方式是:

__device__ int sum(const thread_block& g, int *x, int n) {
    // ...
    g.sync()
    return total;
}

__global__ void parallel_kernel(...) {
    // ...
    // Entire thread block must call sum
    thread_block tb = this_thread_block();
    sum(tb, x, n);
    // ...
}

8.4. 分组类型

8.4.1. 隐式分组

隐式组表示内核的启动配置。无论内核如何编写,它始终具有固定数量的线程、块和块维度,一个单独的网格和网格维度。此外,如果使用了多设备协作启动API,它可以拥有多个网格(每个设备一个网格)。这些组为分解成更细粒度的组提供了起点,这些组通常由硬件加速,并且更专门针对开发人员正在解决的问题。

虽然您可以在代码的任何位置创建隐式组,但这样做存在风险。为隐式组创建句柄是一个集体操作——组内的所有线程都必须参与。如果该组创建在并非所有线程都能到达的条件分支中,则可能导致死锁或数据损坏。因此,建议您预先创建隐式组的句柄(尽可能早,在任何分支发生之前),并在整个内核中使用该句柄。出于同样的原因,组句柄必须在声明时初始化(没有默认构造函数),并且不建议使用复制构造函数。

8.4.1.1. 线程块组

任何CUDA程序员都已经熟悉一种特定的线程组:线程块。Cooperative Groups扩展引入了一种新的数据类型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()的令牌。更多详情请参阅here

void barrier_wait(thread_block::arrival_token&& t): 等待thread_block屏障,接收从barrier_arrive()返回的到达令牌作为右值引用。更多详情here

static unsigned int thread_rank(): 调用线程在[0, num_threads)范围内的排名

static dim3 group_index(): 在启动的网格中块的3维索引

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 数据类型,后者可用于表示更广泛的组类别。

8.4.1.2. 集群组

该组对象代表在单个集群中启动的所有线程。请参阅线程块集群。这些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()返回的到达令牌作为右值引用。更多详情请见here

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()的别名)

8.4.1.3. 网格分组

该组对象代表在单个网格中启动的所有线程。除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()的令牌。更多详情请见here

void barrier_wait(grid_group::arrival_token&& t): 在网格屏障上等待,接收从barrier_arrive()返回的到达令牌作为右值引用。更多详情请见here

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(): 在启动的网格中块的3维索引

static dim3 cluster_index(): 在启动的网格中集群的3维索引

遗留成员函数(别名):

static unsigned long long size(): 组中的线程总数(num_threads()的别名)

static dim3 group_dim(): 启动网格的维度(dim_blocks()的别名)

8.4.1.4. 多网格组

该组对象表示在多设备协作启动中跨所有设备启动的所有线程。与grid.group不同,所有API都要求您使用了适当的启动API。

class multi_grid_group;

构建方式:

// Kernel must be launched with the cooperative multi-device API
multi_grid_group g = this_multi_grid();

公共成员函数:

bool is_valid() const: 返回multi_grid_group是否可用

void sync() const: 同步组内命名的线程

unsigned long long num_threads() const: 组中的线程总数

unsigned long long thread_rank() const: 调用线程在[0, num_threads)范围内的排名

unsigned int grid_rank() const: 网格在[0,num_grids]范围内的等级

unsigned int num_grids() const: 启动的总网格数

遗留成员函数(别名):

unsigned long long size() const: 组中的线程总数(num_threads()的别名)

弃用通知:multi_grid_group 已在 CUDA 11.3 中针对所有设备弃用。

8.4.2. 显式分组

8.4.2.1. 线程块瓦片

一种模板化的平铺组版本,其中使用模板参数来指定平铺大小——由于在编译时已知这一点,因此有可能实现更优化的执行。

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: 请参阅Warp Shuffle Functions, 注意:对于大于32的大小,组内所有线程必须指定相同的src_rank,否则行为是未定义的。

T shfl_up(T var, int delta) const: 请参阅Warp Shuffle Functions,仅适用于小于或等于32的大小。

T shfl_down(T var, int delta) const: 请参阅Warp Shuffle Functions,仅适用于大小小于或等于32的情况。

T shfl_xor(T var, int delta) const: 请参阅Warp Shuffle Functions,仅适用于小于或等于32的大小。

int any(int predicate) const: 参考Warp Vote Functions

int all(int predicate) const: 请参考Warp Vote Functions

unsigned int ballot(int predicate) const: 请参考Warp Vote Functions,仅适用于大小小于或等于32的情况。

unsigned int match_any(T val) const: 请参考Warp Match Functions,仅适用于大小小于或等于32的情况。

unsigned int match_all(T val, int &pred) const: 请参考Warp Match Functions,仅适用于大小小于或等于32的情况。

遗留成员函数(别名):

unsigned long long size() const: 组中的线程总数(num_threads()的别名)

注意:

  • 这里使用了模板化数据结构thread_block_tile,组的大小作为模板参数而非函数参数传递给tiled_partition调用。

  • shfl, shfl_up, shfl_down, and shfl_xor 函数在使用C++11或更高版本编译时可接受任何类型的对象。这意味着只要满足以下约束条件,就可以对非整数类型进行洗牌操作:

    • 符合可平凡复制的要求,即 is_trivially_copyable::value == true

    • 当分块大小小于或等于32时,sizeof(T) <= 32;对于更大的分块,sizeof(T) <= 8

  • 在计算能力7.5或更低的硬件上,大于32的tile需要为其预留少量内存。这可以通过使用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的tile。接受block_tile_memory参数的this_thread_block重载是一个集体操作,必须在thread_block中的所有线程中调用。

    block_tile_memory可以在计算能力8.0或更高的硬件上使用,以便能够编写针对多个不同计算能力的单一源代码。在不需要的情况下,当在共享内存中实例化时,它应该不消耗内存。

示例:

/// 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);

    // ...
}
8.4.2.1.1. Warp同步代码模式

开发者可能编写了依赖于特定warp大小的隐式假设的warp同步代码,并围绕该数值进行编程。现在需要明确指定这一参数。

__global__ void cooperative_kernel(...) {
    // obtain default "current thread block" group
    thread_block my_block = this_thread_block();

    // subdivide into 32-thread, tiled subgroups
    // Tiled subgroups evenly partition a parent group into
    // adjacent sets of threads - in this case each one warp in size
    auto my_tile = tiled_partition<32>(my_block);

    // This operation will be performed by only the
    // first 32-thread tile of each block
    if (my_tile.meta_group_rank() == 0) {
        // ...
        my_tile.sync();
    }
}
8.4.2.1.2. 单线程组

表示当前线程的组可以从this_thread函数获取:

thread_block_tile<1> this_thread();

以下memcpy_async API使用thread_group,将int元素从源复制到目标:

#include <cooperative_groups.h>
#include <cooperative_groups/memcpy_async.h>

cooperative_groups::memcpy_async(cooperative_groups::this_thread(), dest, src, sizeof(int));

关于使用this_thread执行异步拷贝的更详细示例,可以在使用cuda::pipeline的单阶段异步数据拷贝使用cuda::pipeline的多阶段异步数据拷贝章节中找到。

8.4.2.2. 合并组

在CUDA的SIMT架构中,硬件层面的多处理器以32个线程为一组(称为warp)执行。如果应用程序代码中存在数据依赖的条件分支导致warp内的线程发生分化,则该warp会串行执行每个分支路径,同时禁用不在当前路径上的线程。保持活跃状态的线程被称为合并线程。Cooperative Groups功能可以发现并创建一个包含所有合并线程的组。

通过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: 请参阅Warp Shuffle Functions

T shfl_up(T var, int delta) const: 请参阅Warp Shuffle Functions

T shfl_down(T var, int delta) const: 请参考Warp Shuffle Functions

int any(int predicate) const: 参考Warp Vote Functions

int all(int predicate) const: 请参考Warp Vote Functions

unsigned int ballot(int predicate) const: 请参阅Warp Vote Functions

unsigned int match_any(T val) const: 请参考Warp Match Functions

unsigned int match_all(T val, int &pred) const: 参见Warp Match Functions

遗留成员函数(别名):

unsigned long long size() const: 组中的线程总数(num_threads()的别名)

注意:

shfl, shfl_up, and shfl_down 函数在使用C++11或更高版本编译时,可以接受任何类型的对象。这意味着只要满足以下约束条件,就可以对非整数类型进行洗牌操作:

  • 符合可平凡复制的条件,即 is_trivially_copyable::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();
    }
}
8.4.2.2.1. 发现模式

开发者通常需要处理当前活跃的线程集合。这里不对存在的线程做任何假设,开发者直接处理当前存在的线程。以下示例展示了"在warp内跨线程进行原子增量聚合"的操作(使用正确的CUDA 9.0内置函数集编写):

{
    unsigned int writemask = __activemask();
    unsigned int total = __popc(writemask);
    unsigned int prefix = __popc(writemask & __lanemask_lt());
    // Find the lowest-numbered active lane
    int elected_lane = __ffs(writemask) - 1;
    int base_offset = 0;
    if (prefix == 0) {
        base_offset = atomicAdd(p, total);
    }
    base_offset = __shfl_sync(writemask, base_offset, elected_lane);
    int thread_offset = prefix + base_offset;
    return thread_offset;
}

这可以使用Cooperative Groups重写如下:

{
    cg::coalesced_group g = cg::coalesced_threads();
    int prev;
    if (g.thread_rank() == 0) {
        prev = atomicAdd(p, g.num_threads());
    }
    prev = g.thread_rank() + g.shfl(prev, 0);
    return prev;
}

8.5. 分组分区

8.5.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 整除。允许的父组类型为 thread_blockthread_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

示例:

/// The following code will create a 32-thread tile
thread_block block = this_thread_block();
thread_block_tile<32> tile32 = tiled_partition<32>(block);

我们可以将这些组进一步划分为更小的组,每组包含4个线程:

auto tile4 = tiled_partition<4>(tile32);
// or using a general group
// thread_group tile4 = tiled_partition(tile32, 4);

例如,如果我们接下来要包含以下这行代码:

if (tile4.thread_rank()==0) printf("Hello from tile4 rank 0\n");

那么该语句将由块中每第四个线程打印:每个tile4组中排名为0的线程,这些线程对应于block组中排名为0、4、8、12等的线程。

8.5.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

8.5.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。

该实现可能导致调用线程等待,直到父组的所有成员都调用了该操作后才会恢复执行。

注意:此功能仍在评估中,未来可能会有轻微调整。

代码生成要求: 最低计算能力7.0,C++11

示例:

/// 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));
}

8.6. 分组集合操作

Cooperative Groups库提供了一组可由线程组执行的集体操作。这些操作需要指定组中所有线程的参与才能完成。除非参数描述中明确允许使用不同值,否则组中所有线程都需要为每个集体调用传递对应参数的相同值。否则调用的行为将是未定义的。

8.6.1. 同步

8.6.1.1. barrier_arrivebarrier_wait

T::arrival_token T::barrier_arrive();
void T::barrier_wait(T::arrival_token&&);

barrier_arrivebarrier_wait 成员函数提供了与 cuda::barrier (了解更多) 类似的同步API。协作组(Cooperative Groups)会自动初始化组屏障,但由于这些操作的集体性质,到达和等待操作有一个额外限制:组内所有线程必须在每个阶段都到达屏障并等待一次。 当使用组调用 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();
}

8.6.1.2. sync

static void T::sync();

template <typename T>
void sync(T& group);

sync 同步组内命名的线程。组类型 T 可以是任何现有的组类型,因为它们都支持同步功能。该功能可作为每个组类型的成员函数使用,也可作为以组为参数的独立函数调用。 如果组是 grid_groupmulti_grid_group,则必须使用相应的协作启动API来启动内核。功能等同于 T.barrier_wait(T.barrier_arrive())

8.6.2. 数据传输

8.6.2.1. memcpy_async

memcpy_async 是一种组级集体内存拷贝操作,利用硬件加速支持从全局内存到共享内存的非阻塞内存事务。对于组内指定的一组线程,memcpy_async 将通过单个流水线阶段移动指定数量的字节或输入类型的元素。此外,为了在使用 memcpy_async API 时获得最佳性能,共享内存和全局内存都需要16字节对齐。需要注意的是,虽然一般情况下这是内存拷贝操作,但只有当源是全局内存、目标是共享内存且两者都能以16、8或4字节对齐寻址时,该操作才是异步的。异步复制的数据应仅在调用wait或wait_prior之后读取,这些调用标志着相应阶段已完成将数据移动到共享内存。

必须等待所有未完成的请求可能会损失一些灵活性(但换来简单性)。为了高效重叠数据传输和执行,关键在于能够在等待和处理第N个请求的同时,启动第N+1memcpy_async请求。为此,可使用memcpy_async并通过基于集合阶段的wait_prior API进行等待。详见wait and 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,则两者必须指定相同的对齐方式。

勘误说明 CUDA 11.1引入的memcpy_async API同时支持源地址和目标地址的输入布局参数,要求布局参数以元素数量而非字节数表示。元素类型由TyElem推导得出,其大小为sizeof(TyElem)。若使用cuda::aligned_size_t类型作为布局参数,则指定的元素数量乘以sizeof(TyElem)必须是N的倍数,建议采用std::bytechar作为元素类型。

如果指定的副本形状或布局类型为cuda::aligned_size_t,则保证对齐至少为min(16, N)。在这种情况下,dstsrc指针都需要按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;
    }
}

8.6.2.2. wait and wait_prior

template <typename TyGroup>
void wait(TyGroup & group);

template <unsigned int NumStages, typename TyGroup>
void wait_prior(TyGroup & group);

waitwait_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 fo 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
}

8.6.3. 数据操作

8.6.3.1. reduce

template <typename TyGroup, typename TyArg, typename TyOp>
auto reduce(const TyGroup& group, TyArg&& val, TyOp&& op) -> decltype(op(val, val));

reduce 对传入线程组中每个线程提供的数据执行归约操作。该功能利用硬件加速(在计算能力80及更高版本的设备上)支持算术加法、最小值或最大值运算,以及逻辑与、或、异或操作,同时为旧世代硬件提供软件回退方案。仅4字节类型的数据可获得硬件加速。

group: 有效的组类型包括 coalesced_groupthread_block_tile

val: 满足以下要求的任意类型:

  • 符合可平凡复制的要求,即 is_trivially_copyable::value == true

  • sizeof(T) <= 32 对于 coalesced_group 以及大小小于或等于32的tile,sizeof(T) <= 8 对于更大的tile

  • 对于给定的函数对象具有合适的算术或比较运算符。

注意: 组内的不同线程可以为此参数传递不同的值。

op: 能够为整型提供硬件加速的有效函数对象包括 plus(), less(), greater(), bit_and(), bit_xor(), bit_or()。这些函数对象必须被构造,因此需要TyVal模板参数,例如 plus()。Reduce操作还支持lambda表达式和其他可通过operator()调用的函数对象。

异步归约

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);

*_async API的异步变体通过其中一个参与线程异步计算结果,并将其存储或更新到指定目标,而不是由每个线程返回结果。要观察这些异步调用的效果,需要同步调用线程组或包含它们的更大线程组。

  • 对于原子存储或更新变体,atomic参数可以是CUDA C++标准库中提供的cuda::atomiccuda::atomic_ref。此API变体仅在CUDA C++标准库支持这些类型的平台和设备上可用。归约的结果将根据指定的op原子性地更新原子变量,例如在使用cg::plus()时,结果会被原子性地加到原子变量上。atomic持有的类型必须与TyArg的类型匹配。原子变量的作用域必须包含组内所有线程,如果多个组同时使用同一个原子变量,则其作用域必须包含所有使用该变量的组中的所有线程。原子更新采用宽松内存顺序执行。

  • 如果是指针存储变体,归约的结果将被弱存储到dst指针中。

代码生成要求:最低需要计算能力5.0,硬件加速需要计算能力8.0,C++11。

cooperative_groups/reduce.h 头文件需要被包含。

整数向量近似标准差的示例:

#include <cooperative_groups.h>
#include <cooperative_groups/reduce.h>
namespace cg = cooperative_groups;

/// Calculate approximate standard deviation of integers in vec
__device__ int std_dev(const cg::thread_block_tile<32>& tile, int *vec, int length) {
    int thread_sum = 0;

    // calculate average first
    for (int i = tile.thread_rank(); i < length; i += tile.num_threads()) {
        thread_sum += vec[i];
    }
    // cg::plus<int> allows cg::reduce() to know it can use hardware acceleration for addition
    int avg = cg::reduce(tile, thread_sum, cg::plus<int>()) / length;

    int thread_diffs_sum = 0;
    for (int i = tile.thread_rank(); i < length; i += tile.num_threads()) {
        int diff = vec[i] - avg;
        thread_diffs_sum += diff * diff;
    }

    // temporarily use floats to calculate the square root
    float diff_sum = static_cast<float>(cg::reduce(tile, thread_diffs_sum, cg::plus<int>())) / length;

    return static_cast<int>(sqrtf(diff_sum));
}

块级归约示例:

#include <cooperative_groups.h>
#include <cooperative_groups/reduce.h>
namespace cg=cooperative_groups;

/// The following example accepts input in *A and outputs a result into *sum
/// It spreads the data equally within the block
__device__ void block_reduce(const int* A, int count, cuda::atomic<int, cuda::thread_scope_block>& total_sum) {
    auto block = cg::this_thread_block();
    auto tile = cg::tiled_partition<32>(block);
    int thread_sum = 0;

    // Stride loop over all values, each thread accumulates its part of the array.
    for (int i = block.thread_rank(); i < count; i += block.size()) {
        thread_sum += A[i];
    }

    // reduce thread sums across the tile, add the result to the atomic
    // cg::plus<int> allows cg::reduce() to know it can use hardware acceleration for addition
 cg::reduce_update_async(tile, total_sum, thread_sum, cg::plus<int>());

 // synchronize the block, to ensure all async reductions are ready
    block.sync();
}

8.6.3.2. 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;
}

Reduce功能受限于编译时实现可用的信息。因此,为了利用CC 8.0中引入的硬件指令,cg::命名空间提供了多个反映硬件特性的函数对象。这些对象看起来类似于C++ STL中的对应物,但less/greater除外。与STL存在差异的原因是,这些函数对象的设计初衷是真实反映硬件指令的操作行为。

功能描述:

  • cg::plus: 接受两个值并使用operator+返回两者之和。

  • cg::less: 接受两个值并使用operator<返回较小的值。不同之处在于它返回的是较低的值而非布尔值。

  • cg::greater: 接受两个值并使用operator<返回较大的值。这与常规比较不同之处在于返回的是较大值而非布尔值。

  • cg::bit_and: 接受两个值并返回运算符&的结果。

  • cg::bit_xor: 接受两个值并返回运算符^的结果。

  • cg::bit_or: 接受两个值并返回运算符|的结果。

示例:

{
    // 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;});
}

8.6.3.3. inclusive_scanexclusive_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_scanexclusive_scan 对传入组中每个指定线程提供的数据执行扫描操作。对于exclusive_scan,每个线程的结果是该线程之前(thread_rank较低)所有线程数据的归约。inclusive_scan的结果则同时包含调用线程自身的数据。

group: 有效的组类型包括 coalesced_groupthread_block_tile

val: 满足以下要求的任意类型:

  • 符合可平凡复制的要求,即 is_trivially_copyable<TyArg>::value == true

  • sizeof(T) <= 32 对于 coalesced_group 以及大小小于或等于32的tile,sizeof(T) <= 8 对于更大的tile

  • 对于给定的函数对象具有合适的算术或比较运算符。

注意: 组内的不同线程可以为此参数传递不同的值。

op: 为方便使用而定义的函数对象包括Reduce Operators中描述的plus(), less(), greater(), bit_and(), bit_xor(), bit_or()。这些对象必须被构造,因此需要TyVal模板参数,例如plus()inclusive_scanexclusive_scan也支持可通过operator()调用的lambda表达式和其他函数对象。没有此参数的重载使用cg::plus()

扫描更新

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++标准库中提供的cuda::atomiccuda::atomic_ref。这些API变体仅在CUDA C++标准库支持这些类型的平台和设备上可用。这些变体会根据op操作符,使用组内所有线程输入值的总和来更新atomic原子对象。每个线程会将原子对象的先前值与扫描结果进行组合后返回。atomic持有的类型必须与TyVal类型匹配。原子操作的作用域必须包含组内所有线程,如果多个组同时使用同一个原子对象,其作用域必须包含所有使用该原子对象的所有组内线程。原子更新操作采用宽松的内存排序方式执行。

以下伪代码展示了scan的更新变体如何工作:

/*
 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) {
    atomicaly {
        old = atomic.load();
        atomic.store(op(old, total));
    }
}
old = group.shfl(old, selected_thread);
return op(inclusive_scan(group, val, op), old);
*/

代码生成要求:最低需要计算能力5.0,C++11。

cooperative_groups/scan.h 头文件需要被包含。

示例:

#include <stdio.h>
#include <cooperative_groups.h>
#include <cooperative_groups/scan.h>
namespace cg = cooperative_groups;

__global__ void kernel() {
    auto thread_block = cg::this_thread_block();
    auto tile = cg::tiled_partition<8>(thread_block);
    unsigned int val = cg::inclusive_scan(tile, tile.thread_rank());
    printf("%u: %u\n", tile.thread_rank(), val);
}

/*  prints for each group:
    0: 0
    1: 1
    2: 3
    3: 6
    4: 10
    5: 15
    6: 21
    7: 28
*/

使用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 ...};
}

8.6.4. 执行控制

8.6.4.1. invoke_oneinvoke_one_broadcast

template<typename Group, typename Fn, typename... Args>
void invoke_one(const Group& group, Fn&& fn, Args&&... args);

template<typename Group, typename Fn, typename... Args>
auto invoke_one_broadcast(const Group& group, Fn&& fn, Args&&... args) -> decltype(fn(args...));

invoke_one 从调用group中任意选择一个线程,并使用该线程以提供的参数args调用可调用对象fn。 对于invoke_one_broadcast的情况,调用结果还会广播给组内所有线程,并从该集体操作中返回。

调用组可以在调用提供的可调用对象之前和/或之后与选定的线程同步。这意味着在提供的可调用体内部不允许调用组内的通信,否则无法保证向前推进。在提供的可调用体内部允许与调用组外的线程进行通信。线程选择机制保证是确定性的。

在计算能力9.0或更高的设备上,当使用显式组类型调用时,可能会使用硬件加速来选择线程。

group: 所有组类型都适用于invoke_one,而coalesced_groupthread_block_tile适用于invoke_one_broadcast

fn: 可通过operator()调用的函数或对象。

args: 与可调用对象fn参数类型相匹配的类型参数包。

invoke_one_broadcast的情况下,所提供的可调用对象fn的返回类型必须满足以下要求:

  • 符合可平凡复制的条件,即 is_trivially_copyable<T>::value == true

  • sizeof(T) <= 32 对于 coalesced_group 以及大小小于或等于32的tile,sizeof(T) <= 8 对于更大的tile

代码生成要求:最低需要计算能力5.0,硬件加速需要计算能力9.0,C++11。

来自 发现模式部分 的聚合原子示例,重写为使用invoke_one_broadcast:

#include <cooperative_groups.h>
#include <cuda/atomic>
namespace cg = cooperative_groups;

template<cuda::thread_scope Scope>
__device__ unsigned int atomicAddOneRelaxed(cuda::atomic<unsigned int, Scope>& atomic) {
    auto g = cg::coalesced_threads();
    auto prev = cg::invoke_one_broadcast(g, [&] () {
        return atomic.fetch_add(g.num_threads(), cuda::memory_order_relaxed);
    });
    return prev + g.thread_rank();
}

8.7. 网格同步

在引入协作组(Cooperative Groups)之前,CUDA编程模型仅支持在内核完成边界处进行线程块间的同步。内核边界会隐式导致状态失效,并可能带来性能影响。

例如,在某些应用场景中,程序会包含大量小型内核,每个内核代表处理流水线中的一个阶段。当前CUDA编程模型要求这些内核必须存在,以确保处理前一个流水线阶段的线程块已完成数据生产,而后继流水线阶段的线程块才准备消费这些数据。在这种情况下,若能提供全局线程块间同步能力,就能将应用程序重构为使用持久线程块——这些线程块可以在设备上完成特定阶段时自行同步。

要在网格内同步,从内核中,你只需使用grid.sync()函数:

grid_group grid = this_grid();
grid.sync();

在启动内核时,需要使用cudaLaunchCooperativeKernel CUDA运行时启动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平台

8.8. 多设备同步

为了在使用协作组(Cooperative Groups)时实现跨多设备同步,需要使用cudaLaunchCooperativeKernelMultiDevice CUDA API。这与现有CUDA API有显著不同,允许单个主机线程跨多个设备启动内核。除了cudaLaunchCooperativeKernel提供的约束和保证外,该API还具有以下额外语义:

  • 该API将确保启动操作是原子性的,也就是说,如果API调用成功,那么所提供的线程块数量将在所有指定设备上启动。

  • 通过此API启动的函数必须完全相同。驱动程序在这方面没有进行显式检查,因为这基本上不可行。应用程序需自行确保这一点。

  • 提供的cudaLaunchParams中任意两个条目不能映射到同一设备。

  • 此启动针对的所有设备必须具有相同的计算能力 - 主要和次要版本。

  • 所有设备上的块大小、网格大小和每个网格的共享内存量必须相同。请注意,这意味着每个设备可启动的最大块数将受SM数量最少的设备限制。

  • 模块中存在的任何用户定义的__device____constant____managed__设备全局变量(这些变量属于正在启动的CUfunction)会在每个设备上独立实例化。用户需要负责正确初始化这些设备全局变量。

弃用通知:cudaLaunchCooperativeKernelMultiDevice已在CUDA 11.3中对所有设备弃用。替代方法的示例可在多设备共轭梯度示例中找到。

在多设备同步中实现最佳性能,需要为所有参与设备通过cuCtxEnablePeerAccesscudaDeviceEnablePeerAccess启用对等访问。

启动参数应使用结构体数组定义(每个设备一个),并通过cudaLaunchCooperativeKernelMultiDevice启动

示例:

cudaDeviceProp deviceProp;
cudaGetDeviceCount(&numGpus);

// Per device launch parameters
cudaLaunchParams *launchParams = (cudaLaunchParams*)malloc(sizeof(cudaLaunchParams) * numGpus);
cudaStream_t *streams = (cudaStream_t*)malloc(sizeof(cudaStream_t) * numGpus);

// The kernel arguments are copied over during launch
// Its also possible to have individual copies of kernel arguments per device, but
// the signature and name of the function/kernel must be the same.
void *kernelArgs[] = { /* Add kernel arguments */ };

for (int i = 0; i < numGpus; i++) {
    cudaSetDevice(i);
    // Per device stream, but its also possible to use the default NULL stream of each device
    cudaStreamCreate(&streams[i]);
    // Loop over other devices and cudaDeviceEnablePeerAccess to get a faster barrier implementation
}
// Since all devices must be of the same compute capability and have the same launch configuration
// it is sufficient to query device 0 here
cudaGetDeviceProperties(&deviceProp[i], 0);
dim3 dimBlock(numThreads, 1, 1);
dim3 dimGrid(deviceProp.multiProcessorCount, 1, 1);
for (int i = 0; i < numGpus; i++) {
    launchParamsList[i].func = (void*)my_kernel;
    launchParamsList[i].gridDim = dimGrid;
    launchParamsList[i].blockDim = dimBlock;
    launchParamsList[i].sharedMem = 0;
    launchParamsList[i].stream = streams[i];
    launchParamsList[i].args = kernelArgs;
}
cudaLaunchCooperativeKernelMultiDevice(launchParams, numGpus);

此外,与网格级同步类似,生成的设备代码看起来非常相似:

multi_grid_group multi_grid = this_multi_grid();
multi_grid.sync();

然而,代码需要通过向nvcc传递-rdc=true进行单独编译。

最佳实践是首先通过查询设备属性cudaDevAttrCooperativeMultiDeviceLaunch来确认设备是否支持多设备协同启动:

int dev = 0;
int supportsMdCoopLaunch = 0;
cudaDeviceGetAttribute(&supportsMdCoopLaunch, cudaDevAttrCooperativeMultiDeviceLaunch, dev);

如果设备0支持该属性,这将把supportsMdCoopLaunch设置为1。仅支持计算能力6.0及以上的设备。此外,您需要在Linux平台(不使用MPS)或当前版本的Windows上运行,且设备处于TCC模式。

更多信息请参阅cudaLaunchCooperativeKernelMultiDevice API文档。

9. CUDA动态并行

9.1. 简介

9.1.1. 概述

动态并行是CUDA编程模型的扩展功能,允许CUDA内核直接在GPU上创建新任务并与之同步。这种在程序需要时动态创建并行的能力提供了令人兴奋的可能性。

直接从GPU创建工作的能力可以减少在主机和设备之间传输执行控制和数据的需求,因为现在可以通过在设备上执行的线程在运行时做出启动配置决策。此外,数据相关的并行工作可以在内核运行时内联生成,动态利用GPU的硬件调度器和负载均衡器,并根据数据驱动的决策或工作负载进行自适应调整。以前需要修改算法和编程模式以消除递归、不规则循环结构或其他不符合扁平单级并行结构的构造,现在可以更透明地表达这些算法和模式。

本文档描述了CUDA支持动态并行功能的扩展能力,包括利用这些功能所需的CUDA编程模型修改与新增内容,以及发挥这一新增能力的使用指南和最佳实践。

动态并行性仅支持计算能力3.5及以上的设备。

9.1.2. 术语表

本指南中使用的术语定义。

Grid

网格(Grid)是线程(Threads)的集合。网格中的线程执行核函数(Kernel Function),并被划分为线程块(Thread Blocks)

Thread Block

线程块(Thread Block)是一组在同一个流式多处理器(SM)上执行的线程。线程块内的线程可以访问共享内存,并且能够显式同步。

Kernel Function

内核函数是一种隐式并行子程序,它在CUDA执行和内存模型下为网格中的每个线程执行。

Host

主机(Host)指的是最初调用CUDA的执行环境。通常指运行在系统CPU处理器上的线程。

Parent

一个父线程、线程块或网格是指已启动新网格(即网格)的线程。只有当其启动的所有子网格都完成后,父线程才会被视为完成。

Child

子线程、块或网格是由父网格启动的。在父线程、线程块或网格被视为完成之前,子网格必须完成。

Thread Block Scope

具有线程块作用域的对象其生命周期仅限于单个线程块内。只有当创建该对象的线程块中的线程对其进行操作时,它们才具有定义的行为,并在创建它们的线程块执行完成后被销毁。

Device Runtime

设备运行时(Device Runtime)指的是使内核函数能够使用动态并行性的运行时系统和API。

9.2. 执行环境与内存模型

9.2.1. 执行环境

CUDA执行模型基于线程、线程块和网格的基本单元,内核函数定义了由线程块和网格内各个线程执行的程序。当调用内核函数时,网格属性由执行配置描述,这在CUDA中有特殊语法。CUDA对动态并行性的支持扩展了在设备上运行的线程对新网格进行配置、启动和隐式同步的能力。

9.2.1.1. 父网格与子网格

配置并启动新网格的设备线程属于父网格,而通过调用创建的网格则是子网格。

子网格的调用和完成是正确嵌套的,这意味着父网格在所有由其线程创建的子网格完成之前不会被视作完成,且运行时保证父网格与子网格之间存在隐式同步。

Parent-Child Launch Nesting

图26 父子级启动嵌套

9.2.1.2. CUDA原语的作用范围

在主机和设备上,CUDA运行时提供了一个API用于启动内核,并通过流和事件来跟踪启动之间的依赖关系。在主机系统中,启动状态以及引用流和事件的CUDA原语由进程内的所有线程共享;然而进程之间独立执行,可能不会共享CUDA对象。

在设备上,启动的内核和CUDA对象对网格中的所有线程都是可见的。这意味着,例如,一个流可以由一个线程创建,并被网格中的任何其他线程使用。

9.2.1.3. 同步

警告

在CUDA 11.6中已弃用从父块与子内核进行显式同步(即在设备代码中使用cudaDeviceSynchronize()),并在compute_90+编译中移除。对于计算能力<9.0的设备,需要通过指定-DCUDA_FORCE_CDP1_IF_SUPPORTED进行编译时选择加入,才能继续在设备代码中使用cudaDeviceSynchronize()。请注意,这将在未来的CUDA版本中完全移除。

来自任何线程的CUDA运行时操作(包括内核启动)在整个网格的所有线程中都是可见的。这意味着父网格中的调用线程可以执行同步操作,以控制由网格中任何线程在网格中任何线程创建的流上启动的子网格的启动顺序。只有当网格中所有线程的所有启动都完成后,网格的执行才被视为完成。如果网格中的所有线程在所有子启动完成之前退出,将自动触发隐式同步操作。

9.2.1.4. 流与事件

CUDA 事件允许控制网格启动之间的依赖关系:在同一流中启动的网格会按顺序执行,而事件可用于在流之间创建依赖关系。在设备上创建的流和事件正是为了实现这一相同目的。

在网格范围内创建的流和事件仅在该网格范围内有效,若在创建它们的网格之外使用,则行为未定义。如前所述,当网格退出时,由网格启动的所有工作都会隐式同步;其中包括通过流启动的工作,所有依赖关系都会得到适当解决。对于在网格范围之外被修改的流进行操作,其行为是未定义的。

在主机上创建的流和事件在任何内核中使用时具有未定义行为,同样,由父网格创建的流和事件如果在子网格中使用也会产生未定义行为。

9.2.1.5. 排序与并发

设备运行时内核启动的顺序遵循CUDA流顺序语义。在一个网格内,所有启动到同一流的内核(稍后将讨论的fire-and-forget流除外)都是按顺序执行的。当同一网格中的多个线程向同一流发起启动时,流内的顺序取决于网格内的线程调度,这可以通过同步原语(如__syncthreads())来控制。

需要注意的是,虽然命名流(named streams)会被网格(grid)内的所有线程共享,但隐式的NULL流仅在线程块(thread block)内的线程间共享。如果线程块中的多个线程向隐式流提交任务,这些任务将按顺序执行。如果不同线程块中的多个线程向隐式流提交任务,这些任务可能会并发执行。若希望线程块内多个线程提交的任务能并发执行,则应使用显式的命名流。

动态并行使得在程序中更容易表达并发性;然而,CUDA执行模型中设备运行时并未引入新的并发保证。对于设备上任意数量的不同线程块之间,并不能保证其并发执行。

缺乏并发保证的情况同样适用于父网格及其子网格。当父网格启动子网格时,一旦流依赖条件满足且硬件资源可承载子网格,子网格就可能开始执行,但并不保证在父网格到达隐式同步点之前开始执行。

虽然通常可以轻松实现并发,但它可能会因设备配置、应用程序工作负载和运行时调度的不同而变化。因此,依赖不同线程块之间的任何并发都是不安全的。

9.2.1.6. 设备管理

设备运行时没有多GPU支持;设备运行时只能在其当前执行的设备上运行。但是,允许查询系统中任何支持CUDA的设备的属性。

9.2.2. 内存模型

父网格和子网格共享相同的全局和常量内存存储,但拥有各自独立的本地和共享内存。

9.2.2.1. 一致性与连贯性

9.2.2.1.1. 全局内存

父网格和子网格可以一致地访问全局内存,但子网格与父网格之间的内存一致性保证较弱。在子网格执行过程中,只有一个时间点其内存视图与父线程完全一致:即当父线程调用子网格的时刻。

在子网格调用之前,父线程中的所有全局内存操作对子网格都是可见的。随着移除cudaDeviceSynchronize(),父网格无法再访问子网格中线程所做的修改。在父网格退出之前访问子网格中线程所做修改的唯一方法是通过在cudaStreamTailLaunch流中启动的内核。

在以下示例中,执行child_launch的子网格只能确保看到在子网格启动前对data所做的修改。由于父线程0正在执行启动操作,子网格将与父线程0所见的内存状态保持一致。由于第一个__syncthreads()调用,子网格将看到data[0]=0data[1]=1、...、data[255]=255(若没有__syncthreads()调用,则子网格只能确保看到data[0]=0)。子网格的返回仅在隐式同步点得到保证。这意味着子网格中线程所做的修改永远无法保证对父网格可见。要访问child_launch所做的修改,需要向cudaStreamTailLaunch流中启动一个tail_launch内核。

__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);
}
9.2.2.1.2. 零拷贝内存

零拷贝系统内存具有与全局内存相同的连贯性和一致性保证,并遵循上述详细语义。内核无法分配或释放零拷贝内存,但可以使用从主机程序传入的零拷贝指针。

9.2.2.1.3. 常量内存

常量无法从设备端修改,只能从主机端修改。但是,如果在某个并发的网格在其生命周期内任何时刻访问该常量时,主机端同时修改该常量的行为是未定义的。

9.2.2.1.4. 共享内存与本地内存

共享内存和本地内存分别对线程块或线程是私有的,在父级和子级之间不可见或不一致。当这些位置中的对象在其所属范围之外被引用时,行为是未定义的,并可能导致错误。

NVIDIA编译器会尝试在检测到指向本地或共享内存的指针被作为参数传递给内核启动时发出警告。在运行时,程序员可以使用__isGlobal()内置函数来判断指针是否引用全局内存,从而可以安全地传递给子启动。

请注意,调用cudaMemcpy*Async()cudaMemset*Async()可能会在设备上启动新的子内核以保持流语义。因此,向这些API传递共享或本地内存指针是非法的,将返回错误。

9.2.2.1.5. 本地内存

本地内存是执行线程的私有存储空间,对该线程之外不可见。在启动子内核时,将指向本地内存的指针作为启动参数传递是非法操作。从子内核解引用此类本地内存指针的结果将是未定义的。

例如以下操作是非法的,如果x_arraychild_launch访问,将导致未定义行为:

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);
}
9.2.2.1.6. 纹理内存

对映射纹理的全局内存区域进行写入时,与纹理访问之间不存在一致性。纹理内存的一致性在调用子网格时以及子网格完成时强制执行。这意味着在子内核启动前对内存的写入会反映在子内核的纹理内存访问中。与上述全局内存类似,子内核对内存的写入永远无法保证会反映在父内核的纹理内存访问中。在父网格退出前访问子网格线程所做修改的唯一方式,是通过在cudaStreamTailLaunch流中启动的内核。父内核和子内核的并发访问可能导致数据不一致。

9.3. 编程接口

9.3.1. CUDA C++ 参考文档

本节介绍为支持动态并行而对CUDA C++语言扩展所做的更改和新增内容。

CUDA内核通过CUDA C++动态并行功能使用的语言接口和API,称为设备运行时,其功能与主机端可用的CUDA运行时API非常相似。为了便于在主机或设备环境中运行的代码复用,CUDA运行时API的语法和语义在可能的情况下都得到了保留。

与CUDA C++中的所有代码一样,这里概述的API和代码都是基于每线程的。这使得每个线程能够针对接下来要执行的内核或操作做出独特、动态的决策。块内线程之间执行任何提供的设备运行时API时无需同步要求,这使得设备运行时API函数可以在任意发散的内核代码中调用而不会导致死锁。

9.3.1.1. 设备端内核启动

内核可以从设备上使用标准的CUDA <<< >>>语法启动:

kernel_name<<< Dg, Db, Ns, S >>>([kernel arguments]);
  • Dg 的类型是 dim3,用于指定网格的维度和大小

  • Db 的类型是 dim3,用于指定每个线程块的维度和大小

  • Ns 的类型为 size_t,指定了每个线程块为此调用动态分配的共享内存字节数(不包括静态分配的内存)。Ns 是一个可选参数,默认值为 0。

  • S 的类型为 cudaStream_t,用于指定与此调用关联的流。该流必须已在发起调用的同一网格中分配。S 是一个可选参数,默认为 NULL 流。

9.3.1.1.1. 启动是异步的

与主机端启动相同,所有设备端内核启动相对于启动线程都是异步的。也就是说,<<<>>>启动命令会立即返回,启动线程将继续执行,直到遇到隐式启动同步点(例如在cudaStreamTailLaunch流中启动的内核)。

子网格启动会被提交到设备上,并将独立于父线程执行。子网格可能在启动后的任意时间开始执行,但无法保证在启动线程到达隐式启动同步点之前开始执行。

9.3.1.1.2. 启动环境配置

所有全局设备配置设置(例如从cudaDeviceGetCacheConfig()返回的共享内存和L1缓存大小,以及从cudaDeviceGetLimit()返回的设备限制)都将从父设备继承。同样,诸如堆栈大小等设备限制将保持原样配置。

对于主机启动的内核,从主机设置的每个内核配置将优先于全局设置。当内核从设备启动时,这些配置也将被使用。无法从设备重新配置内核的环境。

9.3.1.2.

设备运行时支持命名流和未命名流(NULL)。网格内的任何线程都可以使用命名流,但流句柄不能传递给其他子/父内核。换句话说,流应被视为创建它的网格所私有。

与主机端启动类似,在独立流中启动的工作可能会并发运行,但并不保证实际并发性。依赖子内核间并发性的程序不受CUDA编程模型支持,将产生未定义行为。

设备端不支持主机端NULL流的跨流屏障语义(详见下文)。为了保持与主机运行时的语义兼容性,所有设备流必须使用cudaStreamCreateWithFlags() API创建,并传入cudaStreamNonBlocking标志。cudaStreamCreate()调用是仅限主机运行时的API,在设备端编译时会失败。

由于设备运行时不支持cudaStreamSynchronize()cudaStreamQuery(),当应用程序需要知道流启动的子内核已完成时,应改用启动到cudaStreamTailLaunch流中的内核。

9.3.1.2.1. 隐式(NULL)流

在主机程序中,未命名(NULL)流与其他流具有额外的屏障同步语义(详见默认流)。设备运行时提供了一个线程块内所有线程共享的单一隐式未命名流,但由于所有命名流都必须使用cudaStreamNonBlocking标志创建,因此向NULL流提交的工作不会对其他任何流(包括其他线程块的NULL流)中的待处理工作插入隐式依赖。

9.3.1.2.2. 即发即弃流

名为fire-and-forget的CUDA流(cudaStreamFireAndForget)允许用户以更少的样板代码和无流跟踪开销的方式启动fire-and-forget工作。它在功能上与每次启动创建新流并在该流中启动相同,但速度更快。

即发即弃(fire-and-forget)的网格启动会立即被调度执行,无需等待之前启动的网格完成。除了父网格结束时隐式同步的情况外,其他网格启动都无法依赖于即发即弃启动的完成。因此,在父网格的即发即弃工作完成之前,尾部启动或父网格流中的下一个网格都不会启动。

// In this example, C2's launch will not wait for C1's completion
__global__ void P( ... ) {
   C1<<< ... , cudaStreamFireAndForget >>>( ... );
   C2<<< ... , cudaStreamFireAndForget >>>( ... );
}

fire-and-forget流不能用于记录或等待事件。尝试这样做会导致cudaErrorInvalidValue错误。当编译时定义了CUDA_FORCE_CDP1_IF_SUPPORTED时,不支持fire-and-forget流。使用fire-and-forget流需要以64位模式进行编译。

9.3.1.2.3. 尾部启动流

名为尾流启动(cudaStreamTailLaunch)的功能允许一个网格在其完成后调度一个新网格启动。在大多数情况下,应该可以使用尾流启动来实现与cudaDeviceSynchronize()相同的功能。

每个网格(grid)都有自己的尾部启动流(tail launch stream)。网格启动的所有非尾部启动工作会在尾部流启动前隐式同步。也就是说,父网格的尾部启动不会执行,直到父网格及其通过普通流、每线程流或即发即忘流(fire-and-forget streams)启动的所有工作都已完成。如果两个网格被启动到同一个网格的尾部启动流中,后启动的网格会等到前一个网格及其所有衍生工作完成后才会启动。

// 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位模式进行编译。

9.3.1.3. 事件

仅支持CUDA事件的流间同步功能。这意味着cudaStreamWaitEvent()受支持,但cudaEventSynchronize()cudaEventElapsedTime()cudaEventQuery()不受支持。由于不支持cudaEventElapsedTime(),必须通过cudaEventCreateWithFlags()创建cudaEvents,并传递cudaEventDisableTiming标志。

与命名流类似,事件对象可以在创建它们的网格内的所有线程之间共享,但这些事件对象仅属于该网格本地,不能传递给其他内核。事件句柄不保证在网格之间是唯一的,因此在未创建该事件句柄的网格中使用它会导致未定义行为。

9.3.1.4. 同步

程序需要自行执行足够的线程间同步操作,例如通过CUDA Event,如果调用线程需要与其他线程调用的子网格进行同步。

由于无法从父线程显式同步子工作,因此无法保证子网格中发生的更改对父网格内的线程可见。

9.3.1.5. 设备管理

只有运行内核的设备才能从该内核进行控制。这意味着设备运行时不支持诸如cudaSetDevice()之类的设备API。从GPU看到的活跃设备(通过cudaGetDevice()返回)将与主机系统看到的设备编号相同。cudaDeviceGetAttribute()调用可以请求关于其他设备的信息,因为此API允许将设备ID指定为调用的参数。请注意,设备运行时未提供通用的cudaGetDeviceProperties() API - 必须单独查询属性。

9.3.1.6. 内存声明

9.3.1.6.1. 设备与常量内存

使用__device____constant__内存空间说明符在文件作用域声明的内存在使用设备运行时表现相同。所有内核都可以读写设备变量,无论该内核最初是由主机还是设备运行时启动的。同样地,所有内核对于模块作用域声明的__constant__变量都具有相同的视图。

9.3.1.6.2. 纹理与表面

CUDA支持动态创建的纹理和表面对象14,其中纹理对象可以在主机端创建,传递给内核,由该内核使用,然后从主机端销毁。设备运行时不允许在设备代码内创建或销毁纹理或表面对象,但从主机端创建的纹理和表面对象可以在设备上自由使用和传递。无论它们在何处创建,动态创建的纹理对象始终有效,并且可以从父内核传递给子内核。

注意

设备运行时不支持从设备端启动的内核中使用旧版模块范围(即费米风格)的纹理和表面。模块范围(旧版)纹理可以从主机端创建,并像任何内核一样在设备代码中使用,但只能由顶级内核(即从主机端启动的内核)使用。

9.3.1.6.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);
}
9.3.1.6.4. 符号地址

设备端符号(即标记为__device__的符号)可以通过简单的&运算符在内核中引用,因为所有全局作用域的设备变量都位于内核可见的地址空间中。这也适用于__constant__符号,尽管在这种情况下指针将引用只读数据。

鉴于设备端符号可以直接引用,那些引用符号的CUDA运行时API(例如cudaMemcpyToSymbol()cudaGetSymbolAddress())是冗余的,因此设备运行时不再支持。请注意这意味着运行中的内核无法修改常量数据,即使在子内核启动前也不行,因为对__constant__空间的引用是只读的。

9.3.1.7. API错误与启动失败

与CUDA运行时惯例一样,任何函数都可能返回错误代码。最后返回的错误代码会被记录,并可通过cudaGetLastError()调用进行检索。错误是按线程记录的,因此每个线程都可以识别自己生成的最新错误。错误代码的类型为cudaError_t

与主机端启动类似,设备端启动也可能因多种原因(如无效参数等)而失败。用户必须调用cudaGetLastError()来检测启动是否产生了错误,但需注意启动后未报错并不代表子内核已成功完成。

对于设备端异常,例如访问无效地址,子网格中的错误将返回给主机。

9.3.1.7.1. 启动设置API

内核启动是通过设备运行时库暴露的系统级机制,因此可以直接通过底层的cudaGetParameterBuffer()cudaLaunchDevice() API从PTX访问。CUDA应用程序被允许自行调用这些API,但需满足与PTX相同的要求。在这两种情况下,用户都需要负责根据规范以正确格式填充所有必要的数据结构。这些数据结构保证向后兼容。

与主机端启动类似,设备端操作符<<<>>>映射到底层内核启动API。这样针对PTX的用户将能够执行启动,并且编译器前端可以将<<<>>>转换为这些调用。

表 10 仅限设备的新启动实现函数

运行时API启动函数

与主机运行时行为的差异描述(若无描述则表示行为相同)

cudaGetParameterBuffer

自动从<<<>>>生成。注意与主机等效API不同。

cudaLaunchDevice

自动从<<<>>>生成。注意与主机等效API不同。

这些启动函数的API与CUDA Runtime 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);

9.3.1.8. API参考

此处详细介绍了设备运行时支持的CUDA Runtime API部分。主机和设备运行时API具有相同的语法;除非另有说明,语义也相同。下表提供了与主机可用版本相比的API概览。

表 11 支持的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

9.3.2. 从PTX进行设备端启动

本节面向针对并行线程执行(PTX)的编程语言和编译器实现者,计划在其语言中支持动态并行功能。它提供了与在PTX级别支持内核启动相关的底层细节。

9.3.2.1. 内核启动API

设备端内核启动可以通过以下两个可从PTX访问的API实现:cudaLaunchDevice()cudaGetParameterBuffer()cudaLaunchDevice()通过调用cudaGetParameterBuffer()获取并填充了待启动内核参数的参数缓冲区来启动指定内核。如果待启动内核不需要任何参数,则参数缓冲区可以为NULL,即无需调用cudaGetParameterBuffer()

9.3.2.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);

第一个参数是指向待启动内核的指针,第二个参数是保存待启动内核实际参数的参数缓冲区。参数缓冲区的布局在下方参数缓冲区布局部分进行说明。其他参数指定启动配置,例如网格维度、块维度、共享内存大小以及与启动关联的流(有关启动配置的详细说明,请参阅执行配置)。

9.3.2.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
)
;

以下CUDA级别的cudaGetParameterBuffer()声明被映射到前面提到的PTX级别声明:

// CUDA-level Declaration of cudaGetParameterBuffer()
extern "C" __device__
void *cudaGetParameterBuffer(size_t alignment, size_t size);

第一个参数指定参数缓冲区的对齐要求,第二个参数指定以字节为单位的大小要求。在当前实现中,cudaGetParameterBuffer()返回的参数缓冲区始终保证64字节对齐,且对齐要求参数会被忽略。不过,建议传递正确的对齐要求值(即要放入参数缓冲区的任何参数的最大对齐值)给cudaGetParameterBuffer(),以确保未来的可移植性。

9.3.2.2. 参数缓冲区布局

参数缓冲区中禁止参数重新排序,并且要求放置在参数缓冲区中的每个单独参数必须对齐。也就是说,每个参数必须放置在参数缓冲区的第n字节处,其中n是大于前一个参数所占最后一个字节偏移量的参数大小的最小倍数。参数缓冲区的最大大小为4KB。

有关CUDA编译器生成的PTX代码更详细说明,请参阅PTX-3.5规范。

9.3.3. 动态并行性的工具包支持

9.3.3.1. 在CUDA代码中包含设备运行时API

与主机端运行时API类似,CUDA设备运行时API的原型会在程序编译期间自动包含。无需显式包含cuda_device_runtime_api.h

9.3.3.2. 编译与链接

在使用nvcc编译和链接具有动态并行特性的CUDA程序时,程序会自动链接到静态设备运行时库libcudadevrt

设备运行时以静态库形式提供(Windows上为cudadevrt.lib,Linux下为libcudadevrt.a),使用设备运行时的GPU应用程序必须链接该库。设备库的链接可以通过nvcc和/或nvlink完成。下面展示两个简单示例。

如果可以从命令行指定所有必需的源文件,设备运行时程序可以一步完成编译和链接:

$ nvcc -arch=sm_75 -rdc=true hello_world.cu -o hello -lcudadevrt

也可以先将CUDA .cu源文件编译为目标文件,然后通过两阶段流程将这些文件链接在一起:

$ nvcc -arch=sm_75 -dc hello_world.cu -o hello_world.o
$ nvcc -arch=sm_75 -rdc=true hello_world.o -o hello -lcudadevrt

更多详情请参阅《CUDA驱动编译器NVCC指南》中的"使用单独编译"章节。

9.4. 编程指南

9.4.1. 基础

设备运行时是主机运行时的一个功能子集。API级别的设备管理、内核启动、设备内存拷贝、流管理和事件管理功能都通过设备运行时暴露。

对于已有CUDA经验的开发者来说,设备运行时编程会感到非常熟悉。设备运行时的语法和语义与主机API大体相同,本文档前文已详述的例外情况除外。

以下示例展示了一个结合动态并行性的简单Hello World程序:

#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

9.4.2. 性能

9.4.2.1. 启用动态并行的内核开销

在控制动态启动时处于活动状态的系统软件可能会对当时运行的任何内核施加开销,无论该内核是否自行调用内核启动。这种开销源于设备运行时的执行跟踪和管理软件,并可能导致性能下降。通常,链接到设备运行时库的应用程序会产生这种开销。

9.4.3. 实现限制与局限性

动态并行保证本文档描述的所有语义,但某些硬件和软件资源取决于具体实现,会限制使用设备运行时的程序的规模、性能和其他特性。

9.4.3.1. 运行时

9.4.3.1.1. 内存占用

设备运行时系统软件会为各种管理目的预留内存,特别是用于跟踪待处理网格启动的预留空间。提供配置控制选项可以减小此预留区域的大小,但会以某些启动限制为代价。详情请参阅下方的配置选项

9.4.3.1.2. 待处理的内核启动

当内核启动时,所有相关的配置和参数数据都会被追踪,直到内核执行完成。这些数据存储在系统管理的启动池中。

固定大小的启动池大小可通过从主机调用cudaDeviceSetLimit()并指定cudaLimitDevRuntimePendingLaunchCount来进行配置。

9.4.3.1.3. 配置选项

设备运行时系统软件的资源分配通过主机程序中的cudaDeviceSetLimit() API进行控制。这些限制必须在任何内核启动之前设置,且在GPU正在运行程序时不可更改。

可以设置以下命名限制:

限制

行为

cudaLimitDevRuntimePendingLaunchCount

Controls the amount of memory set aside for buffering kernel launches and events which have not yet begun to execute, due either to unresolved dependencies or lack of execution resources. When the buffer is full, an attempt to allocate a launch slot during a device side kernel launch will fail and return cudaErrorLaunchOutOfResources, while an attempt to allocate an event slot will fail and return cudaErrorMemoryAllocation. The default number of launch slots is 2048. Applications may increase the number of launch and/or event slots by setting cudaLimitDevRuntimePendingLaunchCount. The number of event slots allocated is twice the value of that limit.

cudaLimitStackSize

控制每个GPU线程的堆栈大小(以字节为单位)。CUDA驱动程序会根据需要自动增加每次内核启动时的每线程堆栈大小,该大小在每次启动后不会重置回原始值。如需设置不同的每线程堆栈大小,可调用cudaDeviceSetLimit()来设置此限制。堆栈将立即调整大小,必要时设备会阻塞直到所有先前请求的任务完成。可通过调用cudaDeviceGetLimit()获取当前每线程堆栈大小。

9.4.3.1.4. 内存分配与生命周期

cudaMalloc()cudaFree() 在主机和设备环境中有不同的语义。当从主机调用时,cudaMalloc() 会从未使用的设备内存中分配新区域。当从设备运行时调用时,这些函数会映射到设备端的 malloc()free()。这意味着在设备环境中,可分配的总内存受限于设备 malloc() 堆大小,该大小可能小于可用的未使用设备内存。此外,如果主机程序对设备上通过 cudaMalloc() 分配的指针调用 cudaFree(),或者反之,都会导致错误。

cudaMalloc() 在主机上

cudaMalloc() 在设备上

cudaFree() 在主机上

已支持

不支持

cudaFree() 在设备上

不支持

已支持

分配限制

释放设备内存

cudaLimitMallocHeapSize

9.4.3.1.5. SM ID与Warp ID

请注意,在PTX中%smid%warpid被定义为易变值。设备运行时可能会将线程块重新调度到不同的SM上以更高效地管理资源。因此,依赖%smid%warpid在线程或线程块生命周期内保持不变是不安全的。

9.4.3.1.6. ECC错误

CUDA内核中的代码无法收到ECC错误的通知。ECC错误将在整个启动树完成后在主机端报告。嵌套程序执行期间出现的任何ECC错误将生成异常或继续执行(具体取决于错误类型和配置)。

9.5. CDP2 与 CDP1对比

本节总结了新版(CDP2)与旧版(CDP1)CUDA动态并行接口之间的差异、兼容性和互操作性。同时说明了在计算能力低于9.0的设备上如何选择退出CDP2接口。

9.5.1. CDP1与CDP2之间的差异

在CDP2或计算能力9.0及更高版本的设备上,显式的设备端同步不再可行。必须改用隐式同步(如尾部启动)。

尝试在CDP2环境或计算能力9.0及以上的设备上查询或设置cudaLimitDevRuntimeSyncDepth(或CU_LIMIT_DEV_RUNTIME_SYNC_DEPTH)会导致cudaErrorUnsupportedLimit错误。

CDP2不再为无法放入固定大小池的待启动任务提供虚拟化池。必须将cudaLimitDevRuntimePendingLaunchCount设置得足够大,以避免耗尽启动槽位。

对于CDP2,同时存在的事件总数有一个限制(请注意,事件只有在启动完成后才会被销毁),等于待处理启动次数的两倍。cudaLimitDevRuntimePendingLaunchCount必须设置得足够大,以避免耗尽事件槽位。

流是按网格(使用CDP2或计算能力9.0及更高版本的设备)跟踪的,而不是按线程块跟踪的。这允许将工作启动到由另一个线程块创建的流中。尝试使用CDP1执行此操作会导致cudaErrorInvalidValue

CDP2引入了尾部启动(cudaStreamTailLaunch)和即发即弃(cudaStreamFireAndForget)命名流。

CDP2 仅在64位编译模式下受支持。

9.5.2. 兼容性与互操作性

CDP2是默认选项。可以通过编译时添加-DCUDA_FORCE_CDP1_IF_SUPPORTED参数,在计算能力低于9.0的设备上选择不使用CDP2。

使用CUDA 12.0及更新版本的功能编译器(默认)

使用CUDA 12.0之前版本编译的函数,或使用CUDA 12.0及更新版本并指定了-DCUDA_FORCE_CDP1_IF_SUPPORTED参数

编译

如果设备代码引用cudaDeviceSynchronize会导致编译错误。

如果代码引用cudaStreamTailLaunchcudaStreamFireAndForget将出现编译错误。如果设备代码引用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错误。

9.6. 传统CUDA动态并行(CDP1)

关于文档的CDP2版本,请参阅上方的CUDA动态并行

9.6.1. 执行环境与内存模型 (CDP1)

关于文档的CDP2版本,请参阅上方的执行环境与内存模型

9.6.1.1. 执行环境 (CDP1)

有关文档的CDP2版本,请参阅上方的执行环境

CUDA执行模型基于线程、线程块和网格的基本概念,其中内核函数定义了由线程块和网格内各个线程执行的程序。当调用内核函数时,网格属性通过执行配置来描述,这在CUDA中有特殊语法。CUDA对动态并行性的支持扩展了在设备上运行的线程对新网格进行配置、启动和同步的能力。

警告

在CUDA 11.6中已弃用从父块与子内核进行显式同步(即在设备代码中使用cudaDeviceSynchronize()),针对compute_90+的编译已移除该功能,并计划在未来的CUDA版本中完全移除。

9.6.1.1.1. 父网格与子网格 (CDP1)

关于本文档的CDP2版本,请参阅上方的父网格与子网格

配置并启动新网格的设备线程属于父网格,而通过调用创建的网格则是子网格。

子网格的调用和完成是正确嵌套的,这意味着父网格在所有由其线程创建的子网格完成之前不会被视作已完成。即使调用线程没有显式同步所启动的子网格,运行时也保证父网格与子网格之间存在隐式同步。

警告

在CUDA 11.6中已弃用从父块与子内核进行显式同步(即在设备代码中使用cudaDeviceSynchronize()),针对compute_90+的编译已移除该功能,并计划在未来的CUDA版本中完全移除。

The GPU Devotes More Transistors to Data Processing

图27 父子级启动嵌套

9.6.1.1.2. CUDA原语的作用范围(CDP1)

请参阅上方的CUDA原语范围,了解该文档的CDP2版本。

在主机和设备上,CUDA运行时都提供了用于启动内核、等待已启动工作完成以及通过流和事件跟踪启动间依赖关系的API。在主机系统中,启动状态以及引用流和事件的CUDA原语由进程内的所有线程共享;但进程之间独立执行,可能不会共享CUDA对象。

设备上也存在类似的层级结构:启动的内核和CUDA对象对线程块内的所有线程可见,但在不同线程块之间是独立的。这意味着例如一个流可以由一个线程创建,并被同一线程块中的任何其他线程使用,但不能与其他线程块中的线程共享。

9.6.1.1.3. 同步 (CDP1)

关于文档的CDP2版本,请参阅上方的同步部分。

警告

在CUDA 11.6中已弃用从父块与子内核进行显式同步(即在设备代码中使用cudaDeviceSynchronize()),针对compute_90+编译已移除该功能,并计划在未来的CUDA版本中完全移除。

来自任何线程的CUDA运行时操作(包括内核启动)在整个线程块内都是可见的。这意味着父网格中的调用线程可以通过该线程、线程块中的其他线程或在同一线程块内创建的流来对启动的网格执行同步操作。只有当块中所有线程的启动都完成后,线程块的执行才被视为完成。如果块中的所有线程在所有子启动完成之前退出,将自动触发同步操作。

9.6.1.1.4. 流与事件 (CDP1)

关于文档的CDP2版本,请参阅上方的流与事件

CUDA 事件允许控制网格启动之间的依赖关系:在同一流中启动的网格会按顺序执行,而事件可用于在流之间创建依赖关系。在设备上创建的流和事件正是为了实现这一相同目的。

在网格内创建的流和事件存在于线程块作用域内,但当它们在其创建的线程块之外使用时,行为是未定义的。如上所述,当线程块退出时,由该块启动的所有工作都会隐式同步;其中包括启动到流中的工作,所有依赖关系都会得到适当解决。对在线程块作用域之外被修改的流进行操作的行为是未定义的。

在主机上创建的流和事件在任何内核中使用时具有未定义行为,同样,由父网格创建的流和事件如果在子网格中使用也会产生未定义行为。

9.6.1.1.5. 排序与并发 (CDP1)

关于该文档的CDP2版本,请参阅上文的排序与并发部分。

设备运行时内核启动的顺序遵循CUDA流排序语义。在一个线程块内,所有启动到同一流中的内核都是按顺序执行的。当同一线程块中的多个线程向同一流发起启动时,流内的顺序取决于块内的线程调度,这可以通过同步原语如__syncthreads()来控制。

请注意,由于流(stream)由线程块(thread block)内的所有线程共享,隐式的NULL流也是共享的。如果线程块中的多个线程向隐式流提交任务,这些任务将按顺序执行。如需实现并发,则应使用显式命名的流。

动态并行使得在程序中更容易表达并发性;然而,CUDA执行模型中设备运行时并未引入新的并发保证。对于设备上任意数量的不同线程块之间,并不能保证其并发执行。

缺乏并发保证的情况同样适用于父线程块及其子网格。当父线程块启动子网格时,在父线程块达到显式同步点(例如cudaDeviceSynchronize())之前,不能保证子网格会开始执行。

警告

在CUDA 11.6中已弃用从父块与子内核进行显式同步(即在设备代码中使用cudaDeviceSynchronize()),针对compute_90+的编译已移除该功能,并计划在未来的CUDA版本中完全移除。

虽然通常可以轻松实现并发,但它可能会因设备配置、应用程序工作负载和运行时调度的不同而变化。因此,依赖不同线程块之间的任何并发都是不安全的。

9.6.1.1.6. 设备管理 (CDP1)

有关文档的CDP2版本,请参阅上方的设备管理

设备运行时没有多GPU支持;设备运行时只能在其当前执行的设备上运行。但是,允许查询系统中任何支持CUDA的设备的属性。

9.6.1.2. 内存模型 (CDP1)

有关文档的CDP2版本,请参阅上方的内存模型

父网格和子网格共享相同的全局和常量内存存储,但拥有各自独立的本地和共享内存。

9.6.1.2.1. 一致性与连贯性(CDP1)

关于该文档的CDP2版本,请参阅上文的一致性与连贯性

9.6.1.2.1.1. 全局内存 (CDP1)

有关文档的CDP2版本,请参阅上方的全局内存

父网格和子网格可以一致地访问全局内存,但子网格与父网格之间的内存一致性保证较弱。在子网格执行过程中,有两个时间点其内存视图与父线程完全一致:一是当父线程调用子网格时,二是当子网格执行完成并通过父线程中的同步API调用发出信号时。

警告

在CUDA 11.6中已弃用从父块与子内核进行显式同步(即在设备代码中使用cudaDeviceSynchronize()),针对compute_90+的编译已移除该功能,并计划在未来的CUDA版本中完全移除。

在子网格调用之前,父线程中的所有全局内存操作对子网格都是可见的。在父线程同步等待子网格完成后,子网格的所有内存操作对父线程都是可见的。

在以下示例中,执行child_launch的子网格只能确保看到在子网格启动前对data所做的修改。由于父网格的线程0负责启动操作,子网格将与父网格线程0所见的内存状态保持一致。由于第一个__syncthreads()调用,子网格将看到data[0]=0data[1]=1、……、data[255]=255(若没有__syncthreads()调用,则仅保证子网格能看到data[0])。当子网格返回时,线程0能确保看到其子网格中线程所做的修改。这些修改只有在第二个__syncthreads()调用后,才会对父网格的其他线程可见:

__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);
       cudaDeviceSynchronize();
   }

   __syncthreads();
}

void host_launch(int *data) {
    parent_launch<<< 1, 256 >>>(data);
}
9.6.1.2.1.2. 零拷贝内存 (CDP1)

关于文档的CDP2版本,请参阅上文的零拷贝内存

零拷贝系统内存具有与全局内存相同的连贯性和一致性保证,并遵循上述详细语义。内核无法分配或释放零拷贝内存,但可以使用从主机程序传入的零拷贝指针。

9.6.1.2.1.3. 常量内存 (CDP1)

关于文档的CDP2版本,请参阅上文的常量内存

常量是不可变的,无法从设备端修改,即使在父级和子级启动之间也是如此。也就是说,所有__constant__变量的值必须在启动前从主机端设置好。所有子内核都会自动从其对应的父内核继承常量内存。

在内核线程中获取常量内存对象的地址与所有CUDA程序具有相同的语义,并且自然支持将该指针从父级传递到子级或从子级传递到父级。

9.6.1.2.1.4. 共享内存与本地内存 (CDP1)

请参阅上方的共享与本地内存,查看该文档的CDP2版本。

共享内存和本地内存分别对线程块或线程是私有的,在父级和子级之间不可见或不一致。当这些位置中的对象在其所属范围之外被引用时,行为是未定义的,并可能导致错误。

NVIDIA编译器会尝试在检测到指向本地或共享内存的指针被作为参数传递给内核启动时发出警告。在运行时,程序员可以使用__isGlobal()内置函数来判断指针是否引用全局内存,从而可以安全地传递给子启动。

请注意,调用cudaMemcpy*Async()cudaMemset*Async()可能会在设备上启动新的子内核以保持流语义。因此,向这些API传递共享或本地内存指针是非法的,将返回错误。

9.6.1.2.1.5. 本地内存 (CDP1)

关于CDP2版本的文档,请参阅上文的本地内存

本地内存是执行线程的私有存储空间,对该线程之外不可见。在启动子内核时,将指向本地内存的指针作为启动参数传递是非法操作。从子内核解引用此类本地内存指针的结果将是未定义的。

例如以下操作是非法的,如果x_arraychild_launch访问,将导致未定义行为:

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);
}
9.6.1.2.1.6. 纹理内存 (CDP1)

关于文档的CDP2版本,请参阅上方的纹理内存

对映射纹理的全局内存区域进行写入时,与纹理访问之间不存在一致性。纹理内存的一致性在调用子网格时以及子网格完成时强制执行。这意味着在子内核启动前对内存的写入会反映在子内核的纹理内存访问中。同样,子内核对内存的写入也会反映在父内核的纹理内存访问中,但前提是父内核已同步等待子内核完成。父内核和子内核的并发访问可能导致数据不一致。

警告

在CUDA 11.6中已弃用从父块与子内核进行显式同步(即在设备代码中使用cudaDeviceSynchronize()),针对compute_90+的编译已移除该功能,并计划在未来的CUDA版本中完全移除。

9.6.2. 编程接口 (CDP1)

关于文档的CDP2版本,请参阅上方的编程接口

9.6.2.1. CUDA C++ 参考文档 (CDP1)

请参阅上方的CUDA C++参考文档,获取该文档的CDP2版本。

本节介绍为支持动态并行而对CUDA C++语言扩展所做的更改和新增内容。

CUDA内核通过CUDA C++动态并行功能使用的语言接口和API,称为设备运行时,其功能与主机端可用的CUDA运行时API非常相似。为了便于在主机或设备环境中运行的代码复用,CUDA运行时API的语法和语义在可能的情况下都得到了保留。

与CUDA C++中的所有代码一样,这里概述的API和代码都是基于每线程的。这使得每个线程能够针对接下来要执行的内核或操作做出独特、动态的决策。块内线程之间执行任何提供的设备运行时API时无需同步要求,这使得设备运行时API函数可以在任意发散的内核代码中调用而不会导致死锁。

9.6.2.1.1. 设备端内核启动 (CDP1)

有关文档的CDP2版本,请参阅上方的Kernel Launch APIs

内核可以从设备上使用标准的CUDA <<< >>>语法启动:

kernel_name<<< Dg, Db, Ns, S >>>([kernel arguments]);
  • Dg 的类型是 dim3,用于指定网格的维度和大小

  • Db 的类型是 dim3,用于指定每个线程块的维度和大小

  • Ns 的类型为 size_t,指定了为此调用动态分配的每个线程块的共享内存字节数,该内存是静态分配内存之外的额外部分。Ns 是一个可选参数,默认值为 0。

  • S 的类型为 cudaStream_t,用于指定与此调用关联的流。该流必须在发起调用的同一线程块中分配。S 是可选参数,默认值为 0。

9.6.2.1.1.1. 启动是异步的 (CDP1)

关于文档的CDP2版本,请参阅上方的异步启动

与主机端启动相同,所有设备端内核启动相对于启动线程都是异步的。也就是说,<<<>>>启动命令会立即返回,启动线程将继续执行,直到遇到显式的启动同步点,例如cudaDeviceSynchronize()

警告

在CUDA 11.6中已弃用从父块与子内核进行显式同步(即在设备代码中使用cudaDeviceSynchronize()),针对compute_90+的编译已移除该功能,并计划在未来的CUDA版本中完全移除。

网格启动被提交到设备后,将独立于父线程执行。子网格可能在启动后的任意时间开始执行,但无法保证在启动线程到达显式启动同步点之前开始执行。

9.6.2.1.1.2. 启动环境配置 (CDP1)

有关CDP2版本的文档,请参阅上方的启动环境配置

所有全局设备配置设置(例如从cudaDeviceGetCacheConfig()返回的共享内存和L1缓存大小,以及从cudaDeviceGetLimit()返回的设备限制)都将从父设备继承。同样,诸如堆栈大小等设备限制将保持原样配置。

对于主机启动的内核,从主机设置的每个内核配置将优先于全局设置。当内核从设备启动时,这些配置也将被使用。无法从设备重新配置内核的环境。

9.6.2.1.2. 流(CDP1)

关于文档的CDP2版本,请参阅上方的

设备运行时支持命名流和未命名流(NULL)。命名流可由线程块内的任何线程使用,但流句柄不能传递给其他块或子/父内核。换句话说,流应被视为创建它的块所私有。不保证流句柄在块之间是唯一的,因此在未分配流的块中使用流句柄将导致未定义行为。

与主机端启动类似,在独立流中启动的工作可能会并发运行,但并不保证实际并发性。依赖子内核间并发性的程序不受CUDA编程模型支持,将产生未定义行为。

设备端不支持主机端NULL流的跨流屏障语义(详见下文)。为了保持与主机运行时的语义兼容性,所有设备流必须使用cudaStreamCreateWithFlags() API创建,并传入cudaStreamNonBlocking标志。cudaStreamCreate()调用是仅限主机运行时的API,在设备端编译时会失败。

由于设备运行时不支持cudaStreamSynchronize()cudaStreamQuery(),当应用程序需要确认流启动的子内核已完成时,应改用cudaDeviceSynchronize()

警告

在CUDA 11.6中已弃用从父块与子内核进行显式同步(即在设备代码中使用cudaDeviceSynchronize()),针对compute_90+的编译已移除该功能,并计划在未来的CUDA版本中完全移除。

9.6.2.1.2.1. 隐式(NULL)流(CDP1)

关于文档的CDP2版本,请参阅上文的隐式(NULL)流

在主机程序中,未命名(NULL)流与其他流具有额外的屏障同步语义(详见默认流)。设备运行时提供了一个在块内所有线程间共享的单一隐式未命名流,但由于所有命名流都必须使用cudaStreamNonBlocking标志创建,因此向NULL流提交的工作不会对其他任何流(包括其他线程块的NULL流)中的待处理工作插入隐式依赖。

9.6.2.1.3. 事件 (CDP1)

有关文档的CDP2版本,请参阅上方的事件

仅支持CUDA事件的流间同步功能。这意味着cudaStreamWaitEvent()受支持,但cudaEventSynchronize()cudaEventElapsedTime()cudaEventQuery()不受支持。由于不支持cudaEventElapsedTime(),必须通过cudaEventCreateWithFlags()创建cudaEvents,并传递cudaEventDisableTiming标志。

与所有设备运行时对象一样,事件对象可以在创建它们的线程块内的所有线程之间共享,但这些对象仅限于该块内部,不能传递给其他内核或同一内核内的其他块。事件句柄在不同块之间不保证唯一性,因此在未创建该事件句柄的块中使用它会导致未定义行为。

9.6.2.1.4. 同步 (CDP1)

关于文档的CDP2版本,请参阅上方的同步部分。

警告

在CUDA 11.6中已弃用从父块与子内核进行显式同步(即在设备代码中使用cudaDeviceSynchronize()),针对compute_90+的编译已移除该功能,并计划在未来的CUDA版本中完全移除。

cudaDeviceSynchronize() 函数将同步线程块中任何线程启动的所有工作,直到调用 cudaDeviceSynchronize() 的位置。请注意,cudaDeviceSynchronize() 可以从分歧代码中调用(参见 Block Wide Synchronization (CDP1))。

程序需要执行足够的额外线程间同步操作,例如通过调用__syncthreads(),如果调用线程需要与其他线程调用的子网格进行同步。

9.6.2.1.4.1. 块级同步 (CDP1)

关于文档的CDP2版本,请参阅上方的CUDA动态并行

cudaDeviceSynchronize()函数并不隐含块内同步。特别是,在没有通过__syncthreads()指令进行显式同步的情况下,调用线程无法对其他线程(除自身外)已启动的工作做出任何假设。例如,如果一个块中的多个线程各自启动工作,并且希望所有这些工作一次性同步(可能是由于基于事件的依赖关系),则需要由程序来确保在调用cudaDeviceSynchronize()之前所有这些工作都已由所有线程提交。

由于实现允许在块中的任何线程上同步启动,因此多个线程同时调用cudaDeviceSynchronize()很可能会在第一次调用时就耗尽所有工作,导致后续调用无效。

9.6.2.1.5. 设备管理 (CDP1)

有关文档的CDP2版本,请参阅上方的设备管理

只有运行内核的设备才能从该内核进行控制。这意味着设备运行时不支持诸如cudaSetDevice()之类的设备API。从GPU看到的活跃设备(通过cudaGetDevice()返回)将与主机系统看到的设备编号相同。cudaDeviceGetAttribute()调用可以请求关于其他设备的信息,因为此API允许将设备ID指定为调用的参数。请注意,设备运行时未提供通用的cudaGetDeviceProperties() API - 必须单独查询属性。

9.6.2.1.6. 内存声明 (CDP1)

有关文档的CDP2版本,请参阅上方的内存声明

9.6.2.1.6.1. 设备与常量内存(CDP1)

请参阅上方的设备和常量内存,查看该文档的CDP2版本。

使用__device____constant__内存空间说明符在文件作用域声明的内存在使用设备运行时表现相同。所有内核都可以读写设备变量,无论该内核最初是由主机还是设备运行时启动的。同样地,所有内核对于模块作用域声明的__constant__变量都具有相同的视图。

9.6.2.1.6.2. 纹理与表面(CDP1)

有关文档的CDP2版本,请参阅上方的纹理与表面

CUDA支持动态创建的纹理和表面对象14,其中纹理对象可以在主机端创建,传递给内核,由该内核使用,然后从主机端销毁。设备运行时不允许在设备代码内创建或销毁纹理或表面对象,但从主机端创建的纹理和表面对象可以在设备上自由使用和传递。无论它们在何处创建,动态创建的纹理对象始终有效,并且可以从父内核传递给子内核。

注意

设备运行时不支持从设备端启动的内核中使用旧版模块范围(即费米风格)的纹理和表面。模块范围(旧版)纹理可以从主机端创建,并像任何内核一样在设备代码中使用,但只能由顶级内核(即从主机端启动的内核)使用。

9.6.2.1.6.3. 共享内存变量声明 (CDP1)

关于文档的CDP2版本,请参阅上方的共享内存变量声明

在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);
}
9.6.2.1.6.4. 符号地址 (CDP1)

关于该文档的CDP2版本,请参阅上文的符号地址

设备端符号(即标记为__device__的符号)可以通过简单的&运算符在内核中引用,因为所有全局作用域的设备变量都位于内核可见的地址空间中。这也适用于__constant__符号,尽管在这种情况下指针将引用只读数据。

鉴于设备端符号可以直接引用,那些引用符号的CUDA运行时API(例如cudaMemcpyToSymbol()cudaGetSymbolAddress())是冗余的,因此设备运行时不再支持。请注意这意味着运行中的内核无法修改常量数据,即使在子内核启动前也不行,因为对__constant__空间的引用是只读的。

9.6.2.1.7. API错误与启动失败(CDP1)

请参阅上方的API错误与启动失败,查看该文档的CDP2版本。

与CUDA运行时惯例一样,任何函数都可能返回错误代码。最后返回的错误代码会被记录,并可通过cudaGetLastError()调用进行检索。错误是按线程记录的,因此每个线程都可以识别自己生成的最新错误。错误代码的类型为cudaError_t

与主机端启动类似,设备端启动也可能因多种原因失败(如参数无效等)。用户必须调用cudaGetLastError()来检测启动是否产生了错误,但需要注意的是,启动后未报错并不代表子内核已成功完成。

对于设备端异常,例如访问无效地址,子网格中的错误将返回给主机,而不是通过父级调用cudaDeviceSynchronize()返回。

9.6.2.1.7.1. 启动设置API (CDP1)

关于文档的CDP2版本,请参阅上方的Launch Setup APIs

内核启动是通过设备运行时库暴露的系统级机制,因此可以直接通过底层的cudaGetParameterBuffer()cudaLaunchDevice() API从PTX访问。CUDA应用程序被允许自行调用这些API,但需满足与PTX相同的要求。在这两种情况下,用户都需要负责根据规范以正确格式填充所有必要的数据结构。这些数据结构保证向后兼容。

与主机端启动类似,设备端操作符<<<>>>映射到底层内核启动API。这样针对PTX的用户将能够执行启动,并且编译器前端可以将<<<>>>转换为这些调用。

表 12 仅限设备的新启动实现函数

运行时API启动函数

与主机运行时行为的差异描述(若无描述则表示行为相同)

cudaGetParameterBuffer

自动从<<<>>>生成。注意与主机等效API不同。

cudaLaunchDevice

自动从<<<>>>生成。注意与主机等效API不同。

这些启动函数的API与CUDA Runtime 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);
9.6.2.1.8. API参考文档 (CDP1)

请参阅上方的API参考文档,获取该文档的CDP2版本。

此处详细介绍了设备运行时支持的CUDA Runtime API部分。主机和设备运行时API具有相同的语法;除非另有说明,语义也相同。下表提供了与主机可用版本相比的API概览。

表13支持的API函数

运行时API函数

详情

cudaDeviceSynchronize

仅同步从线程自身块启动的工作。

警告:请注意,从设备代码调用此API在CUDA 11.6中已被弃用,针对compute_90+的编译已被移除,并计划在未来的CUDA版本中完全删除。

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

9.6.2.2. 从PTX启动设备端计算(CDP1)

关于文档的CDP2版本,请参阅上方的从PTX启动设备端

本节面向针对并行线程执行(PTX)的编程语言和编译器实现者,计划在其语言中支持动态并行功能。它提供了与在PTX级别支持内核启动相关的底层细节。

9.6.2.2.1. 内核启动API (CDP1)

有关文档的CDP2版本,请参阅上方的Kernel Launch APIs

设备端内核启动可以通过以下两个可从PTX访问的API实现:cudaLaunchDevice()cudaGetParameterBuffer()cudaLaunchDevice()通过调用cudaGetParameterBuffer()获取并填充了待启动内核参数的参数缓冲区来启动指定内核。如果待启动内核不需要任何参数,则参数缓冲区可以为NULL,即无需调用cudaGetParameterBuffer()

9.6.2.2.1.1. cudaLaunchDevice (CDP1)

关于文档的CDP2版本,请参阅上文的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
)
;
// PTX-level Declaration of cudaLaunchDevice() when .address_size is 32
.extern .func(.param .b32 func_retval0) cudaLaunchDevice
(
  .param .b32 func,
  .param .b32 parameterBuffer,
  .param .align 4 .b8 gridDimension[12],
  .param .align 4 .b8 blockDimension[12],
  .param .b32 sharedMemSize,
  .param .b32 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);

第一个参数是指向待启动内核的指针,第二个参数是存储待启动内核实际参数的参数缓冲区。参数缓冲区的布局在下方参数缓冲区布局(CDP1)中说明。其他参数指定启动配置,例如网格维度、块维度、共享内存大小以及与启动关联的流(有关启动配置的详细说明,请参阅执行配置)。

9.6.2.2.1.2. cudaGetParameterBuffer (CDP1)

关于CDP2版本的文档,请参阅上文的cudaGetParameterBuffer

cudaGetParameterBuffer() 在使用前需要在PTX层级进行声明。根据地址大小的不同,PTX层级的声明必须采用以下两种形式之一:

// PTX-level Declaration of cudaGetParameterBuffer() when .address_size is 64
// When .address_size is 64
.extern .func(.param .b64 func_retval0) cudaGetParameterBuffer
(
  .param .b64 alignment,
  .param .b64 size
)
;
// PTX-level Declaration of cudaGetParameterBuffer() when .address_size is 32
.extern .func(.param .b32 func_retval0) cudaGetParameterBuffer
(
  .param .b32 alignment,
  .param .b32 size
)
;

以下CUDA级别的cudaGetParameterBuffer()声明被映射到前面提到的PTX级别声明:

// CUDA-level Declaration of cudaGetParameterBuffer()
extern "C" __device__
void *cudaGetParameterBuffer(size_t alignment, size_t size);

第一个参数指定参数缓冲区的对齐要求,第二个参数指定以字节为单位的大小要求。在当前实现中,cudaGetParameterBuffer()返回的参数缓冲区始终保证64字节对齐,且对齐要求参数会被忽略。不过,建议传递正确的对齐要求值(即要放入参数缓冲区的任何参数的最大对齐值)给cudaGetParameterBuffer(),以确保未来的可移植性。

9.6.2.2.2. 参数缓冲区布局 (CDP1)

关于文档的CDP2版本,请参阅上方的参数缓冲区布局

参数缓冲区中禁止参数重新排序,并且要求放置在参数缓冲区中的每个单独参数必须对齐。也就是说,每个参数必须放置在参数缓冲区的第n字节处,其中n是大于前一个参数所占最后一个字节偏移量的参数大小的最小倍数。参数缓冲区的最大大小为4KB。

有关CUDA编译器生成的PTX代码更详细说明,请参阅PTX-3.5规范。

9.6.2.3. 动态并行工具包支持 (CDP1)

有关文档的CDP2版本,请参阅上方的动态并行工具包支持

9.6.2.3.1. 在CUDA代码中包含设备运行时API(CDP1)

关于本文档的CDP2版本,请参阅上方的在CUDA代码中包含设备运行时API

与主机端运行时API类似,CUDA设备运行时API的原型会在程序编译过程中自动包含。无需显式包含cuda_device_runtime_api.h

9.6.2.3.2. 编译与链接 (CDP1)

关于文档的CDP2版本,请参阅上方的编译与链接

在使用nvcc编译和链接具有动态并行特性的CUDA程序时,程序会自动链接到静态设备运行时库libcudadevrt

设备运行时以静态库形式提供(Windows上为cudadevrt.lib,Linux下为libcudadevrt.a),使用设备运行时的GPU应用程序必须链接该库。设备库的链接可以通过nvcc和/或nvlink完成。下面展示两个简单示例。

如果可以从命令行指定所有必需的源文件,设备运行时程序可以一步完成编译和链接:

$ nvcc -arch=sm_75 -rdc=true hello_world.cu -o hello -lcudadevrt

也可以先将CUDA .cu源文件编译为目标文件,然后通过两阶段流程将这些文件链接在一起:

$ nvcc -arch=sm_75 -dc hello_world.cu -o hello_world.o
$ nvcc -arch=sm_75 -rdc=true hello_world.o -o hello -lcudadevrt

更多详情请参阅《CUDA驱动编译器NVCC指南》中的"使用单独编译"章节。

9.6.3. 编程指南 (CDP1)

关于该文档的CDP2版本,请参阅上方的编程指南

9.6.3.1. 基础概念 (CDP1)

关于该文档的CDP2版本,请参阅上方的基础部分。

设备运行时是主机运行时的一个功能子集。API级别的设备管理、内核启动、设备内存拷贝、流管理和事件管理功能都通过设备运行时暴露。

对于已有CUDA经验的开发者来说,设备运行时编程会感到非常熟悉。设备运行时的语法和语义与主机API大体相同,本文档前文已详述的例外情况除外。

警告

在CUDA 11.6中已弃用从父块与子内核进行显式同步(即在设备代码中使用cudaDeviceSynchronize()),针对compute_90+的编译已移除该功能,并计划在未来的CUDA版本中完全移除。

以下示例展示了一个结合动态并行性的简单Hello World程序:

#include <stdio.h>

__global__ void childKernel()
{
    printf("Hello ");
}

__global__ void parentKernel()
{
    // launch child
    childKernel<<<1,1>>>();
    if (cudaSuccess != cudaGetLastError()) {
        return;
    }

    // wait for child to complete
    if (cudaSuccess != cudaDeviceSynchronize()) {
        return;
    }

    printf("World!\n");
}

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

9.6.3.2. 性能 (CDP1)

关于该文档的CDP2版本,请参阅上方的性能部分。

9.6.3.2.1. 同步 (CDP1)

关于文档的CDP2版本,请参阅上方的CUDA动态并行

警告

在CUDA 11.6中已弃用从父块与子内核进行显式同步(例如在设备代码中使用cudaDeviceSynchronize()),针对compute_90+的编译已移除该功能,并计划在未来的CUDA版本中完全移除。

一个线程的同步操作可能会影响同一线程块中其他线程的性能,即使这些线程自身并未调用cudaDeviceSynchronize()。这种影响取决于底层实现。通常来说,线程块结束时对子内核的隐式同步比显式调用cudaDeviceSynchronize()更高效。因此建议仅在需要与子内核同步且线程块尚未结束时调用cudaDeviceSynchronize()

9.6.3.2.2. 支持动态并行的内核开销 (CDP1)

关于文档的CDP2版本,请参阅上文的启用动态并行性的内核开销

在控制动态启动时处于活动状态的系统软件可能会对当时运行的任何内核施加开销,无论该内核是否自行调用内核启动。这种开销源于设备运行时的执行跟踪和管理软件,并可能导致性能下降,例如,与从主机端调用相比,从设备端调用的库函数。通常,链接到设备运行时库的应用程序会产生这种开销。

9.6.3.3. 实现限制与约束条件(CDP1)

关于文档的CDP2版本,请参阅上文的实现限制与局限性

动态并行保证本文档描述的所有语义,但某些硬件和软件资源取决于具体实现,会限制使用设备运行时的程序的规模、性能和其他特性。

9.6.3.3.1. 运行时 (CDP1)

有关文档的CDP2版本,请参阅上方的运行时

9.6.3.3.1.1. 内存占用 (CDP1)

有关该文档的CDP2版本,请参阅上文的内存占用

设备运行时系统软件会为各种管理目的预留内存,特别是预留一块用于同步期间保存父网格状态的内存,以及另一块用于跟踪待启动网格的内存。可通过配置控制来减小这些预留内存的大小,但会相应限制某些启动功能。详情请参阅下方的配置选项(CDP1)

大部分保留内存被分配作为父内核状态的备用存储,用于在同步子启动时使用。保守估计,这部分内存必须支持存储设备上可能存在的最大活动线程数的状态。这意味着每个可调用cudaDeviceSynchronize()的父代可能需要高达860MB的设备内存(具体取决于设备配置),即使这些内存未被全部使用,也无法供程序使用。

9.6.3.3.1.2. 嵌套与同步深度 (CDP1)

关于文档的CDP2版本,请参阅上方的CUDA动态并行

使用设备运行时,一个内核可以启动另一个内核,而该内核又可以启动另一个内核,依此类推。每个从属启动都被视为一个新的嵌套层级,层级总数就是程序的嵌套深度同步深度定义为程序将在子启动上显式同步的最深层级。通常这比程序的嵌套深度少一,但如果程序不需要在所有层级都调用cudaDeviceSynchronize(),那么同步深度可能与嵌套深度有显著差异。

警告

在CUDA 11.6中已弃用从父块与子内核进行显式同步(即在设备代码中使用cudaDeviceSynchronize()),针对compute_90+的编译已移除该功能,并计划在未来的CUDA版本中完全移除。

总体最大嵌套深度限制为24层,但从实际角度来看,真正的限制是系统为每个新层级所需的内存容量(参见上文的内存占用(CDP1))。任何导致内核启动深度超过最大限制的操作都将失败。请注意,这也可能适用于cudaMemcpyAsync()函数,因为它本身可能会触发内核启动。详情请参阅配置选项(CDP1)

默认情况下,系统会为两级同步预留足够的存储空间。这个最大同步深度(以及相应的预留存储空间)可以通过调用cudaDeviceSetLimit()并指定cudaLimitDevRuntimeSyncDepth来控制。为了确保嵌套程序能成功执行,必须在从主机启动顶层内核之前配置好需要支持的同步层级数。如果在超过指定最大同步深度的层级调用cudaDeviceSynchronize(),将会返回错误。

系统检测到在父内核从不调用cudaDeviceSynchronize()的情况下无需为父状态保留空间时,允许进行优化。在这种情况下,由于显式父子同步永远不会发生,程序所需的内存占用将远小于保守最大值。此类程序可以指定较浅的最大同步深度,以避免后备存储的过度分配。

9.6.3.3.1.3. 待处理的内核启动 (CDP1)

关于该文档的CDP2版本,请参阅上方的Pending Kernel Launches

当内核启动时,所有相关的配置和参数数据都会被追踪,直到内核执行完成。这些数据存储在系统管理的启动池中。

启动池分为固定大小的池和性能较低的虚拟化池。设备运行时系统软件会首先尝试在固定大小的池中跟踪启动数据。当固定大小的池已满时,虚拟化池将用于跟踪新的启动。

固定大小的启动池大小可通过从主机调用cudaDeviceSetLimit()并指定cudaLimitDevRuntimePendingLaunchCount来进行配置。

9.6.3.3.1.4. 配置选项 (CDP1)

关于文档的CDP2版本,请参阅上方的配置选项

设备运行时系统软件的资源分配通过主机程序中的cudaDeviceSetLimit() API进行控制。这些限制必须在任何内核启动之前设置,且在GPU正在运行程序时不可更改。

警告

在CUDA 11.6中已弃用从父块与子内核进行显式同步(即在设备代码中使用cudaDeviceSynchronize()),针对compute_90+的编译已移除该功能,并计划在未来的CUDA版本中完全移除。

可以设置以下命名限制:

限制

行为

cudaLimitDevRuntimeSyncDepth

设置允许调用cudaDeviceSynchronize()的最大深度。启动操作可以在此深度以下执行,但超过此限制的显式同步将返回cudaErrorLaunchMaxDepthExceeded。默认的最大同步深度为2。

cudaLimitDevRuntimePendingLaunchCount

Controls the amount of memory set aside for buffering kernel launches which have not yet begun to execute, due either to unresolved dependencies or lack of execution resources. When the buffer is full, the device runtime system software will attempt to track new pending launches in a lower performance virtualized buffer. If the virtualized buffer is also full, i.e. when all available heap space is consumed, launches will not occur, and the thread’s last error will be set to cudaErrorLaunchPendingCountExceeded. The default pending launch count is 2048 launches.

cudaLimitStackSize

控制每个GPU线程的堆栈大小(以字节为单位)。CUDA驱动程序会根据需要自动增加每次内核启动时的每线程堆栈大小,该大小在每次启动后不会重置回原始值。如需设置不同的每线程堆栈大小,可调用cudaDeviceSetLimit()来设置此限制。堆栈将立即调整大小,必要时设备会阻塞直到所有先前请求的任务完成。可通过调用cudaDeviceGetLimit()获取当前每线程堆栈大小。

9.6.3.3.1.5. 内存分配与生命周期 (CDP1)

关于文档的CDP2版本,请参阅上文的内存分配与生命周期

cudaMalloc()cudaFree() 在主机和设备环境中有不同的语义。当从主机调用时,cudaMalloc() 会从未使用的设备内存中分配新区域。当从设备运行时调用时,这些函数会映射到设备端的 malloc()free()。这意味着在设备环境中,可分配的总内存受限于设备 malloc() 堆大小,该大小可能小于可用的未使用设备内存。此外,如果主机程序对设备上通过 cudaMalloc() 分配的指针调用 cudaFree(),或者反之,都会导致错误。

cudaMalloc() 在主机上

cudaMalloc() 在设备上

cudaFree() 在主机上

已支持

不支持

cudaFree() 在设备上

不支持

已支持

分配限制

释放设备内存

cudaLimitMallocHeapSize

9.6.3.3.1.6. SM ID与Warp ID (CDP1)

关于文档的CDP2版本,请参阅上文的SM Id和Warp Id

请注意,在PTX中%smid%warpid被定义为易变值。设备运行时可能会将线程块重新调度到不同的SM上以更高效地管理资源。因此,依赖%smid%warpid在线程或线程块生命周期内保持不变是不安全的。

9.6.3.3.1.7. ECC错误(CDP1)

关于文档的CDP2版本,请参阅上方的ECC错误

CUDA内核中的代码无法收到ECC错误的通知。ECC错误将在整个启动树完成后在主机端报告。嵌套程序执行期间出现的任何ECC错误将生成异常或继续执行(具体取决于错误类型和配置)。

14(1,2,3)

动态创建的纹理和表面对象是对CUDA 5.0引入的CUDA内存模型的补充。详情请参阅CUDA编程指南

10. 虚拟内存管理

10.1. 简介

虚拟内存管理API为应用程序提供了一种直接管理CUDA提供的统一虚拟地址空间的方式,用于将物理内存映射到GPU可访问的虚拟地址。这些API在CUDA 10.2中引入,额外提供了与其他进程及图形API(如OpenGL和Vulkan)交互的新方法,同时还提供了用户可根据应用程序需求调整的新内存属性。

在CUDA编程模型中,内存分配调用(如cudaMalloc())历来返回指向GPU内存的地址。获取的地址可用于任何CUDA API或设备内核中。然而,已分配的内存无法根据用户需求调整大小。若要增加分配量,用户必须显式分配更大的缓冲区,从初始分配复制数据,释放后再跟踪新分配的地址。这通常导致应用程序性能降低和峰值内存利用率升高。本质上,用户拥有类似malloc的接口来分配GPU内存,但缺乏对应的realloc功能加以补充。虚拟内存管理API将地址与内存的概念解耦,允许应用程序分别处理。这些API支持应用程序根据需要灵活映射和取消映射虚拟地址范围内的内存。

在使用cudaEnablePeerAccess启用对等设备访问内存分配时,所有过去和未来的用户分配都会被映射到目标对等设备。这导致用户在不知情的情况下需要承担将所有cudaMalloc分配映射到对等设备的运行时开销。然而,在大多数情况下,应用程序仅通过共享少量分配与另一台设备进行通信,并非所有分配都需要映射到所有设备。借助虚拟内存管理,应用程序可以明确选择特定分配对目标设备可访问。

CUDA虚拟内存管理API为用户提供了精细控制GPU内存的能力。它提供的API让用户能够:

  • 将分配在不同设备上的内存放入一个连续的虚拟地址范围。

  • 使用平台特定的机制进行进程间通信以实现内存共享。

  • 在支持新内存类型的设备上选择启用它们。

为了分配内存,虚拟内存管理编程模型提供了以下功能:

  • 分配物理内存。

  • 预留虚拟地址范围。

  • 将分配的内存映射到虚拟地址(VA)范围。

  • 控制映射范围的访问权限。

请注意,本节描述的API套件需要支持UVA的系统。

10.2. 查询支持

在尝试使用虚拟内存管理API之前,应用程序必须确保它们想要使用的设备支持CUDA虚拟内存管理。以下代码示例展示了如何查询虚拟内存管理支持:

int deviceSupportsVmm;
CUresult result = cuDeviceGetAttribute(&deviceSupportsVmm, CU_DEVICE_ATTRIBUTE_VIRTUAL_MEMORY_MANAGEMENT_SUPPORTED, device);
if (deviceSupportsVmm != 0) {
    // `device` supports Virtual Memory Management
}

10.3. 分配物理内存

使用虚拟内存管理API进行内存分配的第一步是创建一个物理内存块,作为分配的基础。为了分配物理内存,应用程序必须使用cuMemCreate API。该函数创建的分配没有任何设备或主机映射。函数参数CUmemGenericAllocationHandle描述了要分配内存的属性,例如分配的位置、是否将分配共享给另一个进程(或其他图形API),或要分配内存的物理属性。用户必须确保请求的分配大小与适当的粒度对齐。关于分配粒度要求的信息可以通过cuMemGetAllocationGranularity查询。以下代码片段展示了如何使用cuMemCreate分配物理内存:

CUmemGenericAllocationHandle allocatePhysicalMemory(int device, size_t size) {
    CUmemAllocationProp prop = {};
    prop.type = CU_MEM_ALLOCATION_TYPE_PINNED;
    prop.location.type = CU_MEM_LOCATION_TYPE_DEVICE;
    prop.location.id = device;

    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进行引用。这与cudaMalloc风格的分配方式不同,后者返回指向GPU内存的指针,设备上执行的CUDA内核可直接访问该内存。分配的内存除了使用cuMemGetAllocationPropertiesFromHandle查询属性外,不能用于任何其他操作。为了使该内存可访问,应用程序必须将此内存映射到由cuMemAddressReserve保留的VA范围,并为其提供适当的访问权限。应用程序必须使用cuMemRelease API释放分配的内存。

10.3.1. 可共享内存分配

通过cuMemCreate,用户现在能够在分配时向CUDA表明某个特定分配是专为进程间通信和图形互操作目的预留的。应用程序可以通过将CUmemAllocationProp::requestedHandleTypes设置为平台特定字段来实现这一点。在Windows平台上,当CUmemAllocationProp::requestedHandleTypes设置为CU_MEM_HANDLE_TYPE_WIN32时,应用程序还必须在CUmemAllocationProp::win32HandleMetaData中指定LPSECURITYATTRIBUTES属性。该安全属性定义了可导出分配能被传输到其他进程的范围。

CUDA虚拟内存管理API函数不支持传统的进程间通信功能与其内存交互。相反,它们提供了一种新的进程间通信机制,该机制使用操作系统特定的句柄。应用程序可以通过使用cuMemExportToShareableHandle获取与分配相对应的这些操作系统特定句柄。这样获得的句柄可以通过使用常规的操作系统原生机制进行进程间通信传输。接收方进程应使用cuMemImportFromShareableHandle导入分配。

用户必须确保在尝试导出通过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

memMapIpcDrv示例可作为使用IPC与虚拟内存管理分配的参考案例。

10.3.2. 内存类型

在CUDA 10.2之前,应用程序无法通过用户控制的方式分配某些设备可能支持的特殊类型内存。通过cuMemCreate,应用程序还可以使用CUmemAllocationProp::allocFlags指定内存类型要求,以选择任何特定的内存功能。应用程序还必须确保所请求的内存类型在分配设备上受支持。

10.3.2.1. 可压缩内存

可压缩内存可用于加速访问具有非结构化稀疏性及其他可压缩数据模式的数据。根据所操作的数据类型,压缩可以节省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
}

10.4. 预留虚拟地址范围

由于虚拟内存管理将地址和内存的概念区分开来,应用程序必须划分出一个地址范围,该范围能够容纳由cuMemCreate进行的内存分配。预留的地址范围至少必须等于用户计划在其中放置的所有物理内存分配大小的总和。

应用程序可以通过向cuMemAddressReserve传递适当参数来预留虚拟地址范围。获取的地址范围不会关联任何设备或主机物理内存。预留的虚拟地址范围可以映射到系统中任何设备的内存块,从而为应用程序提供一个由不同设备内存支撑和映射的连续虚拟地址范围。应用程序应使用cuMemAddressFree将虚拟地址范围返还给CUDA。用户必须确保在调用cuMemAddressFree前解除整个虚拟地址范围的映射。这些函数在概念上类似于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

10.5. 虚拟别名支持

虚拟内存管理API提供了一种方法,可以通过多次调用cuMemMap并使用不同的虚拟地址来为同一分配创建多个虚拟内存映射或"代理",即所谓的虚拟别名。除非PTX ISA中另有说明,否则对分配的一个代理的写入操作将被视为与同一内存的任何其他代理不一致且不连贯,直到写入设备操作(网格启动、内存拷贝、内存设置等)完成。在写入设备操作之前存在于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;
  asm volatile ("fence.proxy.alias;" ::: "memory");
  printf("%d\n", *B);    // *B == *A == 0x1
}

10.6. 内存映射

前两节中分配的物理内存和划分出的虚拟地址空间代表了虚拟内存管理API引入的内存与地址区分。要使分配的内存可用,用户必须先将内存放入地址空间。从cuMemAddressReserve获取的地址范围与从cuMemCreatecuMemImportFromShareableHandle获取的物理分配必须通过cuMemMap相互关联。

用户可以将来自多个设备的分配关联到连续的虚拟地址范围中,只要他们预留了足够的地址空间。为了解耦物理分配和地址范围,用户必须使用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);

10.7. 访问权限控制

虚拟内存管理API使应用程序能够通过访问控制机制显式保护其虚拟地址范围。使用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);
}

通过虚拟内存管理实现的访问控制机制,允许用户明确指定希望与系统中其他对等设备共享哪些内存分配。如前所述,cudaEnablePeerAccess会强制将所有当前及未来的cudaMalloc分配映射到目标对等设备。虽然这种方式在许多情况下很方便——用户无需跟踪系统中每个设备对每个分配内存的映射状态——但对于关注应用程序性能的用户而言,这种方法会影响性能。虚拟内存管理提供的分配粒度级访问控制机制,能够以最小开销实现对等内存映射。

vectorAddMMAP示例可作为使用虚拟内存管理API的参考案例。

10.8. Fabric 内存

CUDA 12.4 引入了一种新的 VMM 分配句柄类型 CU_MEM_HANDLE_TYPE_FABRIC。在支持的平台上且 NVIDIA IMEX 守护进程运行时,该分配句柄类型不仅支持通过任意通信机制(如 MPI)在节点内共享分配,还能跨节点共享。这使得多节点 NVLINK 系统中的 GPU 可以映射同一 NVLINK 架构中所有其他 GPU 的内存,即使它们位于不同节点,从而极大扩展了基于 NVLINK 的多 GPU 编程规模。

10.8.1. 查询支持

在尝试使用Fabric Memory之前,应用程序必须确保其想要使用的设备支持Fabric Memory。以下代码示例展示了如何查询Fabric Memory支持情况:

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作为句柄类型且不需要依赖操作系统原生机制进行进程间通信来交换可共享句柄外,Fabric Memory的使用与其他分配句柄类型并无差异。

10.9. 多播支持

多播对象管理API为应用程序提供了一种创建多播对象的方式,结合上述虚拟内存管理API,允许应用程序在支持NVLINK连接的GPU上(若通过NVSWITCH连接)利用NVLINK SHARP技术。NVLINK SHARP使CUDA应用程序能够利用网络结构内计算来加速诸如广播和归约等操作,这些操作在通过NVSWITCH连接的GPU之间进行。为实现此功能,多个NVLINK连接的GPU组成一个多播团队,团队中的每个GPU都会用物理内存备份一个多播对象。因此,一个由N个GPU组成的多播团队会拥有N个物理副本,每个副本位于参与GPU本地,共同构成一个多播对象。通过映射多播对象使用的multimem PTX指令可与该多播对象的所有副本协同工作。

要使用多播对象,应用程序需要

  • 查询多播支持

  • 使用cuMulticastCreate创建一个多播句柄。

  • 将与应加入多播团队的所有GPU控制进程共享多播句柄。这与上述描述的cuMemExportToShareableHandle功能协同工作。

  • 使用cuMulticastAddDevice添加所有应参与多播组的GPU。

  • 对于每个参与的GPU,按照上述描述将使用cuMemCreate分配的物理内存绑定到多播句柄。在绑定任何设备的内存之前,需要将所有设备添加到多播团队中。

  • 保留一个地址范围,映射多播句柄并设置访问权限,如上文常规单播映射所述。可以对同一物理内存进行单播和多播映射。请参阅上文的虚拟别名支持部分,了解如何确保对同一物理内存的多个映射之间的一致性。

  • multimem PTX指令与多播映射结合使用。

Multi GPU Programming Models GitHub 代码库中的 multi_node_p2p 示例包含了一个完整的使用Fabric Memory(包括Multicast Objects)来利用NVLINK SHARP的案例。请注意,此示例面向NCCL或NVSHMEM等库的开发者。它展示了像NVSHMEM这样的高级编程模型在(多节点)NVLINK域内部的工作原理。应用程序开发者通常应该使用更高级的MPI、NCCL或NVSHMEM接口,而不是直接使用这个API。

10.9.1. 查询支持

在尝试使用多播对象之前,应用程序必须确保其要使用的设备支持这些功能。以下代码示例展示了如何查询Fabric内存支持:

int deviceSupportsMultiCast;
CUresult result = cuDeviceGetAttribute(&deviceSupportsMultiCast, CU_DEVICE_ATTRIBUTE_MULTICAST_SUPPORTED, device);
if (deviceSupportsMultiCast != 0) {
    // `device` supports Multicast Objects
}

10.9.2. 分配多播对象

可以通过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;
}

10.9.3. 向多播对象添加设备

可以使用cuMulticastAddDevice将设备添加到多播组中:

cuMulticastAddDevice(&mcHandle, device);

在将任何设备的内存绑定到多播对象之前,需要在所有控制应参与多播组的设备上完成此步骤。

10.9.4. 将内存绑定到多播对象

在创建多播对象并将所有参与设备添加到多播对象后,需要使用cuMemCreate为每个设备分配物理内存来支持该对象:

cuMulticastBindMem(mcHandle, mcOffset, memHandle, memOffset, size, 0 /*flags*/);

10.9.5. 使用多播映射

要在CUDA C++中使用多播映射,需要使用multimem PTX指令配合内联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);
    asm volatile ("multimem.red.release.sys.global.add.u32 [%0], %1;" :: "l"(arrival_counter_mc), "n"(1) : "memory");

    // 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
    asm volatile ("fence.proxy.alias;" ::: "memory");

    // 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);
}

11. 流序内存分配器

11.1. 简介

使用cudaMalloccudaFree管理内存分配会导致GPU在所有执行的CUDA流之间同步。流序内存分配器使应用程序能够将内存分配和释放与其他工作(如内核启动和异步拷贝)一起排序到CUDA流中。通过利用流排序语义来重用内存分配,这提高了应用程序的内存使用效率。该分配器还允许应用程序控制分配器的内存缓存行为。当设置适当的释放阈值时,缓存行为允许分配器在应用程序表明愿意接受更大的内存占用时避免昂贵的操作系统调用。该分配器还支持进程之间轻松安全地共享分配。

对于许多应用而言,流序内存分配器减少了对自定义内存管理抽象的需求,使需要高性能内存管理的应用更易于实现定制化内存管理。对于已具备自定义内存分配器的应用和库,采用流序内存分配器可使多个库共享由驱动程序管理的公共内存池,从而降低内存的过度消耗。此外,驱动程序能基于其对分配器及其他流管理API的认知进行优化。最后,Nsight Compute和下一代CUDA调试器在其CUDA 11.3工具包支持中已集成对该分配器的识别能力。

11.2. 查询支持

用户可以通过调用cudaDeviceGetAttribute()并传入设备属性cudaDevAttrMemoryPoolsSupported来确定设备是否支持流序内存分配器。

从CUDA 11.3开始,可以通过cudaDevAttrMemoryPoolSupportedHandleTypes设备属性查询IPC内存池支持情况。较早版本的驱动程序会返回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来清除错误,而非避免错误。

11.3. API基础 (cudaMallocAsync 和 cudaFreeAsync)

API接口cudaMallocAsynccudaFreeAsync构成了分配器的核心。cudaMallocAsync返回一个分配的内存块,而cudaFreeAsync则释放一个分配的内存块。这两个API都接受流参数来定义内存块何时可用及何时停止使用。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);

当在非分配流的其他流中使用分配时,用户必须确保访问操作发生在分配操作之后,否则行为是未定义的。用户可以通过同步分配流,或使用CUDA事件来同步生产流和消费流来确保这一点。

cudaFreeAsync() 向流中插入一个释放操作。用户必须确保释放操作发生在分配操作之后以及对该分配的任何使用之后。此外,在释放操作开始后对该分配的任何使用都会导致未定义行为。应使用事件和/或流同步操作来确保在释放流开始释放操作之前,其他流上对该分配的所有访问都已完成。

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);

用户可以使用cudaFreeAsync()释放通过cudaMalloc()分配的存储空间。用户必须确保在释放操作开始前所有访问操作都已完成。

cudaMalloc(&ptr, size);
kernel<<<..., stream>>>(ptr, ...);
cudaFreeAsync(ptr, stream);

用户可以使用cudaFree()释放通过cudaMallocAsync分配的内存。当通过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);

11.4. 内存池与cudaMemPool_t

内存池封装了虚拟地址和物理内存资源,这些资源根据池的属性和特性进行分配和管理。内存池的主要特性在于其管理的内存类型和位置。

所有对cudaMallocAsync的调用都会使用内存池的资源。在没有指定内存池的情况下,cudaMallocAsync会使用所提供流设备的当前内存池。可以通过cudaDeviceSetMempool设置设备的当前内存池,并通过cudaDeviceGetMempool进行查询。默认情况下(在没有调用cudaDeviceSetMempool时),当前内存池是设备的默认内存池。API cudaMallocFromPoolAsyncc++ overloads of cudaMallocAsync允许用户指定用于分配的内存池,而无需将其设为当前池。API cudaDeviceGetDefaultMempoolcudaMemPoolCreate为用户提供了内存池的句柄。

注意

设备的当前内存池是该设备的本地内存池。因此,在不指定内存池的情况下进行分配,将始终产生与流所在设备本地关联的分配。

注意

cudaMemPoolSetAttributecudaMemPoolGetAttribute 用于控制内存池的属性。

11.5. 默认/隐式池

可以通过cudaDeviceGetDefaultMempool API获取设备的默认内存池。从设备默认内存池分配的存储空间是该设备上不可迁移的设备内存分配,这些分配始终可以从该设备访问。默认内存池的可访问性可以通过cudaMemPoolSetAccess修改,并通过cudaMemPoolGetAccess查询。由于默认池不需要显式创建,它们有时被称为隐式池。设备的默认内存池不支持IPC。

11.6. 显式池

API 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));

11.7. 物理页缓存行为

默认情况下,分配器会尝试最小化内存池占用的物理内存。为了减少操作系统分配和释放物理内存的调用次数,应用程序必须为每个内存池配置内存占用空间。应用程序可以通过释放阈值属性(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.

11.8. 资源使用统计

在CUDA 11.3中,新增了内存池属性cudaMemPoolAttrReservedMemCurrentcudaMemPoolAttrReservedMemHighcudaMemPoolAttrUsedMemCurrentcudaMemPoolAttrUsedMemHigh,用于查询内存池的内存使用情况。

查询内存池的cudaMemPoolAttrReservedMemCurrent属性可获取该池当前占用的物理GPU内存总量。查询内存池的cudaMemPoolAttrUsedMemCurrent属性则返回从该池分配且不可重用的所有内存总大小。

cudaMemPoolAttr*MemHigh属性是记录自上次重置以来cudaMemPoolAttr*MemCurrent属性所达到最大值的标记。可以通过使用cudaMemPoolSetAttribute API将它们重置为当前值。

// 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);
}

11.9. 内存重用策略

为了处理内存分配请求,驱动程序会优先尝试重用之前通过cudaFreeAsync()释放的内存,然后再向操作系统申请更多内存。例如,在某个流中释放的内存可以立即被同一流中的后续分配请求重用。同样地,当一个流与CPU同步后,之前在该流中释放的内存就可以被任何流中的分配请求重新使用。

流顺序分配器具有几种可控的分配策略。池属性cudaMemPoolReuseFollowEventDependenciescudaMemPoolReuseAllowOpportunisticcudaMemPoolReuseAllowInternalDependencies控制这些策略。升级到较新的CUDA驱动程序可能会更改、增强、扩充和/或重新排序重用策略。

11.9.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);

11.9.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);

11.9.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);

11.9.4. 禁用重用策略

虽然可控的重用策略提高了内存利用率,但用户可能希望禁用它们。允许机会性重用(如cudaMemPoolReuseAllowOpportunistic)会基于CPU和GPU执行的交错情况,在分配模式中引入运行间的差异。当用户更倾向于在分配失败时显式同步事件或流时,内部依赖项插入(如cudaMemPoolReuseAllowInternalDependencies)可能会以意外且潜在非确定性的方式序列化工作。

11.10. 多GPU支持的设备可访问性

与通过虚拟内存管理API控制分配可访问性类似,内存池分配的可访问性并不遵循cudaDeviceEnablePeerAccesscuCtxEnablePeerAccess。相反,API cudaMemPoolSetAccess用于修改哪些设备可以访问池中的分配。默认情况下,分配仅对分配所在设备可访问,且此访问权限不可撤销。要启用其他设备的访问权限,访问设备必须与内存池所在设备具备对等能力;可通过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);
}

11.11. IPC内存池

支持进程间通信(IPC)的内存池能够实现进程间轻松、高效且安全地共享GPU内存。CUDA的IPC内存池提供了与CUDA虚拟内存管理API相同的安全优势。

使用内存池在进程间共享内存分为两个阶段。首先,进程需要共享对池的访问权限,然后共享该池中的特定分配。第一阶段建立并强制执行安全性。第二阶段协调每个进程中使用的虚拟地址,以及映射在导入进程中何时需要有效。

11.11.1. 创建与共享IPC内存池

共享访问内存池涉及获取内存池的操作系统原生句柄(通过cudaMemPoolExportToShareableHandle() API),使用常规操作系统原生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);

11.11.2. 导入过程中的访问权限设置

导入的内存池最初只能从其所在设备访问。导入的内存池不会继承导出进程设置的任何可访问性。导入进程需要启用访问权限(使用cudaMemPoolSetAccess),以便从计划访问该内存的任何GPU进行访问。

如果导入的内存池属于导入过程中不可见的设备,用户必须使用cudaMemPoolSetAccess API来启用分配将在其上使用的GPU的访问权限。

11.11.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);

11.11.4. IPC导出池限制

IPC池目前不支持将物理块释放回操作系统。因此,cudaMemPoolTrimTo API实际上不执行任何操作,而cudaMemPoolAttrReleaseThreshold实际上会被忽略。此行为由驱动程序控制,而非运行时控制,可能会在未来的驱动程序更新中更改。

11.11.5. IPC导入池限制

不允许从导入池进行分配;具体来说,导入池不能被设为当前池,也不能在cudaMallocFromPoolAsync API中使用。因此,分配重用策略属性对这些池没有意义。

IPC池目前不支持将物理块释放回操作系统。因此,cudaMemPoolTrimTo API实际上不执行任何操作,而cudaMemPoolAttrReleaseThreshold实际上会被忽略。

资源使用统计属性查询仅反映导入到进程中的分配以及相关的物理内存。

11.12. 同步API操作

作为CUDA驱动程序一部分的内存分配器带来的优化之一是与同步API的集成。当用户请求CUDA驱动程序同步时,驱动程序会等待异步工作完成。在返回之前,驱动程序将确定哪些释放操作能确保同步完成。无论指定流或禁用分配策略如何,这些分配的内存都可重新用于分配。驱动程序还会在此检查cudaMemPoolAttrReleaseThreshold并释放任何可释放的多余物理内存。

11.13. 附录

11.13.1. cudaMemcpyAsync 当前上下文/设备敏感性

在当前CUDA驱动中,任何涉及cudaMallocAsync内存的异步memcpy操作,都应使用指定流的上下文作为调用线程的当前上下文来执行。但对于cudaMemcpyPeerAsync则不需要这样做,因为该API会引用指定的设备主上下文而非当前上下文。

11.13.2. cuPointerGetAttribute 查询

在调用cudaFreeAsync释放内存分配后,再对其调用cuPointerGetAttribute会导致未定义行为。具体而言,无论该内存分配是否仍可通过给定流访问:其行为仍然是未定义的。

11.13.3. cuGraphAddMemsetNode

cuGraphAddMemsetNode 不适用于通过流顺序分配器分配的内存。但是,可以通过流捕获来对这些分配进行内存设置。

11.13.4. 指针属性

cuPointerGetAttributes查询适用于流顺序分配的内存。由于流顺序分配不与特定上下文关联,查询CU_POINTER_ATTRIBUTE_CONTEXT会成功但会在*data中返回NULL。属性CU_POINTER_ATTRIBUTE_DEVICE_ORDINAL可用于确定分配位置:这在选择上下文以使用cudaMemcpyPeerAsync进行p2h2p复制时非常有用。属性CU_POINTER_ATTRIBUTE_MEMPOOL_HANDLE在CUDA 11.3中添加,可用于调试以及在进行IPC前确认分配来自哪个内存池。

12. 图记忆节点

12.1. 简介

图内存节点允许图形创建并拥有内存分配。图内存节点具有GPU有序生命周期语义,这些语义规定了何时允许在设备上访问内存。这些GPU有序生命周期语义支持驱动程序管理的内存重用,并与流有序分配API cudaMallocAsynccudaFreeAsync 的语义相匹配,这些API在创建图形时可以被捕获。

图分配在图的整个生命周期内具有固定地址,包括重复实例化和启动。这使得其他操作可以直接引用该内存,而无需更新图,即使CUDA更改了底层物理内存。在图内部,生命周期不重叠的分配可以使用相同的底层物理内存。

CUDA可能会在多个图(graph)之间重复使用相同的物理内存进行分配,根据GPU的有序生命周期语义来别名化虚拟地址映射。例如,当不同的图被启动到同一个流(stream)中时,CUDA可能会虚拟别名化相同的物理内存,以满足具有单图生命周期的分配需求。

12.2. 支持与兼容性

图内存节点需要支持11.4版本的CUDA驱动以及GPU上的流顺序分配器。以下代码片段展示了如何检查给定设备是否支持这些功能。

int driverVersion = 0;
int deviceSupportsMemoryPools = 0;
int deviceSupportsMemoryNodes = 0;
cudaDriverGetVersion(&driverVersion);
if (driverVersion >= 11020) { // avoid invalid value error in cudaDeviceGetAttribute
    cudaDeviceGetAttribute(&deviceSupportsMemoryPools, cudaDevAttrMemoryPoolsSupported, device);
}
deviceSupportsMemoryNodes = (driverVersion >= 11040) && (deviceSupportsMemoryPools != 0);

在驱动程序版本检查内部执行属性查询可避免11.0和11.1版本驱动返回无效值错误码。请注意,当计算检测器发现CUDA返回错误代码时会发出警告,在读取属性前进行版本检查可规避此问题。图内存节点仅支持11.4及更新版本的驱动程序。

12.3. API基础

图内存节点是表示内存分配或释放操作的图节点。简而言之,分配内存的节点称为分配节点,释放内存的节点则称为释放节点。由分配节点创建的内存分配称为图分配。CUDA在节点创建时为图分配分配虚拟地址。虽然这些虚拟地址在分配节点的生命周期内是固定的,但分配内容在释放操作后不会持久保留,可能会被引用不同分配的内存访问所覆盖。

每次运行图时,图分配被视为重新创建。图分配的生命周期(与节点的生命周期不同)从GPU执行到达分配图节点时开始,并在以下任一情况发生时结束:

  • GPU执行到达释放图节点

  • GPU执行到达释放cudaFreeAsync()流调用

  • 在调用cudaFree()释放后立即执行

注意

图销毁不会自动释放任何存活的图分配内存,即使它结束了分配节点的生命周期。该分配必须在另一个图中或使用cudaFreeAsync()/cudaFree()来释放。

与其他图结构类似,图内存节点通过依赖边在图中排序。程序必须确保访问图内存的操作:

  • 在分配节点之后排序

  • 在释放内存的操作之前是有序的

图分配的生命周期通常根据GPU执行开始和结束(而非API调用)。GPU顺序是指工作在GPU上运行的顺序,而非工作入队或描述的顺序。因此,图分配被认为是"GPU有序"的。

12.3.1. 图节点API

图形内存节点可以通过内存节点创建API cudaGraphAddMemAllocNodecudaGraphAddMemFreeNode 显式创建。cudaGraphAddMemAllocNode 分配的地址会通过传入的 CUDA_MEM_ALLOC_NODE_PARAMS 结构体的 dptr 字段返回给用户。在使用分配图形的所有操作必须排序在分配节点之后。类似地,任何释放节点必须排序在图形内该分配的所有使用之后。cudaGraphAddMemFreeNode 用于创建释放节点。

下图展示了一个包含分配节点和释放节点的示例图。内核节点abc被安排在分配节点之后、释放节点之前,这样内核就能安全访问分配的内存。内核节点e未被安排在分配节点之后,因此无法安全访问内存。内核节点d未被安排在释放节点之前,因此它也无法安全访问内存。

Kernel Nodes

图28 内核节点

以下代码片段构建了该图中的图形:

// Create the graph - it starts out empty
cudaGraphCreate(&graph, 0);

// parameters for a basic allocation
cudaMemAllocNodeParams params = {};
params.poolProps.allocType = cudaMemAllocationTypePinned;
params.poolProps.location.type = cudaMemLocationTypeDevice;
// specify device 0 as the resident device
params.poolProps.location.id = 0;
params.bytesize = size;

cudaGraphAddMemAllocNode(&allocNode, graph, NULL, 0, &params);
nodeParams->kernelParams[0] = params.dptr;
cudaGraphAddKernelNode(&a, graph, &allocNode, 1, &nodeParams);
cudaGraphAddKernelNode(&b, graph, &a, 1, &nodeParams);
cudaGraphAddKernelNode(&c, graph, &a, 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;
cudaGraphAddMemFreeNode(&freeNode, graph, dependencies, 2, params.dptr);
// free node does not depend on kernel node d, so it must not access the freed graph allocation.
cudaGraphAddKernelNode(&d, graph, &c, 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.
cudaGraphAddKernelNode(&e, graph, NULL, 0, &nodeParams);

12.3.2. 流捕获

可以通过捕获相应的流顺序分配和释放调用cudaMallocAsynccudaFreeAsync来创建图形内存节点。在这种情况下,捕获的分配API返回的虚拟地址可以被图形内的其他操作使用。由于流顺序依赖关系将被捕获到图形中,流顺序分配API的排序要求保证了图形内存节点将与捕获的流操作正确排序(对于正确编写的流代码)。

为清晰起见,忽略内核节点de,以下代码片段展示了如何使用流捕获从前图创建图形:

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);

12.3.3. 在分配图之外访问和释放图内存

图分配不需要由分配图来释放。当图不释放分配时,该分配会在图执行后持续存在,并可以被后续的CUDA操作访问。只要访问操作通过CUDA事件和其他流排序机制在分配之后有序执行,这些分配可以在另一个图中访问,或直接使用流操作访问。分配随后可以通过常规调用cudaFreecudaFreeAsync来释放,或通过启动另一个带有相应释放节点的图来释放,或通过重新启动分配图(如果它是用cudaGraphInstantiateFlagAutoFreeOnLaunch标志实例化的)。在内存被释放后访问它是非法的——释放操作必须通过图依赖关系、CUDA事件和其他流排序机制在所有访问该内存的操作之后有序执行。

注意

Because graph allocations may share underlying physical memory with each other, the Virtual Aliasing Support rules relating to consistency and coherency must be considered. Simply put, the free operation must be ordered after the full device operation (for example, compute kernel / memcpy) completes. Specifically, out of band synchronization - for example a handshake through memory as part of a compute kernel that accesses the graph-allocated memory - is not sufficient for providing ordering guarantees between the memory writes to graph memory and the free operation of that graph memory.

以下代码片段演示了如何通过以下方式在分配图外部访问图形分配,并正确建立执行顺序:使用单一流、在流之间使用事件,以及使用内置于分配和释放图的事件。

使用单一流建立的顺序:

void *dptr;
cudaGraphAddMemAllocNode(&allocNode, allocGraph, NULL, 0, &params);
dptr = params.dptr;

cudaGraphInstantiate(&allocGraphExec, allocGraph, NULL, NULL, 0);

cudaGraphLaunch(allocGraphExec, stream);
kernel<<< …, stream >>>(dptr, …);
cudaFreeAsync(dptr, stream);

通过记录和等待CUDA事件建立的执行顺序:

void *dptr;

// Contents of allocating graph
cudaGraphAddMemAllocNode(&allocNode, allocGraph, NULL, 0, &params);
dptr = params.dptr;

// contents of consuming/freeing graph
nodeParams->kernelParams[0] = params.dptr;
cudaGraphAddKernelNode(&a, graph, NULL, 0, &nodeParams);
cudaGraphAddMemFreeNode(&freeNode, freeGraph, &a, 1, dptr);

cudaGraphInstantiate(&allocGraphExec, allocGraph, NULL, NULL, 0);
cudaGraphInstantiate(&freeGraphExec, freeGraph, NULL, NULL, 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);

通过使用图外部事件节点建立的顺序:

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
cudaGraphAddMemAllocNode(&allocNode, allocGraph, NULL, 0, &params);
dptr = params.dptr;
// note: this event record node depends on the alloc node
cudaGraphAddEventRecordNode(&recordNode, allocGraph, &allocNode, 1, allocEvent);
cudaGraphInstantiate(&allocGraphExec, allocGraph, NULL, NULL, 0);

// contents of consuming/freeing graph with event wait nodes
cudaGraphAddEventWaitNode(&streamUseDoneEventNode, waitAndFreeGraph, NULL, 0, streamUseDoneEvent);
cudaGraphAddEventWaitNode(&allocReadyEventNode, waitAndFreeGraph, NULL, 0, allocEvent);
nodeParams->kernelParams[0] = params.dptr;

// The allocReadyEventNode provides ordering with the alloc node for use in a consuming graph.
cudaGraphAddKernelNode(&kernelNode, waitAndFreeGraph, &allocReadyEventNode, 1, &nodeParams);

// 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;
cudaGraphAddMemFreeNode(&freeNode, waitAndFreeGraph, &dependencies, 2, dptr);
cudaGraphInstantiate(&waitAndFreeGraphExec, waitAndFreeGraph, NULL, NULL, 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);

12.3.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);

12.4. 优化的内存重用

CUDA通过两种方式重用内存:

  • 图内的虚拟和物理内存重用基于虚拟地址分配,类似于流顺序分配器。

  • 图之间的物理内存复用是通过虚拟别名实现的:不同的图可以将相同的物理内存映射到它们各自的虚拟地址。

12.4.1. 图中地址复用

CUDA 可能会在图形中重复使用内存,方法是将相同的虚拟地址范围分配给生命周期不重叠的不同分配。由于虚拟地址可能会被重复使用,因此不能保证指向生命周期不重叠的不同分配的指针是唯一的。

下图展示了添加一个新的分配节点(2),它可以重用依赖节点(1)释放的地址。

Adding New Alloc Node 2

图29 添加新分配节点2

下图展示了添加一个新的分配节点(4)。新的分配节点不依赖于释放节点(2),因此无法重用来自关联分配节点(2)的地址。如果分配节点(2)使用了被释放节点(1)释放的地址,那么新的分配节点3将需要一个新地址。

Adding New Alloc Node 3

图30 添加新分配节点3

12.4.2. 物理内存管理与共享

CUDA负责在GPU顺序到达分配节点之前将物理内存映射到虚拟地址。作为内存占用和映射开销的优化,如果多个图不会同时运行,它们可以使用相同的物理内存进行不同的分配;然而,如果物理页面同时绑定到多个执行中的图,或绑定到未释放的图分配,则无法重复使用这些物理页面。

CUDA 可能在图实例化、启动或执行过程中的任何时刻更新物理内存映射。CUDA 还可能在未来的图启动之间引入同步机制,以防止活跃的图分配引用相同的物理内存。与任何分配-释放-再分配模式一样,如果程序在分配生命周期之外访问指针,错误的访问可能会静默地读取或写入另一个分配拥有的活跃数据(即使该分配的虚拟地址是唯一的)。使用计算消毒工具可以捕获此类错误。

下图展示了在同一流中顺序启动的多个计算图。在此示例中,每个计算图都会释放其分配的所有内存。由于同一流中的计算图永远不会并发运行,CUDA可以且应该使用相同的物理内存来满足所有分配需求。

Sequentially Launched Graphs

图31 顺序启动的图

12.5. 性能考量

当多个图形被启动到同一个流中时,CUDA会尝试为它们分配相同的物理内存,因为这些图形的执行无法重叠。为了优化性能并避免重新映射的开销,图形之间的物理映射会在多次启动之间保留。如果之后某个图形被启动到可能与其他图形执行重叠的流中(例如启动到不同的流),那么CUDA必须执行一些重新映射操作,因为并发图形需要不同的内存以避免数据损坏。

通常,CUDA中图形内存的重新映射可能是由以下操作引起的:

  • 更改图形启动的目标流

  • 对图形内存池执行修剪操作,显式释放未使用的内存(在物理内存占用中讨论)

  • 当另一个图中未释放的分配映射到同一内存时重新启动图,将在重新启动前导致内存重新映射

重映射必须按照执行顺序进行,但需在该图之前的所有执行完成后(否则仍在使用中的内存可能会被取消映射)。由于这种顺序依赖性,以及映射操作是操作系统调用,映射操作可能相对耗时。应用程序可以通过将包含分配内存节点的图一致地启动到同一流中来避免这一开销。

12.5.1. 首次启动 / cudaGraphUpload

在图形实例化期间无法分配或映射物理内存,因为执行该图形的流是未知的。映射操作会在图形启动时完成。调用cudaGraphUpload可以通过立即执行该图形的所有映射并将其与上传流关联,从而将分配成本与启动分离。如果随后在同一流中启动该图形,它将无需任何额外的重新映射即可启动。

为图上传和图启动使用不同的流,其行为类似于切换流,可能会导致重映射操作。此外,允许无关的内存池管理从空闲流中提取内存,这可能会抵消上传操作的影响。

12.6. 物理内存占用

异步分配的池管理行为意味着,销毁包含内存节点(即使其分配已释放)的图不会立即将物理内存返回给操作系统供其他进程使用。为了显式将内存释放回操作系统,应用程序应使用cudaDeviceGraphMemTrim API。

cudaDeviceGraphMemTrim 将解除映射并释放图形内存节点保留但未实际使用的物理内存。尚未释放的分配以及已调度或正在运行的图形被视为正在使用物理内存,不会受到影响。使用该修剪API可使物理内存可供其他分配API及其他应用程序或进程使用,但会导致CUDA在下次启动被修剪的图形时重新分配和重新映射内存。请注意,cudaDeviceGraphMemTrim 操作的内存池与 cudaMemPoolTrimTo() 不同。图形内存池不暴露给流顺序内存分配器。CUDA允许应用程序通过 cudaDeviceGetGraphMemAttribute API查询其图形内存占用情况。查询属性 cudaGraphMemAttrReservedMemCurrent 可返回驱动程序为当前进程中的图形分配保留的物理内存量。查询 cudaGraphMemAttrUsedMemCurrent 则返回当前至少被一个图形映射的物理内存量。这两个属性均可用于追踪CUDA为分配图形而获取新物理内存的时机。这两个属性对于检查共享机制节省了多少内存非常有用。

12.7. 对等访问

可以配置图形分配以便从多个GPU访问,在这种情况下,CUDA会根据需要将分配映射到对等GPU上。CUDA允许需要不同映射的图形分配重用相同的虚拟地址。当这种情况发生时,地址范围会被映射到不同分配所需的所有GPU上。这意味着分配有时可能允许比创建时请求更多的对等访问;然而,依赖这些额外的映射仍然是一个错误。

12.7.1. 使用Graph Node API实现节点间直接访问

cudaGraphAddMemAllocNode API 通过节点参数结构体中的 accessDescs 数组字段接收内存映射请求。内嵌结构体 poolProps.location 用于指定分配的驻留设备。由于假设需要从分配GPU进行访问,因此应用程序无需在 accessDescs 数组中为驻留设备指定条目。

cudaMemAllocNodeParams params = {};
params.poolProps.allocType = cudaMemAllocationTypePinned;
params.poolProps.location.type = cudaMemLocationTypeDevice;
// specify device 1 as the resident device
params.poolProps.location.id = 1;
params.bytesize = size;

// allocate an allocation resident on device 1 accessible from device 1
cudaGraphAddMemAllocNode(&allocNode, graph, NULL, 0, &params);

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.
params.accessDescCount = 2;
params.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).
cudaGraphAddMemAllocNode(&allocNode, graph, NULL, 0, &params);

12.7.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);
cudaMallocAsync(&dptr1, size, memPool, stream);
cudaStreamEndCapture(stream, &graph1);

cudaMemPoolSetAccess(memPool, &accessDesc, 1);

cudaStreamBeginCapture(stream);
cudaMallocAsync(&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 cudaMallocAsync call.

13. 数学函数

参考手册列出了所有在设备代码中受支持的C/C++标准库数学函数及其描述,以及所有仅在设备代码中受支持的内联函数。

本节提供部分函数在适用情况下的精度信息,采用ULP(最小精度单位)进行量化。有关"最后一位单位"(ULP)定义的更多信息,请参阅Jean-Michel Muller的论文《关于ulp(x)的定义》,RR-5504,LIP RR-2005-09,INRIA,LIP。2005年,第16页,详见https://hal.inria.fr/inria-00070503/document

设备代码中支持的数学函数不会设置全局errno变量,也不会报告任何浮点异常来指示错误;因此,如果需要错误诊断机制,用户应针对函数的输入和输出实施额外的检查。用户需确保指针参数的有效性。用户不得将未初始化的参数传递给数学函数,因为这可能导致未定义行为:这些函数会在用户程序中内联展开,因此会受到编译器优化的影响。

13.1. 标准函数

本节中的函数既可用于主机代码,也可用于设备代码。

本节规定了每个函数在设备上执行时的误差范围,以及在主机未提供该函数的情况下在主机上执行时的误差范围。

误差范围是通过大量但非穷尽的测试生成的,因此它们并非保证的边界。

单精度浮点函数

加法和乘法运算符合IEEE标准,因此最大误差为0.5 ulp。

将单精度浮点数操作数舍入为整数(结果仍为单精度浮点数)的推荐方法是使用rintf()而非roundf()。原因是roundf()在设备上会映射为4条指令序列,而rintf()仅映射为单条指令。truncf()ceilf()floorf()同样各自映射为单条指令。

表14单精度数学标准库函数,具有最大ULP误差。最大误差定义为CUDA库函数返回的结果与按照四舍五入到最近的偶数舍入模式获得的正确舍入单精度结果之间差异的ULP绝对值。

函数

最大ULP误差

x+y

0 (IEEE-754 四舍五入到最近的偶数)

x*y

0 (IEEE-754 四舍五入到最近的偶数)

x/y

当使用-prec-div=true编译时,计算能力\(\ge 2\)的设备设为0

2 (全范围), 否则

1/x

当使用 -prec-div=true 编译时,计算能力 \(\ge 2\) 的设备设为0

1 (全范围), 否则

rsqrtf(x)

1/sqrtf(x)

2 (全范围)

仅当编译器将1/sqrtf(x)转换为rsqrtf(x)时适用。

sqrtf(x)

当使用-prec-sqrt=true编译时为0

否则,对于计算能力\(\ge 5.2\)的设备值为1

以及3用于较旧的架构

cbrtf(x)

1 (全范围)

rcbrtf(x)

1 (全范围)

hypotf(x,y)

3 (全范围)

rhypotf(x,y)

2 (全范围)

norm3df(x,y,z)

3 (全范围)

rnorm3df(x,y,z)

2 (全范围)

norm4df(x,y,z,t)

3 (全范围)

rnorm4df(x,y,z,t)

2 (全范围)

normf(dim,arr)

无法提供误差范围,因为使用了快速算法会因舍入导致精度损失。

rnormf(dim,arr)

无法提供误差界限,因为使用了快速算法,但由于舍入会导致精度损失。

expf(x)

2 (全范围)

exp2f(x)

2 (全范围)

exp10f(x)

2 (全范围)

expm1f(x)

1 (全范围)

logf(x)

1 (全范围)

log2f(x)

1 (全范围)

log10f(x)

2 (全范围)

log1pf(x)

1 (全范围)

sinf(x)

2 (全范围)

cosf(x)

2 (全范围)

tanf(x)

4 (全范围)

sincosf(x,sptr,cptr)

2 (全范围)

sinpif(x)

1 (全范围)

cospif(x)

1 (全范围)

sincospif(x,sptr,cptr)

1 (全范围)

asinf(x)

2 (全范围)

acosf(x)

2 (全范围)

atanf(x)

2 (全范围)

atan2f(y,x)

3 (全范围)

sinhf(x)

3 (全范围)

coshf(x)

2 (全范围)

tanhf(x)

2 (全范围)

asinhf(x)

3 (全范围)

acoshf(x)

4 (全范围)

atanhf(x)

3 (全范围)

powf(x,y)

4 (全范围)

erff(x)

2 (全范围)

erfcf(x)

4 (全范围)

erfinvf(x)

2 (全范围)

erfcinvf(x)

4 (全范围)

erfcxf(x)

4 (全范围)

normcdff(x)

5 (全范围)

normcdfinvf(x)

5 (全范围)

lgammaf(x)

6 (区间外 -10.001 … -2.264;区间内数值更大)

tgammaf(x)

5 (全范围)

fmaf(x,y,z)

0 (全范围)

frexpf(x,exp)

0 (全范围)

ldexpf(x,exp)

0 (全范围)

scalbnf(x,n)

0 (全范围)

scalblnf(x,l)

0 (全范围)

logbf(x)

0 (全范围)

ilogbf(x)

0 (全范围)

j0f(x)

当 |x| < 8 时为 9

否则,最大绝对误差为2.2 x 10-6

j1f(x)

当 |x| < 8 时为 9

否则,最大绝对误差为2.2 x 10-6

jnf(n,x)

当n = 128时,最大绝对误差为2.2 x 10-6

y0f(x)

当 |x| < 8 时为 9

否则,最大绝对误差为2.2 x 10-6

y1f(x)

当 |x| < 8 时为 9

否则,最大绝对误差为2.2 x 10-6

ynf(n,x)

当|x|小于n时,ceil(2 + 2.5n)

否则,最大绝对误差为2.2 x 10-6

cyl_bessel_i0f(x)

6 (全范围)

cyl_bessel_i1f(x)

6 (全范围)

fmodf(x,y)

0 (全范围)

remainderf(x,y)

0 (全范围)

remquof(x,y,iptr)

0 (全范围)

modff(x,iptr)

0 (全范围)

fdimf(x,y)

0 (全范围)

truncf(x)

0 (全范围)

roundf(x)

0 (全范围)

rintf(x)

0 (全范围)

nearbyintf(x)

0 (全范围)

ceilf(x)

0 (全范围)

floorf(x)

0 (全范围)

lrintf(x)

0 (全范围)

lroundf(x)

0 (全范围)

llrintf(x)

0 (全范围)

llroundf(x)

0 (全范围)

双精度浮点函数

将双精度浮点数操作数舍入为整数(结果仍为双精度浮点数)的推荐方法是使用rint(),而不是round()。原因是round()在设备上映射为5条指令序列,而rint()仅映射为单条指令。trunc()ceil()floor()同样都各自映射为单条指令。

表15双精度数学标准库函数,具有最大ULP误差。最大误差定义为CUDA库函数返回的结果与按照四舍五入到最近的偶数舍入模式获得正确舍入的双精度结果之间的ULP差异绝对值。

函数

最大ulp误差

x+y

0 (IEEE-754 四舍五入到最近的偶数)

x*y

0 (IEEE-754 四舍五入到最近的偶数)

x/y

0 (IEEE-754 四舍五入到最近的偶数)

1/x

0 (IEEE-754 四舍五入到最近的偶数)

sqrt(x)

0 (IEEE-754 四舍五入到最近的偶数)

rsqrt(x)

1 (全范围)

cbrt(x)

1 (全范围)

rcbrt(x)

1 (全范围)

hypot(x,y)

2 (全范围)

rhypot(x,y)

1 (全范围)

norm3d(x,y,z)

2 (全范围)

rnorm3d(x,y,z)

1 (全范围)

norm4d(x,y,z,t)

2 (全范围)

rnorm4d(x,y,z,t)

1 (全范围)

norm(dim,arr)

无法提供误差范围,因为使用了快速算法会因舍入导致精度损失。

rnorm(dim,arr)

无法提供误差范围,因为使用了快速算法会因舍入导致精度损失。

exp(x)

1 (全范围)

exp2(x)

1 (全范围)

exp10(x)

1 (全范围)

expm1(x)

1 (全范围)

log(x)

1 (全范围)

log2(x)

1 (全范围)

log10(x)

1 (全范围)

log1p(x)

1 (全范围)

sin(x)

2 (全范围)

cos(x)

2 (全范围)

tan(x)

2 (全范围)

sincos(x,sptr,cptr)

2 (全范围)

sinpi(x)

2 (全范围)

cospi(x)

2 (全范围)

sincospi(x,sptr,cptr)

2 (全范围)

asin(x)

2 (全范围)

acos(x)

2 (全范围)

atan(x)

2 (全范围)

atan2(y,x)

2 (全范围)

sinh(x)

2 (全范围)

cosh(x)

1 (全范围)

tanh(x)

1 (全范围)

asinh(x)

3 (全范围)

acosh(x)

3 (全范围)

atanh(x)

2 (全范围)

pow(x,y)

2 (全范围)

erf(x)

2 (全范围)

erfc(x)

5 (全范围)

erfinv(x)

5 (全范围)

erfcinv(x)

6 (全范围)

erfcx(x)

4 (全范围)

normcdf(x)

5 (全范围)

normcdfinv(x)

8 (全范围)

lgamma(x)

4 (区间外 -23.0001 … -2.2637;区间内数值更大)

tgamma(x)

10 (全范围)

fma(x,y,z)

0 (IEEE-754 四舍五入到最近的偶数)

frexp(x,exp)

0 (全范围)

ldexp(x,exp)

0 (全范围)

scalbn(x,n)

0 (全范围)

scalbln(x,l)

0 (全范围)

logb(x)

0 (全范围)

ilogb(x)

0 (全范围)

j0(x)

当 |x| 小于 8 时为 7

否则,最大绝对误差为5 x 10-12

j1(x)

当 |x| < 8 时为 7

否则,最大绝对误差为5 x 10-12

jn(n,x)

当n = 128时,最大绝对误差为5 x 10-12

y0(x)

当 |x| < 8 时为7

否则,最大绝对误差为5 x 10-12

y1(x)

当 |x| 小于 8 时为 7

否则,最大绝对误差为5 x 10-12

yn(n,x)

对于 |x| > 1.5n 的情况,最大绝对误差为 5 x 10-12

cyl_bessel_i0(x)

6 (全范围)

cyl_bessel_i1(x)

6 (全范围)

fmod(x,y)

0 (全范围)

remainder(x,y)

0 (全范围)

remquo(x,y,iptr)

0 (全范围)

modf(x,iptr)

0 (全范围)

fdim(x,y)

0 (全范围)

trunc(x)

0 (全范围)

round(x)

0 (全范围)

rint(x)

0 (全范围)

nearbyint(x)

0 (全范围)

ceil(x)

0 (全范围)

floor(x)

0 (全范围)

lrint(x)

0 (全范围)

lround(x)

0 (全范围)

llrint(x)

0 (全范围)

llround(x)

0 (全范围)

四精度浮点函数

请注意,四精度数学函数目前仅适用于计算能力10.0及更高版本的设备。 由于实现细节的特殊性,设备代码中对__float128_Float128类型的支持也仅限于特定的主机平台组合,另请参阅Host Compiler Extensions

表 16 具有最大ULP误差的四倍精度数学标准库函数。最大误差定义为CUDA库函数返回的结果与按照四舍五入到最近的偶数舍入模式获得的正确四倍精度结果之间差异的ULP绝对值。

函数

最大ulp误差

x+y __nv_fp128_add(x, y)

0 (IEEE-754 四舍五入到最近的偶数)

x-y __nv_fp128_sub(x, y)

0 (IEEE-754 四舍五入到最近的偶数)

x*y __nv_fp128_mul(x, y)

0 (IEEE-754 四舍五入到最近的偶数)

x/y __nv_fp128_div(x, y)

0 (IEEE-754 四舍五入到最近的偶数)

__nv_fp128_sqrt(x)

0 (IEEE-754 四舍五入到最近的偶数)

__nv_fp128_fma(x, y, z)

0 (IEEE-754 四舍五入到最近的偶数)

__nv_fp128_sin(x)

1 (全范围)

__nv_fp128_cos(x)

1 (全范围)

__nv_fp128_tan(x)

1 (全范围)

__nv_fp128_asin(x)

1 (全范围)

__nv_fp128_acos(x)

1 (全范围)

__nv_fp128_atan(x)

1 (全范围)

__nv_fp128_exp(x)

1 (全范围)

__nv_fp128_exp2(x)

1 (全范围)

__nv_fp128_exp10(x)

1 (全范围)

__nv_fp128_expm1(x)

1 (全范围)

__nv_fp128_log(x)

1 (全范围)

__nv_fp128_log2(x)

1 (全范围)

__nv_fp128_log10(x)

1 (全范围)

__nv_fp128_log1p(x)

1 (全范围)

__nv_fp128_pow(x, y)

1 (全范围)

__nv_fp128_sinh(x)

1 (全范围)

__nv_fp128_cosh(x)

1 (全范围)

__nv_fp128_tanh(x)

1 (全范围)

__nv_fp128_asinh(x)

1 (全范围)

__nv_fp128_acosh(x)

1 (全范围)

__nv_fp128_atanh(x)

1 (全范围)

__nv_fp128_hypot(x, y)

1 (全范围)

__nv_fp128_ceil(x)

0 (全范围)

__nv_fp128_trunc(x)

0 (全范围)

__nv_fp128_floor(x)

0 (全范围)

__nv_fp128_round(x)

0 (全范围)

__nv_fp128_rint(x)

0 (全范围)

__nv_fp128_fabs(x)

0 (全范围)

__nv_fp128_copysign(x, y)

0 (全范围)

__nv_fp128_fmax(x, y)

0 (全范围)

__nv_fp128_fmin(x, y)

0 (全范围)

__nv_fp128_fdim(x, y)

0 (全范围)

__nv_fp128_fmod(x, y)

0 (全范围)

__nv_fp128_remainder(x, y)

0 (全范围)

__nv_fp128_frexp(x, nptr)

0 (全范围)

__nv_fp128_modf(x, iptr)

0 (全范围)

__nv_fp128_ldexp(x, exp)

0 (全范围)

__nv_fp128_ilogb(x)

0 (全范围)

13.2. 内置函数

本节中的函数只能在设备代码中使用。

这些函数中包含一些标准函数的快速但精度较低的版本。它们的名称以__为前缀(例如__sinf(x))。由于映射到更少的本地指令,这些函数执行速度更快。编译器提供了一个选项(-use_fast_math),可以强制将表17中的每个函数编译为其对应的内联函数版本。除了会降低受影响函数的精度外,还可能导致特殊情况的处理方式发生变化。更稳健的做法是仅在性能提升显著且能够接受精度降低和特殊处理方式变化的情况下,有选择地将数学函数调用替换为内联函数调用。

表 17 受 -use_fast_math 影响的函数

运算符/函数

设备函数

x/y

__fdividef(x,y)

sinf(x)

__sinf(x)

cosf(x)

__cosf(x)

tanf(x)

__tanf(x)

sincosf(x,sptr,cptr)

__sincosf(x,sptr,cptr)

logf(x)

__logf(x)

log2f(x)

__log2f(x)

log10f(x)

__log10f(x)

expf(x)

__expf(x)

exp10f(x)

__exp10f(x)

powf(x,y)

__powf(x,y)

tanhf(x)

__tanhf(x)

单精度浮点函数

__fadd_[rn,rz,ru,rd]()__fmul_[rn,rz,ru,rd]() 对应编译器永远不会合并为FMAD的加法和乘法运算。相比之下,由'*'和'+'运算符生成的加法和乘法运算经常会被合并为FMAD。

带有_rn后缀的函数使用"四舍五入到最接近的偶数"舍入模式进行操作。

_rz为后缀的函数使用向零取整的舍入模式进行操作。

带有_ru后缀的函数采用向上取整(向正无穷方向)的舍入模式运行。

带有_rd后缀的函数采用向下取整(向负无穷方向)的舍入模式运行。

浮点除法的精度会根据代码是否使用-prec-div=false-prec-div=true编译而有所不同。当代码使用-prec-div=false编译时,常规除法运算符/__fdividef(x,y)具有相同的精度,但对于2126 < |y| < 2128的情况,__fdividef(x,y)会返回零结果,而/运算符则会根据表18所示的精度范围给出正确结果。此外,对于2126 < |y| < 2128的情况,如果x为无穷大,__fdividef(x,y)会返回NaN(由于无穷大乘以零的结果),而/运算符则会返回无穷大。另一方面,当代码使用-prec-div=true编译或完全不使用任何-prec-div选项时(因为其默认值为true),/运算符符合IEEE标准。

表 18 单精度浮点内置函数。(由CUDA运行时库支持,并带有相应的误差界限)

函数

误差范围

__fadd_[rn,rz,ru,rd](x,y)

符合IEEE标准。

__fsub_[rn,rz,ru,rd](x,y)

符合IEEE标准。

__fmul_[rn,rz,ru,rd](x,y)

符合IEEE标准。

__fmaf_[rn,rz,ru,rd](x,y,z)

符合IEEE标准。

__frcp_[rn,rz,ru,rd](x)

符合IEEE标准。

__fsqrt_[rn,rz,ru,rd](x)

符合IEEE标准。

__frsqrt_rn(x)

符合IEEE标准。

__fdiv_[rn,rz,ru,rd](x,y)

符合IEEE标准。

__fdividef(x,y)

对于 |y| 在区间 [\(2^{-126}, 2^{126}\)] 内的情况,最大 ulp 误差为 2。

__expf(x)

最大ulp误差为2 + floor(abs(1.173 * x))

__exp10f(x)

最大ulp误差为2 + floor(abs(2.97 * x))

__logf(x)

对于 x 在 [0.5, 2] 区间内,最大绝对误差为 \(2^{-21.41}\),否则最大 ulp 误差为 3。

__log2f(x)

对于 x 在 [0.5, 2] 区间内,最大绝对误差为 \(2^{-22}\),否则最大 ulp 误差为 2。

__log10f(x)

对于 x 在 [0.5, 2] 区间内,最大绝对误差为 \(2^{-24}\),否则最大 ulp 误差为 3。

__sinf(x)

对于区间 [\(-\pi, \pi\)] 内的 x,最大绝对误差为 \(2^{-21.41}\),其他区间误差更大。

__cosf(x)

对于区间 [\(-\pi, \pi\)] 内的 x,最大绝对误差为 \(2^{-21.19}\),其他区间误差更大。

__sincosf(x,sptr,cptr)

__sinf(x)__cosf(x)相同。

__tanf(x)

源自其实现方式 __sinf(x) * (1/__cosf(x))

__powf(x, y)

源自其实现方式 exp2f(y * __log2f(x))

__tanhf(x)

当前实现的最大相对误差为\(2^{-11}\)。 即使在-ftz=true编译器设置下,此快速内置函数的次正规结果也不会被刷新为零。 适用于计算能力至少为7.5的设备; 在其他设备上默认采用常规tanhf()函数行为。

双精度浮点函数

__dadd_rn()__dmul_rn() 分别映射到加法与乘法运算,编译器永远不会将它们合并为FMAD操作。相比之下,由'*'和'+'运算符生成的加法与乘法运算则经常会被合并为FMAD操作。

表 19 双精度浮点内置函数。(由CUDA运行时库支持,并带有相应的误差范围)

函数

误差范围

__dadd_[rn,rz,ru,rd](x,y)

符合IEEE标准。

__dsub_[rn,rz,ru,rd](x,y)

符合IEEE标准。

__dmul_[rn,rz,ru,rd](x,y)

符合IEEE标准。

__fma_[rn,rz,ru,rd](x,y,z)

符合IEEE标准。

__ddiv_[rn,rz,ru,rd](x,y)(x,y)

符合IEEE标准。

需要计算能力> 2。

__drcp_[rn,rz,ru,rd](x)

符合IEEE标准。

需要计算能力大于 2。

__dsqrt_[rn,rz,ru,rd](x)

符合IEEE标准。

需要计算能力> 2。

14. C++ 语言支持

使用NVCC编译中所述,使用nvcc编译的CUDA源文件可以包含主机代码和设备代码的混合。CUDA前端编译器旨在模拟主机编译器对C++输入代码的行为。输入源代码根据C++ ISO/IEC 14882:2003、C++ ISO/IEC 14882:2011、C++ ISO/IEC 14882:2014或C++ ISO/IEC 14882:2017规范进行处理,CUDA前端编译器旨在模拟主机编译器与ISO规范的任何差异。此外,支持的语言通过本文档13中描述的CUDA特定结构进行了扩展,并受以下所述限制的约束。

C++11语言特性C++14语言特性C++17语言特性分别提供了对C++11、C++14、C++17和C++20特性的支持矩阵。限制列出了语言限制。多态函数包装器扩展Lambda描述了附加特性。代码示例提供了代码示例。

14.1. C++11 语言特性

下表列出了已被纳入C++11标准的新语言特性。"Proposal"列提供了指向ISO C++委员会提案的链接,该提案描述了该特性;而"Available in nvcc (device code)"列则标明了首个实现该特性(若已实现)用于设备代码的nvcc版本。

表 20 C++11语言特性

语言特性

C++11提案

nvcc支持情况(设备代码)

右值引用

N2118

7.0

  *this的右值引用

N2439

7.0

通过右值初始化类对象

N1610

7.0

非静态数据成员初始化器

N2756

7.0

可变参数模板

N2242

7.0

扩展可变参数模板模板参数

N2555

7.0

初始化列表

N2672

7.0

静态断言

N1720

7.0

auto类型变量

N1984

7.0

        多声明符 auto

N1737

7.0

移除auto作为存储类说明符

N2546

7.0

        新的函数声明语法

N2541

7.0

Lambda表达式

N2927

7.0

表达式的声明类型

N2343

7.0

        不完整的返回类型

N3276

7.0

直角括号

N1757

7.0

函数模板的默认模板参数

DR226

7.0

解决表达式的SFINAE问题

DR339

7.0

别名模板

N2258

7.0

外部模板

N1987

7.0

空指针常量

N2431

7.0

强类型枚举

N2347

7.0

枚举的前向声明

N2764 DR1206

7.0

标准化的属性语法

N2761

7.0

通用常量表达式

N2235

7.0

对齐支持

N2341

7.0

有条件支持的行为

N1627

7.0

将未定义行为转变为可诊断的错误

N1727

7.0

委托构造函数

N1986

7.0

继承构造函数

N2540

7.0

显式转换运算符

N2437

7.0

新增字符类型

N2249

7.0

Unicode字符串字面量

N2442

7.0

原始字符串字面量

N2442

7.0

字面量中的通用字符名称

N2170

7.0

用户自定义字面量

N2765

7.0

标准布局类型

N2342

7.0

默认函数

N2346

7.0

已删除的函数

N2346

7.0

扩展友元声明

N1791

7.0

扩展 sizeof

N2253 DR850

7.0

内联命名空间

N2535

7.0

无限制联合体

N2544

7.0

将局部和未命名类型作为模板参数

N2657

7.0

基于范围的for循环

N2930

7.0

显式虚拟重写

N2928 N3206 N3272

7.0

对垃圾回收和基于可达性的泄漏检测提供最小支持

N2670

N/A (参见 限制条件)

允许移动构造函数抛出异常 [noexcept]

N3050

7.0

定义移动特殊成员函数

N3053

7.0

并发性

序列点

N2239

原子操作

N2427

强比较与交换

N2748

双向栅栏

N2752

内存模型

N2429

数据依赖顺序:原子操作与内存模型

N2664

异常传播

N2179

允许在信号处理程序中使用原子操作

N2547

线程本地存储

N2659

支持并发的动态初始化和销毁

N2660

C++11中的C99特性

__func__ 预定义标识符

N2340

7.0

C99预处理器

N1653

7.0

long long

N1811

7.0

扩展整数类型

N1988

14.2. C++14 语言特性

下表列出了已被纳入C++14标准的新语言特性。

表21 C++14语言特性

语言特性

C++14提案

nvcc支持情况(设备代码)

针对某些C++上下文转换的调整

N3323

9.0

二进制字面量

N3472

9.0

具有推导返回类型的函数

N3638

9.0

通用lambda捕获(初始化捕获)

N3648

9.0

通用(多态)lambda表达式

N3649

9.0

变量模板

N3651

9.0

放宽对constexpr函数的要求

N3652

9.0

成员初始化器和聚合

N3653

9.0

明确内存分配

N3664

指定大小的内存释放

N3778

[[已弃用]] 属性

N3760

9.0

使用单引号作为数字分隔符

N3781

9.0

14.3. C++17 语言特性

在nvcc 11.0及更高版本中支持所有C++17语言特性,但需遵守此处描述的限制条件。

14.4. C++20 语言特性

在nvcc 12.0及更高版本中支持所有C++20语言特性,但需遵守此处描述的限制。

14.5. 限制

14.5.1. 主机编译器扩展

设备代码不支持主机编译器特定的语言扩展。

__Complex 类型仅在主机代码中受支持。

__int128 类型在设备代码中受支持,前提是编译时使用的宿主编译器也支持该类型。

__float128 类型在计算能力10.0及更高版本的设备上受支持,前提是与支持该类型的主机编译器一起编译。编译器可能会以较低精度的浮点表示形式处理__float128类型的常量表达式。

14.5.2. 预处理器符号

14.5.2.1. __CUDA_ARCH__

  1. 以下实体的类型签名不应依赖于__CUDA_ARCH__是否被定义,也不应依赖于__CUDA_ARCH__的具体值:

    • __global__ 函数和函数模板

    • __device____constant__ 变量

    • 纹理和表面

    示例:

    #if !defined(__CUDA_ARCH__)
    typedef int mytype;
    #else
    typedef double mytype;
    #endif
    
    __device__ mytype xxx;         // error: xxx's type depends on __CUDA_ARCH__
    __global__ void foo(mytype in, // error: foo's type depends on __CUDA_ARCH__
                        mytype *ptr)
    {
      *ptr = in;
    }
    
  2. 如果从主机实例化并启动一个__global__函数模板,那么无论是否定义了__CUDA_ARCH__,也不管__CUDA_ARCH__的值是多少,都必须使用相同的模板参数来实例化该函数模板。

    示例:

    __device__ int result;
    template <typename T>
    __global__ void kern(T in)
    {
      result = in;
    }
    
    __host__ __device__ void foo(void)
    {
    #if !defined(__CUDA_ARCH__)
      kern<<<1,1>>>(1);      // 错误:"kern"仅在未定义__CUDA_ARCH__时实例化!
                             // 
    #endif
    }
    
    int main(void)
    {
      foo();
      cudaDeviceSynchronize();
      return 0;
    }
    
  3. 在单独编译模式下,具有外部链接的函数或变量定义的存在与否不应取决于__CUDA_ARCH__是否被定义或__CUDA_ARCH__的具体值14

    示例:

    #if !defined(__CUDA_ARCH__)
    void foo(void) { }                  // 错误:foo()的定义
                                        // 仅在__CUDA_ARCH__未定义时存在
                                        // 
    #endif
    
  4. 在单独编译时,__CUDA_ARCH__不能在头文件中使用,否则不同对象可能包含不同行为。或者,必须保证所有对象都针对相同的compute_arch进行编译。如果在头文件中定义了一个弱函数或模板函数,并且其行为依赖于__CUDA_ARCH__,那么当对象针对不同的计算架构编译时,这些对象中的函数实例可能会发生冲突。

    例如,如果a.h包含以下内容:

    template<typename T>
    __device__ T* getptr(void)
    {
    #if __CUDA_ARCH__ == 700
      return NULL; /* 无地址 */
    #else
      __shared__ T arr[256];
      return arr;
    #endif
    }
    

    那么如果a.cub.cu都包含a.h并为相同类型实例化getptr,且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
    

    在链接时只会使用一个版本的getptr,因此行为将取决于选择了哪个版本。为避免这种情况,要么a.cub.cu必须针对相同的计算架构编译,要么不应在共享的头文件函数中使用__CUDA_ARCH__

编译器不保证会为上述描述的__CUDA_ARCH__不支持的用法生成诊断信息。

14.5.3. 限定符

14.5.3.1. 设备内存空间指定符

不允许在以下位置使用__device____shared____managed____constant__内存空间说明符:

  • class, struct, 和 union 数据成员,

  • 形式参数,

  • 在主机上执行的函数内的非外部变量声明。

在设备上执行的函数中,对于既非extern也非static的变量声明,不允许使用__device____constant____managed__内存空间说明符。

A __device__, __constant__, __managed__ or __shared__ variable definition cannot have a class type with a non-empty constructor or a non-empty destructor. A constructor for a class type is considered empty at a point in the translation unit, if it is either a trivial constructor or it satisfies all of the following conditions:

  • 构造函数已定义。

  • 构造函数没有参数,初始化列表为空,函数体是一个空的复合语句。

  • 其类没有虚函数、虚基类和非静态数据成员初始化器。

  • 其类的所有基类的默认构造函数可视为空。

  • 对于其类中所有类类型(或其数组)的非静态数据成员,默认构造函数可以被视为空。

A destructor for a class is considered empty at a point in the translation unit, if it is either a trivial destructor or it satisfies all of the following conditions:

  • 析构函数已被定义。

  • 析构函数体是一个空的复合语句。

  • 它的类没有虚函数和虚基类。

  • 其类的所有基类的析构函数可视为空。

  • 对于其类中所有类类型(或其数组)的非静态数据成员,析构函数可视为空。

在全程序编译模式下进行编译时(有关此模式的说明请参阅nvcc用户手册),__device____shared____managed____constant__变量不能使用extern关键字定义为外部变量。唯一的例外是动态分配的__shared__变量,如__shared__中所述。

在单独编译模式下进行编译时(有关此模式的描述请参阅nvcc用户手册),可以使用extern关键字将__device____shared____managed____constant__变量定义为外部变量。当nvlink找不到外部变量的定义时(除非是动态分配的__shared__变量),它将生成错误。

14.5.3.2. __managed__ 内存空间限定符

标有__managed__内存空间说明符的变量("托管"变量)具有以下限制:

  • 托管变量的地址不是常量表达式。

  • 托管变量的类型不能带有const限定符。

  • 托管变量不得具有引用类型。

  • 当CUDA运行时可能处于无效状态时,不应使用托管变量的地址或值,包括以下情况:

    • 在具有静态或线程本地存储持续时间的对象的静态/动态初始化或销毁过程中。

    • 在调用exit()后执行的代码中(例如,使用gcc的"__attribute__((destructor))"标记的函数)。

    • 在可能未初始化CUDA运行时的代码中执行(例如,使用gcc的"__attribute__((constructor))"标记的函数)。

  • 托管变量不能用作decltype()表达式中不带括号的标识符表达式参数。

  • 托管变量具有与动态分配的托管内存相同的连贯性和一致性行为。

  • 当一个包含托管变量的CUDA程序在配备多GPU的执行平台上运行时,这些变量仅分配一次,而非每个GPU单独分配。

  • 在主机上执行的函数中不允许声明没有extern链接的托管变量。

  • 在设备上执行的函数中,不允许声明没有extern或static链接的托管变量。

以下是托管变量的合法与非法使用示例:

__device__ __managed__ int xxx = 10;         // OK

int *ptr = &xxx;                             // error: use of managed variable
                                             // (xxx) in static initialization
struct S1_t {
  int field;
  S1_t(void) : field(xxx) { };
};
struct S2_t {
  ~S2_t(void) { xxx = 10; }
};

S1_t temp1;                                 // error: use of managed variable
                                            // (xxx) in dynamic initialization

S2_t temp2;                                 // error: use of managed variable
                                            // (xxx) in the destructor of
                                            // object with static storage
                                            // duration

__device__ __managed__ const int yyy = 10;  // error: const qualified type

__device__ __managed__ int &zzz = xxx;      // error: reference type

template <int *addr> struct S3_t { };
S3_t<&xxx> temp;                            // error: address of managed
                                            // variable(xxx) not a
                                            // constant expression

__global__ void kern(int *ptr)
{
  assert(ptr == &xxx);                      // OK
  xxx = 20;                                 // OK
}
int main(void)
{
  int *ptr = &xxx;                          // OK
  kern<<<1,1>>>(ptr);
  cudaDeviceSynchronize();
  xxx++;                                    // OK
  decltype(xxx) qqq;                        // error: managed variable(xxx) used
                                            // as unparenthized argument to
                                            // decltype

  decltype((xxx)) zzz = yyy;                // OK
}

14.5.3.3. 易变限定符

编译器可以自由优化对全局或共享内存的读写操作(例如,通过将全局读取缓存到寄存器或L1缓存中),只要它遵守内存栅栏函数(Memory Fence Functions)的内存排序语义和同步函数(Synchronization Functions)的内存可见性语义。

可以通过使用volatile关键字禁用这些优化:如果位于全局内存或共享内存中的变量被声明为volatile,编译器会假定其值可能随时被其他线程更改或使用,因此任何对该变量的引用都会编译为实际的内存读取或写入指令。

14.5.4. 指针

在主机上执行的代码中对全局或共享内存指针进行解引用,或在设备上执行的代码中对主机内存指针进行解引用,会导致未定义行为,最常见的是段错误和应用程序终止。

通过获取__device____shared____constant__变量的地址所得到的地址只能在设备代码中使用。而通过cudaGetSymbolAddress()获取的__device____constant__变量地址(如设备内存章节所述)只能在主机代码中使用。

14.5.5. 运算符

14.5.5.1. 赋值运算符

__constant__ 变量只能通过运行时函数从主机代码赋值(设备内存);无法从设备代码进行赋值。

__shared__ 变量不能在其声明中包含初始化部分。

不允许对内置变量中定义的任何内置变量进行赋值。

14.5.5.2. 地址运算符

不允许获取内置变量中定义的任何内置变量的地址。

14.5.6. 运行时类型信息(RTTI)

以下RTTI相关特性在主机代码中受支持,但在设备代码中不受支持。

  • typeid 运算符

  • std::type_info

  • dynamic_cast 运算符

14.5.7. 异常处理

异常处理仅在主机代码中受支持,但在设备代码中不受支持。

不支持为__global__函数指定异常规范。

14.5.8. 标准库

标准库仅在主机代码中受支持,但在设备代码中不受支持,除非另有说明。

14.5.9. 命名空间保留

除非另有说明异常情况,向cuda::nv::cooperative_groups::或任何嵌套命名空间添加声明或定义都属于未定义行为。

示例:

namespace cuda{
   // Bad: class declaration added to namespace cuda
   struct foo{};

   // Bad: function definition added to namespace cuda
   cudaStream_t make_stream(){
      cudaStream_t s;
      cudaStreamCreate(&s);
      return s;
   }
} // namespace cuda

namespace cuda{
   namespace utils{
      // Bad: function definition added to namespace nested within cuda
      cudaStream_t make_stream(){
          cudaStream_t s;
          cudaStreamCreate(&s);
          return s;
      }
   } // namespace utils
} // namespace cuda

namespace utils{
   namespace cuda{
     // Okay: namespace cuda may be used nested within a non-reserved namespace
     cudaStream_t make_stream(){
          cudaStream_t s;
          cudaStreamCreate(&s);
          return s;
      }
   } // namespace cuda
} // namespace utils

// Bad: Equivalent to adding symbols to namespace cuda at global scope
using namespace utils;

14.5.10. 函数

14.5.10.1. 外部链接

在使用extern限定符声明的函数中,只有在函数与设备代码定义于同一编译单元(即单个文件或通过可重定位设备代码和nvlink链接在一起的多个文件)时,才允许在设备代码中调用该函数。

14.5.10.2. 隐式声明与显式默认函数

F表示一个在首次声明时隐式声明或显式默认的函数。F的执行空间说明符(__host__, __device__)是所有调用它的函数执行空间说明符的并集(注意在此分析中,__global__调用者将被视为__device__调用者)。例如:

class Base {
  int x;
public:
  __host__ __device__ Base(void) : x(10) {}
};

class Derived : public Base {
  int y;
};

class Other: public Base {
  int z;
};

__device__ void foo(void)
{
  Derived D1;
  Other D2;
}

__host__ void bar(void)
{
  Other D3;
}

在这里,隐式声明的构造函数"Derived::Derived"将被视为__device__函数,因为它仅从__device__函数"foo"中调用。而隐式声明的构造函数"Other::Other"将被视为__host__ __device__函数,因为它既从__device__函数"foo"又从__host__函数"bar"中被调用。

此外,如果F是虚析构函数,那么每个被F重写的虚析构函数D的执行空间将被添加到F的执行空间集合中,前提是D要么不是隐式定义的,要么是在非首次声明处显式默认的。

例如:

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(); };
__device__ Base2::~Base2() = default;
struct Derived2 : Base2 { }; // implicitly-declared virtual destructor
                             // ~Derived2 has __device__ execution
                             // space specifiers

14.5.10.3. 函数参数

__global__ 函数的参数通过常量内存传递给设备,从Volta架构开始限制为32,764字节,旧架构则为4 KB。

__global__ 函数不能有可变数量的参数。

__global__ 函数的参数不能通过引用传递。

In separate compilation mode, if a __device__ or __global__ function is ODR-used in a particular translation unit, then the parameter and return types of the function must be complete in that translation unit.

示例:

//first.cu:
struct S;
__device__ void foo(S); // error: type 'S' is incomplete
__device__ auto *ptr = foo;

int main() { }

//second.cu:
struct S { int x; };
__device__ void foo(S) { }
//compiler invocation
$nvcc -std=c++14 -rdc=true first.cu second.cu -o first
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
14.5.10.3.1. __global__ 函数参数处理

当从设备代码启动__global__函数时,每个参数必须可简单复制且可简单销毁。

当从主机代码启动__global__函数时,允许每个参数类型是非平凡可复制或非平凡可析构的,但对此类类型的处理不遵循标准C++模型,如下所述。用户代码必须确保此工作流程不会影响程序正确性。该工作流程在以下两个方面与标准C++存在差异:

  1. Memcpy instead of copy constructor invocation

    When lowering a __global__ function launch from host code, the compiler generates stub functions that copy the parameters one or more times by value, before eventually using memcpy to copy the arguments to the __global__ function’s parameter memory on the device. This occurs even if an argument was non-trivially-copyable, and therefore may break programs where the copy constructor has side effects.

    Example:

    #include <cassert>
    struct S {
     int x;
     int *ptr;
     __host__ __device__ S() { }
     __host__ __device__ S(const S &) { ptr = &x; }
    };
    
    __global__ void foo(S in) {
     // this assert may fail, because the compiler
     // generated code will memcpy the contents of "in"
     // from host to kernel parameter memory, so the
     // "in.ptr" is not initialized to "&in.x" because
     // the copy constructor is skipped.
     assert(in.ptr == &in.x);
    }
    
    int main() {
      S tmp;
      foo<<<1,1>>>(tmp);
      cudaDeviceSynchronize();
    }
    

    Example:

    #include <cassert>
    
    __managed__ int counter;
    struct S1 {
    S1() { }
    S1(const S1 &) { ++counter; }
    };
    
    __global__ void foo(S1) {
    
    /* this assertion may fail, because
       the compiler generates stub
       functions on the host for a kernel
       launch, and they may copy the
       argument by value more than once.
    */
    assert(counter == 1);
    }
    
    int main() {
    S1 V;
    foo<<<1,1>>>(V);
    cudaDeviceSynchronize();
    }
    
  2. 析构函数可能在``__global__``函数完成前被调用

    内核启动与主机执行是异步的。因此,如果__global__函数参数具有非平凡的析构函数,该析构函数可能在主机代码中执行,甚至在__global__函数完成执行之前。这可能会破坏那些依赖析构函数副作用的程序。

    示例:

    struct S {
     int *ptr;
     S() : ptr(nullptr) { }
     S(const S &) { cudaMallocManaged(&ptr, sizeof(int)); }
     ~S() { cudaFree(ptr); }
    };
    
    __global__ void foo(S in) {
    
      //错误:此存储操作可能写入已被释放的内存(见下文)
      *(in.ptr) = 4;
    
    }
    
    int main() {
     S V;
    
     /* 对象'V'首先通过值复制到编译器生成的存根函数中,该函数负责内核启动,
      * 存根函数将参数内容按位复制到内核参数内存中。
      * 然而,GPU内核执行与主机执行是异步的。
      * 因此,当存根函数返回时,S::~S()将被执行并释放已分配的内存,即使内核可能尚未完成执行。
      */
     foo<<<1,1>>>(V);
     cudaDeviceSynchronize();
    }
    
14.5.10.3.2. 工具包与驱动兼容性

开发者必须使用12.1工具包及r530或更高版本的驱动程序来编译、启动和调试接受大于4KB参数的核函数。如果在旧版驱动程序上启动此类核函数,CUDA将报错CUDA_ERROR_NOT_SUPPORTED

14.5.10.4. 函数内的静态变量

在函数F的直接或嵌套块作用域内声明静态变量V时,允许使用可变内存空间说明符,其中:

  • F 是一个 __global__ 或仅限 __device__ 的函数。

  • F 是一个 __host__ __device__ 函数,且 __CUDA_ARCH__ 已定义 18

如果在V的声明中没有显式指定内存空间,在设备编译过程中会默认隐式添加__device__说明符。

V 的初始化限制与在命名空间范围内声明的具有相同内存空间说明符的变量相同,例如 __device__ 变量不能具有"非空"构造函数(参见 Device Memory Space Specifiers)。

下面展示了函数作用域静态变量的合法与非法使用示例。

struct S1_t {
  int x;
};

struct S2_t {
  int x;
  __device__ S2_t(void) { x = 10; }
};

struct S3_t {
  int x;
  __device__ S3_t(int p) : x(p) { }
};

__device__ void f1() {
  static int i1;              // OK, implicit __device__ memory space specifier
  static int i2 = 11;         // OK, implicit __device__ memory space specifier
  static __managed__ int m1;  // OK
  static __device__ int d1;   // OK
  static __constant__ int c1; // OK

  static S1_t i3;             // OK, implicit __device__ memory space specifier
  static S1_t i4 = {22};      // OK, implicit __device__ memory space specifier

  static __shared__ int i5;   // OK

  int x = 33;
  static int i6 = x;          // error: dynamic initialization is not allowed
  static S1_t i7 = {x};       // error: dynamic initialization is not allowed

  static S2_t i8;             // error: dynamic initialization is not allowed
  static S3_t i9(44);         // error: dynamic initialization is not allowed
}

__host__ __device__ void f2() {
  static int i1;              // OK, implicit __device__ memory space specifier
                              // during device compilation.
#ifdef __CUDA_ARCH__
  static __device__ int d1;   // OK, declaration is only visible during device
                              // compilation  (__CUDA_ARCH__ is defined)
#else
  static int d0;              // OK, declaration is only visible during host
                              // compilation (__CUDA_ARCH__ is not defined)
#endif

  static __device__ int d2;   // error: __device__ variable inside
                              // a host function during host compilation
                              // i.e. when __CUDA_ARCH__ is not defined

  static __shared__ int i2;  // error: __shared__ variable inside
                             // a host function during host compilation
                             // i.e. when __CUDA_ARCH__ is not defined
}

14.5.10.5. 函数指针

在主机代码中获取的__global__函数地址不能用于设备代码(例如启动内核)。同样,在设备代码中获取的__global__函数地址也不能用于主机代码。

不允许在主机代码中获取__device__函数的地址。

14.5.10.6. 函数递归

__global__ 函数不支持递归。

14.5.10.7. 友元函数

无法在友元声明中定义 __global__ 函数或函数模板。

示例:

struct S1_t {
  friend __global__
  void foo1(void);  // OK: not a definition
  template<typename T>
  friend __global__
  void foo2(void); // OK: not a definition

  friend __global__
  void foo3(void) { } // error: definition in friend declaration

  template<typename T>
  friend __global__
  void foo4(void) { } // error: definition in friend declaration
};

14.5.10.8. 操作符函数

操作符函数不能是__global__函数。

14.5.10.9. 分配与释放函数

用户自定义的operator newoperator new[]operator deleteoperator delete[]不能用于替换编译器提供的对应__host____device__内置函数。

14.5.11.

14.5.11.1. 数据成员

不支持静态数据成员,除非它们同时也是const限定的(参见Const-qualified variables)。

14.5.11.2. 函数成员

静态成员函数不能是__global__函数。

14.5.11.3. 虚函数

当派生类中的函数重写基类中的虚函数时,被重写函数和重写函数的执行空间说明符(即__host____device__)必须匹配。

不允许将带有虚函数的类对象作为参数传递给__global__函数。

如果在主机代码中创建了一个对象,在设备代码中调用该对象的虚函数将导致未定义行为。

如果在设备代码中创建了一个对象,在主机代码中调用该对象的虚函数将导致未定义行为。

在使用微软主机编译器时,请参阅Windows特定说明了解额外限制条件。

示例:

struct S1 { virtual __host__ __device__ void foo() { } };

__managed__ S1 *ptr1, *ptr2;

__managed__ __align__(16) char buf1[128];
__global__ void kern() {
  ptr1->foo();     // error: virtual function call on a object
                   //        created in host code.
  ptr2 = new(buf1) S1();
}

int main(void) {
  void *buf;
  cudaMallocManaged(&buf, sizeof(S1), cudaMemAttachGlobal);
  ptr1 = new (buf) S1();
  kern<<<1,1>>>();
  cudaDeviceSynchronize();
  ptr2->foo();  // error: virtual function call on an object
                //        created in device code.
}

14.5.11.4. 虚基类

不允许将派生自虚基类的类对象作为参数传递给__global__函数。

在使用微软主机编译器时,请参阅Windows-Specific了解其他限制条件。

14.5.11.5. 匿名联合体

命名空间作用域匿名联合体的成员变量不能在__global____device__函数中被引用。

14.5.11.6. Windows系统专用

CUDA编译器遵循IA64 ABI进行类布局,而微软主机编译器则不然。设T表示指向成员类型的指针,或满足以下任一条件的类类型:

  • T 包含虚函数。

  • T 有一个虚基类。

  • T 具有多重继承,且包含多个直接或间接的空基类。

  • T的所有直接和间接基类B都是空的,且T的第一个字段F的类型在其定义中使用了B,使得BF的定义中被放置在偏移量0的位置。

C表示T或一个以T作为字段类型或基类类型的类类型。对于类型C,CUDA编译器计算类布局和大小的方式可能与微软主机编译器不同。

只要类型C仅在主机或设备代码中使用,程序就应该能正确运行。

在主机和设备代码之间传递类型为C的对象会导致未定义行为,例如作为__global__函数的参数或通过cudaMemcpy*()调用传递。

在设备代码中访问C类型的对象或其任何子对象,或调用其成员函数,如果该对象是在主机代码中创建的,则行为未定义。

在主机代码中访问C类型的对象或其任何子对象,或在主机代码中调用成员函数,如果该对象是在设备代码中创建的,则行为未定义19

14.5.12. 模板

如果满足以下任一条件,类型或模板不能用于__global__函数模板实例化或__device__/__constant__变量实例化的类型、非类型或模板模板参数中:

  • 类型或模板定义在__host____host__ __device__中。

  • 类型或模板是具有privateprotected访问权限的类成员,且其父类未在__device____global__函数内定义。

  • 该类型未命名。

  • 该类型由上述任意类型组合而成。

示例:

template <typename T>
__global__ void myKernel(void) { }

class myClass {
private:
    struct inner_t { };
public:
    static void launch(void)
    {
       // error: inner_t is used in template argument
       // but it is private
       myKernel<inner_t><<<1,1>>>();
    }
};

// C++14 only
template <typename T> __device__ T d1;

template <typename T1, typename T2> __device__ T1 d2;

void fn() {
  struct S1_t { };
  // error (C++14 only): S1_t is local to the function fn
  d1<S1_t> = {};

  auto lam1 = [] { };
  // error (C++14 only): a closure type cannot be used for
  // instantiating a variable template
  d2<int, decltype(lam1)> = 10;
}

14.5.13. 三字母组与双字母组

任何平台都不支持三字符组。Windows不支持双字符组。

14.5.14. 常量限定变量

设'V'表示一个命名空间作用域变量或类静态成员变量,该变量具有const限定类型且没有执行空间注解(例如__device__, __constant__, __shared__)。V被视为主机代码变量。

V的值可以直接在设备代码中使用,如果

  • 在使用点之前,V已通过常量表达式初始化,

  • V的类型不是易失性限定的,且

  • 它具有以下类型之一:

    • 除使用Microsoft编译器作为主机编译器外,内置浮点类型

    • 内置整型。

设备源代码不能包含对V的引用或获取V的地址。

示例:

const int xxx = 10;
struct S1_t {  static const int yyy = 20; };

extern const int zzz;
const float www = 5.0;
__device__ void foo(void) {
  int local1[xxx];          // OK
  int local2[S1_t::yyy];    // OK

  int val1 = xxx;           // OK

  int val2 = S1_t::yyy;     // OK

  int val3 = zzz;           // error: zzz not initialized with constant
                            // expression at the point of use.

  const int &val3 = xxx;    // error: reference to host variable
  const int *val4 = &xxx;   // error: address of host variable
  const float val5 = www;   // OK except when the Microsoft compiler is used as
                            // the host compiler.
}
const int zzz = 20;

14.5.15. 长双精度浮点

设备代码中不支持使用long double类型。

14.5.16. 弃用注解

在使用gccclangxlCiccpgcc主机编译器时,nvcc支持使用deprecated属性;在使用cl.exe主机编译器时,支持使用deprecated declspec。当启用C++14方言时,它还支持[[deprecated]]标准属性。当定义__CUDA_ARCH__时(即在设备编译阶段),CUDA前端编译器将为从__device____global____host__ __device__函数体内引用已弃用实体生成弃用诊断。其他对已弃用实体的引用将由主机编译器处理,例如从__host__函数内的引用。

CUDA前端编译器不支持各种主机编译器所支持的#pragma gcc diagnostic#pragma warning机制。因此,CUDA前端编译器生成的弃用诊断不受这些编译指示影响,但主机编译器生成的诊断将会受到影响。要抑制设备代码的警告,用户可以使用NVIDIA特定的编译指示#pragma nv_diag_suppressnvcc标志-Wno-deprecated-declarations可用于抑制所有弃用警告,而标志-Werror=deprecated-declarations可将弃用警告转换为错误。

14.5.17. Noreturn 注解

当使用gccclangxlCiccpgcc主机编译器时,nvcc支持使用noreturn属性;当使用cl.exe主机编译器时,支持使用noreturn declspec。此外,在启用C++11方言时,还支持标准属性[[noreturn]]

该属性/declspec可在主机和设备代码中使用。

14.5.18. [[likely]] / [[unlikely]] 标准属性

这些属性在所有支持C++标准属性语法的配置中均可接受。这些属性可用于向设备编译器优化器提示,与不包含该语句的任何替代路径相比,某个语句被执行的可能性更高或更低。

示例:

__device__ int foo(int x) {

 if (i < 10) [[likely]] { // the 'if' block will likely be entered
  return 4;
 }
 if (i < 20) [[unlikely]] { // the 'if' block will not likely be entered
  return 1;
 }
 return 0;
}

如果在主机代码中使用这些属性时__CUDA_ARCH__未定义,那么这些属性将出现在主机编译器解析的代码中,若主机编译器不支持这些属性则可能产生警告。例如,clang11主机编译器会生成"未知属性"警告。

14.5.19. const 和 pure GNU 属性

当使用支持这些属性的语言方言和主机编译器(例如使用g++主机编译器)时,这些属性同时适用于主机和设备函数。

对于带有pure属性标注的设备函数,设备代码优化器会假设该函数不会改变调用者函数可见的任何可变状态(例如内存)。

对于使用const属性标注的设备函数,设备代码优化器会假设该函数不会访问或更改调用者函数可见的任何可变状态(例如内存)。

示例:

__attribute__((const)) __device__ int get(int in);

__device__ int doit(int in) {
int sum = 0;

//because 'get' is marked with 'const' attribute
//device code optimizer can recognize that the
//second call to get() can be commoned out.
sum = get(in);
sum += get(in);

return sum;
}

14.5.20. __nv_pure__ 属性

__nv_pure__ 属性同时支持主机和设备函数。对于主机函数,当使用支持GNU pure属性的语言方言时,__nv_pure__属性会被转换为GNU pure属性。类似地,当使用MSVC作为主机编译器时,该属性会被转换为MSVC的noalias属性。

当一个设备函数被标注为__nv_pure__属性时,设备代码优化器会假定该函数不会改变调用者函数可见的任何可变状态(例如内存)。

14.5.21. 英特尔主机编译器特定

CUDA前端编译器解析器无法识别Intel编译器支持的部分内置函数(例如icc)。当使用Intel编译器作为主机编译器时,nvcc会在预处理阶段启用宏__INTEL_COMPILER_USE_INTRINSIC_PROTOTYPES。该宏会在相关头文件中显式声明Intel编译器的内置函数,从而使nvcc能够支持在主机代码中使用这些函数20

14.5.22. C++11 特性

主机编译器默认启用的C++11特性同样受到nvcc支持,但需遵循本文档所述限制。此外,使用-std=c++11标志调用nvcc时,将启用所有C++11特性,并会以对应的C++11方言选项调用主机预处理器、编译器和链接器21

14.5.22.1. Lambda表达式

与lambda表达式关联的闭包类的所有成员函数22的执行空间说明符由编译器按以下方式推导。如C++11标准所述,编译器会在包含lambda表达式的最小块作用域、类作用域或命名空间作用域中创建闭包类型。计算包围闭包类型的最内层函数作用域,并将对应函数的执行空间说明符分配给闭包类成员函数。如果不存在包围函数作用域,则执行空间说明符为__host__

下方展示了lambda表达式和计算执行空间说明符的示例(位于注释中)。

auto globalVar = [] { return 0; }; // __host__

void f1(void) {
  auto l1 = [] { return 1; };      // __host__
}

__device__ void f2(void) {
  auto l2 = [] { return 2; };      // __device__
}

__host__ __device__ void f3(void) {
  auto l3 = [] { return 3; };      // __host__ __device__
}

__device__ void f4(int (*fp)() = [] { return 4; } /* __host__ */) {
}

__global__ void f5(void) {
  auto l5 = [] { return 5; };      // __device__
}

__device__ void f6(void) {
  struct S1_t {
    static void helper(int (*fp)() = [] {return 6; } /* __device__ */) {
    }
  };
}

lambda表达式的闭包类型不能用于__global__函数模板实例化的类型或非类型参数中,除非该lambda是在__device____global__函数内定义的。

示例:

template <typename T>
__global__ void foo(T in) { };

template <typename T>
struct S1_t { };

void bar(void) {
  auto temp1 = [] { };

  foo<<<1,1>>>(temp1);                    // error: lambda closure type used in
                                          // template type argument
  foo<<<1,1>>>( S1_t<decltype(temp1)>()); // error: lambda closure type used in
                                          // template type argument
}

14.5.22.2. std::initializer_list

默认情况下,CUDA编译器会隐式地将std::initializer_list的成员函数视为具有__host__ __device__执行空间说明符,因此可以直接从设备代码中调用它们。使用nvcc标志--no-host-device-initializer-list可以禁用此行为;此时std::initializer_list的成员函数将被视为__host__函数,无法直接从设备代码调用。

示例:

#include <initializer_list>

__device__ int foo(std::initializer_list<int> in);

__device__ void bar(void)
  {
    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).
  }

14.5.22.3. 右值引用

默认情况下,CUDA编译器会隐式地将std::movestd::forward函数模板视为具有__host__ __device__执行空间说明符,因此可以直接从设备代码调用它们。使用nvcc标志--no-host-device-move-forward将禁用此行为;此时std::movestd::forward将被视为__host__函数,无法直接从设备代码调用。

14.5.22.4. 常量表达式函数与函数模板

默认情况下,constexpr函数无法从不兼容执行空间的函数中调用23。实验性nvcc标志--expt-relaxed-constexpr移除了这一限制24。当指定该标志时,主机代码可以调用__device__ constexpr函数,设备代码可以调用__host__ constexpr函数。nvcc会在指定--expt-relaxed-constexpr时定义宏__CUDACC_RELAXED_CONSTEXPR__。请注意,即使相应模板被标记为constexpr关键字,函数模板实例化也可能不是constexpr函数(C++11标准章节[dcl.constexpr.p6])。

14.5.22.5. 常量表达式变量

设'V'表示一个命名空间作用域变量或类静态成员变量,该变量已被标记为constexpr且没有执行空间注解(例如__device__, __constant__, __shared__)。V被视为主机代码变量。

如果V是标量类型25且不是long double,并且该类型没有被volatile限定,那么V的值可以直接在设备代码中使用。此外,如果V是非标量类型,那么V的标量元素可以在constexpr修饰的__device____host__ __device__函数内部使用,前提是该函数调用是一个常量表达式26。设备源代码不能包含对V的引用或获取V的地址。

示例:

constexpr int xxx = 10;
constexpr int yyy = xxx + 4;
struct S1_t { static constexpr int qqq = 100; };

constexpr int host_arr[] = { 1, 2, 3};
constexpr __device__ int get(int idx) { return host_arr[idx]; }

__device__ int foo(int idx) {
  int v1 = xxx + yyy + S1_t::qqq;  // OK
  const int &v2 = xxx;             // error: reference to host constexpr
                                   // variable
  const int *v3 = &xxx;            // error: address of host constexpr
                                   // variable
  const int &v4 = S1_t::qqq;       // error: reference to host constexpr
                                   // variable
  const int *v5 = &S1_t::qqq;      // error: address of host constexpr
                                   // variable

  v1 += get(2);                    // OK: 'get(2)' is a constant
                                   // expression.
  v1 += get(idx);                  // error: 'get(idx)' is not a constant
                                   // expression
  v1 += host_arr[2];               // error: 'host_arr' does not have
                                   // scalar type.
  return v1;
}

14.5.22.6. 内联命名空间

For an input CUDA translation unit, the CUDA compiler may invoke the host compiler for compiling the host code within the translation unit. In the code passed to the host compiler, the CUDA compiler will inject additional compiler generated code, if the input CUDA translation unit contained a definition of any of the following entities:

  • __global__ 函数或函数模板实例化

  • __device__, __constant__

  • 具有表面或纹理类型的变量

编译器生成的代码包含对已定义实体的引用。如果该实体定义在内联命名空间中,并且在外层命名空间中定义了具有相同名称和类型签名的另一个实体,主机编译器可能会认为此引用存在歧义,从而导致主机编译失败。

通过在行内命名空间中为这些实体定义唯一名称,可以避免此限制。

示例:

__device__ int Gvar;
inline namespace N1 {
  __device__ int Gvar;
}

// <-- CUDA compiler inserts a reference to "Gvar" at this point in the
// translation unit. This reference will be considered ambiguous by the
// host compiler and compilation will fail.

示例:

inline namespace N1 {
  namespace N2 {
    __device__ int Gvar;
  }
}

namespace N2 {
  __device__ int Gvar;
}

// <-- CUDA compiler inserts reference to "::N2::Gvar" at this point in
// the translation unit. This reference will be considered ambiguous by
// the host compiler and compilation will fail.
14.5.22.6.1. 内联未命名命名空间

以下实体不能在内联未命名命名空间的命名空间范围内声明:

  • __managed__, __device__, __shared____constant__ 变量

  • __global__ 函数和函数模板

  • 具有表面或纹理类型的变量

示例:

inline namespace {
  namespace N2 {
    template <typename T>
    __global__ void foo(void);            // error

    __global__ void bar(void) { }         // error

    template <>
    __global__ void foo<int>(void) { }    // error

    __device__ int x1b;                   // error
    __constant__ int x2b;                 // error
    __shared__ int x3b;                   // error

    texture<int> q2;                      // error
    surface<int> s2;                      // error
  }
};

14.5.22.7. thread_local

在设备代码中不允许使用thread_local存储说明符。

14.5.22.8. __global__ 函数与函数模板

如果与lambda表达式关联的闭包类型被用于__global__函数模板实例化的模板参数中,则该lambda表达式必须在__device____global__函数的直接或嵌套块作用域内定义,或者必须是一个extended lambda

示例:

template <typename T>
__global__ void kernel(T in) { }

__device__ void foo_device(void)
{
  // All kernel instantiations in this function
  // are valid, since the lambdas are defined inside
  // a __device__ function.

  kernel<<<1,1>>>( [] __device__ { } );
  kernel<<<1,1>>>( [] __host__ __device__ { } );
  kernel<<<1,1>>>( []  { } );
}

auto lam1 = [] { };

auto lam2 = [] __host__ __device__ { };

void foo_host(void)
{
   // OK: instantiated with closure type of an extended __device__ lambda
   kernel<<<1,1>>>( [] __device__ { } );

   // OK: instantiated with closure type of an extended __host__ __device__
   // lambda
   kernel<<<1,1>>>( [] __host__ __device__ { } );

   // error: unsupported: instantiated with closure type of a lambda
   // that is not an extended lambda
   kernel<<<1,1>>>( []  { } );

   // error: unsupported: instantiated with closure type of a lambda
   // that is not an extended lambda
   kernel<<<1,1>>>( lam1);

   // error: unsupported: instantiated with closure type of a lambda
   // that is not an extended lambda
   kernel<<<1,1>>>( lam2);
}

__global__ 函数或函数模板不能被声明为 constexpr

__global__ 函数或函数模板不能拥有类型为 std::initializer_listva_list 的参数。

一个 __global__ 函数不能包含右值引用类型的参数。

一个可变参数的__global__函数模板有以下限制:

  • 仅允许单个pack参数。

  • pack参数必须列在模板参数列表的最后。

示例:

// ok
template <template <typename...> class Wrapper, typename... Pack>
__global__ void foo1(Wrapper<Pack...>);

// error: pack parameter is not last in parameter list
template <typename... Pack, template <typename...> class Wrapper>
__global__ void foo2(Wrapper<Pack...>);

// error: multiple parameter packs
template <typename... Pack1, int...Pack2, template<typename...> class Wrapper1,
          template<int...> class Wrapper2>
__global__ void foo3(Wrapper1<Pack1...>, Wrapper2<Pack2...>);

14.5.22.9. __managed__ 和 __shared__ 变量

`__managed____shared__ 变量不能用关键字 constexpr 标记。

14.5.22.10. 默认函数

在首次声明时显式默认的函数上的执行空间说明符会被CUDA编译器忽略。相反,CUDA编译器将按照隐式声明和显式默认函数中的描述推断执行空间说明符。

如果函数是显式默认的,则不会忽略执行空间说明符,但不是在首次声明时。

示例:

struct S1 {
  // warning: __host__ annotation is ignored on a function that
  //          is explicitly-defaulted on its first declaration
  __host__ S1() = default;
};

__device__ void foo1() {
  //note: __device__ execution space is derived for S1::S1
  //       based on implicit call from within __device__ function
  //       foo1
  S1 s1;
}

struct S2 {
  __host__ S2();
};

//note: S2::S2 is not defaulted on its first declaration, and
//      its execution space is fixed to __host__  based on its
//      first declaration.
S2::S2() = default;

__device__ void foo2() {
   // error: call from __device__ function 'foo2' to
   //        __host__ function 'S2::S2'
   S2 s2;
}

14.5.23. C++14 特性

主机编译器默认启用的C++14特性同样受到nvcc支持。向nvcc传递-std=c++14标志会启用所有C++14特性,并调用主机预处理器、编译器和链接器使用对应的C++14方言选项27。本节描述受支持的C++14特性的限制条件。

14.5.23.1. 自动推导返回类型的函数

__global__ 函数不能有推导的返回类型。

如果一个__device__函数具有推导返回类型,CUDA前端编译器在调用主机编译器之前会将该函数声明更改为具有void返回类型。这可能会导致在主机代码中检查__device__函数的推导返回类型时出现问题。因此,CUDA编译器将对在设备函数体之外引用此类推导返回类型的情况发出编译时错误,除非当__CUDA_ARCH__未定义时该引用不存在。

示例:

__device__ auto fn1(int x) {
  return x;
}

__device__ decltype(auto) fn2(int x) {
  return x;
}

__device__ void device_fn1() {
  // OK
  int (*p1)(int) = fn1;
}

// error: referenced outside device function bodies
decltype(fn1(10)) g1;

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 S1_t { };

// error: referenced outside device function bodies
struct S1_derived_t : S1_t<decltype(fn1)> { };

14.5.23.2. 变量模板

在使用Microsoft主机编译器时,__device__/__constant__变量模板不能具有const限定类型。

示例:

// error: a __device__ variable template cannot
// have a const qualified type on Windows
template <typename T>
__device__ const T d1(2);

int *const x = nullptr;
// error: a __device__ variable template cannot
// have a const qualified type on Windows
template <typename T>
__device__ T *const d2(x);

// OK
template <typename T>
__device__ const T *d3;

__device__ void fn() {
  int t1 = d1<int>;

  int *const t2 = d2<int>;

  const int *t3 = d3<int>;
}

14.5.24. C++17 特性

主机编译器默认启用的C++17特性同样受到nvcc支持。传递nvcc -std=c++17标志会启用所有C++17特性,并使用对应的C++17方言选项调用主机预处理器、编译器和链接器28。本节描述受支持的C++17特性的限制条件。

14.5.24.1. 内联变量

  • 使用__device____constant____managed__内存空间说明符声明的命名空间作用域内联变量,如果代码在nvcc全程序编译模式下编译,必须具有内部链接。

    示例:

    inline __device__ int xxx; // 使用nvcc全程序编译模式时错误
                               // 使用nvcc单独编译模式时正常
    
    inline __shared__ int yyy0; // 正常
    
    static inline __device__ int yyy; // 正常:具有内部链接
    namespace {
    inline __device__ int zzz; // 正常:具有内部链接
    }
    
  • 当使用g++主机编译器时,使用__managed__内存空间说明符声明的内联变量可能对调试器不可见。

14.5.24.2. 结构化绑定

结构化绑定不能使用变量内存空间说明符来声明。

示例:

struct S { int x; int y; };
__device__ auto [a1, b1] = S{4,5}; // error

14.5.25. C++20 特性

主机编译器默认启用的C++20特性同样受到nvcc支持。传递nvcc的-std=c++20标志会启用所有C++20特性,并调用主机预处理器、编译器和链接器使用对应的C++20方言选项29。本节描述受支持的C++20特性的限制条件。

14.5.25.1. 模块支持

CUDA C++ 不支持模块功能,无论是在主机代码还是设备代码中。使用 moduleexportimport 关键字会被识别为错误。

14.5.25.2. 协程支持

设备代码不支持协程。在设备函数范围内使用co_awaitco_yieldco_return关键字时,会在设备编译期间被诊断为错误。

14.5.25.3. 三向比较运算符

三路比较运算符在主机和设备代码中均受支持,但某些用法隐式依赖于主机实现提供的标准模板库功能。使用这些运算符可能需要指定标志--expt-relaxed-constexpr以消除警告,且该功能要求主机实现满足设备代码的要求。

示例:

#include<compare>
struct S {
  int x, y, z;
  auto operator<=>(const S& rhs) const = default;
  __host__ __device__ bool operator<=>(int rhs) const { return false; }
};
__host__ __device__ bool f(S a, S b) {
  if (a <=> 1) // ok, calls a user-defined host-device overload
    return true;
  return a < b; // call to an implicitly-declared function and requires
                // a device-compatible std::strong_ordering implementation
}

14.5.25.4. 常量求值函数

通常情况下,跨执行空间的调用是不允许的,会导致编译器诊断(警告或错误)。但当被调用函数使用consteval说明符声明时,此限制不适用。因此,__device____global__函数可以调用__host__consteval函数,而__host__函数也可以调用__device__ consteval函数。

示例:

namespace N1 {
//consteval host function
consteval int hcallee() { return 10; }

__device__ int dfunc() { return hcallee(); /* OK */ }
__global__ void gfunc() { (void)hcallee(); /* OK */ }
__host__ __device__ int hdfunc() { return hcallee();  /* OK */ }
int hfunc() { return hcallee(); /* OK */ }
} // namespace N1


namespace N2 {
//consteval device function
consteval __device__ int dcallee() { return 10; }

__device__ int dfunc() { return dcallee(); /* OK */ }
__global__ void gfunc() { (void)dcallee(); /* OK */ }
__host__ __device__ int hdfunc() { return dcallee();  /* OK */ }
int hfunc() { return dcallee(); /* OK */ }
}

14.6. 多态函数包装器

nvfunctional头文件中提供了一个多态函数包装类模板nvstd::function。该模板类的实例可用于存储、复制和调用任何可调用目标,例如lambda表达式。nvstd::function既可用于主机代码也可用于设备代码。

示例:

#include <nvfunctional>

__device__ int foo_d() { return 1; }
__host__ __device__ int foo_hd () { return 2; }
__host__ int foo_h() { return 3; }

__global__ void kernel(int *result) {
  nvstd::function<int()> fn1 = foo_d;
  nvstd::function<int()> fn2 = foo_hd;
  nvstd::function<int()> fn3 =  []() { return 10; };

  *result = fn1() + fn2() + fn3();
}

__host__ __device__ void hostdevice_func(int *result) {
  nvstd::function<int()> fn1 = foo_hd;
  nvstd::function<int()> fn2 =  []() { return 10; };

  *result = fn1() + fn2();
}

__host__ void host_func(int *result) {
  nvstd::function<int()> fn1 = foo_h;
  nvstd::function<int()> fn2 = foo_hd;
  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 foo_d() { return 1; }
__host__ int foo_h() { return 3; }
auto lam_h = [] { return 0; };

__global__ void k(void) {
  // error: initialized with address of __host__ function
  nvstd::function<int()> fn1 = foo_h;

  // error: initialized with address of functor with
  // __host__ operator() function
  nvstd::function<int()> fn2 = lam_h;
}

__global__ void kern(nvstd::function<int()> f1) { }

void foo(void) {
  // error: initialized with address of __device__ function
  nvstd::function<int()> fn1 = foo_d;

  auto lam_d = [=] __device__ { return 1; };

  // error: initialized with address of functor with
  // __device__ operator() function
  nvstd::function<int()> fn2 = lam_d;

  // error: passing nvstd::function from host to device
  kern<<<1,1>>>(fn2);
}

nvstd::function 定义在 nvfunctional 头文件中,如下所示:

namespace nvstd {
  template <class _RetType, class ..._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<class _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);
      __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 <class _R, class... _ArgTypes>
  __device__ __host__
  bool operator==(const function<_R(_ArgTypes...)>&, nullptr_t) noexcept;

  template <class _R, class... _ArgTypes>
  __device__ __host__
  bool operator==(nullptr_t, const function<_R(_ArgTypes...)>&) noexcept;

  template <class _R, class... _ArgTypes>
  __device__ __host__
  bool operator!=(const function<_R(_ArgTypes...)>&, nullptr_t) noexcept;

  template <class _R, class... _ArgTypes>
  __device__ __host__
  bool operator!=(nullptr_t, const function<_R(_ArgTypes...)>&) noexcept;

  // specialized algorithms
  template <class _R, class... _ArgTypes>
  __device__ __host__
  void swap(function<_R(_ArgTypes...)>&, function<_R(_ArgTypes...)>&);
}

14.7. 扩展Lambda表达式

nvcc标志'--extended-lambda'允许在lambda表达式中显式指定执行空间注解30。执行空间注解应位于'lambda-introducer'之后和可选的'lambda-declarator'之前。当指定了'--extended-lambda'标志时,nvcc将定义宏__CUDACC_EXTENDED_LAMBDA__

一个'扩展__device__ lambda'是指被显式标注为'__device__'的lambda表达式,并且定义在__host____host__ __device__函数的直接或嵌套块作用域内。

一个'扩展的__host__ __device__ lambda'是指一个lambda表达式,它被显式地同时标注了'__host__'和'__device__',并且定义在一个__host____host__ __device__函数的直接或嵌套块作用域内。

‘扩展lambda’指的是扩展的__device__ lambda或扩展的__host__ __device__ lambda。扩展lambda可用于__global__函数模板实例化的类型参数中。

如果未明确指定执行空间注解,则会根据与lambda关联的闭包类所包含的作用域进行计算,如C++11支持部分所述。执行空间注解将应用于与lambda关联的闭包类的所有方法。

示例:

void foo_host(void) {
  // not an extended lambda: no explicit execution space annotations
  auto lam1 = [] { };

  // extended __device__ lambda
  auto lam2 = [] __device__ { };

  // extended __host__ __device__ lambda
  auto lam3 = [] __host__ __device__ { };

  // not an extended lambda: explicitly annotated with only '__host__'
  auto lam4 = [] __host__ { };
}

__host__ __device__ void foo_host_device(void) {
  // not an extended lambda: no explicit execution space annotations
  auto lam1 = [] { };

  // extended __device__ lambda
  auto lam2 = [] __device__ { };

  // extended __host__ __device__ lambda
  auto lam3 = [] __host__ __device__ { };

  // not an extended lambda: explicitly annotated with only '__host__'
  auto lam4 = [] __host__ { };
}

__device__ void foo_device(void) {
  // none of the lambdas within this function are extended lambdas,
  // because the enclosing function is not a __host__ or __host__ __device__
  // function.
  auto lam1 = [] { };
  auto lam2 = [] __device__ { };
  auto lam3 = [] __host__ __device__ { };
  auto lam4 = [] __host__ { };
}

// lam1 and lam2 are not extended lambdas because they are not defined
// within a __host__ or __host__ __device__ function.
auto lam1 = [] { };
auto lam2 = [] __host__ __device__ { };

14.7.1. 扩展Lambda类型特征

编译器提供了类型特征,用于在编译时检测扩展lambda的闭包类型:

__nv_is_extended_device_lambda_closure_type(type):如果'type'是为扩展__device__ lambda创建的闭包类,则该特性为真,否则为假。

__nv_is_extended_device_lambda_with_preserved_return_type(type): 如果'type'是为扩展__device__ lambda创建的闭包类,并且该lambda是使用尾置返回类型定义的(带有限制条件),则该特性为true,否则为false。如果尾置返回类型定义引用了任何lambda参数名称,则返回类型不会被保留。

__nv_is_extended_host_device_lambda_closure_type(type): 如果'type'是为扩展的__host__ __device__ lambda创建的闭包类,则该特性为true,否则为false。

这些特性可以在所有编译模式下使用,无论是否启用了lambda或扩展lambda31

示例:

#define IS_D_LAMBDA(X) __nv_is_extended_device_lambda_closure_type(X)
#define IS_DPRT_LAMBDA(X) __nv_is_extended_device_lambda_with_preserved_return_type(X)
#define IS_HD_LAMBDA(X) __nv_is_extended_host_device_lambda_closure_type(X)

auto lam0 = [] __host__ __device__ { };

void foo(void) {
  auto lam1 = [] { };
  auto lam2 = [] __device__ { };
  auto lam3 = [] __host__ __device__ { };
  auto lam4 = [] __device__ () --> double { return 3.14; }
  auto lam5 = [] __device__ (int x) --> decltype(&x) { return 0; }

  // lam0 is not an extended lambda (since defined outside function scope)
  static_assert(!IS_D_LAMBDA(decltype(lam0)), "");
  static_assert(!IS_DPRT_LAMBDA(decltype(lam0)), "");
  static_assert(!IS_HD_LAMBDA(decltype(lam0)), "");

  // lam1 is not an extended lambda (since no execution space annotations)
  static_assert(!IS_D_LAMBDA(decltype(lam1)), "");
  static_assert(!IS_DPRT_LAMBDA(decltype(lam1)), "");
  static_assert(!IS_HD_LAMBDA(decltype(lam1)), "");

  // lam2 is an extended __device__ lambda
  static_assert(IS_D_LAMBDA(decltype(lam2)), "");
  static_assert(!IS_DPRT_LAMBDA(decltype(lam2)), "");
  static_assert(!IS_HD_LAMBDA(decltype(lam2)), "");

  // lam3 is an extended __host__ __device__ lambda
  static_assert(!IS_D_LAMBDA(decltype(lam3)), "");
  static_assert(!IS_DPRT_LAMBDA(decltype(lam3)), "");
  static_assert(IS_HD_LAMBDA(decltype(lam3)), "");

  // lam4 is an extended __device__ lambda with preserved return type
  static_assert(IS_D_LAMBDA(decltype(lam4)), "");
  static_assert(IS_DPRT_LAMBDA(decltype(lam4)), "");
  static_assert(!IS_HD_LAMBDA(decltype(lam4)), "");

  // lam5 is not an extended __device__ lambda with preserved return type
  // because it references the operator()'s parameter types in the trailing return type.
  static_assert(IS_D_LAMBDA(decltype(lam5)), "");
  static_assert(!IS_DPRT_LAMBDA(decltype(lam5)), "");
  static_assert(!IS_HD_LAMBDA(decltype(lam5)), "");
}

14.7.2. 扩展Lambda限制

CUDA编译器在调用主机编译器之前,会将扩展lambda表达式替换为命名空间作用域内定义的占位符类型实例。该占位符类型的模板参数需要获取包含原始扩展lambda表达式的外围函数地址。这对于正确执行任何模板参数涉及扩展lambda闭包类型的__global__函数模板都是必需的。外围函数的计算方式如下。

根据定义,扩展lambda表达式存在于__host____host__ __device__函数的直接或嵌套块作用域内。如果该函数不是lambda表达式的operator(),则它被视为扩展lambda的封闭函数。否则,扩展lambda定义在一个或多个封闭lambda表达式的operator()的直接或嵌套块作用域内。如果最外层的此类lambda表达式定义在函数F的直接或嵌套块作用域中,则F为计算得出的封闭函数,否则封闭函数不存在。

示例:

void foo(void) {
  // enclosing function for lam1 is "foo"
  auto lam1 = [] __device__ { };

  auto lam2 = [] {
     auto lam3 = [] {
        // enclosing function for lam4 is "foo"
        auto lam4 = [] __host__ __device__ { };
     };
  };
}

auto lam6 = [] {
  // enclosing function for lam7 does not exist
  auto lam7 = [] __host__ __device__ { };
};

以下是扩展lambda表达式的限制条件:

  1. 不能在另一个扩展lambda表达式内部定义扩展lambda。

    示例:

    void foo(void) {
      auto lam1 = [] __host__ __device__  {
        // 错误:在另一个扩展lambda内部定义了扩展lambda
        auto lam2 = [] __host__ __device__ { };
      };
    }
    
  2. 扩展lambda表达式无法在泛型lambda表达式内部定义。

    示例:

    void foo(void) {
      auto lam1 = [] (auto) {
        // 错误:在泛型lambda内部定义了扩展lambda
        auto lam2 = [] __host__ __device__ { };
      };
    }
    
  3. 如果一个扩展lambda定义在一个或多个嵌套lambda表达式的直接或嵌套块作用域内,那么最外层的此类lambda表达式必须定义在某个函数的直接或嵌套块作用域内。

    示例:

    auto lam1 = []  {
      // 错误:外层封闭lambda没有定义在
      // 非lambda运算符()函数内
      auto lam2 = [] __host__ __device__ { };
    };
    
  4. 扩展lambda的封闭函数必须具有名称,并且可以获取其地址。如果封闭函数是类成员,则必须满足以下条件:

    • 包含成员函数的所有类都必须有名称。

    • 成员函数在其父类中不能具有私有或受保护的访问权限。

    • 所有封闭类在其各自的父类中不得具有私有或受保护的访问权限。

    示例:

    void foo(void) {
      // OK
      auto lam1 = [] __device__ { return 0; };
      {
        // OK
        auto lam2 = [] __device__ { return 0; };
        // OK
        auto lam3 = [] __device__ __host__ { return 0; };
      }
    }
    
    struct S1_t {
      S1_t(void) {
        // Error: cannot take address of enclosing function
        auto lam4 = [] __device__ { return 0; };
      }
    };
    
    class C0_t {
      void foo(void) {
        // Error: enclosing function has private access in parent class
        auto temp1 = [] __device__ { return 10; };
      }
      struct S2_t {
        void foo(void) {
          // Error: enclosing class S2_t has private access in its
          // parent class
          auto temp1 = [] __device__ { return 10; };
        }
      };
    };
    
  5. 在定义扩展lambda表达式的位置,必须能够明确获取外围例程的地址。在某些情况下这可能不可行,例如当类typedef遮蔽了同名的模板类型参数时。

    示例:

    template <typename> struct A {
      typedef void Bar;
      void test();
    };
    
    template<> struct A<void> { };
    
    template <typename Bar>
    void A<Bar>::test() {
      /* 在发送给主机编译器的代码中,nvcc会在此处注入一个地址表达式,形式为:
         (void (A< Bar> ::*)(void))(&A::test))
    
         然而,类typedef 'Bar'(指向void)遮蔽了模板参数'Bar',导致A::test中的地址表达式实际上引用:
         (void (A< void> ::*)(void))(&A::test))
    
         ..这无法正确获取外围例程'A::test'的地址
      */
      auto lam1 = [] __host__ __device__ { return 4; };
    }
    
    int main() {
      A<int> xxx;
      xxx.test();
    }
    
  6. 无法在函数内部的局部类中定义扩展lambda。

    示例:

    void foo(void) {
      struct S1_t {
        void bar(void) {
          // 错误:bar是函数内部局部类的成员
          auto lam4 = [] __host__ __device__ { return 0; };
        }
      };
    }
    
  7. 扩展lambda的封闭函数不能有推导的返回类型。

    示例:

    auto foo(void) {
      // 错误:foo的返回类型是推导得出的。
      auto lam1 = [] __host__ __device__ { return 0; };
    }
    
  8. __host__ __device__ 扩展lambda表达式不能是泛型lambda。

    示例:

    void foo(void) {
      // 错误:__host__ __device__ 扩展lambda不能是泛型lambda
      auto lam1 = [] __host__ __device__ (auto i) { return i; };
    
      // 错误:__host__ __device__ 扩展lambda不能是泛型lambda
      auto lam2 = [] __host__ __device__ (auto ...i) {
                   return sizeof...(i);
                  };
    }
    
  9. 如果外围函数是函数模板或成员函数模板的实例化,和/或该函数是类模板的成员,则模板必须满足以下约束条件:

    • 模板最多只能有一个可变参数,并且必须列在模板参数列表的最后。

    • 模板参数必须命名。

    • 模板实例化的参数类型不能涉及函数内部的局部类型(扩展lambda的闭包类型除外),也不能是私有或受保护的类成员。

    示例:

    template <typename T>
    __global__ void kern(T in) { in(); }
    
    template <typename... T>
    struct foo {};
    
    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 lam1 =  [] __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 lam1 =  [] __device__ { return 10; };
    }
    
    template <typename T, T>
    void bar3(void) {
      // Error: for enclosing function, the second template
      // parameter is not named.
      auto lam1 =  [] __device__ { return 10; };
    }
    
    int main() {
      foo<char, int, float> f1;
      foo<char, int> f2;
      bar1(f1, f2);
      bar2(f1, 10);
      bar3<int, 10>();
    }
    

    示例:

    template <typename T>
    __global__ void kern(T in) { in(); }
    
    template <typename T>
    void bar4(void) {
      auto lam1 =  [] __device__ { return 10; };
      kern<<<1,1>>>(lam1);
    }
    
    struct C1_t { struct S1_t { }; friend int main(void); };
    int main() {
      struct S1_t { };
      // Error: enclosing function for device lambda in bar4
      // is instantiated with a type local to main.
      bar4<S1_t>();
    
      // Error: enclosing function for device lambda in bar4
      // is instantiated with a type that is a private member
      // of a class.
      bar4<C1_t::S1_t>();
    }
    
  10. 使用Visual Studio主机编译器时,封闭函数必须具有外部链接。存在此限制是因为该主机编译器不支持将非外部链接函数的地址用作模板参数,而CUDA编译器转换需要此功能以支持扩展lambda表达式。

  11. 使用Visual Studio主机编译器时,扩展lambda不应在'if-constexpr'块的主体中定义。

  12. 扩展lambda对捕获变量有以下限制:

    • 在发送给主机编译器的代码中,该变量可能会通过值传递给一系列辅助函数,然后用于直接初始化用于表示扩展lambda闭包类型的类类型字段32

    • 变量只能通过值捕获。

    • 如果数组的维度超过7,则无法捕获该数组类型的变量。

    • 对于数组类型的变量,在发送给主机编译器的代码中,闭包类型的数组字段首先进行默认初始化,然后数组字段的每个元素从被捕获数组变量的对应元素复制赋值。因此,数组元素类型在主机代码中必须是可默认构造且可复制赋值的。

    • 作为可变参数包元素的函数参数不能被捕获。

    • 捕获变量的类型不能涉及以下类型:函数内部的局部类型(扩展lambda的闭包类型除外),或是私有或受保护的类成员。

    • 对于 __host__ __device__ 扩展 lambda,lambda 表达式的 operator() 返回类型或参数类型中使用的类型不能涉及以下类型:函数局部类型(扩展 lambda 的闭包类型除外)、私有或受保护的类成员。

    • __host__ __device__扩展lambda不支持初始化捕获。__device__扩展lambda支持初始化捕获,除非初始化捕获是数组类型或std::initializer_list类型。

    • 扩展lambda的函数调用运算符不是constexpr。扩展lambda的闭包类型不是字面类型。在扩展lambda的声明中不能使用constexpr和consteval说明符。

    • 在扩展lambda中词法嵌套的if-constexpr块内,变量不能被隐式捕获,除非它已经在if-constexpr块外部被隐式捕获过,或者出现在扩展lambda的显式捕获列表中(参见下面的示例)。

    示例

    void foo(void) {
      // OK: an init-capture is allowed for an
      // extended __device__ lambda.
      auto lam1 = [x = 1] __device__ () { return x; };
    
      // Error: an init-capture is not allowed for
      // an extended __host__ __device__ lambda.
      auto lam2 = [x = 1] __host__ __device__ () { return x; };
    
      int a = 1;
      // Error: an extended __device__ lambda cannot capture
      // variables by reference.
      auto lam3 = [&a] __device__ () { return a; };
    
      // Error: by-reference capture is not allowed
      // for an extended __device__ lambda.
      auto lam4 = [&x = a] __device__ () { return x; };
    
      struct S1_t { };
      S1_t s1;
      // Error: a type local to a function cannot be used in the type
      // of a captured variable.
      auto lam6 = [s1] __device__ () { };
    
      // Error: an init-capture cannot be of type std::initializer_list.
      auto lam7 = [x = {11}] __device__ () { };
    
      std::initializer_list<int> b = {11,22,33};
      // Error: an init-capture cannot be of type std::initializer_list.
      auto lam8 = [x = b] __device__ () { };
    
      // Error scenario (lam9) and supported scenarios (lam10, lam11)
      // for capture within 'if-constexpr' block
      int yyy = 4;
      auto lam9 = [=] __device__ {
        int result = 0;
        if constexpr(false) {
          //Error: An extended __device__ lambda cannot first-capture
          //      'yyy' in constexpr-if context
          result += yyy;
        }
        return result;
      };
    
      auto lam10 = [yyy] __device__ {
        int result = 0;
        if constexpr(false) {
          //OK: 'yyy' already listed in explicit capture list for the extended lambda
          result += yyy;
        }
        return result;
      };
    
      auto lam11 = [=] __device__ {
        int result = yyy;
        if constexpr(false) {
          //OK: 'yyy' already implicit captured outside the 'if-constexpr' block
          result += yyy;
        }
        return result;
      };
    }
    
  13. 当解析函数时,CUDA编译器会为该函数中的每个扩展lambda分配一个计数器值。这个计数器值用于传递给主机编译器的替代命名类型。因此,扩展lambda是否在函数内定义不应依赖于__CUDA_ARCH__的特定值,也不应依赖于__CUDA_ARCH__是否未定义。

    示例

    template <typename T>
    __global__ void kernel(T in) { in(); }
    
    __host__ __device__ void foo(void) {
      // 错误:扩展lambda的数量和相对声明顺序依赖于__CUDA_ARCH__
    #if defined(__CUDA_ARCH__)
      auto lam1 = [] __device__ { return 0; };
      auto lam1b = [] __host___ __device__ { return 10; };
    #endif
      auto lam2 = [] __device__ { return 4; };
      kernel<<<1,1>>>(lam2);
    }
    
  14. As described above, the CUDA compiler replaces a __device__ extended lambda defined in a host function with a placeholder type defined in namespace scope. Unless the trait __nv_is_extended_device_lambda_with_preserved_return_type() returns true for the closure type of the extended lambda, the placeholder type does not define a operator() function equivalent to the original lambda declaration. An attempt to determine the return type or parameter types of the operator() function of such a lambda may therefore work incorrectly in host code, as the code processed by the host compiler will be semantically different than the input code processed by the CUDA compiler. However, it is OK to introspect the return type or parameter types of the operator() function within device code. Note that this restriction does not apply to __host__ __device__ extended lambdas, or to __device__ extended lambdas for which the trait __nv_is_extended_device_lambda_with_preserved_return_type() returns true.

    Example

    #include <type_traits>
    const char& getRef(const char* p) { return *p; }
    
    void foo(void) {
      auto lam1 = [] __device__ { return "10"; };
    
      // Error: attempt to extract the return type
      // of a __device__ lambda in host code
      std::result_of<decltype(lam1)()>::type xx1 = "abc";
    
    
      auto lam2 = [] __host__ __device__  { return "10"; };
    
      // OK : lam2 represents a __host__ __device__ extended lambda
      std::result_of<decltype(lam2)()>::type xx2 = "abc";
    
      auto lam3 = []  __device__ () -> const char * { return "10"; };
    
      // OK : lam3 represents a __device__ extended lambda with preserved return type
      std::result_of<decltype(lam3)()>::type xx2 = "abc";
      static_assert( std::is_same_v< std::result_of<decltype(lam3)()>::type, const char *>);
    
      auto lam4 = [] __device__ (char x) -> decltype(getRef(&x)) { return 0; };
      // lam4'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(lam4)), "" );
    }
    
  15. 对于扩展设备lambda: - 仅在设备代码中支持对operator()的参数类型进行内省。 - 仅在设备代码中支持对operator()的返回类型进行内省,除非特征函数__nv_is_extended_device_lambda_with_preserved_return_type()返回true。

  16. 如果由扩展lambda表示的函数对象从主机代码传递到设备代码(例如作为__global__函数的参数),那么lambda表达式中捕获变量的任何表达式都必须保持不变,无论是否定义了__CUDA_ARCH__宏,也不管该宏具有什么特定值。这个限制产生的原因是lambda的闭包类布局取决于编译器处理lambda表达式时捕获变量的顺序;如果闭包类布局在设备和主机编译中不同,程序可能会执行错误。

    示例

    __device__ int result;
    
    template <typename T>
    __global__ void kernel(T in) { result = in(); }
    
    void foo(void) {
      int x1 = 1;
      auto lam1 = [=] __host__ __device__ {
        // 错误:"x1"仅在定义了__CUDA_ARCH__时被捕获
    #ifdef __CUDA_ARCH__
        return x1 + 1;
    #else
        return 10;
    #endif
      };
      kernel<<<1,1>>>(lam1);
    }
    
  17. As described previously, the CUDA compiler replaces an extended __device__ lambda expression with an instance of a placeholder type in the code sent to the host compiler. This placeholder type does not define a pointer-to-function conversion operator in host code, however the conversion operator is provided in device code. Note that this restriction does not apply to __host__ __device__ extended lambdas.

    Example

    template <typename T>
    __global__ void kern(T in) {
      int (*fp)(double) = in;
    
      // OK: conversion in device code is supported
      fp(0);
      auto lam1 = [](double) { return 1; };
    
      // OK: conversion in device code is supported
      fp = lam1;
      fp(0);
    }
    
    void foo(void) {
      auto lam_d = [] __device__ (double) { return 1; };
      auto lam_hd = [] __host__ __device__ (double) { return 1; };
      kern<<<1,1>>>(lam_d);
      kern<<<1,1>>>(lam_hd);
    
      // OK : conversion for __host__ __device__ lambda is supported
      // in host code
      int (*fp)(double) = lam_hd;
    
      // Error: conversion for __device__ lambda is not supported in
      // host code.
      int (*fp2)(double) = lam_d;
    }
    
  18. As described previously, the CUDA compiler replaces an extended __device__ or __host__ __device__ lambda expression with an instance of a placeholder type in the code sent to the host compiler. This placeholder type may define C++ special member functions (e.g. constructor, destructor). As a result, some standard C++ type traits may return different results for the closure type of the extended lambda, in the CUDA frontend compiler versus the host compiler. The following type traits are affected: std::is_trivially_copyable, std::is_trivially_constructible, std::is_trivially_copy_constructible, std::is_trivially_move_constructible, std::is_trivially_destructible.

    Care must be taken that the results of these type traits are not used in __global__ function template instantiation or in __device__ / __constant__ / __managed__ variable template instantiation.

    Example

    template <bool b>
    void __global__ foo() { printf("hi"); }
    
    template <typename T>
    void dolaunch() {
    
    // ERROR: this kernel launch may fail, because CUDA frontend compiler
    // and host compiler may disagree on the result of
    // std::is_trivially_copyable() trait on the closure type of the
    // extended lambda
    foo<std::is_trivially_copyable<T>::value><<<1,1>>>();
    cudaDeviceSynchronize();
    }
    
    int main() {
    int x = 0;
    auto lam1 = [=] __host__ __device__ () { return x; };
    dolaunch<decltype(lam1)>();
    }
    

CUDA编译器将为1-12节描述的部分情况生成编译器诊断信息;对于13-17节的情况不会生成诊断信息,但主机编译器可能无法编译生成的代码。

14.7.3. 关于__host__ __device__ lambda函数的说明

__device__ lambda不同,__host__ __device__ lambda可以从主机代码中调用。如前所述,CUDA编译器会将主机代码中定义的扩展lambda表达式替换为命名占位符类型的实例。对于扩展的__host__ __device__ lambda,其占位符类型会通过间接函数调用31来调用原始lambda的operator()

间接函数调用的存在可能导致主机编译器对扩展__host__ __device__ lambda的优化程度低于仅隐式或显式标记为__host__的lambda。在后一种情况下,主机编译器可以轻松将lambda函数体内联到调用上下文中。但对于扩展__host__                                  __device__ lambda,主机编译器会遇到间接函数调用,可能难以直接内联原始的__host__ __device__ lambda函数体。

14.7.4. *this 按值捕获

当在非静态类成员函数中定义lambda表达式,且lambda表达式的主体引用了类成员变量时,C++11/C++14规则要求通过值捕获类的this指针,而非直接捕获引用的成员变量。如果该lambda是在主机函数中定义的扩展__device____host____device__ lambda,并且在GPU上执行,那么当this指针指向主机内存时,在GPU上访问引用的成员变量将导致运行时错误。

示例:

#include <cstdio>

template <typename T>
__global__ void foo(T in) { printf("\n value = %d", in()); }

struct S1_t {
  int xxx;
  __host__ __device__ S1_t(void) : xxx(10) { };

  void doit(void) {

    auto lam1 = [=] __device__ {
       // reference to "xxx" causes
       // the 'this' pointer (S1_t*) to be captured by value
       return xxx + 1;

    };

    // Kernel launch fails at run time because 'this->xxx'
    // is not accessible from the GPU
    foo<<<1,1>>>(lam1);
    cudaDeviceSynchronize();
  }
};

int main(void) {
  S1_t s1;
  s1.doit();
}

C++17 solves this problem by adding a new “*this” capture mode. In this mode, the compiler makes a copy of the object denoted by “*this” instead of capturing the pointer this by value. The “*this” capture mode is described in more detail here: http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2016/p0018r3.html .

CUDA编译器支持在__device____global__函数内定义的lambda表达式使用"*this"捕获模式,同时也支持在主机代码中使用--extended-lambda nvcc标志时定义的扩展__device__ lambda表达式。

以下是修改为使用“*this”捕获模式的上述示例:

#include <cstdio>

template <typename T>
__global__ void foo(T in) { printf("\n value = %d", in()); }

struct S1_t {
  int xxx;
  __host__ __device__ S1_t(void) : xxx(10) { };

  void doit(void) {

    // note the "*this" capture specification
    auto lam1 = [=, *this] __device__ {

       // reference to "xxx" causes
       // the object denoted by '*this' to be captured by
       // value, and the GPU code will access copy_of_star_this->xxx
       return xxx + 1;

    };

    // Kernel launch succeeds
    foo<<<1,1>>>(lam1);
    cudaDeviceSynchronize();
  }
};

int main(void) {
  S1_t s1;
  s1.doit();
}

在主机代码中定义的未标注lambda或扩展的__host____device__ lambda不允许使用"*this"捕获模式。以下是支持和不支持的用法示例:

struct S1_t {
  int xxx;
  __host__ __device__ S1_t(void) : xxx(10) { };

  void host_func(void) {

    // OK: use in an extended __device__ lambda
    auto lam1 = [=, *this] __device__ { return xxx; };

    // Error: use in an extended __host__ __device__ lambda
    auto lam2 = [=, *this] __host__ __device__ { return xxx; };

    // Error: use in an unannotated lambda in host function
    auto lam3 = [=, *this]  { return xxx; };
  }

  __device__ void device_func(void) {

    // OK: use in a lambda defined in a __device__ function
    auto lam1 = [=, *this] __device__ { return xxx; };

    // OK: use in a lambda defined in a __device__ function
    auto lam2 = [=, *this] __host__ __device__ { return xxx; };

    // OK: use in a lambda defined in a __device__ function
    auto lam3 = [=, *this]  { return xxx; };
  }

   __host__ __device__ void host_device_func(void) {

    // OK: use in an extended __device__ lambda
    auto lam1 = [=, *this] __device__ { return xxx; };

    // Error: use in an extended __host__ __device__ lambda
    auto lam2 = [=, *this] __host__ __device__ { return xxx; };

    // Error: use in an unannotated lambda in a __host__ __device__ function
    auto lam3 = [=, *this]  { return xxx; };
  }
};

14.7.5. 补充说明

  1. ADL Lookup: As described earlier, the CUDA compiler will replace an extended lambda expression with an instance of a placeholder type, before invoking the host compiler. One template argument of the placeholder type uses the address of the function enclosing the original lambda expression. This may cause additional namespaces to participate in argument dependent lookup (ADL), for any host function call whose argument types involve the closure type of the extended lambda expression. This may cause an incorrect function to be selected by the host compiler.

    Example:

    namespace N1 {
      struct S1_t { };
      template <typename T>  void foo(T);
    };
    
    namespace N2 {
      template <typename T> int foo(T);
    
      template <typename T>  void doit(T in) {     foo(in);  }
    }
    
    void bar(N1::S1_t in) {
      /* extended __device__ lambda. In the code sent to the host compiler, this
         is replaced with the placeholder type instantiation expression
         ' __nv_dl_wrapper_t< __nv_dl_tag<void (*)(N1::S1_t in),(&bar),1> > { }'
    
         As a result, the namespace 'N1' participates in ADL lookup of the
         call to "foo" in the body of N2::doit, causing ambiguity.
      */
      auto lam1 = [=] __device__ { };
      N2::doit(lam1);
    }
    

    In the example above, the CUDA compiler replaced the extended lambda with a placeholder type that involves the N1 namespace. As a result, the namespace N1 participates in the ADL lookup for foo(in) in the body of N2::doit, and host compilation fails because multiple overload candidates N1::foo and N2::foo are found.

14.8. 放宽的常量表达式 (-expt-relaxed-constexpr)

默认情况下,不支持以下跨执行空间调用:

  1. 在主机代码生成阶段(即当__CUDA_ARCH__宏未定义时)从__host__函数调用仅限__device__constexpr函数。示例:

    constexpr __device__ int D() { return 0; }
    int main() {
        int x = D();  //错误:从主机代码调用仅限__device__的constexpr函数
    }
    
  2. 在设备代码生成阶段(即定义了__CUDA_ARCH__宏时),从__device____global__函数中调用仅限__host__constexpr函数。示例:

    constexpr  int H() { return 0; }
    __device__ void dmain()
    {
        int x = H();  //错误:从设备代码中调用仅限主机的constexpr函数
    }
    

实验性标志-expt-relaxed-constexpr可用于放宽此限制。当指定此标志时,编译器将支持上述跨执行空间调用,具体如下:

  1. 如果跨执行空间调用constexpr函数发生在需要常量求值的上下文中(例如在constexpr变量的初始化器中),则支持这种调用。示例:

    constexpr __host__ int H(int x) { return x+1; };
    __global__ void doit() {
    constexpr int val = H(1); // 正确:调用发生在需要
                              // 常量求值的上下文中
    }
    
    constexpr __device__ int D(int x) { return x+1; }
    int main() {
    constexpr int val = D(1); // 正确:调用发生在需要
                              // 常量求值的上下文中
    }
    
  2. 否则:

    1. 在设备代码生成过程中,会为仅限__host__的constexpr函数H生成设备代码,除非H未被使用或仅在constexpr上下文中调用。示例:

      // 注意:"H"会出现在生成的设备代码中,因为它是在非constexpr上下文中从设备代码调用的
      constexpr __host__ int H(int x) { return x+1; }
      
      __device__ int doit(int in) {
        in = H(in);  // 即使参数不是常量表达式也是允许的
        return in;
      }
      
    2. 适用于``__device__``函数的所有代码限制同样适用于从设备代码调用的仅限主机端常量表达式函数``H``。然而,编译器可能不会针对``H``的这些限制发出任何构建时诊断信息 15

      例如,以下代码模式在H函数体内不受支持(与任何__device__函数一样),但编译器可能不会生成诊断信息:

      • ODR使用了主机变量或__host__限定的非constexpr函数。示例:

        int qqq, www;
        
        constexpr __host__ int* H(bool b) { return b ? &qqq : &www; };
        
        __device__ int doit(bool flag) {
          int *ptr;
          ptr = H(flag); // 错误:H()尝试引用主机变量'qqq'和'www'
                         // 代码可以编译,但无法正确执行
          return *ptr;
        }
        
      • 使用异常处理(throw/catch)和运行时类型识别(RTTI)(typeid, dynamic_cast)。示例:

        struct Base { };
        struct Derived : public Base { };
        
        // 注意:"H"会在生成的设备代码中被发出
        constexpr int H(bool b, Base *ptr) {
          if (b) {
            return 1;
          } else if (typeid(ptr) == typeid(Derived)) { // 错误:在GPU上执行的代码中使用了typeid
            return 2;
          } else {
            throw int{4}; // 错误:在GPU上执行的代码中使用了throw
          }
        }
        __device__ void doit(bool flag) {
          int val;
          Derived d;
          val = H(flag, &d); //错误:H()尝试使用typeid和throw(),这在GPU上执行的代码中是不允许的
        }
        
    3. 在主机代码生成过程中,__device__限定的constexpr函数D的函数体会保留在发送给主机编译器的代码中。如果D的函数体尝试ODR使用命名空间作用域的设备变量或__device__限定的非constexpr函数,则不支持从主机代码调用D(代码可能在编译时不会报错,但在运行时可能出现错误行为)。示例:

      __device__ int qqq, www;
      constexpr __device__ int* D(bool b) { return b ? &qqq : &www; };
      
      int doit(bool flag) {
        int *ptr;
        ptr = D(flag); // 错误:D()尝试引用设备变量'qqq'和'www'
                       // 代码会编译通过,但无法正确执行
        return *ptr;
      }
      
    4. 注意:鉴于上述限制以及缺乏对错误使用的编译器诊断,在从设备代码调用标准C++头文件中的constexpr __host__函数时要格外小心,因为该函数的实现会根据主机平台而变化,例如基于gcc主机编译器的libstdc++版本。当移植到不同平台或主机编译器版本时(如果目标C++库实现如前面所述odr-uses了主机代码变量或函数),此类代码可能会静默失效。

      示例:

      __device__ int get(int in) {
       int val = std::foo(in); // "std::foo"是主机编译器标准库头文件中定义的constexpr函数
                               // 警告:如果std::foo实现ODR-uses了主机变量或函数
                               // 代码将无法正确工作
      }
      
15

Diagnostics are usually generated during parsing, but the host-only function H may already have been parsed before the call to H from device code is encountered later in the translation unit.

14.9. 代码示例

14.9.1. 数据聚合类

class PixelRGBA {
public:
    __device__ PixelRGBA(): r_(0), g_(0), b_(0), a_(0) { }

    __device__ PixelRGBA(unsigned char r, unsigned char g,
                         unsigned char b, unsigned char a = 255):
                         r_(r), g_(g), b_(b), a_(a) { }

private:
    unsigned char r_, g_, b_, a_;

    friend PixelRGBA operator+(const PixelRGBA&, const PixelRGBA&);
};

__device__
PixelRGBA operator+(const PixelRGBA& p1, const PixelRGBA& p2)
{
    return PixelRGBA(p1.r_ + p2.r_, p1.g_ + p2.g_,
                     p1.b_ + p2.b_, p1.a_ + p2.a_);
}

__device__ void func(void)
{
    PixelRGBA p1, p2;
    // ...      // Initialization of p1 and p2 here
    PixelRGBA p3 = p1 + p2;
}

14.9.2. 派生类

__device__ void* operator new(size_t bytes, MemoryPool& p);
__device__ void operator delete(void*, MemoryPool& p);
class Shape {
public:
    __device__ Shape(void) { }
    __device__ void putThis(PrintBuffer *p) const;
    __device__ virtual void Draw(PrintBuffer *p) const {
         p->put("Shapeless");
    }
    __device__ virtual ~Shape() {}
};
class Point : public Shape {
public:
    __device__ Point() : x(0), y(0) {}
    __device__ Point(int ix, int iy) : x(ix), y(iy) { }
    __device__ void PutCoord(PrintBuffer *p) const;
    __device__ void Draw(PrintBuffer *p) const;
    __device__ ~Point() {}
private:
    int x, y;
};
__device__ Shape* GetPointObj(MemoryPool& pool)
{
    Shape* shape = new(pool) Point(rand(-20,10), rand(-100,-20));
    return shape;
}

14.9.3. 类模板

template <class T>
class myValues {
    T values[MAX_VALUES];
public:
    __device__ myValues(T clear) { ... }
    __device__ void setValue(int Idx, T value) { ... }
    __device__ void putToMemory(T* valueLocation) { ... }
};

template <class T>
void __global__ useValues(T* memoryBuffer) {
    myValues<T> myLocation(0);
    ...
}

__device__ void* buffer;

int main()
{
    ...
    useValues<int><<<blocks, threads>>>(buffer);
    ...
}

14.9.4. 函数模板

template <typename T>
__device__ bool func(T x)
{
   ...
   return (...);
}

template <>
__device__ bool func<int>(T x) // Specialization
{
   return true;
}

// Explicit argument specification
bool result = func<double>(0.5);

// Implicit argument deduction
int x = 1;
bool result = func(x);

14.9.5. 函子类

class Add {
public:
    __device__  float operator() (float a, float b) const
    {
        return a + b;
    }
};

class Sub {
public:
    __device__  float operator() (float a, float b) const
    {
        return a - b;
    }
};

// Device code
template<class O> __global__
void VectorOperation(const float * A, const float * B, float * C,
                     unsigned int N, O op)
{
    unsigned int iElement = blockDim.x * blockIdx.x + threadIdx.x;
    if (iElement < N)
        C[iElement] = op(A[iElement], B[iElement]);
}

// Host code
int main()
{
    ...
    VectorOperation<<<blocks, threads>>>(v1, v2, v3, N, Add());
    ...
}
16

例如,用于启动内核的<<<...>>>语法。

17

This does not apply to entities that may be defined in more than one translation unit, such as compiler generated template instantiations.

18

目的是在设备编译期间允许对__host__ __device__函数中的静态变量使用可变内存空间说明符,但在主机编译期间禁止这样做

19

调试类型C疑似布局不匹配的一种方法是使用printf输出主机和设备代码中sizeof(C)offsetof(C, field)的值。

20

请注意,由于存在额外的声明,这可能会对编译时间产生负面影响。

21

目前,-std=c++11 标志仅支持以下主机编译器:gcc 版本 >= 4.7、clang、icc >= 15 和 xlc >= 13.1

22

包括 operator()

23

这些限制与非constexpr被调用函数相同。

24

请注意,实验性标志的行为在未来的编译器版本中可能会发生变化。

25

C++标准章节 [basic.types]

26

C++标准章节 [expr.const]

27

目前,-std=c++14 标志仅支持以下主机编译器:gcc 版本 >= 5.1、clang 版本 >= 3.7 和 icc 版本 >= 17

28

目前,-std=c++17 标志仅支持以下主机编译器:gcc 版本 >= 7.0、clang 版本 >= 8.0、Visual Studio 版本 >= 2017、pgi 编译器版本 >= 19.0、icc 编译器版本 >= 19.0

29

目前,-std=c++20 标志仅支持以下主机编译器:gcc 版本 >= 10.0、clang 版本 >= 10.0、Visual Studio 版本 >= 2022 以及 nvc++ 版本 >= 20.7。

30

使用icc主机编译器时,此标志仅支持icc版本大于等于1800。

31(1,2)

如果未启用扩展lambda模式,这些特性将始终返回false。

32

相比之下,C++标准规定捕获的变量用于直接初始化闭包类型的字段。

15. 纹理获取

本节介绍用于计算纹理函数返回值的公式,该计算基于纹理对象的各种属性(参见纹理与表面内存)。

绑定到纹理对象的纹理表示为一个数组 T

  • N 个纹素用于一维纹理,

  • N x M 纹素用于二维纹理,

  • N x M x L 个纹素用于三维纹理。

它使用非归一化的纹理坐标xyz,或者归一化的纹理坐标x/Ny/Mz/L来获取数据,如纹理内存中所述。本节假设坐标都在有效范围内。纹理内存解释了如何根据寻址模式将超出范围的坐标重新映射到有效范围内。

15.1. 最近点采样

在此过滤模式下,纹理获取返回的值为

  • tex(x)=T[i] 对于一维纹理来说,

  • tex(x,y)=T[i,j] 对于二维纹理来说,

  • tex(x,y,z)=T[i,j,k] 表示三维纹理的纹理取值

其中 i=floor(x), j=floor(y), 以及 k=floor(z)

图32展示了一个一维纹理(N=4)的最近点采样方法。

_images/nearest-point-sampling-of-1-d-texture-of-4-texels.png

图32 最近点采样过滤模式

对于整数纹理,纹理获取返回的值可以选择性地重新映射到[0.0, 1.0]范围(参见纹理内存)。

15.2. 线性滤波

在此过滤模式下(仅适用于浮点纹理),纹理获取返回的值为

  • \(tex(x)=(1-\alpha)T[i]+{\alpha}T[i+1]\) 针对一维纹理,

  • \(tex(x)=(1-\alpha)T[i]+{\alpha}T[i+1]\) 针对一维纹理,

  • \(tex(x,y)=(1-\alpha)(1-\beta)T[i,j]+\alpha(1-\beta)T[i+1,j]+(1-\alpha){\beta}T[i,j+1]+\alpha{\beta}T[i+1,j+1]\) 针对二维纹理的公式,

  • \(tex(x,y,z)\) =

    \((1-\alpha)(1-\beta)(1-\gamma)T[i,j,k]+\alpha(1-\beta)(1-\gamma)T[i+1,j,k]+\)

    \((1-\alpha)\beta(1-\gamma)T[i,j+1,k]+\alpha\beta(1-\gamma)T[i+1,j+1,k]+\)

    \((1-\alpha)(1-\beta){\gamma}T[i,j,k+1]+\alpha(1-\beta){\gamma}T[i+1,j,k+1]+\)

    \((1-\alpha)\beta{\gamma}T[i,j+1,k+1]+\alpha\beta{\gamma}T[i+1,j+1,k+1]\)

    对于三维纹理,

其中:

  • \(i=floor(x\ B)*, \alpha=frac(x\ B)*, *x\ B\ =x-0.5,\)

  • \(j=floor(y\ B)*, \beta=frac(y\ B)*, *y\ B\ =y-0.5,\)

  • \(k=floor(z\ B)*, \gamma=frac(z\ B)*, *z\ B\ = z-0.5,\)

\(\alpha\)\(\beta\)\(\gamma\)以9位定点格式存储,其中8位表示小数部分(因此1.0可以被精确表示)。

图33展示了使用N=4对一维纹理进行线性滤波的过程。

_images/linear-filtering-of-1-d-texture-of-4-texels.png

图33 线性滤波模式

15.3. 表查找

一个表查找函数TL(x),其中x的取值范围为[0,R],可以通过TL(x)=tex((N-1)/R)x+0.5)来实现,以确保TL(0)=T[0]TL(R)=T[N-1]

图34展示了使用纹理过滤实现从N=4的一维纹理中进行R=4R=1的表格查找。

_images/1-d-table-lookup-using-linear-filtering.png

图34 使用线性滤波的一维查找表

16. 计算能力

计算设备的通用规格和特性取决于其计算能力(参见Compute Capability)。

表20表21展示了当前支持的每个计算能力相关的功能和技术规格。

浮点标准 回顾了与IEEE浮点标准的合规性。

章节计算能力5.x计算能力6.x计算能力7.x计算能力8.x计算能力9.0分别提供了关于计算能力5.x、6.x、7.x、8.x和9.0设备架构的更多详细信息。

16.1. 功能可用性

计算特性随计算架构一同引入,目的是确保该特性在所有后续架构中均可用。如表20所示,特性在引入后的计算能力版本中标记为"yes"表示其可用性。

架构引入的高度专业化计算功能不能保证在所有后续计算能力版本中都可用。这些功能针对特定运算的加速优化,这些运算并非面向所有计算能力等级(由计算能力次版本号表示),或者可能在未来的代际中发生重大变化(由计算能力主版本号表示)。

对于给定的计算能力,可能有两组计算特性:

计算能力 #.#:主要的一组计算特性,这些特性旨在为后续计算架构提供支持。这些特性及其可用性总结在表20中。

计算能力 #.#a: 一小部分高度专业化的功能集,用于加速特定操作,这些功能不保证在所有后续计算架构中都可用或可能发生重大变化。这些功能在相应的"计算能力 #.#"小节中进行了总结。

设备代码的编译针对特定的计算能力。设备代码中出现的功能必须适用于目标计算能力。例如:

  • compute_90编译目标允许使用Compute Capability 9.0特性,但不允许使用Compute Capability 9.0a特性。

  • compute_90a 编译目标允许使用完整的计算设备功能集,包括9.0a特性和9.0特性。

16.2. 功能与技术规格

表 22 各计算能力支持的功能特性

功能支持

计算能力

(未列出的功能支持所有计算能力)

5.0, 5.2

5.3

6.x

7.x

8.x

9.0

10.x

12.0

对全局内存中的32位整数值进行原子操作的函数(原子函数)

对共享内存中的32位整数值进行原子操作的函数(原子函数)

全局内存中对64位整数值进行操作的原子函数(原子函数)

在共享内存中对64位整数值进行原子操作的函数(原子函数

对全局内存中的128位整数值进行操作的原子函数(原子函数)

对共享内存中的128位整数值进行操作的原子函数(原子函数)

对全局和共享内存中的32位浮点值执行原子加法操作(atomicAdd())

对全局内存和共享内存中的64位浮点值执行原子加法操作(atomicAdd())

对全局内存中的float2和float4浮点向量执行原子加法操作(atomicAdd())

Warp投票函数 (Warp Vote Functions)

内存栅栏函数 (Memory Fence Functions)

同步函数 (Synchronization Functions)

表面函数 (Surface Functions)

统一内存编程 (Unified Memory Programming)

动态并行性 (CUDA Dynamic Parallelism)

半精度浮点运算:加法、减法、乘法、比较、warp shuffle函数、转换

Bfloat16精度浮点运算:加法、减法、乘法、比较、warp shuffle函数、转换

张量核心

混合精度Warp矩阵函数 (Warp矩阵函数)

硬件加速的 memcpy_async (使用cuda::pipeline的异步数据拷贝)

硬件加速的分裂到达/等待屏障(异步屏障)

L2缓存驻留管理 (设备内存L2访问管理)

加速动态编程的DPX指令

分布式共享内存

线程块集群

张量内存加速器 (TMA) 单元

请注意,下表中使用的KB和K单位分别对应1024字节(即一个KiB)和1024。

表23 各计算能力版本的技术规格

计算能力

技术规格

5.0

5.2

5.3

6.0

6.1

6.2

7.0

7.2

7.5

8.0

8.6

8.7

8.9

9.0

10.x

12.0

每个设备的最大常驻网格数 (并发内核执行)

32

16

128

32

16

128

16

128

线程块网格的最大维度

3

线程块网格的最大x维度 [线程块]

231-1

线程块网格的最大y或z维度

65535

线程块的最大维度

3

块的最大x或y维度

1024

块的最大z维度

64

每个块的最大线程数

1024

线程束大小

32

每个SM的最大常驻块数

32

16

32

16

24

32

每个SM的最大常驻线程束数量

64

32

64

48

64

48

每个SM的最大常驻线程数

2048

1024

2048

1536

2048

1536

每个SM的32位寄存器数量

64 K

每个线程块的最大32位寄存器数量

64 K

32 K

64 K

32 K

64 K

每个线程的最大32位寄存器数量

255

每个SM的最大共享内存容量

64 KB

96 KB

64 KB

96 KB

64 KB

96 KB

64 KB

164 KB

100 KB

164 KB

100 KB

228 KB

100 KB

每个线程块的最大共享内存量 33

48 KB

96 KB

96 KB

64 KB

163 KB

99 KB

163 KB

99 KB

227 KB

99 KB

共享内存块数量

32

每个线程的最大本地内存量

512 KB

常量内存大小

64 KB

每个SM的常量内存缓存工作集

8 KB

4 KB

8 KB

每个SM的纹理内存缓存工作集大小

12 KB 至 48 KB之间

24 KB 至 48 KB之间

32 ~ 128 KB

32或 64 KB

28 KB ~ 192 KB

28 KB ~ 128 KB

28 KB ~ 192 KB

28 KB ~ 128 KB

28 KB ~ 256 KB

28 KB ~ 128 KB

使用CUDA数组的一维纹理对象的最大宽度

65536

131072

使用线性内存的一维纹理对象最大宽度

227

228

227

228

227

228

一维分层纹理对象的最大宽度和层数

16384 x 2048

32768 x 2048

使用CUDA数组的2D纹理对象的最大宽度和高度

65536 x 65536

131072 x 65536

使用线性内存的2D纹理对象的最大宽度和高度

65536 x 65536

131072 x 65000

支持纹理采集的CUDA数组所使用的2D纹理对象的最大宽度和高度

16384 x 16384

32768 x 32768

二维分层纹理对象的最大宽度、高度和层数

16384 x 16384 x 2048

32768 x 32768 x 2048

使用CUDA数组的3D纹理对象的最大宽度、高度和深度

4096 x 4096 x 4096

16384 x 16384 x 16384

立方体贴图纹理对象的最大宽度(和高度)

16384

32768

立方体贴图分层纹理对象的最大宽度(和高度)及层数

16384 x 2046

32768 x 2046

内核可绑定的最大纹理数量

256

使用CUDA数组的一维表面对象最大宽度

16384

32768

一维分层表面对象的最大宽度和层数

16384 x 2048

32768 x 2048

使用CUDA数组的2D表面对象的最大宽度和高度

65536 x 65536

1 31072 x 65536

二维分层表面对象的最大宽度、高度和层数

16384 x 16384 x 2048

32768 x 32768 x 1048

使用CUDA数组的3D表面对象的最大宽度、高度和深度

4096 x 4096 x 4096

16384 x 16384 x 16384

使用CUDA数组的立方体贴图表面对象的最大宽度(和高度)

16384

32768

立方体贴图分层表面对象的最大宽度(和高度)及层数

16384 x 2046

32768 x 2046

单个内核可使用的最大表面数量

16

32

16.3. 浮点数标准

所有计算设备遵循IEEE 754-2008二进制浮点运算标准,但存在以下偏差:

  • 没有动态可配置的舍入模式;不过,大多数操作支持通过设备内置函数暴露的多种IEEE舍入模式。

  • 目前没有机制可以检测是否发生了浮点异常,所有操作的行为都如同IEEE-754异常始终被屏蔽一般。如果出现异常事件,系统会按照IEEE-754标准提供被屏蔽的响应。出于同样的原因,虽然支持SNaN编码,但它们不会发出信号,而是作为静默NaN处理。

  • 涉及一个或多个输入NaN的单精度浮点运算结果是一个静默NaN,其位模式为0x7fffffff。

  • 双精度浮点数的绝对值和取反操作在处理NaN时不符合IEEE-754标准;这些值会原样传递不做改变。

代码必须使用-ftz=false-prec-div=true-prec-sqrt=true进行编译,以确保符合IEEE标准(这是默认设置;有关这些编译标志的说明,请参阅nvcc用户手册)。

无论编译器标志-ftz的设置如何,

  • 全局内存上的原子单精度浮点加法始终以清零模式运行,即行为等同于FADD.F32.FTZ.RN

  • 共享内存上的原子单精度浮点加法始终支持非规格化数操作,即行为等同于FADD.F32.RN

根据IEEE-754R标准,如果fminf()fmin()fmaxf()fmax()的输入参数中有一个是NaN而另一个不是,则结果为非NaN参数。

当浮点数值超出整数格式的范围时,IEEE-754标准未定义其转换为整数值的行为。对于计算设备,其行为是将其截断至支持范围的边界。这与x86架构的行为不同。

IEEE-754标准未定义整数除以零和整数溢出的行为。对于计算设备而言,没有检测此类整数运算异常发生的机制。整数除以零会产生一个未指定的、机器特定的值。

https://developer.nvidia.com/content/precision-performance-floating-point-and-ieee-754-compliance-nvidia-gpus 包含有关NVIDIA GPU浮点精度与IEEE 754标准合规性的更多信息。

16.4. 计算能力 5.x

16.4.1. 架构

一个SM包含以下部分:

  • 128个CUDA核心用于算术运算(算术运算的吞吐量请参见算术指令),

  • 32个专用功能单元,用于单精度浮点超越函数,

  • 4个warp调度器。

当一个SM(流式多处理器)获得可执行的warp(线程束)时,它首先将这些warp分配给四个调度器。然后,在每个指令发射时刻,每个调度器会从其分配的就绪warp中选择一个发射一条指令(如果有的话)。

一个SM包含:

  • 一个只读的常量缓存,由所有功能单元共享,可加速从驻留在设备内存中的常量内存空间的读取。

  • 一个24 KB的统一L1/纹理缓存,用于缓存从全局内存的读取操作,

  • 计算能力5.0设备提供64 KB共享内存,计算能力5.2设备提供96 KB共享内存。

统一的L1/纹理缓存也被纹理单元所使用,该单元实现了纹理和表面内存中提到的各种寻址模式和数据过滤功能。

所有SM共享一个L2缓存,用于缓存对本地或全局内存的访问,包括临时寄存器溢出。应用程序可以通过检查l2CacheSize设备属性来查询L2缓存大小(参见Device Enumeration)。

缓存行为(例如,读取是同时缓存在统一的L1/纹理缓存和L2中,还是仅缓存在L2中)可以通过加载指令的修饰符在每个访问基础上进行部分配置。

16.4.2. 全局内存

全局内存访问始终缓存在L2中。

在内核的整个生命周期中只读的数据也可以通过使用__ldg()函数读取(参见Read-Only Data Cache Load Function)缓存在前一节描述的统一L1/纹理缓存中。当编译器检测到某些数据满足只读条件时,它将使用__ldg()来读取这些数据。编译器可能并不总是能够检测到某些数据满足只读条件。用const__restrict__限定符标记用于加载此类数据的指针会增加编译器检测到只读条件的可能性。

在内核整个生命周期内非只读的数据,对于计算能力5.0的设备无法缓存在统一的L1/纹理缓存中。对于计算能力5.2的设备,默认情况下也不会缓存在统一的L1/纹理缓存中,但可以通过以下机制启用缓存:

  • 按照PTX参考手册中的描述,使用适当修饰符的内联汇编执行读取操作;

  • 使用-Xptxas -dlcm=ca编译标志进行编译,这种情况下除了使用内联汇编并带有禁用缓存修饰符的读取操作外,所有读取都会被缓存;

  • 使用编译标志-Xptxas -fscm=ca进行编译,在这种情况下所有读取操作都会被缓存,包括使用内联汇编执行的读取操作,无论使用了何种修饰符。

当使用上述三种机制之一启用缓存时,计算能力5.2的设备会将全局内存读取缓存到统一的L1/纹理缓存中,适用于所有内核启动,除非线程块消耗过多SM寄存器文件的内核启动。这些异常情况会由性能分析器报告。

16.4.3. 共享内存

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

对于warp的共享内存请求,如果两个线程访问同一32位字内的任何地址(即使这两个地址位于同一存储体中),不会产生存储体冲突。在这种情况下,对于读取访问,该字会被广播到请求线程;对于写入访问,每个地址仅由其中一个线程写入(具体由哪个线程执行写入操作是未定义的)。

图22展示了一些跨步访问的示例。

图23展示了一些涉及广播机制的内存读取访问示例。

Strided Shared Memory Accesses in 32 bit bank size mode.

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

Left

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

Middle

以两个32位字为跨度的线性寻址(双向存储体冲突)。

Right

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

Irregular Shared Memory Accesses.

图36 非常规共享内存访问。

Left

通过随机排列实现无冲突访问。

Middle

由于线程3、4、6、7和9访问了bank 5中的同一个字,因此实现了无冲突访问。

Right

无冲突广播访问(线程在同一存储体中访问相同的字)。

16.5. 计算能力 6.x

16.5.1. 架构

一个SM包含以下部分:

  • 64个(计算能力6.0)或128个(6.1和6.2)CUDA核心用于算术运算,

  • 16个(6.0版本)或32个(6.1和6.2版本)用于单精度浮点超越函数的特殊功能单元,

  • 2个(6.0版本)或4个(6.1和6.2版本)warp调度器。

当一个SM(流式多处理器)获得可执行的warp(线程束)时,它首先将这些warp分配给其调度器。然后,在每个指令发射时刻,每个调度器会从其分配的就绪warp中选择一个发射一条指令(如果有的话)。

一个SM包含:

  • 一个只读的常量缓存,由所有功能单元共享,可加速从驻留在设备内存中的常量内存空间的读取。

  • 用于从全局内存读取的统一L1/纹理缓存,大小为24 KB(6.0和6.2版本)或48 KB(6.1版本),

  • 共享内存大小为64 KB(6.0和6.2版本)或96 KB(6.1版本)。

统一的L1/纹理缓存也被纹理单元所使用,该单元实现了纹理和表面内存中提到的各种寻址模式和数据过滤功能。

所有SM共享一个L2缓存,用于缓存对本地或全局内存的访问,包括临时寄存器溢出。应用程序可以通过检查l2CacheSize设备属性来查询L2缓存大小(参见Device Enumeration)。

缓存行为(例如,读取是同时缓存在统一的L1/纹理缓存和L2中,还是仅缓存在L2中)可以通过加载指令的修饰符在每个访问基础上进行部分配置。

16.5.2. 全局内存

全局内存的行为方式与计算能力5.x的设备相同(参见全局内存)。

16.5.3. 共享内存

共享内存的行为与计算能力5.x设备中的表现相同(参见共享内存)。

16.6. 计算能力 7.x

16.6.1. 架构

一个SM包含以下部分:

  • 64个FP32核心用于单精度算术运算,

  • 32个FP64核心用于双精度算术运算,34

  • 64个INT32核心用于整数运算,

  • 8个混合精度Tensor Core,用于深度学习矩阵运算

  • 16个专用功能单元用于单精度浮点超越函数,

  • 4个warp调度器。

一个SM(流式多处理器)会静态地将其线程束(warps)分配给各个调度器。随后,在每个指令发射时刻,每个调度器会从其分配的就绪线程束中选择一个发射一条指令(如果有符合条件的线程束)。

一个SM包含:

  • 一个只读的常量缓存,由所有功能单元共享,可加速从驻留在设备内存中的常量内存空间的读取。

  • 统一的数据缓存和共享内存,总大小为128 KB(Volta)或96 KB(Turing)。

共享内存是从统一数据缓存中划分出来的,可以配置为不同大小(参见共享内存)。剩余的数据缓存用作L1缓存,同时也被纹理单元使用,该单元实现了纹理和表面内存中提到的各种寻址和数据过滤模式。

16.6.2. 独立线程调度

Volta架构引入了线程束(warp)内的独立线程调度机制,这使得之前无法实现的线程束内同步模式成为可能,并简化了从CPU代码移植时的修改工作。然而,如果开发者基于之前硬件架构的线程束同步特性做出了假设,这可能导致实际执行代码的线程组与预期存在较大差异。

以下是针对Volta安全代码的问题代码模式及建议的修正措施。

  1. 对于使用warp内部函数(__shfl*__any__all__ballot)的应用程序,开发者需要将其代码迁移到新的、安全的同步对应版本,即带有*_sync后缀的函数。新的warp内部函数需要一个线程掩码参数,该参数明确定义了哪些通道(warp的线程)必须参与warp内部函数。详情请参阅Warp Vote FunctionsWarp Shuffle Functions

由于这些内置函数从CUDA 9.0版本开始提供,(如有需要)可以通过以下预处理器宏有条件地执行代码:

#if defined(CUDART_VERSION) && CUDART_VERSION >= 9000
// *_sync intrinsic
#endif

这些内置函数适用于所有架构,而不仅仅是VoltaTuring,在大多数情况下,单一代码库即可满足所有架构需求。但请注意,对于Pascal及更早的架构,掩码中的所有线程必须在收敛时执行相同的warp内置指令,且掩码中所有值的并集必须等于warp的活动掩码。以下代码模式在Volta上有效,但在Pascal或更早的架构上无效。

if (tid % warpSize < 16) {
    ...
    float swapped = __shfl_xor_sync(0xffffffff, val, 16);
    ...
} else {
    ...
    float swapped = __shfl_xor_sync(0xffffffff, val, 16);
    ...
}

__ballot(1)的替代方法是__activemask()。请注意,即使在同一代码路径中,warp内的线程也可能出现分支。因此,__activemask()__ballot(1)可能仅返回当前代码路径上的部分线程。以下无效代码示例在data[i]大于threshold时,将output的第i位设置为1。当dataLen不是32的倍数时,尝试使用__activemask()来支持这种情况。

// Sets bit in output[] to 1 if the correspond element in data[i]
// is greater than 'threshold', using 32 threads in a warp.

for (int i = warpLane; i < dataLen; i += warpSize) {
    unsigned active = __activemask();
    unsigned bitPack = __ballot_sync(active, data[i] > threshold);
    if (warpLane == 0) {
        output[i / 32] = bitPack;
    }
}

这段代码无效,因为CUDA无法保证线程束(warp)仅在循环条件处才会发生分支发散。当由于其他原因导致分支发散时,同一个32位输出元素会被线程束中不同的线程子集计算出冲突的结果。正确的代码应使用非发散循环条件结合__ballot_sync()来安全地枚举参与阈值计算的线程束中的线程集合,如下所示。

for (int i = warpLane; i - warpLane < dataLen; i += warpSize) {
    unsigned active = __ballot_sync(0xFFFFFFFF, i < dataLen);
    if (i < dataLen) {
        unsigned bitPack = __ballot_sync(active, data[i] > threshold);
        if (warpLane == 0) {
            output[i / 32] = bitPack;
        }
    }
}

发现模式展示了__activemask()的有效用例。

  1. If applications have warp-synchronous codes, they will need to insert the new __syncwarp() warp-wide barrier synchronization instruction between any steps where data is exchanged between threads via global or shared memory. Assumptions that code is executed in lockstep or that reads/writes from separate threads are visible across a warp without synchronization are invalid.

    __shared__ float s_buff[BLOCK_SIZE];
    s_buff[tid] = val;
    __syncthreads();
    
    // Inter-warp reduction
    for (int i = BLOCK_SIZE / 2; i >= 32; i /= 2) {
        if (tid < i) {
            s_buff[tid] += s_buff[tid+i];
        }
        __syncthreads();
    }
    
    // Intra-warp reduction
    // Butterfly reduction simplifies syncwarp mask
    if (tid < 32) {
        float temp;
        temp = s_buff[tid ^ 16]; __syncwarp();
        s_buff[tid] += temp;     __syncwarp();
        temp = s_buff[tid ^ 8];  __syncwarp();
        s_buff[tid] += temp;     __syncwarp();
        temp = s_buff[tid ^ 4];  __syncwarp();
        s_buff[tid] += temp;     __syncwarp();
        temp = s_buff[tid ^ 2];  __syncwarp();
        s_buff[tid] += temp;     __syncwarp();
    }
    
    if (tid == 0) {
        *output = s_buff[0] + s_buff[1];
    }
    __syncthreads();
    
  2. 尽管__syncthreads()在文档中一直被描述为同步线程块内的所有线程,但Pascal及更早架构只能在warp级别实现同步。在某些情况下,只要每个warp中至少有一个线程到达屏障,即使并非所有线程都执行该屏障,同步操作也能成功。从Volta架构开始,CUDA内置函数__syncthreads()和PTX指令bar.sync(及其衍生指令)将严格按线程执行,因此必须等待线程块内所有未退出的线程都到达屏障才会成功。依赖先前行为的代码可能会出现死锁,必须进行修改以确保所有未退出的线程都能到达屏障。

compute-saniter提供的racechecksynccheck工具可以帮助定位违规行为。

为了在实施上述纠正措施的同时帮助迁移,开发者可以选择不支持独立线程调度的Pascal调度模型。详情请参阅应用兼容性

16.6.3. 全局内存

全局内存的行为与计算能力5.x设备中的表现相同(参见全局内存)。

16.6.4. 共享内存

为共享内存保留的统一数据缓存大小可按每个内核进行配置。对于Volta架构(计算能力7.0),统一数据缓存大小为128 KB,共享内存容量可设置为0、8、16、32、64或96 KB。对于Turing架构(计算能力7.5),统一数据缓存大小为96 KB,共享内存容量可设置为32 KB或64 KB。与Kepler不同,驱动程序会自动为每个内核配置共享内存容量,以避免共享内存占用瓶颈,同时在可能的情况下允许与已启动的内核并发执行。在大多数情况下,驱动程序的默认行为应能提供最佳性能。

由于驱动程序并不总是了解完整的工作负载情况,因此应用程序有时需要提供关于所需共享内存配置的额外提示。例如,一个很少或根本不使用共享内存的内核可以请求更大的预留空间,以促进与后续需要更多共享内存的内核并发执行。新的cudaFuncSetAttribute() API允许应用程序设置首选的共享内存容量(或称carveout),作为最大支持共享内存容量的百分比(Volta架构为96 KB,Turing架构为64 KB)。

cudaFuncSetAttribute() 相比Kepler引入的传统cudaFuncSetCacheConfig() API,放宽了对首选共享容量的强制要求。传统API将共享内存容量视为内核启动的硬性要求。因此,交错运行具有不同共享内存配置的内核会不必要地在共享内存重新配置后串行化启动。而新API将分配量视为提示。驱动程序可根据需要选择不同配置来执行函数或避免抖动。

// Device code
__global__ void MyKernel(...)
{
    __shared__ float buffer[BLOCK_DIM];
    ...
}

// Host code
int carveout = 50; // prefer shared memory capacity 50% of maximum
// Named Carveout Values:
// carveout = cudaSharedmemCarveoutDefault;   //  (-1)
// carveout = cudaSharedmemCarveoutMaxL1;     //   (0)
// carveout = cudaSharedmemCarveoutMaxShared; // (100)
cudaFuncSetAttribute(MyKernel, cudaFuncAttributePreferredSharedMemoryCarveout, carveout);
MyKernel <<<gridDim, BLOCK_DIM>>>(...);

除了整数百分比外,还提供了几种便捷枚举类型,如上述代码注释所列。当所选整数百分比无法精确映射到支持的容量时(SM 7.0设备支持0、8、16、32、64或96 KB的共享容量),系统将采用下一个更大的容量。例如在上例中,96 KB最大值的50%即48 KB并非支持的共享内存容量,因此该偏好值会被向上取整为64 KB。

计算能力7.x的设备允许单个线程块访问共享内存的全部容量:在Volta架构上为96 KB,在Turing架构上为64 KB。依赖每个块超过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>>>(...);

否则,共享内存的行为与计算能力5.x的设备相同(参见共享内存)。

16.7. 计算能力 8.x

16.7.1. 架构

一个流式多处理器(SM)包含以下组件:

  • 在计算能力8.0的设备中配备64个FP32核心用于单精度算术运算,在计算能力8.6、8.7和8.9的设备中配备128个FP32核心。

  • 在计算能力8.0的设备中配备32个FP64双精度运算核心,在计算能力8.6、8.7和8.9的设备中配备2个FP64核心

  • 64个INT32核心用于整数运算,

  • 4个混合精度的第三代Tensor Core,支持半精度(fp16)、__nv_bfloat16tf32、亚字节和双精度(fp64)矩阵运算,适用于计算能力8.0、8.6和8.7版本(详见Warp Matrix Functions

  • 4个混合精度的第四代Tensor Core,支持计算能力8.9的fp8fp16__nv_bfloat16tf32、亚字节和fp64(详见Warp Matrix Functions

  • 16个专用功能单元用于单精度浮点超越函数,

  • 4个warp调度器。

一个SM(流式多处理器)会静态地将其线程束(warps)分配给各个调度器。随后,在每个指令发射时刻,每个调度器会从其分配的就绪线程束中选择一个发射一条指令(如果有符合条件的线程束)。

一个SM包含:

  • 一个只读的常量缓存,由所有功能单元共享,可加速从驻留在设备内存中的常量内存空间的读取。

  • 统一数据缓存和共享内存总大小为192 KB,适用于计算能力8.0和8.7的设备(是Volta架构128 KB容量的1.5倍),而计算能力8.6和8.9的设备则为128 KB。

共享内存是从统一数据缓存中划分出来的,可以配置为不同大小(参见共享内存)。剩余的数据缓存用作L1缓存,同时也被纹理单元使用,该单元实现了纹理和表面内存中提到的各种寻址和数据过滤模式。

16.7.2. 全局内存

全局内存的行为与计算能力5.x的设备相同(参见全局内存)。

16.7.3. 共享内存

Volta架构类似,为共享内存保留的统一数据缓存大小可根据每个内核进行配置。对于NVIDIA Ampere GPU架构,计算能力8.0和8.7的设备统一数据缓存大小为192 KB,计算能力8.6和8.9的设备则为128 KB。计算能力8.0和8.7的设备可将共享内存容量设置为0、8、16、32、64、100、132或164 KB,而计算能力8.6和8.9的设备则可设置为0、8、16、32、64或100 KB。

应用程序可以通过cudaFuncSetAttribute()设置carveout,即首选的共享内存容量。

cudaFuncSetAttribute(kernel_name, cudaFuncAttributePreferredSharedMemoryCarveout, carveout);

该API可以将共享内存保留区指定为整数百分比(对于计算能力8.0和8.7的设备,最大支持164 KB;对于计算能力8.6和8.9的设备,最大支持100 KB),或者指定为以下值之一:{cudaSharedmemCarveoutDefaultcudaSharedmemCarveoutMaxL1cudaSharedmemCarveoutMaxShared。使用百分比时,保留区会向上舍入到最接近的支持的共享内存容量。例如,对于计算能力8.0的设备,50%将映射到100 KB保留区,而不是82 KB。设置cudaFuncAttributePreferredSharedMemoryCarveout被视为对驱动程序的提示;如果需要,驱动程序可能会选择不同的配置。

计算能力8.0和8.7的设备允许单个线程块寻址高达163 KB的共享内存,而计算能力8.6和8.9的设备则允许高达99 KB的共享内存。依赖每个块超过48 KB共享内存分配的内核是特定架构的,必须使用动态共享内存而非静态大小的共享内存数组。这些内核需要通过显式选择使用cudaFuncSetAttribute()来设置cudaFuncAttributeMaxDynamicSharedMemorySize;有关Volta架构的详细信息,请参阅Shared Memory

请注意,每个线程块的最大共享内存量小于每个SM可用的最大共享内存分区。未分配给线程块的1 KB共享内存保留供系统使用。

16.8. 计算能力 9.0

16.8.1. 架构

一个流式多处理器(SM)包含以下组件:

  • 128个FP32核心用于单精度算术运算,

  • 64个FP64核心用于双精度算术运算,

  • 64个INT32核心用于整数运算,

  • 4个混合精度的第四代Tensor Core,支持新的FP8输入类型(指数(E)和尾数(M)可采用E4M3E5M2格式)、半精度(fp16)、__nv_bfloat16tf32、INT8以及双精度(fp64)矩阵运算(详情参见Warp Matrix Functions),并支持稀疏性。

  • 16个专用功能单元用于单精度浮点超越函数,

  • 4个warp调度器。

一个SM(流式多处理器)会静态地将其线程束(warps)分配给各个调度器。随后,在每个指令发射时刻,每个调度器会从其分配的就绪线程束中选择一个发射一条指令(如果有符合条件的线程束)。

一个SM包含:

  • 一个只读的常量缓存,由所有功能单元共享,可加速从驻留在设备内存中的常量内存空间的读取。

  • 针对计算能力9.0的设备,提供统一数据缓存和共享内存,总容量为256 KB(是NVIDIA Ampere GPU架构192 KB容量的1.33倍)。

共享内存是从统一数据缓存中划分出来的,可以配置为不同大小(参见共享内存)。剩余的数据缓存用作L1缓存,同时也被纹理单元使用,该单元实现了纹理和表面内存中提到的各种寻址和数据过滤模式。

16.8.2. 全局内存

全局内存的行为与计算能力5.x设备相同(参见全局内存)。

16.8.3. 共享内存

NVIDIA Ampere GPU架构类似,为共享内存保留的统一数据缓存大小可根据每个内核进行配置。对于NVIDIA H100 Tensor Core GPU架构,计算能力为9.0的设备统一数据缓存大小为256 KB。共享内存容量可设置为0、8、16、32、64、100、132、164、196或228 KB。

NVIDIA Ampere GPU架构类似,应用程序可以配置其首选的共享内存容量,即carveout。计算能力为9.0的设备允许单个线程块寻址高达227 KB的共享内存。依赖每个块超过48 KB共享内存分配的内核是特定架构的,必须使用动态共享内存而非静态大小的共享内存数组。这些内核需要通过使用cudaFuncSetAttribute()显式选择加入,以设置cudaFuncAttributeMaxDynamicSharedMemorySize;有关Volta架构的详细信息,请参阅共享内存

请注意,每个线程块的最大共享内存量小于每个SM可用的最大共享内存分区。未分配给线程块的1 KB共享内存保留供系统使用。

16.8.4. 加速专用计算的功能

NVIDIA Hopper GPU架构包含加速矩阵乘积累加(MMA)计算的功能,支持:

  • MMA指令的异步执行

  • 作用于跨越warp-group的大型矩阵的MMA指令

  • 动态重新分配warp组之间的寄存器容量,以支持更大的矩阵,以及

  • 操作数矩阵直接从共享内存访问

此功能集仅通过内联PTX在CUDA编译工具链中可用。

强烈建议应用程序通过CUDA-X库(如cuBLAS、cuDNN或cuFFT)来利用这套复杂功能集。

强烈建议设备内核通过CUTLASS来利用这一复杂功能集,这是一个CUDA C++模板抽象库,用于在CUDA中实现各个层级和规模的高性能矩阵乘法(GEMM)及相关计算。

16.9. 计算能力 10.0

16.9.1. 架构

一个流式多处理器(SM)包含以下组件:

  • 128个FP32核心用于单精度算术运算,

  • 64个FP64核心用于双精度算术运算,

  • 64个INT32核心用于整数运算,

  • 4个混合精度的第五代Tensor Core,支持FP8输入类型,指数(E)和尾数(M)可采用E4M3E5M2格式,支持半精度(fp16)、__nv_bfloat16tf32、INT8和双精度(fp64)矩阵运算(详见Warp Matrix Functions),并支持稀疏性。

  • 16个专用功能单元用于单精度浮点超越函数,

  • 4个warp调度器。

一个SM(流式多处理器)会静态地将其线程束(warps)分配给各个调度器。随后,在每个指令发射时刻,每个调度器会从其分配的就绪线程束中选择一个发射一条指令(如果有符合条件的线程束)。

一个SM包含:

  • 一个只读的常量缓存,由所有功能单元共享,可加速从驻留在设备内存中的常量内存空间的读取。

  • 对于计算能力10.0的设备,提供统一数据缓存和共享内存,总大小为256 KB

共享内存是从统一数据缓存中划分出来的,可以配置为不同大小(参见共享内存)。剩余的数据缓存用作L1缓存,同时也被纹理单元使用,该单元实现了纹理和表面内存中提到的各种寻址和数据过滤模式。

16.9.2. 全局内存

全局内存的行为与计算能力5.x的设备相同(参见全局内存)。

16.9.3. 共享内存

为共享内存保留的统一数据缓存大小可按每个内核进行配置,与计算能力9.0相同。对于计算能力10.0的设备,统一数据缓存大小为256 KB。共享内存容量可设置为0、8、16、32、64、100、132、164、196或228 KB。

NVIDIA Ampere GPU架构类似,应用程序可以配置其首选的共享内存容量,即carveout。计算能力为10.0的设备允许单个线程块寻址高达227 KB的共享内存。依赖每个块超过48 KB共享内存分配的内核是特定架构的,必须使用动态共享内存而非静态大小的共享内存数组。这些内核需要通过显式选择使用cudaFuncSetAttribute()来设置cudaFuncAttributeMaxDynamicSharedMemorySize;有关Volta架构的详细信息,请参阅共享内存

请注意,每个线程块的最大共享内存量小于每个SM可用的最大共享内存分区。未分配给线程块的1 KB共享内存保留供系统使用。

16.9.4. 加速专用计算的功能

NVIDIA Blackwell GPU架构扩展了功能,以加速源自NVIDIA Hopper GPU架构的矩阵乘积累加(MMA)运算。

此功能集仅通过内联PTX在CUDA编译工具链中可用。

强烈建议应用程序通过CUDA-X库(如cuBLAS、cuDNN或cuFFT)来利用这套复杂功能集。

强烈建议设备内核通过CUTLASS来利用这一复杂功能集,这是一个CUDA C++模板抽象库,用于在CUDA中实现各个层级和规模的高性能矩阵乘法(GEMM)及相关计算。

16.10. 计算能力 12.0

16.10.1. 架构

一个流式多处理器(SM)包含以下组件:

  • 128个FP32核心用于单精度算术运算,

  • 2个FP64核心用于双精度算术运算,

  • 64个INT32核心用于整数运算,

  • 支持混合精度的第五代Tensor Core,可处理FP8输入类型(指数(E)和尾数(M)采用E4M3E5M2格式)、半精度(fp16)、__nv_bfloat16tf32、INT8以及双精度(fp64)矩阵运算(详见Warp Matrix Functions),并支持稀疏计算。

  • 16个专用功能单元用于单精度浮点超越函数,

  • 4个warp调度器。

一个SM(流式多处理器)会静态地将其线程束(warps)分配给各个调度器。随后,在每个指令发射时刻,每个调度器会从其分配的就绪线程束中选择一个发射一条指令(如果有符合条件的线程束)。

一个SM包含:

  • 一个只读的常量缓存,由所有功能单元共享,可加速从驻留在设备内存中的常量内存空间的读取。

  • 针对计算能力12.0的设备,提供统一数据缓存和共享内存,总容量为128 KB

共享内存是从统一数据缓存中划分出来的,可以配置为不同大小(参见共享内存)。剩余的数据缓存用作L1缓存,同时也被纹理单元使用,该单元实现了纹理和表面内存中提到的各种寻址和数据过滤模式。

16.10.2. 全局内存

全局内存的行为与计算能力5.x设备相同(参见全局内存)。

16.10.3. 共享内存

为共享内存保留的统一数据缓存大小可按每个内核进行配置,与计算能力9.0相同。对于计算能力12.0的设备,统一数据缓存大小为128 KB。共享内存容量可设置为0、8、16、32、64或100 KB。

NVIDIA Ampere GPU架构类似,应用程序可以配置其首选的共享内存容量,即carveout。计算能力12.0的设备允许单个线程块寻址高达227 KB的共享内存。依赖每个块超过48 KB共享内存分配的内核是特定于架构的,必须使用动态共享内存而非静态大小的共享内存数组。这些内核需要通过使用cudaFuncSetAttribute()显式选择加入,以设置cudaFuncAttributeMaxDynamicSharedMemorySize;有关Volta架构的详细信息,请参阅共享内存

请注意,每个线程块的最大共享内存量小于每个SM可用的最大共享内存分区。未分配给线程块的1 KB共享内存保留供系统使用。

16.10.4. 加速专用计算的功能

NVIDIA Blackwell GPU架构扩展了功能,以加速来自NVIDIA Hopper GPU架构的矩阵乘积累加(MMA)运算。

此功能集仅通过内联PTX在CUDA编译工具链中可用。

强烈建议应用程序通过CUDA-X库(如cuBLAS、cuDNN或cuFFT)来利用这套复杂功能集。

强烈建议设备内核通过CUTLASS来利用这一复杂功能集,这是一个CUDA C++模板抽象库,用于在CUDA中实现各个层级和规模的高性能矩阵乘法(GEMM)及相关计算。

33

超过48 KB需要动态共享内存

34

针对计算能力7.5的设备,配备2个FP64核心用于双精度算术运算

17. 驱动API

本节假设您已了解CUDA Runtime中描述的概念。

驱动程序API实现在cuda动态库(cuda.dllcuda.so)中,该库在设备驱动程序安装过程中被复制到系统上。其所有入口点都以cu为前缀。

这是一个基于句柄的命令式API:大多数对象通过不透明的句柄引用,这些句柄可以指定给函数来操作对象。

驱动API中可用的对象总结在表22中。

表 24 CUDA 驱动 API 中可用的对象

对象

句柄

描述

设备

CUdevice

支持CUDA的设备

上下文

CUcontext

大致相当于CPU进程

模块

CUmodule

大致相当于动态库

函数

CUfunction

内核

堆内存

CUdeviceptr

指向设备内存的指针

CUDA数组

CUarray

设备上一维或二维数据的不透明容器,可通过纹理或表面引用读取

纹理对象

CUtexref

描述如何解释纹理内存数据的对象

表面引用

CUsurfref

描述如何读写CUDA数组的对象

CUstream

描述CUDA流的对象

事件

CUevent

描述CUDA事件的对象

在调用驱动程序API的任何函数之前,必须使用cuInit()初始化驱动程序API。然后必须创建一个CUDA上下文,该上下文需附加到特定设备,并根据Context中的说明将其设为当前主机线程的上下文。

在CUDA上下文中,内核(kernels)会由主机代码显式加载为PTX或二进制对象,如模块部分所述。因此,用C++编写的内核必须单独编译为PTX或二进制对象。内核的启动通过API入口点实现,具体方法见内核执行章节。

任何希望在未来设备架构上运行的应用程序都必须加载PTX,而非二进制代码。这是因为二进制代码是架构特定的,因此与未来架构不兼容,而PTX代码会在加载时由设备驱动程序编译为二进制代码。

以下是使用驱动API编写的Kernels示例主机代码:

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);

    ...
}

完整代码可在vectorAddDrv CUDA示例中找到。

17.1. 上下文

CUDA上下文类似于CPU进程。在驱动API中执行的所有资源和操作都被封装在CUDA上下文中,当上下文销毁时系统会自动清理这些资源。除了模块、纹理或表面引用等对象外,每个上下文都有自己独立的地址空间。因此,来自不同上下文的CUdeviceptr值会引用不同的内存位置。

一个主机线程在同一时间只能有一个当前设备上下文。当使用cuCtxCreate(创建上下文时,它会被设置为调用主机线程的当前上下文。在上下文中运行的CUDA函数(大多数不涉及设备枚举或上下文管理的函数)如果线程没有有效的当前上下文,将返回CUDA_ERROR_INVALID_CONTEXT

每个主机线程都有一个当前上下文堆栈。cuCtxCreate()会将新上下文压入堆栈顶部。可以调用cuCtxPopCurrent()来将上下文与主机线程分离。此时上下文处于"浮动"状态,可以被推入任何主机线程作为当前上下文。cuCtxPopCurrent()还会恢复之前存在的当前上下文(如果有的话)。

每个上下文都会维护一个使用计数。cuCtxCreate()创建的使用计数为1的上下文。cuCtxAttach()会增加使用计数,而cuCtxDetach()会减少它。当调用cuCtxDetach()cuCtxDestroy()使使用计数降为0时,上下文将被销毁。

驱动程序API与运行时相互兼容,可以通过cuDevicePrimaryCtxRetain()从驱动程序API访问由运行时管理的主上下文(参见初始化)。

使用计数功能促进了在同一上下文中运行的第三方编写代码之间的互操作性。例如,如果加载了三个库来使用同一上下文,每个库都会调用cuCtxAttach()来增加使用计数,并在库完成上下文使用时调用cuCtxDetach()来减少使用计数。对于大多数库来说,预期应用程序在加载或初始化库之前已经创建了一个上下文;这样,应用程序可以使用自己的启发式方法创建上下文,而库只需在传递给它的上下文上操作。那些希望创建自己上下文的库——其API客户端可能已经创建也可能没有创建自己的上下文——将使用cuCtxPushCurrent()cuCtxPopCurrent(),如下图所示。

Library Context Management

图37 库上下文管理

17.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);

完整代码可在ptxjit CUDA示例中找到。

17.3. 内核执行

cuLaunchKernel() 使用给定的执行配置启动内核。

参数可以通过指针数组传递(作为cuLaunchKernel()的倒数第二个参数),其中第n个指针对应第n个参数并指向从中复制参数的内存区域;或者作为额外选项之一传递(作为cuLaunchKernel()的最后一个参数)。

当参数作为额外选项(CU_LAUNCH_PARAM_BUFFER_POINTER选项)传递时,它们以指向单个缓冲区的指针形式传递,其中参数需根据设备代码中每个参数类型的对齐要求进行适当偏移。

设备代码中内置向量类型的对齐要求列于表5中。对于所有其他基本类型,设备代码中的对齐要求与主机代码中的对齐要求相匹配,因此可以使用__alignof()获取。唯一的例外是当主机编译器将doublelong 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或未对齐的doublelong long的结构体,在设备代码和主机代码中的对齐要求可能不同。这类结构体在填充方式上也可能存在差异。例如,以下结构体在主机代码中完全不进行填充,但在设备代码中会在字段f后填充12个字节,因为字段f4的对齐要求是16字节。

typedef struct {
    float  f;
    float4 f4;
} myStruct;

17.4. 运行时与驱动API的互操作性

应用程序可以混合使用运行时API代码和驱动程序API代码。

如果通过驱动程序API创建并设置当前上下文,后续的运行时调用将使用此上下文,而不是创建新的上下文。

如果运行时已初始化(如CUDA Runtime中所述),则可以使用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等)。

参考手册中设备和版本管理部分的所有功能都可以互换使用。

17.5. 驱动程序入口点访问

17.5.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功能,但需要更新的驱动程序。

17.5.2. 驱动程序函数类型定义

为了帮助检索CUDA驱动API入口点,CUDA工具包提供了包含所有CUDA驱动API函数指针定义的头文件访问权限。这些头文件随CUDA工具包一起安装,并位于工具包的include/目录中。下表总结了包含每个CUDA API头文件typedefs的头文件。

表 25 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;

如果用户对特定版本的API感兴趣,上述方法是更可取的。此外,头文件为安装的CUDA工具包发布时可用的所有驱动符号的最新版本预定义了宏;这些类型定义没有_v*后缀。对于CUDA 11.3工具包,cuMemAlloc_v2是最新版本,因此我们也可以如下定义其函数指针:

PFN_cuMemAlloc pfn_cuMemAlloc;

17.5.3. 驱动函数检索

通过使用驱动程序入口点访问API和适当的typedef,我们可以获取任何CUDA驱动程序API的函数指针。

17.5.3.1. 使用驱动API

驱动程序API需要CUDA版本作为参数,以获取与请求的驱动程序符号ABI兼容的版本。CUDA驱动程序API具有每个函数的ABI,用_v*扩展表示。例如,考虑cuStreamBeginCapture的版本及其对应的来自cudaTypedefs.htypedefs

// 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);

参考上面的代码片段,要获取驱动程序API cuStreamBeginCapture_v1版本地址,CUDA版本参数应精确指定为10.0(10000)。同理,获取该API的_v2版本地址时应使用CUDA 10.1(10010)版本。为特定版本的驱动程序API指定更高的CUDA版本可能不具备可移植性。例如,此处使用11030仍会返回_v2符号,但如果CUDA 11.3中发布了假设性的_v3版本,当搭配CUDA 11.3驱动程序时,cuGetProcAddress API将开始返回新的_v3符号。由于_v2_v3符号的ABI及函数签名可能存在差异,若使用为_v2符号设计的_v10010类型定义来调用_v3函数,将会产生未定义行为。

要获取给定CUDA工具包中驱动程序API的最新版本,我们还可以将CUDA_VERSION指定为version参数,并使用未版本化的typedef来定义函数指针。由于_v2是CUDA 11.3中驱动程序APIcuStreamBeginCapture的最新版本,以下代码片段展示了另一种获取它的方法。

// Assuming we are using CUDA 11.3 Toolkit

#include <cudaTypedefs.h>

// Declare the entry point
PFN_cuStreamBeginCapture pfn_cuStreamBeginCapture_latest;

// Intialize the entry point. Specifying CUDA_VERSION will give the function pointer to the
// cuStreamBeginCapture_v2 symbol since it is latest version on CUDA 11.3.
cuGetProcAddress("cuStreamBeginCapture", &pfn_cuStreamBeginCapture_latest, CUDA_VERSION, CU_GET_PROC_ADDRESS_DEFAULT, &driverStatus);

请注意,使用无效的CUDA版本请求驱动程序API将返回错误CUDA_ERROR_NOT_FOUND。在上述代码示例中,传入小于10000(CUDA 10.0)的版本将是无效的。

17.5.3.2. 使用运行时API

运行时API cudaGetDriverEntryPoint 使用CUDA运行时版本来获取所请求驱动程序符号的ABI兼容版本。在以下代码片段中,所需的最低CUDA运行时版本为CUDA 11.2,因为cuMemAllocAsync是在该版本引入的。

#include <cudaTypedefs.h>

// Declare the entry point
PFN_cuMemAllocAsync pfn_cuMemAllocAsync;

// Intialize the entry point. Assuming CUDA runtime version >= 11.2
cudaGetDriverEntryPoint("cuMemAllocAsync", &pfn_cuMemAllocAsync, cudaEnableDefault, &driverStatus);

// Call the entry point
if(driverStatus == cudaDriverEntryPointSuccess && pfn_cuMemAllocAsync) {
    pfn_cuMemAllocAsync(...);
}

运行时API cudaGetDriverEntryPointByVersion 使用用户提供的CUDA版本来获取所请求驱动程序符号的ABI兼容版本。这允许对请求的ABI版本进行更精确的控制。

17.5.3.3. 获取每线程默认流版本

某些CUDA驱动程序API可以配置为具有默认流每线程默认流语义。具有每线程默认流语义的驱动程序API在其名称后缀有_ptsz_ptds。例如,cuLaunchKernel有一个名为cuLaunchKernel_ptsz每线程默认流变体。通过驱动程序入口点访问API,用户可以请求获取cuLaunchKernel每线程默认流版本,而非默认流版本。为CUDA驱动程序API配置默认流每线程默认流语义会影响同步行为。更多详细信息可查阅此处

可以通过以下方式之一获取驱动程序API的默认流每线程默认流版本:

  • 使用编译标志 --default-stream per-thread 或定义宏 CUDA_API_PER_THREAD_DEFAULT_STREAM 来获得每线程默认流的行为。

  • 使用标志CU_GET_PROC_ADDRESS_LEGACY_STREAM/cudaEnableLegacyStreamCU_GET_PROC_ADDRESS_PER_THREAD_DEFAULT_STREAM/cudaEnablePerThreadDefaultStream分别强制默认流每线程默认流行为。

17.5.3.4. 访问新的CUDA功能

始终建议安装最新的CUDA工具包以获取新的CUDA驱动功能,但如果用户出于某些原因不想更新或无法获取最新工具包,也可以通过API仅更新CUDA驱动来使用新功能。举例说明,假设用户当前使用CUDA 11.3,但希望调用CUDA 12.0驱动中新增的cuFoo接口。以下代码片段演示了这种使用场景:

int main()
{
    // Assuming we have CUDA 12.0 driver installed.

    // Manually define the prototype as cudaTypedefs.h in CUDA 11.3 does not have the cuFoo typedef
    typedef CUresult (CUDAAPI *PFN_cuFoo)(...);
    PFN_cuFoo pfn_cuFoo = NULL;
    CUdriverProcAddressQueryResult driverStatus;

    // Get the address for cuFoo API using cuGetProcAddress. Specify CUDA version as
    // 12000 since cuFoo was introduced then or get the driver version dynamically
    // using cuDriverGetVersion
    int driverVersion;
    cuDriverGetVersion(&driverVersion);
    CUresult status = cuGetProcAddress("cuFoo", &pfn_cuFoo, driverVersion, CU_GET_PROC_ADDRESS_DEFAULT, &driverStatus);

    if (status == CUDA_SUCCESS && pfn_cuFoo) {
        pfn_cuFoo(...);
    }
    else {
        printf("Cannot retrieve the address to cuFoo - driverStatus = %d. Check if the latest driver for CUDA 12.0 is installed.\n", driverStatus);
        assert(0);
    }

    // rest of code here

}

17.5.4. cuGetProcAddress 的潜在影响

以下是关于cuGetProcAddresscudaGetDriverEntryPoint可能存在的具体和理论问题示例集。

17.5.4.1. cuGetProcAddress与隐式链接的影响

cuDeviceGetUuid 在 CUDA 9.2 中引入。该 API 在 CUDA 11.4 中有一个更新的修订版本 (cuDeviceGetUuid_v2)。为了保持次要版本兼容性,在 CUDA 12.0 之前,cuDeviceGetUuid 不会在 cuda.h 中升级为 cuDeviceGetUuid_v2。这意味着通过 cuGetProcAddress 获取函数指针来调用它可能会有不同的行为。直接使用该 API 的示例:

#include <cuda.h>

CUuuid uuid;
CUdevice dev;
CUresult status;

status = cuDeviceGet(&dev, 0); // Get device 0
// handle status

status = cuDeviceGetUuid(&uuid, dev) // Get uuid of device 0

在本示例中,假设用户正在使用CUDA 11.4进行编译。请注意,这将执行cuDeviceGetUuid的行为,而非_v2版本。以下是使用cuGetProcAddress的示例:

#include <cudaTypedefs.h>

CUuuid uuid;
CUdevice dev;
CUresult status;
CUdriverProcAddressQueryResult driverStatus;

status = cuDeviceGet(&dev, 0); // Get device 0
// handle status

PFN_cuDeviceGetUuid pfn_cuDeviceGetUuid;
status = cuGetProcAddress("cuDeviceGetUuid", &pfn_cuDeviceGetUuid, CUDA_VERSION, CU_GET_PROC_ADDRESS_DEFAULT, &driverStatus);
if(CUDA_SUCCESS == status && pfn_cuDeviceGetUuid) {
    // pfn_cuDeviceGetUuid points to ???
}

在本示例中,假设用户正在使用CUDA 11.4进行编译。这将获取cuDeviceGetUuid_v2的函数指针。调用该函数指针将触发新的_v2函数,而非前例中所示的相同cuDeviceGetUuid函数。

17.5.4.2. cuGetProcAddress中的编译时与运行时版本使用

让我们以同样的问题为例,做一个小调整。上一个例子使用了编译时常量CUDA_VERSION来确定获取哪个函数指针。如果用户动态查询驱动程序版本(使用cuDriverGetVersioncudaDriverGetVersion)并将其传递给cuGetProcAddress,情况会变得更加复杂。示例如下:

#include <cudaTypedefs.h>

CUuuid uuid;
CUdevice dev;
CUresult status;
int cudaVersion;
CUdriverProcAddressQueryResult driverStatus;

status = cuDeviceGet(&dev, 0); // Get device 0
// handle status

status = cuDriverGetVersion(&cudaVersion);
// handle status

PFN_cuDeviceGetUuid pfn_cuDeviceGetUuid;
status = cuGetProcAddress("cuDeviceGetUuid", &pfn_cuDeviceGetUuid, cudaVersion, CU_GET_PROC_ADDRESS_DEFAULT, &driverStatus);
if(CUDA_SUCCESS == status && pfn_cuDeviceGetUuid) {
    // pfn_cuDeviceGetUuid points to ???
}

在这个示例中,假设用户正在使用CUDA 11.3进行编译。用户将基于获取cuDeviceGetUuid(非_v2版本)的已知行为来调试、测试和部署此应用程序。由于CUDA保证了次要版本之间的ABI兼容性,预计该应用程序在驱动程序升级至CUDA 11.4后(无需更新工具包和运行时)仍可运行且无需重新编译。但这将导致未定义行为,因为此时PFN_cuDeviceGetUuid的类型定义仍保持原始版本的函数签名,而由于cudaVersion现在会变为11040(CUDA 11.4),cuGetProcAddress将返回指向_v2版本的函数指针,这意味着调用它可能会产生未定义行为。

注意在这种情况下,原始(非_v2版本)的typedef定义如下:

typedef CUresult (CUDAAPI *PFN_cuDeviceGetUuid_v9020)(CUuuid *uuid, CUdevice_v1 dev);

但 _v2 版本的 typedef 定义如下:

typedef CUresult (CUDAAPI *PFN_cuDeviceGetUuid_v11040)(CUuuid *uuid, CUdevice_v1 dev);

因此在这种情况下,API/ABI将保持不变,运行时API调用可能不会引发问题——唯一潜在风险是未知uuid返回值。在API/ABI的影响部分,我们将讨论一个更棘手的API/ABI兼容性问题案例。

17.5.4.3. 带显式版本检查的API版本升级

上面是一个具体的实际例子。现在让我们用一个理论上的例子来说明,这个例子在不同驱动版本之间仍然存在兼容性问题。例如:

CUresult cuFoo(int bar); // Introduced in CUDA 11.4
CUresult cuFoo_v2(int bar); // Introduced in CUDA 11.5
CUresult cuFoo_v3(int bar, void* jazz); // Introduced in CUDA 11.6

typedef CUresult (CUDAAPI *PFN_cuFoo_v11040)(int bar);
typedef CUresult (CUDAAPI *PFN_cuFoo_v11050)(int bar);
typedef CUresult (CUDAAPI *PFN_cuFoo_v11060)(int bar, void* jazz);

请注意,该API自最初在CUDA 11.4中创建以来已修改过两次,而CUDA 11.6的最新版本也修改了该函数的API/ABI接口。针对CUDA 11.5编译的用户代码中的使用方式如下:

#include <cuda.h>
#include <cudaTypedefs.h>

CUresult status;
int cudaVersion;
CUdriverProcAddressQueryResult driverStatus;

status = cuDriverGetVersion(&cudaVersion);
// handle status

PFN_cuFoo_v11040 pfn_cuFoo_v11040;
PFN_cuFoo_v11050 pfn_cuFoo_v11050;
if(cudaVersion < 11050 ) {
    // We know to get the CUDA 11.4 version
    status = cuGetProcAddress("cuFoo", &pfn_cuFoo_v11040, cudaVersion, CU_GET_PROC_ADDRESS_DEFAULT, &driverStatus);
    // Handle status and validating pfn_cuFoo_v11040
}
else {
    // Assume >= CUDA 11.5 version we can use the second version
    status = cuGetProcAddress("cuFoo", &pfn_cuFoo_v11050, cudaVersion, CU_GET_PROC_ADDRESS_DEFAULT, &driverStatus);
    // Handle status and validating pfn_cuFoo_v11050
}

在这个示例中,如果没有针对CUDA 11.6的新类型定义进行更新,并使用这些新类型定义和情况处理重新编译应用程序,应用程序将获取返回的cuFoo_v3函数指针,而使用该函数将导致未定义行为。这个示例的重点在于说明,即使是针对cuGetProcAddress的显式版本检查,也可能无法安全覆盖CUDA主要版本中的次要版本升级。

17.5.4.4. 运行时API使用问题

上述示例主要讨论了使用Driver API获取驱动程序API函数指针时可能出现的问题。接下来我们将探讨在Runtime API中使用cudaApiGetDriverEntryPoint可能存在的潜在问题。

我们将从使用类似于上述的Runtime API开始。

#include <cuda.h>
#include <cudaTypedefs.h>
#include <cuda_runtime.h>

CUresult status;
cudaError_t error;
int driverVersion, runtimeVersion;
CUdriverProcAddressQueryResult driverStatus;

// Ask the runtime for the function
PFN_cuDeviceGetUuid pfn_cuDeviceGetUuidRuntime;
error = cudaGetDriverEntryPoint ("cuDeviceGetUuid", &pfn_cuDeviceGetUuidRuntime, cudaEnableDefault, &driverStatus);
if(cudaSuccess == error && pfn_cuDeviceGetUuidRuntime) {
    // pfn_cuDeviceGetUuid points to ???
}

本例中的函数指针比上述仅涉及驱动程序的示例更为复杂,因为无法控制获取哪个版本的函数;它始终会获取当前CUDA运行时版本的API。更多信息请参阅下表:

静态运行时版本链接

已安装的驱动版本

V11.3

V11.4

V11.3

v1

v1x

V11.4

v1

v2

V11.3 => 11.3 CUDA Runtime and Toolkit (includes header files cuda.h and cudaTypedefs.h)
V11.4 => 11.4 CUDA Runtime and Toolkit (includes header files cuda.h and cudaTypedefs.h)
v1 => cuDeviceGetUuid
v2 => cuDeviceGetUuid_v2

x => Implies the typedef function pointer won't match the returned
     function pointer.  In these cases, the typedef at compile time
     using a CUDA 11.4 runtime, would match the _v2 version, but the
     returned function pointer would be the original (non _v2) function.

表中的问题出现在较新的CUDA 11.4运行时和工具包与较旧驱动程序(CUDA 11.3)的组合中,如上文标记为v1x的情况。这种组合会导致驱动程序返回指向旧函数(非_v2)的指针,但应用程序中使用的typedef却是针对新函数指针的。

17.5.4.5. 运行时API与动态版本控制的问题

当我们考虑应用程序编译所用的CUDA版本、CUDA运行时版本以及应用程序动态链接的CUDA驱动版本的不同组合时,会出现更多复杂情况。

#include <cuda.h>
#include <cudaTypedefs.h>
#include <cuda_runtime.h>

CUresult status;
cudaError_t error;
int driverVersion, runtimeVersion;
CUdriverProcAddressQueryResult driverStatus;
enum cudaDriverEntryPointQueryResult runtimeStatus;

PFN_cuDeviceGetUuid pfn_cuDeviceGetUuidDriver;
status = cuGetProcAddress("cuDeviceGetUuid", &pfn_cuDeviceGetUuidDriver, CUDA_VERSION, CU_GET_PROC_ADDRESS_DEFAULT, &driverStatus);
if(CUDA_SUCCESS == status && pfn_cuDeviceGetUuidDriver) {
    // pfn_cuDeviceGetUuidDriver points to ???
}

// Ask the runtime for the function
PFN_cuDeviceGetUuid pfn_cuDeviceGetUuidRuntime;
error = cudaGetDriverEntryPoint ("cuDeviceGetUuid", &pfn_cuDeviceGetUuidRuntime, cudaEnableDefault, &runtimeStatus);
if(cudaSuccess == error && pfn_cuDeviceGetUuidRuntime) {
    // pfn_cuDeviceGetUuidRuntime points to ???
}

// Ask the driver for the function based on the driver version (obtained via runtime)
error = cudaDriverGetVersion(&driverVersion);
PFN_cuDeviceGetUuid pfn_cuDeviceGetUuidDriverDriverVer;
status = cuGetProcAddress ("cuDeviceGetUuid", &pfn_cuDeviceGetUuidDriverDriverVer, driverVersion, CU_GET_PROC_ADDRESS_DEFAULT, &driverStatus);
if(CUDA_SUCCESS == status && pfn_cuDeviceGetUuidDriverDriverVer) {
    // pfn_cuDeviceGetUuidDriverDriverVer points to ???
}

预期以下函数指针矩阵:

函数指针

应用程序编译/运行时动态链接版本/驱动版本

(3 => CUDA 11.3 和 4 => CUDA 11.4)

3/3/3

3/3/4

3/4/3

3/4/4

4/3/3

4/3/4

4/4/3

4/4/4

pfn_cuDeviceGetUuidDriver

t1/v1

t1/v1

t1/v1

t1/v1

不适用

不适用

t2/v1

t2/v2

pfn_cuDeviceGetUuidRuntime

t1/v1

t1/v1

t1/v1

t1/v2

不适用

不适用

t2/v1

t2/v2

pfn_cuDeviceGetUuidDriverDriverVer

t1/v1

t1/v2

t1/v1

t1/v2

不适用

不适用

t2/v1

t2/v2

tX -> Typedef version used at compile time
vX -> Version returned/used at runtime

如果应用程序是针对CUDA 11.3版本编译的,它将包含原始函数的typedef定义;而如果针对CUDA 11.4版本编译,则会包含_v2函数的typedef定义。因此,请注意存在typedef与实际返回/使用的版本不匹配的情况数量。

17.5.4.6. 运行时API允许CUDA版本的问题

除非另有说明,CUDA运行时API cudaGetDriverEntryPointByVersion 将具有与驱动程序入口点 cuGetProcAddress 类似的含义,因为它允许用户请求特定的CUDA驱动版本。

17.5.4.7. 对API/ABI的影响

在上述使用cuDeviceGetUuid的示例中,API不匹配的影响很小,许多用户可能完全不会注意到,因为添加_v2是为了支持多实例GPU(MIG)模式。因此,在没有MIG的系统上,用户甚至可能意识不到他们正在使用不同的API。

更成问题的是那些改变其应用程序签名(从而改变ABI)的API,例如cuCtxCreate。在CUDA 3.2中引入的_v2版本目前作为默认的cuCtxCreate使用(当包含cuda.h时),但现在CUDA 11.4又引入了更新版本(cuCtxCreate_v3)。该API的签名也被修改,现在需要额外参数。因此,在上述某些情况下,当函数指针的类型定义与实际返回的函数指针不匹配时,可能会出现不明显的ABI不兼容问题,从而导致未定义行为。

例如,假设以下代码是针对安装了CUDA 11.4驱动程序的CUDA 11.3工具包编译的:

PFN_cuCtxCreate cuUnknown;
CUdriverProcAddressQueryResult driverStatus;

status = cuGetProcAddress("cuCtxCreate", (void**)&cuUnknown, cudaVersion, CU_GET_PROC_ADDRESS_DEFAULT, &driverStatus);
if(CUDA_SUCCESS == status && cuUnknown) {
    status = cuUnknown(&ctx, 0, dev);
}

cudaVersion设置为大于等于11040(表示CUDA 11.4)时运行此代码,由于未充分提供cuCtxCreate_v3 API的_v3版本所需的所有参数,可能会导致未定义行为。

17.5.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时,就会出现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

18. CUDA环境变量

下表列出了CUDA相关的环境变量。与多进程服务(Multi-Process Service)相关的环境变量记录在《GPU部署与管理指南》的多进程服务章节中。

表 26 CUDA环境变量

变量

取值

描述

设备枚举与属性

CUDA_VISIBLE_DEVICES

以逗号分隔的GPU标识符序列 支持MIG:MIG-/ instance ID>/ instance ID>

GPU标识符可以表示为整数索引或UUID字符串。GPU UUID字符串应遵循nvidia-smi给出的格式,例如GPU-8932f937-d72c-4106-c12f-20bd9faed9f6。不过为了方便起见,也允许使用缩写形式;只需指定GPU UUID开头足够的数字来唯一标识目标系统中的该GPU。例如,假设系统中没有其他GPU共享此前缀,CUDA_VISIBLE_DEVICES=GPU-8932f937可能是引用上述GPU UUID的有效方式。 只有索引出现在序列中的设备才对CUDA应用程序可见,并且它们按序列顺序枚举。如果其中一个索引无效,则只有索引位于无效索引之前的设备对CUDA应用程序可见。例如,将CUDA_VISIBLE_DEVICES设置为2,1会导致设备0不可见,且设备2会在设备1之前枚举。将CUDA_VISIBLE_DEVICES设置为0,2,-1,1会导致设备0和2可见,而设备1不可见。 MIG格式以MIG关键字开头,GPU UUID应遵循nvidia-smi给出的相同格式。例如MIG-GPU-8932f937-d72c-4106-c12f-20bd9faed9f6/1/2。目前仅支持单个MIG实例枚举。

CUDA_MANAGED_FORCE_DEVICE_ALLOC

0 或 1 (默认为 0)

强制驱动程序将所有托管分配放置在设备内存中。

CUDA_DEVICE_ORDER

FASTEST_FIRST, PCI_BUS_ID, (默认为FASTEST_FIRST)

FASTEST_FIRST会使CUDA使用简单启发式方法按从最快到最慢的顺序枚举可用设备。PCI_BUS_ID按PCI总线ID升序排列设备。

编译

CUDA_CACHE_DISABLE

0 或 1 (默认为 0)

禁用(设置为1时)或启用(设置为0时)即时编译的缓存功能。禁用时,不会向缓存添加或从缓存检索二进制代码。

CUDA_CACHE_PATH

文件路径

指定即时编译器缓存二进制代码的文件夹;默认值为:

  • 在Windows系统上,%APPDATA%\NVIDIA\ComputeCache

  • 在Linux系统上,~/.nv/ComputeCache

CUDA_CACHE_MAXSIZE

integer (默认值在桌面/服务器平台为1073741824 (1 GiB),在嵌入式平台为268435456 (256 MiB),最大值为4294967296 (4 GiB))

指定即时编译器使用的缓存大小(以字节为单位)。超过缓存大小的二进制代码不会被缓存。如果需要为新二进制代码腾出空间,较旧的二进制代码将从缓存中移除。

CUDA_FORCE_PTX_JIT

0 或 1 (默认为 0)

当设置为1时,强制设备驱动程序忽略应用程序中嵌入的任何二进制代码(参见应用程序兼容性),转而即时编译嵌入的PTX代码。如果内核没有嵌入PTX代码,则将加载失败。此环境变量可用于验证PTX代码是否嵌入应用程序中,以及其即时编译是否按预期工作,以确保应用程序与未来架构的前向兼容性(参见即时编译)。

CUDA_DISABLE_PTX_JIT

0 或 1(默认为 0)

当设置为 1 时,禁用嵌入式 PTX 代码的即时编译,并使用应用程序中嵌入的兼容二进制代码(参见应用程序兼容性)。如果内核没有嵌入二进制代码或嵌入的二进制代码是为不兼容的架构编译的,则将加载失败。此环境变量可用于验证应用程序是否为每个内核生成了兼容的SASS代码(参见二进制兼容性)。

CUDA_FORCE_JIT

0 或 1(默认为 0)

当设置为1时,强制设备驱动程序忽略应用程序中嵌入的任何二进制代码(参见应用程序兼容性),转而即时编译嵌入的PTX代码。如果内核没有嵌入PTX代码,将无法加载。此环境变量可用于验证应用程序中是否嵌入了PTX代码,以及其即时编译是否按预期工作,从而确保应用程序与未来架构的前向兼容性(参见即时编译)。对于嵌入的PTX,可以通过设置CUDA_FORCE_PTX_JIT=0来覆盖此行为。

CUDA_DISABLE_JIT

0 或 1(默认为 0)

当设置为1时,将禁用嵌入式PTX代码的即时编译,转而使用应用程序中嵌入的兼容二进制代码(参见应用程序兼容性)。如果内核没有嵌入二进制代码,或者嵌入的二进制代码是为不兼容的架构编译的,则将加载失败。此环境变量可用于验证应用程序是否为每个内核生成了兼容的SASS代码(参见二进制兼容性)。通过设置CUDA_DISABLE_PTX_JIT=0可以覆盖嵌入式PTX的此行为。

执行

CUDA_LAUNCH_BLOCKING

0 或 1 (默认为 0)

禁用(设置为1时)或启用(设置为0时)异步内核启动。

CUDA_DEVICE_MAX_CONNECTIONS

1 到 32 (默认为 8)

设置从主机到每个计算能力3.5及以上设备的计算和复制引擎并发连接(工作队列)的数量。

CUDA_DEVICE_MAX_COPY_CONNECTIONS

1 到 32(默认为 8)

设置从主机到每个计算能力8.0及以上设备的每个异步拷贝引擎的并发拷贝连接(工作队列)数量。当同时设置了CUDA_DEVICE_MAX_CONNECTIONS和CUDA_DEVICE_MAX_COPY_CONNECTIONS时,只有由CUDA_DEVICE_MAX_CONNECTIONS设置的拷贝连接数量会被覆盖。

CUDA_AUTO_BOOST

0 或 1

覆盖由nvidia-smi的--auto-boost-default选项设置的自动加速行为。如果应用程序通过此环境变量请求与nvidia-smi不同的行为,当同一GPU上没有其他正在运行的应用程序成功请求了不同行为时,该请求会被接受,否则将被忽略。

CUDA_SCALE_LAUNCH_QUEUES

“0.25x”, “0.5x”, “2x” or “4x”

按固定倍数缩放可用于启动工作的队列大小。

cuda-gdb (Linux平台)

CUDA_DEVICE_WAITS_ON_EXCEPTION

0 或 1 (默认为 0)

当设置为1时,CUDA应用程序在发生设备异常时会暂停,允许附加调试器进行进一步调试。

MPS服务(Linux平台)

CUDA_DEVICE_DEFAULT_PERSISTING_L2_CACHE_PERCENTAGE_LIMIT

百分比值(介于0到100之间,默认值为0)

计算能力8.x的设备允许将部分L2缓存预留用于持久化访问全局内存的数据。当使用CUDA MPS服务时,预留大小只能通过此环境变量在启动CUDA MPS控制守护进程之前进行控制。也就是说,应在运行命令nvidia-cuda-mps-control -d之前设置该环境变量。

模块加载

CUDA_MODULE_LOADING

DEFAULT(默认)、LAZY(懒加载)、EAGER(急加载)(默认为LAZY)

指定应用程序的模块加载模式。当设置为EAGER时,所有来自cubin、fatbin或PTX文件的内核和数据将在对应的cuModuleLoad*cuLibraryLoad* API调用时完全加载。当设置为LAZY时,特定内核的加载将延迟到通过cuModuleGetFunctioncuKernelGetFunction API调用提取CUfunc句柄时,而cubin中的数据将在加载cubin中的第一个内核时或首次访问cubin中的变量时加载。默认行为可能在未来的CUDA版本中发生变化。

CUDA_MODULE_DATA_LOADING

DEFAULT(默认)、LAZY(懒加载)、EAGER(急加载)(默认为LAZY)

指定应用程序的数据加载模式。当设置为EAGER时,来自cubin、fatbin或PTX文件的所有数据会在对应的cuLibraryLoad*调用时完全加载到内存中。这不会影响内核的LAZY或EAGER加载。当设置为LAZY时,数据加载会延迟到需要句柄时才进行。默认行为可能在未来的CUDA版本中发生变化。如果未设置此环境变量,数据加载行为将从CUDA_MODULE_LOADING继承。

预加载依赖库

CUDA_FORCE_PRELOAD_LIBRARIES

0 或 1 (默认为 0)

当设置为1时,强制驱动程序在初始化期间预加载NVVM和PTX即时编译所需的库。这将增加内存占用和CUDA驱动程序初始化的时间。需要设置此环境变量以避免涉及多个CUDA线程的某些死锁情况。

CUDA Graphs

CUDA_GRAPHS_USE_NODE_PRIORITY

0 或 1

在图形实例化时覆盖cudaGraphInstantiateFlagUseNodePriority标志。当设置为1时,该标志将对所有图形设置;当设置为0时,该标志将对所有图形清除。

19. 统一内存编程

注意

本章适用于计算能力5.0或更高的设备,除非另有说明。对于计算能力低于5.0的设备,请参阅CUDA 11.8工具包文档。

这份关于统一内存的文档分为3部分:

19.1. 统一内存简介

CUDA统一内存为所有处理器提供:

  • 一个统一的内存池,即 单个指针值使系统中的所有处理器 (所有CPU、所有GPU等) 能够通过其原生内存操作 (指针解引用、原子操作等)访问该内存。

  • 系统中所有处理器对统一内存池的并发访问。

统一内存通过以下几种方式改进了GPU编程:

  • 生产力: GPU程序可以同时从GPU和CPU线程访问统一内存,无需创建单独的分配(cudaMalloc())并手动来回复制内存(cudaMemcpy*())。

  • 性能:

    • 通过将数据迁移至访问最频繁的处理器,可以最大化数据访问速度。应用程序可触发手动数据迁移,并利用提示信息来控制迁移启发式算法。

    • 通过避免在CPU和GPU上重复存储内存,可以降低系统总内存使用量。

  • 功能: 它使GPU程序能够处理超出GPU内存容量的数据。

借助CUDA统一内存技术,数据移动仍然会发生,而提示可能有助于提升性能。这些提示并非正确性或功能性的必需条件,也就是说,程序员可以优先关注在GPU和CPU之间并行化应用程序,将数据移动优化作为开发周期后期的性能调优事项。需注意,数据的物理位置对程序不可见且可能随时变更,但无论数据位于何处,任何处理器对数据虚拟地址的访问都将保持有效性和一致性。

获取CUDA统一内存主要有两种方式:

19.1.1. 统一内存的系统需求

下表展示了CUDA统一内存的不同支持级别、检测这些支持级别所需的设备属性,以及指向各支持级别专属文档的链接:

表 27 统一内存支持级别概述

统一内存支持级别

系统设备属性

更多文档

完整的CUDA统一内存:所有内存均获得全面支持。这包括系统分配内存和CUDA托管内存。

Set to 1: pageableMemoryAccess
Systems with hardware acceleration also have the following properties set to 1:
hostNativeAtomicSupported, pageableMemoryAccessUsesHostPageTables, directManagedMemAccessFromHost

支持完整CUDA统一内存的设备上的统一内存

只有CUDA托管内存具备完整支持。

Set to 1: concurrentManagedAccess
Set to 0: pageableMemoryAccess

仅支持CUDA托管内存的设备上的统一内存

CUDA托管内存不完全支持:统一寻址但无法并发访问。

Set to 1: managedMemory
Set to 0: concurrentManagedAccess

不支持统一内存。

设置为0: managedMemory

适用于Tegra的CUDA内存管理

尝试在不支持统一内存的系统上使用统一内存的应用程序行为是未定义的。 以下属性使CUDA应用程序能够检查系统对统一内存的支持级别, 并可在具有不同支持级别的系统之间移植:

  • pageableMemoryAccess: 该属性在支持CUDA统一内存的系统上设置为1,这些系统中所有线程都可以访问系统分配内存和CUDA托管内存。这些系统包括NVIDIA Grace Hopper、IBM Power9 + Volta以及启用HMM的现代Linux系统等(参见下一条)。

    • Linux HMM 需要 Linux 内核版本 6.1.24+、6.2.11+ 或 6.3+, 设备计算能力需达到 7.5 或更高, 并安装 CUDA 驱动版本 535+ 且支持 Open Kernel Modules

  • concurrentManagedAccess: 该属性在具备完整CUDA托管内存支持的系统中设置为1。 当该属性设置为0时,CUDA托管内存中对统一内存仅提供部分支持。 有关Tegra对统一内存的支持,请参阅 CUDA for Tegra Memory Management

程序可以通过使用cudaGetDeviceProperties()查询上表Overview of levels of unified memory support中的属性,来获取GPU对CUDA统一内存的支持级别。

19.1.2. 编程模型

借助CUDA统一内存,不再需要在主机和设备之间进行单独的内存分配,也无需在它们之间显式传输数据。程序可以通过以下方式分配统一内存:

本章大多数示例至少提供两个版本,一个使用CUDA托管内存,另一个使用系统分配内存。 标签页允许您在两者之间进行选择。以下示例展示了统一内存如何简化CUDA程序:

__global__ void write_value(int* ptr, int v) {
  *ptr = v;
}

int main() {
  int* d_ptr = nullptr;
  // Does not require any unified memory support
  cudaMalloc(&d_ptr, sizeof(int));
  write_value<<<1, 1>>>(d_ptr, 1);
  int h_value;
  // Copy memory back to the host and synchronize
  cudaMemcpy(&h_value, d_ptr, sizeof(int),
             cudaMemcpyDefault);
  printf("value = %d\n", h_value); 
  cudaFree(d_ptr); 
  return 0;
}
__global__ void write_value(int* ptr, int v) {
  *ptr = v;
}

int main() {
  // Requires System-Allocated Memory support
  int* ptr = (int*)malloc(sizeof(int));
  write_value<<<1, 1>>>(ptr, 1);
  // Synchronize required
  // (before, cudaMemcpy was synchronizing)
  cudaDeviceSynchronize();
  printf("value = %d\n", *ptr); 
  free(ptr); 
  return 0;
}
__global__ void write_value(int* ptr, int v) {
  *ptr = v;
}

int main() {
  int* d_ptr = nullptr;
  // Does not require any unified memory support
  cudaMalloc(&d_ptr, sizeof(int));
  write_value<<<1, 1>>>(d_ptr, 1);
  int h_value;
  // Copy memory back to the host and synchronize
  cudaMemcpy(&h_value, d_ptr, sizeof(int),
             cudaMemcpyDefault);
  printf("value = %d\n", h_value); 
  cudaFree(d_ptr); 
  return 0;
}
__global__ void write_value(int* ptr, int v) {
  *ptr = v;
}

int main() {
  // Requires System-Allocated Memory support
  int value;
  write_value<<<1, 1>>>(&value, 1);
  // Synchronize required
  // (before, cudaMemcpy was synchronizing)
  cudaDeviceSynchronize();
  printf("value = %d\n", value);
  return 0;
}
__global__ void write_value(int* ptr, int v) {
  *ptr = v;
}

int main() {
  int* d_ptr = nullptr;
  // Does not require any unified memory support
  cudaMalloc(&d_ptr, sizeof(int));
  write_value<<<1, 1>>>(d_ptr, 1);
  int h_value;
  // Copy memory back to the host and synchronize
  cudaMemcpy(&h_value, d_ptr, sizeof(int),
             cudaMemcpyDefault);
  printf("value = %d\n", h_value); 
  cudaFree(d_ptr); 
  return 0;
}
__global__ void write_value(int* ptr, int v) {
  *ptr = v;
}

int main() {
  int* ptr = nullptr;
  // Requires CUDA Managed Memory support
  cudaMallocManaged(&ptr, sizeof(int));
  write_value<<<1, 1>>>(ptr, 1);
  // Synchronize required
  // (before, cudaMemcpy was synchronizing)
  cudaDeviceSynchronize();
  printf("value = %d\n", *ptr); 
  cudaFree(ptr); 
  return 0;
}
__global__ void write_value(int* ptr, int v) {
  *ptr = v;
}

int main() {
  int* d_ptr = nullptr;
  // Does not require any unified memory support
  cudaMalloc(&d_ptr, sizeof(int));
  write_value<<<1, 1>>>(d_ptr, 1);
  int h_value;
  // Copy memory back to the host and synchronize
  cudaMemcpy(&h_value, d_ptr, sizeof(int),
             cudaMemcpyDefault);
  printf("value = %d\n", h_value); 
  cudaFree(d_ptr); 
  return 0;
}
__global__ void write_value(int* ptr, int v) {
  *ptr = v;
}

// Requires CUDA Managed Memory support
__managed__ int value;

int main() {
  write_value<<<1, 1>>>(&value, 1);
  // Synchronize required
  // (before, cudaMemcpy was synchronizing)
  cudaDeviceSynchronize();
  printf("value = %d\n", value);
  return 0;
}

在上面的示例中,设备写入一个值,然后由主机读取:

  • 不使用统一内存: 需要同时为主机和设备端存储写入的值(示例中的h_valued_ptr),并且需要使用cudaMemcpy()在两者之间进行显式拷贝。

  • 使用统一内存:设备可以直接从主机访问数据。ptr / value 无需单独的 h_value / d_ptr 分配,也不需要复制例程,这大大简化并减小了程序的体积。通过:

    • 系统分配: 无需其他更改。

    • 托管内存: 数据分配改为使用cudaMallocManaged(),该函数返回的指针在主机和设备代码中都有效。

19.1.2.1. 系统分配内存的分配API

支持完整CUDA统一内存的系统上,所有内存都是统一内存。 这包括通过系统分配API分配的内存,例如malloc()mmap()、C++的new()运算符, 以及CPU线程栈上的自动变量、线程局部变量、全局变量等。

系统分配的内存可能在首次访问时被填充,具体取决于所使用的API和系统设置。 首次访问意味着:

  • 分配API会立即分配虚拟内存并返回,

  • 当线程首次访问内存时,物理内存会被填充。

通常,物理内存会被选择在运行该线程的处理器"附近"。例如,

  • GPU线程首先访问它:选择线程运行的物理GPU内存。

  • CPU线程首先访问它:选择该线程运行的CPU核心所在内存NUMA节点中的物理CPU内存。

CUDA统一内存提示和预取API,cudaMemAdvisecudaMemPreftchAsync,可用于系统分配的内存。这些API将在下面的数据使用提示部分进行介绍。

__global__ void printme(char *str) {
  printf(str);
}

int main() {
  // Allocate 100 bytes of memory, accessible to both Host and Device code
  char *s = (char*)malloc(100);
  // Physical allocation placed in CPU memory because host accesses "s" first
  strncpy(s, "Hello Unified Memory\n", 99);
  // Here we pass "s" to a kernel without explicitly copying
  printme<<< 1, 1 >>>(s);
  cudaDeviceSynchronize();
  // Free as for normal CUDA allocations
  cudaFree(s); 
  return  0;
}

19.1.2.2. CUDA托管内存分配API: cudaMallocManaged()

在支持CUDA托管内存的系统上,可以使用以下方式分配统一内存:

__host__ cudaError_t cudaMallocManaged(void **devPtr, size_t size);

该API在语法上与cudaMalloc()完全相同:它会分配size字节的托管内存,并将devPtr指向该分配区域。 CUDA托管内存同样通过cudaFree()进行释放。

支持完整CUDA托管内存的系统上,托管内存分配可以被系统中的所有CPU和GPU并发访问。在这些系统上,将主机调用从cudaMalloc()替换为cudaMallocManaged()不会影响程序语义;设备代码无法调用cudaMallocManaged()

以下示例展示了cudaMallocManaged()的用法:

__global__ void printme(char *str) {
  printf(str);
}

int main() {
  // Allocate 100 bytes of memory, accessible to both Host and Device code
  char *s;
  cudaMallocManaged(&s, 100);
  // Note direct Host-code use of "s"
  strncpy(s, "Hello Unified Memory\n", 99);
  // Here we pass "s" to a kernel without explicitly copying
  printme<<< 1, 1 >>>(s);
  cudaDeviceSynchronize();
  // Free as for normal CUDA allocations
  cudaFree(s); 
  return  0;
}

注意

对于支持CUDA托管内存分配但未提供完整支持的系统,请参阅一致性与并发性。 实现细节(可能随时变更):

  • 计算能力5.x的设备在GPU上分配CUDA托管内存。

  • 计算能力6.x及以上的设备会在首次访问时填充内存,这与系统分配内存API的行为一致。

19.1.2.3. 使用__managed__的全局作用域托管变量

CUDA __managed__ 变量的行为类似于通过 cudaMallocManaged() 分配的内存 (参见 Allocation API for CUDA Managed Memory: cudaMallocManaged())。 它们简化了带有全局变量的程序,使得在主机和设备之间交换数据变得特别容易, 无需手动分配或复制。

支持完整CUDA统一内存的系统上, 设备代码无法直接访问文件作用域或全局作用域的变量。 但可以将指向这些变量的指针作为参数传递给内核, 具体示例请参阅系统分配内存:深入示例

__global__ void write_value(int* ptr, int v) {
  *ptr = v;
}

int main() {
  // Requires System-Allocated Memory support
  int value;
  write_value<<<1, 1>>>(&value, 1);
  // Synchronize required
  // (before, cudaMemcpy was synchronizing)
  cudaDeviceSynchronize();
  printf("value = %d\n", value);
  return 0;
}
__global__ void write_value(int* ptr, int v) {
  *ptr = v;
}

// Requires CUDA Managed Memory support
__managed__ int value;

int main() {
  write_value<<<1, 1>>>(&value, 1);
  // Synchronize required
  // (before, cudaMemcpy was synchronizing)
  cudaDeviceSynchronize();
  printf("value = %d\n", value);
  return 0;
}

注意这里没有显式的cudaMemcpy()命令,且写入的值value在CPU和GPU上都是可见的。

CUDA __managed__ 变量隐含 __device__ 属性,等同于 __managed__ __device__ 的组合写法(这也是允许的)。 被标记为 __constant__ 的变量不可同时标记为 __managed__

有效的CUDA上下文是确保__managed__变量正确运行的必要条件。 访问__managed__变量会触发CUDA上下文创建(如果当前设备尚未创建上下文)。 在上面的示例中,内核启动前访问value会触发默认设备上的上下文创建。 如果没有该访问操作,则内核启动会触发上下文创建。

声明为__managed__的C++对象会受到某些特定限制,特别是在涉及静态初始化器的情况下。 有关这些限制的列表,请参阅C++ Language Support

注意

对于不支持完整CUDA托管内存的设备,关于__managed__变量在CUDA流中执行异步操作时的可见性问题,请参阅使用流管理数据可见性及CPU+GPU并发访问章节。

19.1.2.4. 统一内存与映射内存的区别

Unified Memory与Mapped Memory的主要区别在于: CUDA Mapped Memory不保证所有类型的内存访问(例如原子操作)在所有系统上都受支持, 而Unified Memory则提供这种保证。CUDA Mapped Memory所保证的可移植支持的内存操作集合较为有限, 但能在比Unified Memory更多的系统上使用。

19.1.2.5. 指针属性

CUDA程序可以通过调用cudaPointerGetAttributes()来检查指针是否指向CUDA托管内存分配,并测试指针属性value是否为cudaMemoryTypeManaged

该API返回cudaMemoryTypeHost表示已通过cudaHostRegister()注册的系统分配内存,返回cudaMemoryTypeUnregistered表示CUDA未感知的系统分配内存。

指针属性并不说明内存位于何处,而是说明内存是如何分配或注册的。

以下示例展示如何在运行时检测指针类型:

char const* kind(cudaPointerAttributes a, bool pma, bool cma) {
    switch(a.type) {
    case cudaMemoryTypeHost: return pma?
      "Unified: CUDA Host or Registered Memory" :
      "Not Unified: CUDA Host or Registered Memory";
    case cudaMemoryTypeDevice: return "Not Unified: CUDA Device Memory";
    case cudaMemoryTypeManaged: return cma?
      "Unified: CUDA Managed Memory" : "Not Unified: CUDA Managed Memory";
    case cudaMemoryTypeUnregistered: return pma?
      "Unified: System-Allocated Memory" :
      "Not Unified: System-Allocated Memory";
    default: return "unknown";
    }
}

void check_pointer(int i, void* ptr) {
  cudaPointerAttributes attr;
  cudaPointerGetAttributes(&attr, ptr);
  int pma = 0, cma = 0, device = 0;
  cudaGetDevice(&device);
  cudaDeviceGetAttribute(&pma, cudaDevAttrPageableMemoryAccess, device);
  cudaDeviceGetAttribute(&cma, cudaDevAttrConcurrentManagedAccess, device);
  printf("Pointer %d: memory is %s\n", i, kind(attr, pma, cma));
}

__managed__ int managed_var = 5;

int main() {
  int* ptr[5];
  ptr[0] = (int*)malloc(sizeof(int));
  cudaMallocManaged(&ptr[1], sizeof(int));
  cudaMallocHost(&ptr[2], sizeof(int));
  cudaMalloc(&ptr[3], sizeof(int));
  ptr[4] = &managed_var;

  for (int i = 0; i < 5; ++i) check_pointer(i, ptr[i]);
  
  cudaFree(ptr[3]);
  cudaFreeHost(ptr[2]);
  cudaFree(ptr[1]);
  free(ptr[0]);
  return 0;
}

19.1.2.6. 统一内存支持级别的运行时检测

以下示例展示了如何在运行时检测Unified Memory的支持级别:

int main() {
  int d;
  cudaGetDevice(&d);

  int pma = 0;
  cudaDeviceGetAttribute(&pma, cudaDevAttrPageableMemoryAccess, d);
  printf("Full Unified Memory Support: %s\n", pma == 1? "YES" : "NO");
  
  int cma = 0;
  cudaDeviceGetAttribute(&cma, cudaDevAttrConcurrentManagedAccess, d);
  printf("CUDA Managed Memory with full support: %s\n", cma == 1? "YES" : "NO");

  return 0;
}

19.1.2.7. GPU内存超额订阅

统一内存(Unified Memory)允许应用程序超额订阅任何单个处理器的内存: 换句话说,它们可以分配和共享比系统中任何单个处理器内存容量更大的数组, 从而支持对无法放入单个GPU的数据集进行核外处理,而无需显著增加编程模型的复杂性。

19.1.2.8. 性能提示

以下部分描述了可用的统一内存性能提示,这些提示可应用于所有统一内存,例如CUDA托管内存,或在支持完整CUDA统一内存的系统上,也可应用于所有系统分配内存。 这些API是提示性的,也就是说它们不会影响应用程序的语义,仅影响其性能。 因此,可以在任何应用程序的任何位置添加或移除这些提示,而不会影响其结果。

CUDA统一内存可能并不总是具备做出与统一内存相关的最佳性能决策所需的全部信息。 这些性能提示使应用程序能够向CUDA提供更多信息。

请注意,应用程序仅应在能提升性能的情况下使用这些提示。

19.1.2.8.1. 数据预取

cudaMemPrefetchAsync API 是一个异步流序API,可将数据迁移至更靠近指定处理器的位置。 在预取过程中数据仍可被访问。 该迁移操作需等待流中所有前置操作完成后才会启动, 并在流中后续任何操作开始前完成。

cudaError_t cudaMemPrefetchAsync(const void *devPtr,
                                 size_t count,
                                 int dstDevice,
                                 cudaStream_t stream);

包含[devPtr, devPtr + count)的内存区域可能会在给定stream中执行预取任务时迁移到目标设备dstDevice - 如果使用cudaCpuDeviceId则可能迁移到CPU。

考虑以下简单代码示例:

void test_prefetch_sam(cudaStream_t s) {
  char *data = (char*)malloc(N);
  init_data(data, N);                                     // execute on CPU
  cudaMemPrefetchAsync(data, N, myGpuId, s);              // prefetch to GPU
  mykernel<<<(N + TPB - 1) / TPB, TPB, 0, s>>>(data, N);  // execute on GPU
  cudaMemPrefetchAsync(data, N, cudaCpuDeviceId, s);      // prefetch to CPU
  cudaStreamSynchronize(s);
  use_data(data, N);
  free(data);
}
void test_prefetch_managed(cudaStream_t s) {
  char *data;
  cudaMallocManaged(&data, N);
  init_data(data, N);                                     // execute on CPU
  cudaMemPrefetchAsync(data, N, myGpuId, s);              // prefetch to GPU
  mykernel<<<(N + TPB - 1) / TPB, TPB, 0, s>>>(data, N);  // execute on GPU
  cudaMemPrefetchAsync(data, N, cudaCpuDeviceId, s);      // prefetch to CPU
  cudaStreamSynchronize(s);
  use_data(data, N);
  cudaFree(data);
}
19.1.2.8.2. 数据使用提示

当多个处理器同时访问相同数据时,可以使用cudaMemAdvise来提示[devPtr, devPtr + count)范围内的数据将如何被访问:

cudaError_t cudaMemAdvise(const void *devPtr,
                          size_t count,
                          enum cudaMemoryAdvise advice,
                          int device);

其中 advice 可以取以下值:

  • cudaMemAdviseSetReadMostly: 表示该数据将主要用于读取操作,仅偶尔进行写入。 通常,这允许在该内存区域以牺牲写入带宽为代价来换取更高的读取带宽。 示例:

void test_advise_managed(cudaStream_t stream) {
  char *dataPtr;
  size_t dataSize = 64 * TPB;  // 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
  cudaMemAdvise(dataPtr, dataSize, cudaMemAdviseSetReadMostly, myGpuId);
  int outerLoopIter = 0;
  while (outerLoopIter < maxOuterLoopIter) {
    // The data is written to in the outer loop on the CPU
    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
    for (int device = 0; device < maxDevices; device++) {
      cudaMemPrefetchAsync(dataPtr, dataSize, device, stream);
    }
    // The kernel only reads this data in the inner loop
    int innerLoopIter = 0;
    while (innerLoopIter < maxInnerLoopIter) {
      mykernel<<<32, TPB, 0, stream>>>((const char *)dataPtr, dataSize);
      innerLoopIter++;
    }
    outerLoopIter++;
  }
  cudaFree(dataPtr);
}
  • cudaMemAdviseSetPreferredLocation: In general, any memory may be migrated at any time to any location, for example, when a given processor is running out of physical memory. This hint tells the system that migrating this memory region away from its preferred location is undesired, by setting the preferred location for the data to be the physical memory belonging to device. Passing in a value of cudaCpuDeviceId for device sets the preferred location as CPU memory. Other hints, like cudaMemPrefetchAsync, may override this hint, leading the memory to be migrated away from its preferred location.

  • cudaMemAdviseSetAccessedBy: 在某些系统中,在从给定处理器访问数据之前建立内存映射可能有利于性能提升。 该提示告诉系统该数据将被device频繁访问, 使系统能够判断创建这些映射是值得的。 此提示并不指定数据应驻留的位置, 但可以与cudaMemAdviseSetPreferredLocation结合使用来指定位置。

每个建议也可以通过使用以下值之一来取消设置: cudaMemAdviseUnsetReadMostly, cudaMemAdviseUnsetPreferredLocationcudaMemAdviseUnsetAccessedBy

19.1.2.8.3. 查询托管内存上的数据使用属性

程序可以通过以下API查询通过cudaMemAdvisecudaMemPrefetchAsync在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预取内存范围的最后位置。 请注意,这只是返回应用程序请求预取内存范围的最后位置。 它并不表明预取操作到该位置是否已完成或甚至已经开始。

此外,可以通过使用对应的cudaMemRangeGetAttributes函数来查询多个属性。

19.2. 支持完整CUDA统一内存的设备上的统一内存

19.2.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);
}

前三个选项卡展示了编程模型部分中已详细说明的示例。接下来的三个选项卡展示了从设备访问文件作用域或全局作用域变量的多种方式。

请注意,对于外部变量,它可能由完全不与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__专用,因此目前大多数编译器不允许在设备代码中直接使用这些变量。

19.2.1.1. 文件支持的统一内存

由于支持完整CUDA统一内存的系统 允许设备访问主机进程拥有的任何内存, 它们可以直接访问文件支持的内存。

这里,我们展示了一个修改后的初始示例,该示例来自上一节,通过使用文件支持的内存来从GPU打印字符串,并直接从输入文件中读取数据。 在以下示例中,内存由物理文件支持,但该示例同样适用于内存支持的文件,具体细节请参阅统一内存的进程间通信(IPC)部分。

__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的系统),不支持对文件支持内存的原子访问。

19.2.1.2. 使用统一内存的进程间通信(IPC)

注意

截至目前,在Unified Memory中使用IPC可能会对性能产生重大影响。

许多应用倾向于每个进程管理一个GPU,但仍需使用统一内存,例如用于超额订阅,并从多个GPU访问它。

CUDA IPC(参见进程间通信) 不支持托管内存:此类内存的句柄无法通过本节讨论的任何机制进行共享。 在完全支持CUDA统一内存的系统上, 系统分配的内存具备进程间通信(IPC)能力。 当系统分配的内存被共享给其他进程后, 适用的编程模型文件支持的统一内存类似。

有关在Linux下创建支持IPC的系统分配内存的各种方法的更多信息,请参阅以下参考资料:

请注意,使用此技术无法在不同主机及其设备之间共享内存。

19.2.2. 性能调优

为了通过Unified Memory获得良好性能,关键要做到以下几点:

  • 了解系统上的分页机制如何工作,以及如何避免不必要的页面错误。

  • 理解允许将数据保留在访问处理器本地的各种机制。

  • 考虑根据系统的内存传输粒度来优化您的应用程序。

作为一般建议,性能提示 可能会提供更好的性能,但如果使用不当,可能会导致性能 比默认行为更差。 还需注意,任何提示在主机上都会产生性能开销, 因此有用的提示至少必须将性能提升到足以抵消这种开销的程度。

19.2.2.1. 内存分页与页面大小

许多关于统一内存性能调优的部分都假设读者已具备虚拟寻址、内存页和页面大小的基础知识。 本节将尝试定义所有必要的术语,并解释为什么分页对性能至关重要。

当前所有支持统一内存的系统都使用虚拟地址空间: 这意味着应用程序使用的内存地址代表一个虚拟位置, 该位置可能被映射到内存实际所在的物理位置。

所有当前支持的处理器,包括CPU和GPU,都额外使用了内存分页技术。由于所有系统都使用虚拟地址空间,因此存在两种类型的内存页:

  • 虚拟页面:这是操作系统跟踪的每个进程中固定大小的连续虚拟内存块,可以映射到物理内存中。请注意,虚拟页面与映射相关联:例如,单个虚拟地址可能使用不同的页面大小映射到物理内存中。

  • 物理页:这表示处理器主内存管理单元(MMU)支持的固定大小连续内存块,虚拟页可以映射到其中。

目前,所有x86_64 CPU都使用4KiB物理页。 Arm CPU支持多种物理页大小 - 4KiB、16KiB、32KiB和64KiB - 具体取决于CPU型号。 最后,NVIDIA GPU支持多种物理页大小,但更倾向于使用2MiB或更大的物理页。 请注意这些大小可能会在未来硬件中发生变化。

虚拟页面的默认页面大小通常与物理页面大小相对应,但应用程序可以使用不同的页面大小,只要操作系统和硬件支持即可。通常,支持的虚拟页面大小必须是2的幂次方,并且是物理页面大小的倍数。

用于追踪虚拟页面到物理页面映射的逻辑实体被称为页表, 而将特定虚拟页面与特定虚拟大小映射到物理页面的每个条目称为页表项(PTE)。 所有支持的处理器都为页表提供了专用缓存,以加速 虚拟地址到物理地址的转换。这些缓存被称为转译后备缓冲器(TLBs)

应用程序性能调优有两个重要方面:

  • 虚拟页面大小的选择,

  • 系统是否提供一个由CPU和GPU共同使用的统一页表,还是为每个CPU和GPU单独提供独立的页表。

19.2.2.1.1. 选择合适的页面大小

通常来说,较小的页面大小会导致较少的(虚拟)内存碎片,但TLB未命中率更高;而较大的页面大小会导致更多内存碎片,但TLB未命中率更低。此外,与较小页面相比,较大页面的内存迁移通常开销更大,因为我们通常需要迁移完整的存储页面。这可能导致使用大页面的应用程序出现更严重的延迟峰值。更多关于页面错误的详细信息,请参阅下一节。

性能调优的一个重要方面是,与CPU相比,GPU上的TLB缺失通常代价要高得多。这意味着如果GPU线程频繁访问使用足够小页面大小映射的统一内存的随机位置,与使用足够大页面大小映射的统一内存进行相同访问相比,速度可能会明显变慢。虽然CPU线程随机访问使用小页面大小映射的大内存区域时也可能出现类似效果,但速度下降不那么明显,这意味着应用程序可能需要在速度下降和减少内存碎片之间进行权衡。

请注意,通常应用程序不应针对特定处理器的物理页面大小进行性能调优,因为物理页面大小可能会根据硬件而变化。上述建议仅适用于虚拟页面大小。

19.2.2.1.2. CPU与GPU页表:硬件一致性 vs 软件一致性

注意

在后续的性能调优文档中,我们将把CPU和GPU共用页表的系统称为硬件一致性系统。而为CPU和GPU分别维护页表的系统则称为软件一致性系统。

像NVIDIA Grace Hopper这样的硬件一致性系统为CPU和GPU提供了逻辑上统一的页表。这一点很重要,因为为了从GPU访问系统分配的内存,GPU会使用CPU为请求内存创建的页表条目。如果该页表条目使用CPU默认的4KiB或64KiB页面大小,访问大块虚拟内存区域将导致严重的TLB缺失,从而造成显著的性能下降。

有关如何确保系统分配内存使用足够大的页面大小以避免此类问题的示例,请参阅配置大页部分。

另一方面,在CPU和GPU各自拥有独立逻辑页表的系统中,需要考虑不同的性能调优因素:为了保证一致性,这类系统通常会在处理器访问映射到其他处理器物理内存中的地址时使用页错误机制。这种页错误意味着:

  • 需要确保当前拥有该页面的处理器(物理页面当前所在的处理器)无法再访问此页面,可以通过删除页表项或更新页表项来实现。

  • 需要确保请求访问的处理器能够访问该页面,无论是通过创建新的页表条目还是更新现有条目,使其变为有效/活跃状态。

  • 支持此虚拟页面的物理页面必须被移动/迁移到请求访问的处理器:这可能是一项昂贵的操作,工作量与页面大小成正比。

总体而言,在CPU和GPU线程频繁并发访问同一内存页的场景中,硬件一致性系统相比软件一致性系统能带来显著的性能优势:

  • 更少的页面错误:这些系统不需要使用页面错误来模拟一致性或迁移内存,

  • 更少的争用:这些系统以缓存行粒度而非页面大小粒度保持一致性,也就是说,当多个处理器在同一个缓存行内发生争用时,仅交换缓存行(其大小远小于最小页面尺寸);而当不同处理器访问同一页面内的不同缓存行时,则不会产生争用。

这会影响以下场景的性能:

  • CPU和GPU同时对同一地址进行原子更新。

  • 从CPU线程向GPU线程发送信号,或反之。

19.2.2.2. 主机直接统一内存访问

某些设备具备硬件支持,能够从主机对GPU驻留的统一内存进行一致性读取、存储和原子访问。这些设备的属性cudaDevAttrDirectManagedMemAccessFromHost被设置为1。请注意,所有硬件一致性系统都会为通过NVLink连接的设备设置此属性。在这些系统上,主机可以直接访问GPU驻留内存而无需页面错误和数据迁移(有关内存使用提示的更多详情,请参阅数据使用提示)。请注意,使用CUDA托管内存时,必须配合cudaCpuDeviceId使用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
  cudaMemAdvise(ret, 1000 * sizeof(int), cudaMemAdviseSetAccessedBy, cudaCpuDeviceId);

  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));
  cudaMemAdvise(ret, 1000 * sizeof(int), cudaMemAdviseSetAccessedBy, cudaCpuDeviceId);  // 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,然后再次使用相同的ret内存执行append内核。 这段代码将根据系统架构和硬件一致性支持的不同而表现出不同的行为:

  • 在具有directManagedMemAccessFromHost=1的系统上: CPU对托管缓冲区的访问不会触发任何迁移; 数据将保留在GPU内存中,任何后续GPU内核 可以直接继续访问它而不会引发故障或迁移。

  • directManagedMemAccessFromHost=0的系统上: CPU访问托管缓冲区将触发页面错误并启动数据迁移; 任何GPU内核首次尝试访问相同数据时也会触发页面错误, 并将页面迁移回GPU内存。

19.2.2.3. 主机本地原子操作

部分设备,包括硬件一致性系统中通过NVLink连接的设备,支持对CPU驻留内存进行硬件加速的原子访问。这意味着对主机内存的原子访问无需通过页面错误来模拟。对于这些设备,属性cudaDevAttrHostNativeAtomicSupported会被设置为1。

19.2.2.4. 原子访问与同步原语

CUDA统一内存支持主机和设备线程可用的所有原子操作,使所有线程能够通过并发访问相同的共享内存位置进行协作。CUDA C++标准库提供了许多针对主机和设备线程并发使用优化的异构同步原语,包括cuda::atomiccuda::atomic_refcuda::barriercuda::semaphore等众多其他功能。

在不支持CPU和GPU页表:硬件一致性 vs 软件一致性的系统上, 设备对文件支持的主机内存进行原子访问是不被支持的。 以下示例代码在具备CPU和GPU页表:硬件一致性 vs 软件一致性的系统上是有效的, 但在其他系统上会出现未定义行为:

#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 allcate 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页表:硬件一致性 vs 软件一致性的系统上,对统一内存的原子访问可能会引发页面错误,从而导致显著的延迟。请注意,并非这些系统上所有GPU对CPU内存的原子操作都会出现这种情况:通过nvidia-smi -q | grep "Atomic Caps Outbound"列出的操作可能避免页面错误。

在具有CPU和GPU页表:硬件一致性 vs. 软件一致性的系统上,主机与设备之间的原子操作不需要触发页错误,但仍可能因任何内存访问都可能出现的其他原因而发生错误。

19.2.2.5. 统一内存下的Memcpy()/Memset()行为

cudaMemcpy*()cudaMemset*() 接受任何统一内存指针作为参数。

对于cudaMemcpy*()函数,指定为cudaMemcpyKind的方向参数是一个性能提示,当任何参数是统一内存指针时,这个提示可能会对性能产生更大影响。

因此,建议遵循以下性能优化建议:

  • 当统一内存的物理位置已知时,使用准确的cudaMemcpyKind提示。

  • 优先使用cudaMemcpyKindDefault而非不准确的cudaMemcpyKind提示。

  • 始终使用已填充(初始化)的缓冲区:避免使用这些API来初始化内存。

  • 如果两个指针都指向系统分配的内存,请避免使用cudaMemcpy*():改为启动内核或使用CPU内存复制算法如std::memcpy

19.3. 不支持完整CUDA统一内存的设备上的统一内存

19.3.1. 仅支持CUDA托管内存设备上的统一内存

对于计算能力为6.x或更高但不支持可分页内存访问的设备,CUDA托管内存得到完全支持且保持一致性。统一内存的编程模型和性能调优与完全支持CUDA统一内存的设备中描述的模型基本相似,但显著区别在于无法使用系统分配器来分配内存。因此,以下子章节内容不适用:

19.3.2. Windows系统或计算能力5.x设备上的统一内存

计算能力低于6.0的设备或Windows平台支持CUDA托管内存v1.0,但对数据迁移、一致性以及内存超额订阅的支持有限。以下小节将更详细地描述如何在这些平台上使用和优化托管内存。

19.3.2.1. 数据迁移与一致性

计算能力低于6.0的GPU架构不支持按需将托管数据细粒度移动到GPU。每当启动GPU内核时,通常必须将所有托管内存传输到GPU内存,以避免内存访问错误。计算能力6.x引入了一种新的GPU页面错误机制,提供了更无缝的统一内存功能。结合系统范围的虚拟地址空间,页面错误带来了几个优势。首先,页面错误意味着CUDA系统软件不需要在每个内核启动前将所有托管内存分配同步到GPU。如果在GPU上运行的内核访问了其内存中不驻留的页面,就会发生错误,从而允许按需自动将页面迁移到GPU内存。或者,可以将页面映射到GPU地址空间,通过PCIe或NVLink互连进行访问(按需映射有时比迁移更快)。请注意,统一内存是系统范围的:GPU(和CPU)可以对来自CPU内存或系统中其他GPU内存的内存页面进行错误处理和迁移。

19.3.2.2. GPU内存超额订阅

计算能力低于6.0的设备无法分配超过GPU物理内存大小的托管内存。

19.3.2.3. 多GPU

在计算能力低于6.0的设备系统上,通过GPU的点对点功能,托管分配的内存会自动对系统中所有GPU可见。托管内存的行为类似于使用cudaMalloc()分配的非托管内存:当前活动设备是物理分配的主设备,但系统中的其他GPU将通过PCIe总线以降低的带宽访问该内存。

在Linux系统上,只要程序当前使用的所有GPU都支持点对点(peer-to-peer)访问,托管内存就会分配在GPU内存中。如果应用程序开始使用某个不支持与其他已分配托管内存的GPU进行点对点访问的GPU,驱动程序会将所有托管内存迁移到系统内存中。这种情况下,所有GPU都会受到PCIe带宽限制的影响。

在Windows系统上,如果无法建立对等映射(例如在不同架构的GPU之间),那么无论程序是否实际使用这两块GPU,系统都会自动回退到使用零拷贝内存。如果实际上只会使用一块GPU,则需要在启动程序前设置CUDA_VISIBLE_DEVICES环境变量。这将限制可见的GPU范围,并允许在GPU内存中分配托管内存。

另外,在Windows系统上,用户也可以将CUDA_MANAGED_FORCE_DEVICE_ALLOC设置为非零值,以强制驱动程序始终使用设备内存作为物理存储。当此环境变量设置为非零值时,该进程中使用的所有支持托管内存的设备必须彼此具备点对点兼容性。如果使用了支持托管内存的设备,并且该设备与此进程中先前使用的其他支持托管内存的设备不具备点对点兼容性,即使已对这些设备调用::cudaDeviceReset,也会返回错误::cudaErrorInvalidDevice。这些环境变量的说明详见CUDA Environment Variables。请注意,从CUDA 8.0开始,CUDA_MANAGED_FORCE_DEVICE_ALLOC在Linux操作系统上不再生效。

19.3.2.4. 一致性与并发性

对于计算能力低于6.0的设备,无法同时访问托管内存,因为如果在GPU内核运行时CPU访问统一内存分配,则无法保证一致性。

19.3.2.4.1. GPU对托管内存的独占访问

为确保在6.x之前的GPU架构上保持一致性,统一内存编程模型对CPU和GPU同时执行时的数据访问施加了限制。实际上,当任何内核操作正在执行时,GPU对所有托管数据拥有独占访问权,无论特定内核是否正在主动使用这些数据。当托管数据与cudaMemcpy*()cudaMemset*()一起使用时,系统可能会选择从主机或设备访问源或目标数据,这将在cudaMemcpy*()cudaMemset*()执行期间限制CPU对该数据的并发访问。详见Memcpy()/Memset() Behavior With Unified Memory获取更多信息。

对于concurrentManagedAccess属性设置为0的设备,当GPU处于活动状态时,不允许CPU访问任何托管分配或变量。在这些系统上,CPU/GPU并发访问(即使访问不同的托管内存分配)将导致段错误,因为该页面被视为CPU无法访问。

__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;
}

在上面的示例中,当CPU访问y时,GPU程序kernel仍在运行。(注意它发生在cudaDeviceSynchronize()之前。)由于GPU的页面错误处理能力解除了对同时访问的所有限制,该代码在计算能力6.x的设备上可以成功运行。然而,在6.x之前的架构上,即使CPU访问的是与GPU不同的数据,这种内存访问也是无效的。程序在访问y之前必须显式地与GPU同步:

__device__ __managed__ int x, y=2;
__global__  void  kernel() {
    x = 10;
}
int main() {
    kernel<<< 1, 1 >>>();
    cudaDeviceSynchronize();
    y = 20;            //  Success on GPUs not supporing concurrent access
    return  0;
}

如本例所示,在采用6.x之前GPU架构的系统上,CPU线程在执行内核启动与后续同步调用之间,不得访问任何托管数据——无论GPU内核是否实际触及该相同数据(或任何托管数据)。只要存在CPU与GPU并发访问的可能性,就足以触发进程级异常。

请注意,如果在GPU处于活动状态时使用cudaMallocManaged()cuMemAllocManaged()动态分配内存,在提交额外工作或同步GPU之前,该内存的行为是未定义的。在此期间尝试在CPU上访问该内存可能会导致段错误,也可能不会。此情况不适用于使用cudaMemAttachHostCU_MEM_ATTACH_HOST标志分配的内存。

19.3.2.4.2. 显式同步与逻辑GPU活动

请注意,即使在上面的示例中kernel运行速度很快并在CPU接触y之前完成,仍然需要显式同步。统一内存使用逻辑活动来确定GPU是否空闲。这与CUDA编程模型保持一致,该模型规定内核可以在启动后的任何时间运行,并且直到主机发出同步调用之前都不能保证已完成。

任何在逻辑上能确保GPU完成工作的函数调用都是有效的。这包括cudaDeviceSynchronize()cudaStreamSynchronize()cudaStreamQuery()(当它返回cudaSuccess而非cudaErrorNotReady时),且指定的流是GPU上唯一仍在执行的流;cudaEventSynchronize()cudaEventQuery()(当指定事件后没有任何设备工作时);以及文档中明确说明与主机完全同步的cudaMemcpy()cudaMemset()用法。

通过同步流或事件,可以跟踪流之间创建的依赖关系,从而推断其他流的完成情况。依赖关系可以通过cudaStreamWaitEvent()显式创建,或在使用默认(NULL)流时隐式创建。

CPU在流回调中访问托管数据是合法的,前提是GPU上没有其他可能访问托管数据的流处于活动状态。此外,后面不跟随任何设备工作的回调可用于同步:例如,通过在回调内部发出条件变量信号;否则,CPU访问仅在回调期间有效。

有几点重要注意事项:

  • 当GPU处于活动状态时,CPU始终可以访问非托管的零拷贝数据。

  • 当GPU正在运行任何内核时,即使该内核未使用托管数据,也会被视为活跃状态。如果内核可能使用数据,则禁止访问,除非设备属性concurrentManagedAccess为1。

  • 托管内存的GPU间并发访问没有限制,除了适用于非托管内存的多GPU访问的那些限制。

  • 对访问托管数据的并发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;
}
19.3.2.4.3. 使用流管理数据可见性与CPU + GPU并发访问

此前假设在6.x之前的SM架构中:1) 任何活动内核都可能使用任何托管内存,2) 在内核活动时从CPU使用托管内存是无效的。现在我们提出了一种更细粒度的托管内存控制系统,该系统设计用于支持托管内存的所有设备,包括concurrentManagedAccess等于0的旧架构。

CUDA编程模型提供了流(stream)作为机制,让程序能够表示内核启动之间的依赖与独立关系。被发射到同一个流中的内核保证会顺序执行,而发射到不同流中的内核则允许并发执行。流描述了工作项之间的独立性,从而通过并发性实现潜在的更高效率。

统一内存(Unified Memory)在流无关模型的基础上,允许CUDA程序显式地将托管内存分配与CUDA流相关联。通过这种方式,程序员可以根据内核是否在指定流中启动,来表明其对数据的使用情况。这为基于程序特定数据访问模式的并发操作提供了可能性。控制此行为的函数是:

cudaError_t cudaStreamAttachMemAsync(cudaStream_t stream,
                                     void *ptr,
                                     size_t length=0,
                                     unsigned int flags=0);

cudaStreamAttachMemAsync() 函数将起始于 ptrlength 字节内存与指定的 stream 相关联。(目前,length 必须始终为0,表示应附加整个内存区域。)由于这种关联,统一内存系统允许CPU访问该内存区域,只要 stream 中的所有操作已完成,无论其他流是否处于活动状态。实际上,这将活动GPU对托管内存区域的独占所有权限制为按流活动,而不是整个GPU活动。

最重要的是,如果分配不与特定流关联,则该分配对所有正在运行的内核可见,无论它们属于哪个流。这是cudaMallocManaged()分配或__managed__变量的默认可见性;因此,简单情况下的规则是:当任何内核正在运行时,CPU可能无法访问数据。

通过将内存分配与特定流关联,程序确保只有在该流中启动的内核才会访问该数据。统一内存系统不会执行错误检查:程序员有责任确保这一保证得到遵守。

除了支持更高的并发性外,使用cudaStreamAttachMemAsync()还能(通常也确实会)在统一内存系统中实现数据传输优化,这可能会影响延迟和其他开销。

19.3.2.4.4. 流关联示例

将数据与流关联可以实现对CPU + GPU并发的细粒度控制,但在使用计算能力低于6.0的设备时,必须注意哪些数据对哪些流可见。回顾之前的同步示例:

__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;
}

这里我们明确将y与主机可访问性相关联,从而使得CPU可以随时访问。(和之前一样,注意在访问前没有调用cudaDeviceSynchronize()。)现在通过GPU运行kernely的访问将产生未定义的结果。

请注意,将变量与流关联不会改变任何其他变量的关联。例如,将xstream1关联并不能保证只有x会被在stream1中启动的内核访问,因此这段代码会导致错误:

__device__ __managed__ int x, y=2;
__global__  void  kernel() {
    x = 10;
}
int main() {
    cudaStream_t stream1;
    cudaStreamCreate(&stream1);
    cudaStreamAttachMemAsync(stream1, &x);// Associate “x” with stream1.
    cudaDeviceSynchronize();              // Wait for “x” attachment to occur.
    kernel<<< 1, 1, 0, stream1 >>>();     // Note: Launches into stream1.
    y = 20;                               // ERROR: “y” is still associated globally
                                          // with all streams by default
    return  0;
}

请注意,访问y会导致错误,因为尽管x已与流关联,但我们并未告知系统谁可以访问y。因此系统会保守地假设kernel可能会访问它,从而阻止CPU进行此操作。

19.3.2.4.5. 多线程主机程序中的流附加操作

cudaStreamAttachMemAsync()的主要用途是通过CPU线程实现独立任务并行化。通常在此类程序中,每个CPU线程会为自身生成的所有工作创建专属流,因为使用CUDA的NULL流会导致线程间产生依赖关系。

默认情况下,托管数据对所有GPU流的全局可见性可能导致多线程程序中难以避免CPU线程之间的交互。因此,函数cudaStreamAttachMemAsync()用于将线程的托管分配与该线程自身的流相关联,并且这种关联通常在线程的生命周期内不会改变。

这样的程序只需简单调用cudaStreamAttachMemAsync()即可使用统一内存进行数据访问:

// This function performs some task, in its own private stream.
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);
}

在这个示例中,分配流关联仅建立一次,然后data就可以被主机和设备重复使用。虽然最终结果与在主机和设备间显式复制数据相同,但这种方式使代码变得简洁得多。

19.3.2.4.6. 高级主题:模块化程序与数据访问约束

在前面的示例中,cudaMallocManaged()指定了cudaMemAttachHost标志,该标志创建了一个初始对设备端执行不可见的分配。(默认分配对所有流上的所有GPU内核都是可见的。)这确保了在数据分配和为特定流获取数据之间的时间间隔内,不会与其他线程的执行产生意外交互。

如果不使用此标志,当另一个线程启动的内核正在运行时,新分配的内存将被视为GPU正在使用。这可能会影响线程在能够显式将数据附加到私有流之前从CPU访问新分配数据的能力(例如,在基类构造函数中)。因此,为了实现线程间的安全独立性,分配内存时应指定此标志。

注意

另一种方法是在分配附加到流后,在所有线程之间设置一个进程范围的屏障。这将确保所有线程在启动任何内核之前完成其数据/流的关联,从而避免风险。在销毁流之前还需要第二个屏障,因为流的销毁会导致分配恢复为默认可见性。cudaMemAttachHost标志的存在既是为了简化这一过程,也是因为并非总是可以在需要的地方插入全局屏障。

19.3.2.4.7. 流关联统一内存下的Memcpy()/Memset()行为

请参阅Memcpy()/Memset() Behavior With Unified Memory了解在设置了concurrentManagedAccess的设备上cudaMemcpy*/cudaMemset*行为的概述。在未设置concurrentManagedAccess的设备上,适用以下规则:

如果指定了cudaMemcpyHostTo*且源数据是统一内存,那么当该内存在复制流中可从主机端一致性访问时(1),将从主机端进行访问;否则将从设备端访问。类似规则适用于当指定cudaMemcpy*ToHost且目标地址是统一内存的情况。

如果指定了cudaMemcpyDeviceTo*且源数据是统一内存,那么将从设备端访问该数据。源数据必须在复制流中能被设备端一致访问(2);否则将返回错误。类似地,当指定cudaMemcpy*ToDevice且目标地址是统一内存时,上述规则同样适用于目标地址。

如果指定了cudaMemcpyDefault,那么当统一内存无法在复制流中从设备端一致访问(2),或者数据首选位置是cudaCpuDeviceId且可以在复制流中从主机端一致访问(1)时,将从主机端访问统一内存;否则将从设备端访问。

在使用cudaMemset*()处理统一内存时,数据必须能够从执行cudaMemset()操作的流中被设备一致地访问(2);否则将返回错误。

当通过cudaMemcpy*cudaMemset*从设备访问数据时,该操作流被视为在GPU上处于活动状态。在此期间,如果GPU的设备属性concurrentManagedAccess值为零,任何CPU访问与该流关联的数据或具有全局可见性的数据都将导致段错误。程序必须进行适当的同步,以确保在从CPU访问任何关联数据之前操作已完成。

  1. 在给定流中主机可一致访问意味着该内存既不具备全局可见性,也不与给定流相关联。

  1. 在给定流中设备可一致访问意味着该内存要么具有全局可见性,要么与给定流相关联。

20. 延迟加载

20.1. 什么是延迟加载?

延迟加载(Lazy Loading)将CUDA模块和内核的加载从程序初始化推迟到更接近内核执行的时间点。如果一个程序没有使用其包含的所有内核,那么部分内核会被不必要地加载。这种情况非常常见,特别是在包含任何库的情况下。大多数时候,程序只会使用它们所包含库中的一小部分内核。

得益于延迟加载(Lazy Loading)技术,程序能够仅加载实际需要使用的内核,从而节省初始化时间。这降低了GPU内存和主机内存的内存开销。

通过将环境变量CUDA_MODULE_LOADING设置为LAZY来启用延迟加载。

首先,CUDA Runtime将不再在程序初始化时加载所有模块,除非模块包含托管变量。 每个模块将在首次使用其中的变量或内核时加载。 此优化仅适用于CUDA Runtime用户,使用cuModuleLoad的CUDA Driver用户不受影响。该优化已在CUDA 11.8中发布。 对于使用cuLibraryLoad将模块数据加载到内存中的CUDA Driver用户,可以通过设置CUDA_MODULE_DATA_LOADING环境变量来更改此行为。

其次,加载模块(cuModuleLoad*()函数系列)不会立即加载内核,而是会延迟到调用cuModuleGetFunction()时才加载内核。这里存在某些例外情况,部分内核必须在cuModuleLoad*()期间加载,例如指针存储在全局变量中的内核。这项优化同时适用于CUDA Runtime和CUDA Driver用户。CUDA Runtime仅在首次使用/引用内核时才会调用cuModuleGetFunction()。该优化功能随CUDA 11.7版本发布。

这两种优化设计对用户是透明的,前提是遵循CUDA编程模型。

20.2. 延迟加载版本支持

延迟加载是CUDA运行时和CUDA驱动的一项功能。可能需要升级两者才能使用该功能。

20.2.1. 驱动程序

延迟加载(Lazy Loading)需要R515+用户模式库支持,但它具备向前兼容性,意味着可以在较旧的内核模式驱动程序上运行。

如果没有R515+用户模式库,即使工具包版本为11.7+,也无法以任何形式使用延迟加载功能。

20.2.2. 工具包

延迟加载功能在CUDA 11.7中引入,并在CUDA 11.8中获得了重大升级。

如果您的应用程序使用CUDA Runtime,那么为了从延迟加载中获益,您的应用程序必须使用11.7或更高版本的CUDA Runtime。

由于CUDA运行时通常静态链接到程序和库中,这意味着您需要使用CUDA 11.7+工具包重新编译程序,并使用CUDA 11.7+库。

否则你将无法体验到延迟加载的优势,即使你的驱动程序版本支持该功能。

如果只有部分库是11.7+版本,您将仅在这些库中看到延迟加载的优势。其他库仍会急切加载所有内容。

20.2.3. 编译器

延迟加载不需要任何编译器支持。使用11.7之前版本编译器编译的SASS和PTX都可以在启用延迟加载的情况下加载,并能完全享受该功能的优势。但如上所述,仍然需要11.7+版本的CUDA运行时。

20.3. 在惰性模式下触发内核加载

内核和变量的加载是自动进行的,无需显式加载。只需启动内核或引用变量或内核,就会自动加载相关模块和内核。

然而,如果出于任何原因您希望加载内核而不执行或以任何方式修改它,我们推荐以下方法。

20.3.1. CUDA驱动API

内核的加载发生在cuModuleGetFunction()调用期间。 即使没有启用延迟加载,这个调用也是必需的,因为这是获取内核句柄的唯一方式。

不过,您也可以使用此API更精细地控制内核加载时机。

20.3.2. CUDA运行时API

CUDA Runtime API 自动管理模块管理,因此我们建议直接使用 cudaFuncGetAttributes() 来引用内核。

这将确保在不改变状态的情况下加载内核。

20.4. 查询是否启用延迟加载

要检查用户是否启用了延迟加载,可以使用CUresult cuModuleGetLoadingMode ( CUmoduleLoadingMode* mode )

需要注意的是,在运行此函数之前必须初始化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;
}

20.5. 采用延迟加载时可能遇到的问题

延迟加载的设计使其无需对应用程序进行任何修改即可使用。 也就是说,存在一些注意事项,特别是当应用程序不完全符合CUDA编程模型时。

20.5.1. 并发执行

加载内核可能需要上下文同步。 某些程序错误地将内核并发执行的可能性视为保证。 在这种情况下,如果程序假设两个内核能够并发执行, 而其中一个内核必须等待另一个内核执行才能返回,则可能出现死锁。

如果内核A将在一个无限循环中旋转,直到内核B执行。 在这种情况下,启动内核B将触发内核B的延迟加载。如果此加载需要上下文同步, 那么我们就会遇到死锁:内核A正在等待内核B,但加载内核B却卡在等待内核A完成以同步上下文。

这种程序是一种反模式,但如果您出于某种原因想保留它,可以执行以下操作:

  • 在启动之前预加载所有希望并发执行的内核

  • 运行应用程序时使用CUDA_MODULE_DATA_LOADING=EAGER强制立即加载数据,而不强制每个函数都立即加载

20.5.2. 分配器

延迟加载(Lazy Loading)将代码的加载从程序初始化阶段推迟到更接近执行阶段。将代码加载到GPU需要分配内存。

如果您的应用程序在启动时尝试分配全部显存,例如用于自身的分配器,那么可能会出现没有剩余内存加载内核的情况。尽管惰性加载总体上为用户释放了更多内存,但CUDA仍需分配一些内存来加载每个内核,这通常发生在每个内核首次启动时。如果您的应用程序分配器贪婪地占用了所有内存,CUDA将无法分配内存。

可能的解决方案:

  • 使用 cudaMallocAsync() 而非在启动时就分配整个显存的分配器

  • 添加一些缓冲区以补偿内核加载的延迟

  • 在尝试初始化分配器之前,预加载程序中将要使用的所有内核

20.5.3. 自动调优

某些应用程序会启动多个实现相同功能的CUDA内核,以确定哪个内核运行速度最快。 虽然通常建议至少运行一次预热迭代,但对于延迟加载(Lazy Loading)来说这一点尤为重要。 毕竟,如果统计时间包含内核加载时间,将会扭曲最终的性能测试结果。

可能的解决方案:

  • 在测量前至少进行一次预热交互

  • 在启动基准测试内核之前预先加载它

21. 扩展GPU内存

扩展GPU内存(EGM)功能利用高带宽的NVLink-C2C技术,使GPU能够高效访问单节点系统中的所有系统内存。 EGM适用于集成CPU-GPU的NVIDIA系统,它允许分配的物理内存可以被配置中的任何GPU线程访问。EGM确保所有GPU都能以GPU-GPU NVLink或NVLink-C2C的速度访问其资源。

EGM

在此配置中,内存访问通过本地高带宽的NVLink-C2C进行。对于远程内存访问,会使用GPU NVLink,在某些情况下也会使用NVLink-C2C。借助EGM技术,GPU线程能够通过NVSwitch架构访问所有可用的内存资源,包括CPU连接的内存和HBM3。

21.1. 预备知识

在深入探讨EGM功能的API变更之前,我们将先介绍当前支持的拓扑结构、标识符分配、虚拟内存管理的前提条件以及EGM使用的CUDA类型。

21.1.1. EGM平台:系统拓扑结构

目前,EGM可在三种平台上启用:(1) 单节点单GPU:由基于Arm的CPU、CPU附属内存和一块GPU组成。CPU与GPU之间通过高带宽的C2C(芯片到芯片)互连。(2) 单节点多GPU:由四个完全互连的单节点单GPU平台组成。(3) 多节点单GPU:两个或多个单节点多插槽系统。

注意

使用cgroups限制可用设备会阻断EGM路由并导致性能问题。请改用CUDA_VISIBLE_DEVICES

21.1.2. 套接字标识符:它们是什么?如何访问?

NUMA(非统一内存访问)是一种用于多处理器计算机系统的内存架构,它将内存划分为多个节点。每个节点拥有自己的处理器和内存。在这样的系统中,NUMA将系统划分为多个节点,并为每个节点分配一个唯一标识符(numaID)。

EGM使用操作系统分配的NUMA节点标识符。请注意,此标识符与设备的序号不同,并且与最近的主机节点相关联。除了现有方法外,用户还可以通过调用cuDeviceGetAttribute并指定CU_DEVICE_ATTRIBUTE_HOST_NUMA_ID属性类型来获取主机节点标识符(numaID),如下所示:

int numaId;
cuDeviceGetAttribute(&numaId, CU_DEVICE_ATTRIBUTE_HOST_NUMA_ID, deviceOrdinal);

21.1.3. 分配器与EGM支持

将系统内存映射为EGM不会导致任何性能问题。事实上,访问远程插槽的系统内存(映射为EGM)速度会更快。因为EGM流量保证通过NVLinks路由。目前,cuMemCreatecudaMemPoolCreate分配器支持适当的位置类型和NUMA标识符。

21.1.4. 现有API的内存管理扩展

目前,EGM内存可以通过虚拟内存(cuMemCreate)或流序内存(cudaMemPoolCreate)分配器进行映射。用户需要负责在所有插槽上分配物理内存并将其映射到虚拟内存地址空间。

注意

多节点、单GPU平台需要进程间通信。因此我们建议读者参阅第3章

注意

我们建议读者阅读CUDA编程指南中的第10章第11章以获得更好的理解。

新的CUDA属性类型已添加到API中,使这些方法能够使用类似NUMA的节点标识符来理解分配位置:

CUDA 类型

使用场景

CU_MEM_LOCATION_TYPE_HOST_NUMA

CUmemAllocationProp 用于 cuMemCreate

cudaMemLocationTypeHostNuma

cudaMemPoolProps 用于 cudaMemPoolCreate

注意

请参阅 CUDA Driver APICUDA Runtime Data Types 了解更多关于NUMA特定的CUDA类型。

21.2. 使用EGM接口

21.2.1. 单节点,单GPU

任何现有的CUDA主机分配器以及系统分配的内存都可以利用高带宽C2C的优势。对用户而言,本地访问就是当前主机分配的方式。

注意

有关内存分配器和页面大小的更多信息,请参阅调优指南。

21.2.2. 单节点,多GPU

在多GPU系统中,用户需要为主机信息提供放置位置。正如我们提到的,表达这些信息的自然方式是通过使用NUMA节点ID,而EGM遵循这种方法。因此,用户可以使用cuDeviceGetAttribute函数来获取最近的NUMA节点ID。(参见Socket Identifiers: What are they? How to access them?)。然后用户可以通过VMM(虚拟内存管理)API或CUDA内存池来分配和管理EGM内存。

21.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);

21.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);

此外,对于直接连接的对等访问,也可以使用现有的对等访问API cudaMemPoolSetAccess。以下代码片段展示了一个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缺失。

21.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);

22. 通知

22.1. 注意事项

本文档仅供信息参考之用,不应视为对产品功能、状态或质量的保证。NVIDIA公司(“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对客户就本文所述产品的全部及累计责任应受产品销售条款的限制。

22.2. OpenCL

OpenCL是苹果公司的商标,经Khronos Group Inc.授权使用。

22.3. 商标

NVIDIA和NVIDIA标识是美国及其他国家NVIDIA公司的商标或注册商标。其他公司及产品名称可能是其各自关联公司的商标。