笔记对有些较为简单的部分做了省略,有些原文中表达比较拗口的地方,重新组织了话叙,由于部分内容并不是完全翻译,所以不建议作为主要学习资料,建议作为学习对比参考使用,如有不明白的地方或觉得有问题的地方,欢迎私我或者评论。
目录
CUDA C++ Programming Guide( v11.2.0)
1. Introduction
2. Programming Model
2.1. Kernels
2.2. Thread Hierarchy
2.3. Memory Hierarchy
2.4. Heterogeneous Programming
2.5. Compute Capability
3. Programming Interface
3.1. Compilation with NVCC
3.1.1. Compilation Workflow
3.1.1.1. Offline Compilation
3.1.1.2. Just-in-Time Compilation
3.1.2. Binary Compatibility
3.1.3. PTX Compatibility
3.1.4. Application Compatibility
3.1.5. C++ Compatibility
3.1.6. 64-Bit Compatibility
3.2. CUDA Runtime
3.2.1. Initialization
3.2.2. Device Memory
3.2.3. Device Memory L2 Access Management
3.2.3.1. L2 cache Set-Aside for Persisting Accesses
3.2.3.2. L2 Policy for Persisting Accesses
3.2.3.3. L2 Access Properties
3.2.3.4. L2 Persistence Example
3.2.3.5. Reset L2 Access to Normal
3.2.3.6. Manage Utilization of L2 set-aside cache
3.2.3.7. Query L2 cache Properties
3.2.3.8. Control L2 Cache Set-Aside Size for Persisting Memory Access
3.2.5. Page-Locked Host Memory
3.2.5.1. Portable Memory
3.2.5.2. Write-Combining Memory
3.2.5.3. Mapped Memory
3.2.6. Asynchronous Concurrent Execution
3.2.6.1. Concurrent Execution between Host and Device
3.2.6.2. Concurrent Kernel Execution
3.2.6.3. Overlap of Data Transfer and Kernel Execution
3.2.6.4. Concurrent Data Transfers
3.2.6.5. Streams
3.2.6.5.1. Creation and Destruction
3.2.6.5.2. Default Stream
3.2.6.5.3. Explicit Synchronization
3.2.6.5.4. Implicit Synchronization
3.2.6.5.5. Overlapping Behavior
3.2.6.5.6. Host Functions (Callbacks)
3.2.6.5.7. Stream Priorities
3.2.6.6. CUDA Graphs
3.2.6.6.1. Graph Structure
3.2.6.6.1.1. Node Types
3.2.6.6.2. Creating a Graph Using Graph APIs
3.2.6.6.3. Creating a Graph Using Stream Capture
3.2.6.6.3.1. Cross-stream Dependencies and Events
3.2.6.6.3.2. Prohibited and Unhandled Operations
3.2.6.6.3.3. Invalidation
3.2.6.6.4. Updating Instantiated Graphs
3.2.6.6.4.1. Graph Update Limitations
3.2.6.6.4.2. Whole Graph Update
3.2.6.6.4.3. Individual node update
3.2.6.6.5. Using Graph APIs
3.2.6.7. Events
3.2.6.7.1. Creation and Destruction
3.2.6.7.2. Elapsed Time
3.2.6.8. Synchronous Calls
3.2.7. Multi-Device System
3.2.7.1. Device Enumeration
3.2.7.2. Device Selection
3.2.7.3. Stream and Event Behavior
3.2.7.4. Peer-to-Peer Memory Access
3.2.7.4.1. IOMMU on Linux
3.2.7.5. Peer-to-Peer Memory Copy
3.2.8. Unified Virtual Address Space
3.2.9. Interprocess Communication
3.2.10. Error Checking
3.2.11. Call Stack
3.2.12. Texture and Surface Memory
3.2.12.1. Texture Memory
3.2.12.1.1. Texture Object API
3.2.12.1.2. Texture Reference API
3.2.12.1.3. 16-Bit Floating-Point Textures
3.2.12.1.4. Layered Textures
3.2.12.1.5. Cubemap Textures
3.2.12.1.7. Texture Gather
3.2.12.2. Surface Memory
3.2.12.2.1. Surface Object API
3.2.12.2.2. Surface Reference API
3.2.12.2.3. Cubemap Surfaces
3.2.12.2.4. Cubemap Layered Surfaces
3.2.12.3. CUDA Arrays
3.2.12.4. Read/Write Coherency
3.2.13. Graphics Interoperability
3.3. Versioning and Compatibility
3.4. Compute Modes
3.5. Mode Switches
3.6. Tesla Compute Cluster Mode for Windows
4. Hardware Implementation
4.1. SIMT Architecture
Notes
4.2. Hardware Multithreading
5. Performance Guidelines
5.1. Overall Performance Optimization Strategies
5.2.1. Application Level
5.2.2. Device Level
5.2.3. Multiprocessor Level
5.2.3.1. Occupancy Calculator
5.3. Maximize Memory Throughput
5.3.2. Device Memory Accesses
5.4. Maximize Instruction Throughput
5.4.1. Arithmetic Instructions
5.4.2. Control Flow Instructions
5.4.3. Synchronization Instruction
讲解了一些基本的概念
下图展示了cuda程序可以随着硬件的不同进行扩展,可以看到线程块(block)各自独立的在SM上运行,而且随着SM的不同可以重新分配在SM上运行的block。
简单的核函数例子
(1)主要讲了block和grid的组织方式,可以被定义为1维,2维,3维,这样做的目的是可以与某些特定的计算进行对应,如一纬向量计算,二维矩阵计算以及三维矩阵的计算,另外通过block和gird计算thread索引是通过行向量优先的方式计算的,这点和opencv类似。
(2)再次强调GPU执行程序在软件控制角度是以block为单位进行组织的,在执行过程中各个block有可能按照任何顺序执行。每个block内部的线程可以使用同一块共享内存,共享内存的生命周期与block相同。
(1)主要讲几种内存的特性,
local memory是以每个线程为单位的
shared memory是以每个block为单位的
global memory 是可以被所有block访问的。
(2)constant memory和texture memory是只读内存,可以被所有线程访问。
(主要讲了Unified Memory的作用,没怎么看明白,后续重点看)
计算能力版本号的相关的介绍
核函数可以使用CUDA体系指令集PTX写,也可以使用C++语言写,但都需要nvcc 编译成二进制代码,然后再在GPU上运行,nvcc是一个可以简化C++以及PTX代码编译过程的驱动。
被nvcc编译的源文件可能包含host和device代码,nvcc的基础工作流是首先将两种代码分开,然后
(1)将设备码编译成汇编码形式(PTX语言)和/或二进制形式(cubin object)
(2)为了加载和发射每个PTX代码和cubin object形式的核函数,使用必要的cuda 运行时函数来替代核函数中的<<<...>>>语法来调整host代码,
输出调整后的host代码后既可以以c++代码的形式被其他工具编译,也可以通过nvcc在最后编译阶段调用host上的编译器来进行编译。
应用程序然后
(1)可以链接到编译好的host代码(大多数的选择)
(2)也可以在有必要的时候忽略修饰后的host代码并使用CUDA driver API加载和执行PTX代码 或者cubin object
在运行时,被一个应用程序加载的PTX代码通过设备驱动进一步被编译成二进制代码,这个过程叫做即时编译。
当设备驱动即时编译某些PTX代码时,为了避免后续的重复编译,会自动缓冲一份二进制编译代码。
NVRTC,作为一个使用nvcc编译cuda c++代码的另外一种选择,NVRTC在运行时被用来将 cuda c++代码编译成PTX ,NVRTC是一个cudaC++的运行时编译库。
二进制代码时与特定架构相关的,而且是向后兼容的,不向前兼容。
某些PTX的指令必须在更高计算能力的设备上运行,这就为编译二进制代码时提供了下限,此外使用旧的PTX代码编译的二进制代码有可能无法使用最新硬件的一些优化特性。
主要描述应用程序编译时的一些选项。
编译器的前端通过c++语法规则处理cuda源文件,full c++可以支持设备代码编译,但只有部分C++全部支持设备代码。
设备代码的编译模式和host要一致,如果设备代码是32位的那么host代码也应该是32的。
提到了上下文的概念
Device memory有两种,一种是linear memory,另外一种是CUDA arrays.CUDA array是为了纹理获取优化的opaque menmory。.linear memory 在一个单独的统一地址空间分配,意思是各自分配的对象可以通过指针引用另外一个元素。地址空间的寻址大小取决于host和GPU的计算能力,如下图所示:
Linear menory通常使用cudaMalloc()分配内存,使用cudaFree()释放,cudaMemcpy()来在host和device之间进行数据转换。
Linear memory也可以通过cudaMallocPitch()和cudaMalloc3D()分配,这些函数适用于2D和3D arrays的内存分配,他们可以保证满足内存对齐,文中举了一个例子
当核函数重复访问Device中一个区域时,就说访问是持续性的(persisting),如果只是访问一次,那么叫做流线型的(streaming)。从CUDA 11.0开始,计算能力8.0及更高版本的设备具有影响L2缓存中数据持久性的能力,从而有可能提供更高的带宽和对全局内存访问时更低的的延迟。
可以留出一部分L2高速缓存以用于持久存储对全局存储器的数据访问,而且相比于非持久数据访问,持久性数据访问在使用L2高速缓冲时具有更高的优先权。
为持久性访问使用L2高速缓冲的空间是有限制的,单个GPU和多个GPU的限制函数不一样,文中有举例及提及。
访问策略窗口( access policy window)指定了全局存储器的连续区域以及用于访问该区域的L2高速缓存中的持久性属性。接下来举了两个例子,一个是CUDA Stream Example另外一个是CUDA GraphKernelNode Example。
一般在设置访问策略窗口时,会在全局内存中指定一块区域,这里假设A,然后使用hitRatio 指定这块区域中的持久性访问的比例作为hitProp区域,但是L2 set-aside cache size是有限的,最终作为hitProp区域的是min(A*hitPrtio,L2 set-aside cache size)
此外如果两个核共同访问L2 set-aside cache,如果两者设置得size相交不超过L2 set-aside cache size那么没有问题,如果超过,那么会出现冲突。
为不同的全局内存数据访问定义了三种类型的访问属性:
(1)cudaAccessPropertyStreaming:使用流属性进行的内存访问不太可能保留在L2高速缓存中,因为这些访问被优先驱逐。
(2)cudaAccessPropertyPersisting:由于具有持久属性而发生的内存访问更可能保留在L2高速缓存中,因为这些访问优先保留在L2高速缓存的预留部分中。
(3)cudaAccessPropertyNormal:此访问属性将以前应用的持久访问属性强制重置为正常状态。 先前的CUDA内核具有持久属性的内存访问可能在其预期用途后很长时间被保留在L2缓存中。 这种使用后的持久性减少了不使用持久性的后续内核可使用的L2缓存数量。 使用cudaAccessPropertyNormal属性重置访问属性窗口会删除先前访问的持久(优先保留)状态,就好像先前访问没有访问属性一样。
举例一个例子
由于先前设置的持久性属性访问空间如果不重置,那么会影响后续使用,所以在使用结束后要对持久性访问属性进行重置,方法有三种:
(1)使用访问属性cudaAccessPropertyNormal重置先前的持久存储区域。
(2)通过调用cudaCtxResetPersistingL2Cache()将所有持久性L2缓存行重置为正常状态。
(3)自动复位,未触及的lines 会自动重置为正常状态。 强烈建议不要依赖自动复位,因为自动复位所需的时间不确定。
在不同流中的核函数有可能同时使用访问策略窗口( access policy window),但是L2 set-aside cache是全局的,被共同使用,当使用的资源超过L2 set-aside cache size是,会出现性能下降,所以在使用之前要考虑如下事情:
(1)L2预留缓存的大小。
(2)可以同时执行的CUDA内核。
(3)可能同时执行的所有CUDA内核的访问策略窗口。
(4)需要何时以及如何重置L2才能允许普通访问或流访问使用具有相同优先级的先前预留的L2缓存。
与L2缓存相关的属性是cudaDeviceProp结构的一部分,可以使用CUDA运行时API cudaGetDeviceProperties查询。
CUDA设备属性包括:
(1)l2CacheSize:GPU上的可用L2缓存数量。
(2)persistenceingL2CacheMaxSize:可以为持久内存访问而保留的L2高速缓存的最大数量。
(3)accessPolicyMaxWindowSize:访问策略窗口的最大尺寸。
使用CUDA运行时API cudaDeviceGetLimit查询用于持久存储访问的L2预留缓存大小,并使用CUDA运行时API cudaDeviceSetLimit作为cudaLimit进行设置。 设置此限制的最大值是cudaDeviceProp :: persistingL2CacheMaxSize。
举了一个矩阵相乘的例子说明shared menory的优点,即在矩阵相乘中获取元素时,与访问device内存相比,获取shared memory可以获得更低的时延,从而实现运算加速。
cudaHostAlloc() 和cudaFreeHost()是针对主机锁页内存进行操作的。cudaHostRegister()可以将原先通过malloc()分配的内存注册为锁页内存。
使用主机锁页内存有以下几个好处:
(1)在某些设备中,可以同时执行内存拷贝(host和device之间的)和核函数。
(2)在某些设备中,主机锁页内存可以映射到设备地址空间中,可以减少两者之间的内存拷贝。
(3) 在某些具有front-side总线的设备中,如果使用锁页内存,那么host和device之间会拥有更高的带宽。
注意:在非I / O一致性的Tegra设备上不会缓存页面锁定的主机内存。 另外,非I / O相干Tegra设备不支持cudaHostRegister()。
可以将锁页内存块与系统中的任何设备结合使用(有关多设备系统的更多详细信息,请参见多设备系统), 但是默认情况下,使用上述锁页内存仅可以让分配块时的当前设备收益,为了使这些优势可用于所有设备, 需要通过将标志cudaHostAllocPortable传递给cudaHostAlloc()来分配块,或者通过将标志cudaHostRegisterPortable 传递给cudaHostRegister()来进行页面锁定。
默认情况下,页面锁定主机内存分配为可缓存。 将标志cudaHostAllocWriteCombined传递给cudaHostAlloc()时,可以将其分配为write-combining。write-combining释放了主机的L1和L2缓存资源,使更多的缓存可供应用程序的其余部分使用。 此外,在通过PCI Express总线进行传输时,不会监听写write-combining内存,这可以将传输性能提高多达40%。
从主机读取write-combining内存的速度过慢,因此,write-combining内存通常应用于主机仅写入的内存。
通过将标志cudaHostAllocMapped传递给cudaHostAlloc()或将标志cudaHostRegisterMapped传递给cudaHostRegister(),也可以将页面锁定主机内存块映射到设备的地址空间。 因此,此类块通常具有两个地址:一个位于主机内存中,由cudaHostAlloc()或malloc()返回,另一个位于设备内存中,可使用cudaHostGetDevicePointer()进行检索,然后用于从内核内部访问该块。 The only exception is for pointers allocated with cudaHostAlloc() and when a unified address space is used for the host and the device as mentioned in Unified Virtual Address Space.
直接从内核内部访问主机内存不会提供与设备内存相同的带宽,但是具有一些优点:
(1)无需在设备存储器中分配一个块,也无需在该块与主机存储器中的块之间复制数据。 数据传输由内核根据需要隐式执行;
(2)无需使用流(请参阅并发数据传输)将数据传输与内核执行重叠; 源自内核的数据传输会自动与内核执行重叠。
但是,由于映射的页面锁定内存是在主机和设备之间共享的,因此应用程序必须使用流或事件来同步内存访问,以避免任何潜在风险,如在取之前还没有写入内存。
为了能够检索指向任何映射的页面锁定内存的设备指针,必须在执行任何其他CUDA调用之前通过使用带有cudaDeviceMapHost标志的cudaSetDeviceFlags()来启用页面锁定内存映射。 否则,cudaHostGetDevicePointer()将返回错误。
CUDA将以下独立的操作可以相互并发运行:
(1)主机上的计算;
(2)设备上的计算;
(3)内存从主机传输到设备;
(4)内存从设备传输到主机;
(5)内存在给定设备的内存中传输;
(6)设备之间的内存传输。
这些操作之间达到的并发级别取决于设备的功能集和计算能力。
异步库函数可以促进主机并发执行,这些函数可以再devic完成请求任务之前将控制权返回给主机线程,这样减轻了主机线程管理device的压力,可以执行其他任务。如下操作相对于host是异步的:
此外,程序员可以通过将CUDA_LAUNCH_BLOCKING环境变量设置为1来全局禁用系统上运行的所有CUDA应用程序的内核启动异步性。此功能仅用于调试目的,不应用作使生产环境中软件的可靠运行方式。
如果通过分析器(Nsight,Visual Profiler)收集硬件计数器,则内核启动是同步的,除非启用了并发内核分析。 此外,如果异步内存副本涉及未锁页的主机内存,则它们也将是同步的。
一些具有2.x和更高版本的计算能力的设备可以同时执行多个内核。 应用程序可以通过检查concurrentKernels设备属性来查询此功能,对于支持该功能的设备,该属性等于1。
设备可以同时执行的最大内核数取决于其计算能力,以下情况不能实现同时执行内核:
(1)一个CUDA上下文中的内核无法与另一CUDA上下文中的内核同时执行。
(2)使用许多纹理或大量本地内存的内核不太可能与其他内核同时执行。
一些设备可以在内核执行的同时向GPU或从GPU执行异步内存拷贝。 应用程序可以通过检查asyncEngineCount设备属性(请参阅设备枚举)来查询此功能,对于支持该属性的设备,该属性大于零。 如果拷贝中包含主机内存,则必须对其进行页面锁定(即锁页内存)。
还可以在内核执行的同时(在支持并发内核设备属性的设备上)和/或在设备之间进行拷贝(对于支持asyncEngineCount属性的设备)同时执行设备内拷贝。 使用标准内存拷贝函数(目标地址和源地址位于同一设备上)启动设备内副本。
某些具有2.x和更高版本的计算能力的设备可以使设备之间的拷贝重叠。 应用程序可以通过检查asyncEngineCount设备属性(请参阅设备枚举)来查询此功能,对于支持该属性的设备,该属性等于2。 为了重叠,传输中涉及的所有主机内存都必须页锁定。
应用程序通过流管理上述并行操作。 流是按顺序执行的一系列命令(可能由不同的主机线程发出)。 另一方面,不同的流可能会相对于彼此无序或同时执行它们的命令。 这种行为无法得到保证,因此不应依赖于它的正确性(例如,内核间通信未定义)。 当满足命令的所有依赖性时,可以执行在流上发出的命令。 依赖关系可以是先前在同一流上启动的命令,也可以是来自其他流的依赖关系。
举例说明了流的创建和销毁
未指定任何流参数或将流参数设置为零的内核启动和内存拷贝命令将被发布到默认流。 因此,它们按顺序执行。
对于使用--default-stream per-thread编译标志(或在包含CUDA标头(cuda.h和cuda_runtime.h)之前定义CUDA_API_PER_THREAD_DEFAULT_STREAM宏)编译的代码,默认流是常规流,每个主机线程 有自己的默认流。
注意:当代码由nvcc编译时,#define CUDA_API_PER_THREAD_DEFAULT_STREAM 1不能用于启用此行为,因为nvcc隐含在转换单元顶部的cuda_runtime.h。 在这种情况下,需要使用--default-stream per-thread编译标志,或者需要使用-DCUDA_API_PER_THREAD_DEFAULT_STREAM = 1编译器标志定义CUDA_API_PER_THREAD_DEFAULT_STREAM宏。
对于使用--default-stream旧式编译标志进行编译的代码,默认流是称为NULL流的特殊流,并且每个设备都有一个用于所有主机线程的NULL流。 NULL流是特殊的,因为它会导致隐式同步,如“隐式同步”中所述。
对于未指定--default-stream编译标志进行编译的代码,默认使用--default-stream旧版。
有多种方法可以使流彼此显式同步。
cudaDeviceSynchronize()等待所有主机线程的所有流中的所有先前命令完成。
cudaStreamSynchronize()将流作为参数,并等待直到给定流中的所有先前命令完成。 它可用于将主机与特定流同步,从而允许其他流继续在设备上执行。
cudaStreamWaitEvent()将流和事件作为参数(有关事件的描述,请参见事件),并使对cudaStreamWaitEvent()的调用之后添加到给定流中的所有命令延迟执行,直到给定事件完成为止。
cudaStreamQuery()为应用程序提供了一种方法,该方法可以知道流中的所有先前命令是否已完成。
如果主机线程在它们之间出现以下任一操作,则来自不同流的命令不能同时运行:
(该章节有部分没看明白,后续再看看)
两个流之间的命令执行重叠量取决于向每个流发出命令的顺序,以及设备是否支持数据传输和内核执行的重叠(请参见数据传输和内核执行的重叠),并发内核执行( 请参阅并发内核执行)和/或并发数据传输(请参见并发数据传输),如果在有些设备上不支持流重叠执行,那么他们将会被退化为类似序列化执行。
运行时提供了一种通过cudaLaunchHostFunc()将CPU函数调用随时插入流中的方法。 一旦在回调之前向流发出的所有命令都已完成,就会在主机上执行提供的函数。
下面的代码示例在向每个流发布主机到设备的内存副本,内核启动和设备到主机的内存副本之后,将主机函数MyCallback添加到两个流中的每个流中。 每个设备到主机的内存副本完成后,该函数将在主机上开始执行。
在流中主机函数之后发出的命令在主机函数完成之前不会开始执行。
排队到流中的主机函数不得(直接或间接)进行CUDA API调用,因为如果进行此类调用会导致死锁。
(该章节有部分没看明白,后续再看看)
可以对流的优先权进行设置
CUDA Graphs提供了在CPU模型中执行的新的模型,它将定义和执行分开,这样与流相比:(1)CPU启动成本有所降低,因为许多设置是预先完成的。(2)其次,将整个工作流程呈现给CUDA实现优化,而这些优化在基于分段工作机制的流中可能是无法实现的。
要查看使用Graphs进行的优化,请考虑流中发生的情况:将内核放入流中时,主机驱动程序将执行一系列操作,以准备在GPU上执行内核。 这些操作是设置和启动内核所必需的,是必须为发射每个内核支付的时间成本。 对于执行时间短的GPU内核,此开销成本可能占整个端到端执行时间的很大一部分。
使用图(Graphs)提交工作分为三个不同的阶段:定义,实例化和执行。
(1)在定义阶段,程序会在图中创建操作的描述以及它们之间的依赖关系。
(2)实例化对Graphs模板进行快照,对其进行验证,并执行大部分工作的设置和初始化,以最大程度地减少启动时需要完成的工作。 生成的实例称为executable graph。
(3)executable graph可以启动到流中,类似于任何其他CUDA工作。 它可以启动多次,而无需重复实例化。
图的结构包含两个部分:node和edge,其中node标识一个操作,edge表示依赖关系(也可以看成先后关系)。一旦操作所依赖的节点完成,就可以在任何时间调度该操作。 调度由CUDA系统负责。
图的node可以使以下中的一个:
可以通过两种机制创建图形:explicit API and stream capture,explicit API如下例所示:
上图中的graph创建方式如下:
使用stream capture方式创建graph,如下例所示:
流被捕获后,内部的不会在GPU中立即执行,执行调度由graph来设定
没看明白,后续再看。
同步或查询正在捕获的流或捕获的事件的执行状态是无效的,因为它们不代表计划执行的项目。 查询或同步包含active stream capture的 broader 的句柄(例如,任何关联流处于捕获模式时的设备或上下文句柄)的执行状态或同步也是无效的。
当捕获相同上下文中的任何流并且未使用cudaStreamNonBlocking创建该流时,任何尝试使用legacy stream 都是无效的。 这是因为legacy stream句柄 在任何时候都包含这些其他流。 排队到legacy stream将生成一个将对被捕获的流的依赖性,对其进行查询或同步将查询或同步被捕获的流。
因此,在这种情况下,调用同步API也是无效的。 同步API(例如cudaMemcpy())将工作加入legacy stream,并在返回之前对其进行同步。
注意:作为一般规则,当依赖性关系将捕获的内容与未捕获的内容连接起来,而且排队等待执行时,CUDA宁愿返回错误而不是忽略依赖关系。 将流放入或退出捕获模式是一个例外。 这在模式转换之前和之后立即切断了添加到流中的item之间的依赖关系。
与事件相比,通过等待一个流(已经被捕获的并且已经连接到其他不同的捕获图)中的捕获事件来合并两个分开的capture graphs()是无效的。 等待正在捕获的流中的未捕获事件是无效的。
当前,graph中目前不支持将异步操作加入流中的少量API,如果使用正在捕获的流进行调用,则将返回错误,例如cudaStreamAttachMemAsync()。
在流捕获期间尝试执行无效操作时,所有关联的捕获图都将无效。 当捕获图无效时,将无法继续使用任何正在捕获的流或与该图关联的捕获事件,并且将返回错误,直到使用cudaStreamEndCapture()结束流捕获为止。 此调用将使关联的流退出捕获模式,但还将返回错误值和NULL gtraph。
graph有两种方式更新,whole graph update 和individual node ,whole graph update适用于改动比较大的方式, individual node适用于比较小的更新。
cudaGraphExecUpdate()允许使用拓扑相同的Graph(“更新Graph)中的参数更新实例化的图形(“原始图形”)。 更新图的拓扑必须与用于实例化cudaGraphExec_t的原始图相同。 此外,将节点添加到原始图中或从原始图中删除的顺序必须与将节点添加到更新图中(或从更新图中删除)的顺序相匹配。 因此,在使用流捕获时,必须以相同的顺序捕获节点,并且在使用显式图节点创建API时,必须以相同的顺序添加和/或删除所有节点。
典型的工作流程是使用流捕获或图形API创建初始cudaGraph_t。 然后将cudaGraph_t实例化并正常启动。 初始启动后,使用与初始图形相同的方法创建新的cudaGraph_t,并调cudaGraphExecUpdate()。 如果图形更新成功(如上例中的updateResult参数所示),则将启动更新的cudaGraphExec_t。 如果更新由于任何原因失败,则调用cudaGraphExecDestroy()和cudaGraphInstantiate()销毁原始的cudaGraphExec_t并实例化一个新的cudaGraphExec_t。也可以直接更新cudaGraph_t节点(即,使用cudaGraphKernelNodeSetParams()),然后再更新cudaGraphExec_t,但是使用下一节中介绍的显式节点更新API效率更高。
有关使用和当前限制的更多信息,请参见Graph API。
实例化的graph节点参数可以直接更新。 这消除了实例化的开销以及创建新的cudaGraph_t的开销。 如果需要更新的节点数相对于图中的节点总数少,则最好单独更新节点。 以下方法可用于更新cudaGraphExec_t节点.
cudaGraph_t对象不是线程安全的。 用户有责任确保多个线程不会同时访问同一cudaGraph_t。
cudaGraphExec_t不能与其自身并行运行。 同一个图将按照顺序执行。
graph执行是在流中完成的,以便与其他异步工作进行排序执行。 但是,该流仅用于排序; 它不限制图的内部并行性,也不影响图节点的执行位置。
请参阅图形API。
运行时还通过允许应用程序在程序中的任意点异步记录事件并查询何时完成这些事件,来提供一种密切监视设备进度以及执行准确定时的方法。 当事件之前的所有任务或给定流中的所有命令已完成时,事件即告完成。 在所有流中的所有先前任务和命令完成之后,将完成流零中的事件。
用来计算消耗时间
一个host系统可以有多个device。 下面的代码示例演示如何枚举这些device,查询它们的属性以及确定启用CUDA的device的数量.
选择cuda设备例子,没有指定的时候,默认使用设备0.
如果将内核启动发布到与当前设备不相关的流,则启动失败,如以下代码示例所示
即使将内存副本发布给与当前设备不相关的流,它也会成功。
如果输入事件和输入流与不同的设备关联,则cudaEventRecord()将失败。
如果两个输入事件关联到不同的设备,则cudaEventElapsedTime()将失败。
即使输入事件关联到与当前设备不同的设备,cudaEventSynchronize()和cudaEventQuery()也会成功。
即使输入流和输入事件关联到不同的设备,cudaStreamWaitEvent()也会成功。 因此,cudaStreamWaitEvent()可以用于使多个设备彼此同步。
每个设备都有其自己的默认流(请参阅默认流),因此,发布到设备的默认流的命令可能相对于发布到任何其他设备的默认流的命令无序或同时执行
依赖于系统属性,特别是PCIe和/或NVLINK拓扑,设备能够寻址彼此的内存(即,在一个设备上执行的内核可以访问对指向另一设备的内存的指针指向的地址)。 如果cudaDeviceCanAccessPeer()对这两个设备返回true,则在两个设备之间支持此对等内存访问功能。
对等内存访问仅在64位应用程序中受支持,并且必须通过调用cudaDeviceEnablePeerAccess()在两个设备之间启用,如以下代码示例所示。 在未启用NVSwitch的系统上,每个设备最多可支持整个系统范围内的八个对等连接。
仅在Linux上,禁止IOMMU,windows系统不受限制。
可以在两个不同设备的存储器之间执行内存拷贝。当两个设备都使用统一的地址空间时(请参阅统一虚拟地址空间),这可以使用设备内存中提到的常规内存拷贝函数来完成。否则,可以使用cudaMemcpyPeer(),cudaMemcpyPeerAsync(),cudaMemcpy3DPeer()或cudaMemcpy3DPeerAsync()完成此操作,如以下代码示例所示。
与流的正常行为一样,两个设备的内存之间的异步拷贝可能与另一个流中的拷贝或内核执行重叠。
请注意,如果如对等内存访问中所述通过cudaDeviceEnablePeerAccess()在两个设备之间启用了对等访问,则这两个设备之间的对等内存拷贝不再需要通过主机,因此更快。
当应用程序以64位进程运行时,主机和所有计算能力为2.0及更高版本的设备可以使用一个单独的地址空间。 通过CUDA API创建的所有主机内存以及受支持设备上的所有设备内存分配均在此虚拟地址范围内。 作为结果:
可以使用cudaPointerGetAttributes()从指针的值中确定通过CUDA分配的主机上的任何内存或使用统一地址空间的任何设备上的内存位置。
在使用统一地址空间的任何设备的内存中进行复制或复制时,可以将cudaMemcpy *()的cudaMemcpyKind参数设置为cudaMemcpyDefault,以确定指针指向的位置。 这也适用于未通过CUDA分配的主机指针,只要当前设备使用统一寻址。
通过cudaHostAlloc()的分配在所有使用统一地址空间的设备上是自动可移植的,并且由cudaHostAlloc()返回的指针可以直接从在这些设备上运行的内核中使用(即,不存在 需要通过cudaHostGetDevicePointer()获取设备指针,如Mapped Memory中所述
(以上小结翻译来自:https://www.pianshen.com/article/93541625116/)
应用程序可以通过检查UnifiedAddressing设备属性(请参阅设备枚举)是否等于1来查询统一地址空间是否用于特定设备。
(后续再看)
由主机线程创建的任何设备内存指针或事件句柄都可以由同一进程中的任何其他线程直接引用。 但是,它在此进程之外无效,因此不能被属于不同进程的线程直接引用。
要在进程之间共享设备内存指针和事件,应用程序必须使用进程间通信API,这在参考手册中有详细描述。 仅Linux上的64位进程以及计算能力为2.0及更高版本的设备才支持IPC API(进程间通信API)。 请注意,cudaMallocManaged分配不支持IPC API。
使用此API,应用程序可以使用cudaIpcGetMemHandle()获取给定设备内存指针的IPC句柄,使用标准IPC机制(例如,进程间共享内存或文件)将其传递到另一个进程,并使用cudaIpcOpenMemHandle() 检索设备。 来自IPC句柄的指针,它是此其他进程中的有效指针。 可以使用类似的入口点共享事件句柄。
请注意,出于性能原因,可以从较大的内存块中对cudaMalloc()进行的分配进行子分配。 在这种情况下,CUDA IPC API将共享整个基础内存块,这可能会导致其他子分配被共享,这有可能导致进程之间的信息泄露。 为避免这种情况,建议仅共享2MiB对齐大小的分配。
使用IPC API的一个示例是,单个主进程生成一批输入数据,使数据可用于多个次级进程而无需重新生成或复制。
使用CUDA IPC进行相互通信的应用程序应使用相同的CUDA驱动程序和运行时进行编译,链接和运行。
注意:Tegra设备不支持CUDA IPC调用。
所有运行时函数都返回一个错误代码,但是对于异步函数(请参见异步并发执行),该错误代码可能无法报告设备上可能发生的任何异步错误,因为该函数在设备完成任务之前就返回了; 错误代码仅报告执行任务之前主机上发生的错误,通常与参数验证有关; 如果发生异步错误,则随后的一些不相关的运行时函数调用将报告该错误。
因此,在某些异步函数调用之后检查异步错误的唯一方法是在调用之后立即通过调用cudaDeviceSynchronize()(或使用异步并发执行中描述的任何其他同步机制)并检查cudaDeviceSynchronize()返回的错误代码来进行同步 )。
运行时为每个主机线程维护一个错误变量,该变量初始化为cudaSuccess,并在每次发生错误时(无论是参数验证错误还是异步错误)均被错误代码覆盖。 cudaPeekAtLastError()返回此变量。 cudaGetLastError()返回此变量并将其重置为cudaSuccess。
内核启动不会返回任何错误代码,因此必须在内核启动后立即调用cudaPeekAtLastError()或cudaGetLastError()来检索任何启动前的错误。 为了确保cudaPeekAtLastError()或cudaGetLastError()返回的任何错误都不是内核启动之前的调用引起的,必须确保在内核启动之前将运行时错误变量设置为cudaSuccess,例如,通过调用 cudaGetLastError()在内核启动之前。 内核启动是异步的,因此要检查异步错误,应用程序必须在内核启动和对cudaPeekAtLastError()或cudaGetLastError()的调用之间进行同步。
请注意,cudaStreamQuery()和cudaEventQuery()返回的cudaErrorNotReady不会被视为错误,因此cudaPeekAtLastError()或cudaGetLastError()不会报告该错误。
在具有2.x和更高计算能力的设备上,可以使用cudaDeviceGetLimit()查询调用堆栈的大小,并使用cudaDeviceSetLimit()进行设置。当调用堆栈溢出时,如果通过CUDA调试器(cuda-gdb,Nsight)运行应用程序,则内核调用失败,并出现堆栈溢出错误,否则,调用失败。
CUDA支持纹理硬件的子集,GPU将其用于图形以访问texture和surface内存。 如设备内存访问中所述,从texture或surface内存而不是全局内存中读取数据可具有一些性能优势。
有两种不同的API可以访问纹理和表面内存:
(1)所有设备都支持的纹理引用API,
(2)仅具有3.x和更高计算能力的设备支持纹理对象API。
纹理引用API具有纹理对象API所没有的限制。
使用纹理函数中描述的设备函数从内核读取纹理内存。 读取这些函数中的一个纹理调用被叫做纹理获取(texture fetch), 每个纹理获取(texture fetch)都会为纹理对象API指定一个称为纹理对象的参数,或者为纹理引用API指定一个纹理引用。
纹理对象或纹理引用详细说明如下:
(1)纹理,即获取的纹理内存。 纹理对象在运行时创建,并且在创建纹理对象时指定了纹理。 纹理引用是在编译时创建的,然后通过运行时函数将纹理引用绑定到运行时指定的纹理上。 几个不同的纹理引用可能绑定到同一纹理或内存中重叠的纹理。 纹理可以是线性内存或CUDA阵列(在CUDA阵列中描述)的任何区域。
(2)它的维数指定使用一个纹理坐标将纹理寻址为一维数组,使用两个纹理坐标将二维数组寻址或使用三个纹理坐标将三维数组寻址。 数组的元素称为 texels,是纹理元素的缩写。 纹理的宽度,高度和深度是指每个维度中数组的大小。 表15列出了最大纹理宽度,高度和深度,具体取决于设备的计算能力。
(3)texels的类型,仅限于基本整数和单精度浮点类型,以及从基本类型派生的内置矢量类型中定义的任何1、2和4分量矢量类型 整数和单精度浮点类型。
(4)读取模式,有cudaReadModeNormalizedFloat或cudaReadModeElementType两种模式。 如果它是cudaReadModeNormalizedFloat,并且texel的类型是16位或8位整数类型,则纹理获取返回的值实际上将返回为浮点类型,无符号整数类型映射到[0.0 ,1.0],有符号整形映射到[-1.0,1.0], 例如,值为0xff的无符号8位纹理元素读取为1。如果为cudaReadModeElementType,则不执行任何转换。
(5)纹理坐标是否已标准化。 默认情况下,使用[0,N-1]范围内的浮点坐标来引用纹理(通过纹理函数的功能),其中N是维度中纹理的大小。 例如,尺寸为64x32的纹理将分别使用x和y尺寸在[0,63]和[0,31]范围内的坐标进行引用。 规范化的纹理坐标导致在[0.0,1.0-1 / N]范围内指定坐标,而不是在[0,N-1]范围内指定,因此相同的64x32纹理将通过范围在[0,1- 1 / N]在x和y维度上。 如果纹理坐标最好独立于纹理大小,则归一化纹理坐标自然适合某些应用程序的要求。
(6)寻址模式。超出坐标范围时,如何寻址。
(7)The filtering mode,纹理插值模式,如最邻近,线性等
通过如下结构体指定一个纹理对象。
接下来举了一个例子
纹理引用的声明如下:
使用纹理引用前要将其绑定到一个纹理,否则会导致不可以预料的行为。
CUDA阵列支持的16位浮点或半精度与IEEE 754-2008 binary2格式相同。
CUDA C ++不支持匹配的数据类型,但提供了内在函数,可通过无符号短类型__float2half_rn(float)和__half2float(unsigned short)与32位浮点格式进行相互转换。 这些功能仅在设备代码中受支持。 例如,可以在OpenEXR库中找到主机代码的等效功能。
在执行任何过滤之前,纹理获取期间会将16位浮点分量提升为32位浮点。
可以通过调用cudaCreateChannelDescHalf *()函数之一来创建16位浮点格式的通道描述。
一维或二维分层纹理(在Direct3D中也称为纹理数组,在OpenGL中也称为数组纹理)是由一系列层组成的纹理,所有这些层都是尺寸,大小和数据类型相同的常规纹理 。
一维分层纹理是通过整数索引和浮点纹理坐标来进行寻址的,索引表示序列中的一个图层,坐标表示该图层中的纹理坐标。 使用整数索引和两个浮点纹理坐标来对二维分层纹理进行寻址。 索引表示序列中的层序号,浮点坐标表示该层中的纹理坐标。
通过使用cudaArrayLayered标志(对于一维分层纹理,高度为零)调用cudaMalloc3DArray(),分层纹理只能是CUDA数组。
使用tex1DLayered(),tex1DLayered(),tex2DLayered()和tex2DLayered()中描述的设备函数获取分层纹理。 纹理filter(请参阅纹理提取)仅在一个层中完成,而不是跨层完成。
分层纹理仅在计算能力为2.0或更高的设备上受支持。
立方体贴图纹理是一种特殊的二维分层纹理,具有六层,分别代表立方体的面:
(1)层的宽度等于其高度。
(2)使用三个纹理坐标x,y和z寻址立方体贴图,如表2所示,采样方法如下,对于(x,y,z)三个变量,检查这三个变量中哪个绝对值最大,如果x是最大的,那么说明采样在x方向的面中,但具体使用哪个面,由x的正负号决定,剩余的两个分量在找到的面中使用二维坐标进行采样。(此处并未按照原文翻译,如果此处没看明白,可以自行百度 立方体纹理坐标采样,很多介绍Opengl的文章都会提及这部分知识)
通过使用cudaArrayCubemap标志调用cudaMalloc3DArray(),分层纹理只能是CUDA Aarray 。
使用texCubemap()和texCubemap()中描述的设备函数来获取Cubemap纹理。
多维数据集贴图纹理仅在计算能力为2.0及更高版本的设备上受支持。
texture gather是一种特殊的纹理提取,仅适用于二维纹理。 它由tex2Dgather()函数执行,该函数具有与tex2D()相同的参数,外加一个comp参数,值等于0、1、2或3的附加(comp值标识选用通道中的第几个值)。即对在常规纹理采样期间用于双线性滤波的四个纹理像素的每个像素抽取comp决定的通道值,然后返回四个32位数, 例如,四个纹理像素值为(253、20、31、255),(250、25、29、254),(249、16、37、253),(251、22、30、250),并且 comp参数值2,那么tex2Dgather()返回(31,29,37,30)。
texture gather时由于纹理坐标精度带来的问题。
仅使用cudaArrayTextureGather标志创建的CUDA数组支持texture gather,并且CUDA数组的宽度和高度小于表15中为texture gather指定的最大值,该值小于常规纹理获取的最大值。
仅在计算能力为2.0或更高的设备上支持纹理收集。
对于计算能力为2.0或更高的设备,可以使用“表面函数”中描述的函数通过表面对象或表面引用来读取和写入使用cudaArraySurfaceLoadStore标志创建的CUDA Array。
使用cudaCreateSurfaceObject()从类型为struct cudaResourceDesc的资源描述中创建表面对象。
表面引用在文件范围内声明为表面类型的变量:
与纹理存储器不同,表面存储器使用字节寻址。 这意味着用于通过纹理函数访问纹理元素的x坐标需要乘以元素的字节大小才能通过表面函数访问同一元素。 例如,使用tex1d(texRef,x)通过texRef读取绑定到纹理参考texRef和表面参考surfRef的一维浮点CUDA数组的纹理坐标x处的元素,但是使用surf1Dread(surfRef,4 * x) )通过surfRef。 同样,绑定到纹理引用texRef和表面引用surfRef的二维浮点CUDA数组的纹理坐标x和y处的元素通过texRef使用tex2d(texRef,x,y)访问,但是surf2Dread(surfRef, 4 * x,y)通过surfRef(y坐标的字节偏移量是从CUDA数组的基础行距内部计算得出的)。
使用surfCubemapread()和surfCubemapwrite()(surfCubemapread和surfCubemapwrite)作为二维分层表面(即,使用表示一个面的整数索引和两个浮点纹理坐标来访问对应于该面的层中的纹理像素)访问Cubemap曲面 。
Cubemap layered surfaces的访问。
CUDA Array是为纹理获取而优化的opaque memory(维基百科有对其更详细的描述)格式。 它们是一维,二维或三维的,由元素组成,每个元素具有1、2或4个可以带符号或无符号的8位,16位或32位整数,16位浮点数, 或32位浮点数。 内核只能通过如纹理存储器中所述的纹理获取或如表面存储器中所述的表面读写来访问CUDA Array。
纹理和表面内存被缓存(请参阅设备内存访问),并且在同一内核调用中,该缓存在全局内存写入和表面内存写入方面未保持一致,因此任何在同一内核访问取由先前该核通过全局写入或表面写入生成的地址将返回未定义的数据, 换句话说,仅当该存储位置已由先前的kernel call或 memory copy更新时,线程才能安全地读取某些纹理或表面存储位置,但如果被来自同一个kernel call的相同线程或其他线程在先前更新,则该线程无法安全地读取该纹理或表面存储位置 内核调用。
图形学方面关于opengl以及directx与cuda之间的交互,暂时先不看
兼容性描述,一图足以说明
默认计算模式:多个主机线程可以同时使用该设备(通过使用运行时API时,可以在此设备上调用cudaSetDevice(),或者在使用驱动程序API时,通过使当前上下文成为与设备关联的上下文)。
独占进程计算模式:跨系统中所有进程的设备上只能创建一个CUDA上下文。The context may be current to as many threads as desired within the process that created that context.
禁止的计算模式:无法在设备上创建CUDA上下文。
其他略
对于具有显示功能的GPU,画面切换会导致用于显示功能的内存需求增加,则系统可能不得不取消专用于CUDA应用程序的内存分配。 因此,模式切换会导致对CUDA运行时的任何调用失败并返回无效的上下文错误。
使用NVIDIA的系统管理借口(nvidia-smi),可以将Windows设备驱动程序置于Tesla模式和Quadro系列计算能力2.0或更高版本的设备的TCC(特斯拉计算集群)模式下。
此模式具有以下主要优点:
(1)这样就可以在具有非NVIDIA集成显卡的群集节点中使用这些GPU。
(2)它使这些GPU既可以通过远程桌面直接使用,也可以通过依赖于远程桌面的群集管理系统使用。
(3)它使这些GPU可用于作为Windows服务运行的应用程序(即在会话0中)。
但是,TCC模式删除了对任何图形功能的支持。
NVIDIA GPU体系结构是围绕可扩展的多线程流多处理器(SM)阵列构建的。 当主机CPU上的CUDA程序调用内核网格时,将枚举网格的block并将其分配给具有可用执行能力的SM。 线程块的线程在一个多处理器上同时执行,而多个线程块可以在一个多处理器上同时执行。 随着线程块的终止,新的块将在腾出的多处理器上启动。
多处理器旨在同时执行数百个线程。 为了管理大量线程,它采用了一种称为SIMT(单指令多线程)的独特体系结构,该体系结构在SIMT体系结构中进行了描述。 指令是流水线的,利用单个线程内的指令级并行性,以及通过同时进行的硬件多线程进行扩展的线程级并行性。 与CPU内核不同,它们是按顺序发布,没有分支预测或推测性执行。
SIMT体系结构和硬件多线程描述了所有设备共有的流式多处理器的体系结构功能。 计算能力3.x,计算能力5.x,计算能力6.x和计算能力7.x分别提供了计算能力3.x,5.x,6.x和7.x的设备的详细信息。
NVIDIA GPU架构使用小端表示。
SM以32个并行线程为一组,创建,管理,调度和执行线程,称为“wrap”。 组成线程束的各个线程从同一程序地址一起开始,但是它们具有自己的指令地址计数器和寄存器状态,因此可以自由分支和独立执行。 术语“warp”起源于编织,这是第一种并行线程技术。half-warp是经线的前半部分或后半部分。 quarter-warp是wrap的第一,第二,第三或第四部分。
当给SM一个或多个线程块来执行时,它将多个线程块分成多个warp,并且每个warp由warp调度程序调度以执行。 块划分为wrap的方式始终相同; 每个warp包含连续的,递增的线程ID的线程,而第一个warp包含线程0。ThreadHierarchy描述了线程ID与块中线程索引的关系。
Warp一次执行一条通用指令,因此,当Warp的所有32个线程都在其执行路径上达成一致时,可以实现最高的效率。 如果warp的线程通过依赖于数据的条件分支发散,则warp将执行路径上的所有分支,不在该路径上的线程将被disable。 分支发散仅在wrap内发生; 不管它们执行的是通用还是不相交的代码路径,不同的warp都将独立执行。
SIMT体系结构类似于SIMD(单指令,多数据)矢量组织,因为一条指令可控制多个处理元素。 一个主要区别是SIMD向量组织将SIMD宽度公开给软件,而SIMT指令指定单个线程的执行和分支行为。 与SIMD向量机相反,SIMT使程序员能够为独立的标量线程编写线程级并行代码,并为协调线程编写数据并行代码。 为了正确起见,程序员实际上可以忽略SIMT行为。 但是,如果注意减少wrap中的线程发散,可以显著提高性能。 在实践中,这类似于传统代码中的缓存行的作用:缓存行的大小在设计正确性时可以安全地忽略,但在设计峰值性能时必须在代码结构中加以考虑。 另一方面,矢量架构要求该软件将负载合并为矢量并手动管理差异。
在Volta之前,warp使用了在warp中所有32个线程之间共享的单个程序计数器,以及指定了warp的活动线程的活动掩码。 这导致了,同一个wrap中的来自不同区域或不同执行状态的的线程无法互相发信号或交换数据。由锁或互斥锁保护的细粒度共享数据算法很容易导致死锁,取决与竞争线程来自于哪个wrap。
从Volta架构开始,独立线程调度允许线程之间完全并发,而无需考虑wrap。 借助独立线程调度,GPU可以维护每个线程的执行状态,包括程序计数器和调用堆栈,并且可以按每个线程的粒度生成执行结果,以更好地利用执行资源或允许一个线程等待另一个线程生成的数据。 调度优化器确定如何将来自同一wrap的活动线程组合到SIMT单元中。 与以前的NVIDIA GPU一样,这保留了SIMT执行的高吞吐量,但具有更大的灵活性:线程现在可以以次扭曲粒度分散和重新收敛。
如果开发人员对先前硬件体系结构的warp-synchronicity2进行了假设,那么独立的线程调度可能导致参与执行的代码的线程集与预期的完全不同。 特别是,应重新审视warp-synchronous 代码(例如,synchronization-free, intra-warp reductions),以确保与Volta及更高版本的兼容性。 有关更多详细信息,请参见计算能力7.x。
参与当前指令的wrap线程被称为活动线程,而不在当前指令上的线程则处于非活动状态(禁用)。 线程可能由于各种原因而处于非活动状态,包括比wrap中的其他线程更早退出,采用与wrap当前执行的分支路径不同的路径,或者 being the last threads of a block whose number of threads is not a multiple of the warp size.
如果wrap中不止一个线程对全局内存的同一个位置进行非原子写入操作,那么被序列化写入该位置的次数根据device的不同的计算能力而变化,那个县城执行最后的写入操作时未知的。
如果wrap中不止一个线程对全局内存的同一个位置进行读取、修改、写入等原子操作,那么他们都会被序列化,但他们发生的顺序是不确定的。
由SM处理的每个wrap的执行上下文(程序计数器,寄存器等)在wrap的整个生命周期内都保持在芯片上。 因此,从一个执行上下文切换到另一个执行上下文是没有代价的,并且在每个指令发布时间,warp调度程序都会选择一个具有准备好执行其下一条指令的线程的warp(warp的活动线程),然后将指令发布给那些线程 。
特别是,每个SM都具有一组分配给Warp的32位寄存器,一个在并行数据高速缓存或者在线程块之间分配的共享内存。对于给定内核,可以在多处理器上驻留并一起处理的块和wrap的数量取决于内核使用的寄存器和共享内存的数量以及多处理器上可用的寄存器和共享内存的数量。 每个多处理器还具有最大数量的驻留块和最大数量的驻留线程。 这些限制以及多处理器上可用的寄存器和共享内存的数量是设备计算能力的函数,并在附录计算能力中给出。 如果每个多处理器没有足够的寄存器或共享内存来处理至少一个block,则内核将无法启动。
块中的wrap总数如下:
ceil ( T /Wsize , 1 )(原文的公式有问题)
(1)T是每个块的线程数,
(2)Wsize是wrap大小,等于32,
(3)ceil(x,y)等于x,四舍五入到y的最接近倍数。
性能优化围绕三个基本策略:
(1)最大化并行执行以实现最大利用率;
(2)优化内存使用率以实现最大的内存吞吐量;
(3)优化指令使用率以实现最大指令吞吐量。
为每个处理器分配最擅长的工作类型:主机实现串行工作负载; 设备实现并行工作负载。
在较低的级别,应用程序应最大化设备的SM之间的并行执行。
在更低的级别上,应用程序应最大化SM内各个功能单元之间的并行执行。
如硬件多线程中所述,GPU多处理器主要依靠线程级并行性来最大化其功能单元的利用率。 因此,利用率与常驻Wrap数量直接相关。 在每个指令发布时间,warp调度程序都会选择准备执行的指令。 该指令可以是利用来自同一warp的 指令集级并行的的另一条独立指令,或更常见的是来自另一warp的线程级并行的指令。如果选择了准备执行的指令,则会将其发布到wrap的活动线程。
warp准备好执行下一条指令所需的时钟周期数称为latency,当所有warp调度程序在该latency时间周期内的每个时钟周期中始终有某些可以发送给warp的指令时,就可获得了最大利用率, 或者换句话说,就是在等待时间完全“隐藏”时获得了最大利用率。 隐藏L个时钟周期的等待时间所需的指令数量取决于这些指令各自的吞吐量(有关各种算术指令的吞吐量,请参见算术指令)。 如果我们假设指令具有最大吞吐量,则等于:
warp尚未准备好执行其下一条指令的最常见原因是该指令的输入操作对象尚不可用。
如果所有输入操作对象都是寄存器,则延迟是由寄存器相关性引起的,即某些输入操作对象是由一些尚未完成执行的先前指令写入的。 在这种情况下,等待时间等于前一条指令的执行时间,并且warp调度程序必须在该时间内调度其他warp的指令。 执行时间因指令而异。 在具有计算能力7.x的设备上,对于大多数算术指令,通常为4个时钟周期。 这意味着每个SM需要16个活动的warp(4个周期,4个warp调度程序)来隐藏算术指令等待时间(假设warp以最大的吞吐量执行指令,否则需要较少的warp)。 如果各个warp表现出指令级并行性,即在其指令流中有多个独立的指令,则需要较少的warp,因为可以将单个warp的多个独立指令背对背发出。
如果某些输入操作数驻留在片外存储器中,则等待时间会更长:通常为数百个时钟周期。 在如此高的延迟时间内保持warp调度程序繁忙所需的warp数量取决于内核代码及其指令级并行度。 通常,如果没有片外存储器操作数的指令数量(即大部分时间为算术指令)与具有片外存储器操作数的指令数量之比低(通常该比率通常是 称为程序的算术强度),则需要更多的warp.
warp没有准备好执行其下一条指令的另一个原因是它正处于序列化执行(Memory Fence功能)或同步点(Memory Fence功能)等待中。 同步点可以迫使多处理器处于空闲状态,因为越来越多的线程束等待同一块中的其他线程束在同步点之前完成指令的执行。 在这种情况下,每个多处理器具有多个驻留块可以帮助减少空闲,因为来自不同块的warp不需要在同步点彼此等待。
给定内核调用中每个SM上驻留的块和线程数取决于调用的执行配置(执行配置),多处理器的内存资源以及硬件多线程中描述的内核资源需求。 使用-ptxas-options = -v选项进行编译时,编译器将报告寄存器和共享内存的使用情况。
一个块所需的共享内存总量等于静态分配的共享内存量与动态分配的共享内存量之和。
内核使用的寄存器数量可能会对驻留的数量产生重大影响。 例如,对于计算能力为6.x的设备,如果内核使用64个寄存器,并且每个块具有512个线程并且需要很少的共享内存,则两个块(即32个warp)可以驻留在多处理器上,因为它们需要2x512x64寄存器 ,它与多处理器上可用的寄存器数量完全匹配。 但是一旦内核再使用一个寄存器,就只能驻留一个块(即16个warp),因为两个块需要2x512x65寄存器,这比多处理器上可用的寄存器更多。 因此,编译器尝试将寄存器使用率降至最低,同时保持寄存器溢出(请参见设备内存访问)和指令数量降至最少。 可以使用maxrregcount编译器选项或启动边界(如启动范围中所述)来控制寄存器的使用。寄存器文件被组织为32位寄存器。 因此,存储在寄存器中的每个变量至少需要一个32位寄存器,例如: 一个double 变量使用两个32位寄存器。每个块的线程数应选择为warp大小的倍数,以避免稀疏warp造成的计算资源浪费。
存在几种API函数,可以帮助程序员根据寄存器和共享内存的需求选择线程块的大小。
占用计算API cudaOccupancyMaxActiveBlocksPerMultiprocessor可基于内核的块大小和共享内存使用情况提供占用预测。 该功能根据每个SM的并发线程块数报告占用率。
请注意,此值可以转换为其他指标。 乘以每个块的waro数会得出每个多处理器的并发warp数。 通过将并发wap数除以每个多处理器的最大warp数,可以得出占用百分比。
基于占用率的启动配置器API cudaOccupancyMaxPotentialBlockSize和cudaOccupancyMaxPotentialBlockSizeVariableSMem启发式地计算实现最大多处理器级占用率的执行配置。
下面的代码示例计算MyKernel的占用率。 然后,它报告占用率以及每个多处理器的并发warp数与最大扭曲数之比。
以下代码示例根据用户输入,配置了MyKernel的occupancy-based kernel launch。
CUDA工具包还为不依赖CUDA软件堆栈的任何用例提供了一个自文档的独立占用计算器,并在
最大化应用程序整体内存吞吐量的第一步是最小化具有低带宽的数据传输。
这意味着将主机和设备之间的数据传输减到最少,如主机和设备之间的数据传输中所述,因为它们的带宽比全局内存和设备之间的数据传输低得多。
这也意味着通过最大程度地利用片上存储器:共享内存和缓存(即计算能力为2.x和更高版本的设备上可用的L1缓存和L2缓存,以及在所有设备上可以使用的纹理缓存和恒定缓存)来最大程度地减少全局内存和设备之间的数据传输。
共享内存等效于用户管理的缓存:应用程序显式分配和访问它。 如CUDA运行时所示,一种典型的编程模式是将来自设备存储器的数据暂存到共享存储器中。 换句话说,让一个块的每个线程:
(1)将数据从设备内存加载到共享内存,
(2)与该块的所有其他线程同步,以便每个线程可以安全地读取由不同线程填充的共享内存位置,
(3)处理共享内存中的数据,
(4)如有必要,请再次进行同步,以确保共享内存已用结果更新,
(5)将结果写回设备存储器。
内核对存储器的访问吞吐量可能会因每种类型的存储器的访问模式而变化一个数量级。 因此,最大化内存吞吐量的下一步是根据“设备内存访问”中描述的最佳内存访问模式,尽可能最佳地组织内存访问。 这种优化对于全局存储器访问尤为重要,因为与可用的片上带宽和算术指令吞吐量相比,全局存储器带宽较低,因此非最佳的全局存储器访问通常会对性能产生重大影响。
应用程序应努力减少主机和设备之间的数据传输。 一种实现此目的的方法是将更多代码从主机移至设备,即使这意味着运行的内核的并行性可能有些低效。 中间数据结构可以在设备内存中创建,由设备操作并销毁,而无需主机映射或复制到主机内存。
而且,由于与每个传输相关的开销,将许多小传输批量成一个大传输总是比分别进行每个传输更好。
在具有前端总线的系统上,如页锁定主机内存中所述,通过使用页锁定主机内存可以实现主机与设备之间更高的数据传输性能。
此外,使用映射的页面锁定内存(“映射内存”)时,无需分配任何设备内存,也无需在设备和主机内存之间显式复制数据。 每次内核访问映射的内存时,都会隐式执行数据传输。 为了获得最佳性能,必须将这些内存访问与对全局内存的访问合并在一起(请参阅设备内存访问)。 假设它们是正确的,并且映射的内存只能读取或写入一次,则使用映射的页锁内存而不是在设备和主机内存之间进行显式复制可以提高性能。
在设备内存和主机内存在物理上相同的集成系统上,主机和设备内存之间的任何副本都是多余的,应改用映射的页面锁定内存。 应用程序可以通过检查集成设备属性(请参阅设备枚举)等于1来查询集成设备。
一个指令可能需要重新发出多次访问可寻址内存(即全局,本地,共享,常量或纹理内存),具体取决于线程中线程之间内存地址的分布。 分布如何以这种方式影响指令吞吐量特定于每种类型的存储器,并将在以下各节中进行介绍。 例如,对于全局存储器,通常,地址越分散,吞吐量就越降低。
Global Memory
全局内存位于设备内存中,并且可以通过32、64或128字节的内存transactions问设备内存。 这些内存transactions必须自然对齐:内存只能读取或写入与其大小对齐的32、64或128字节的设备内存段(即,其首地址是其大小的倍数)
当warp执行访问全局内存的指令时,它会根据每个线程访问的字的大小以及整个内存中的内存地址分布,将warp中线程的内存访问合并为一个或多个内存transactions 。 通常,如果使用了比较多的transactions,那会传输比较多的为使用字节,从而相应地降低了指令吞吐量。 例如,32字节的内存transaction分成了每个线程的4字节访问,那么吞吐量将除以8。
所需的事务数量以及最终影响的吞吐量取决于设备的计算能力。 计算能力3.x,计算能力5.x,计算能力6.x,计算能力7.x和计算能力8.x提供了有关如何处理各种计算功能的全局内存访问的更多详细信息。
为了最大化全局内存吞吐量,因此重要的是要通过以下方式最大化合并:
(1)遵循基于计算能力3.x,计算能力5.x,计算能力6.x,计算能力7.x和计算能力8.x的最佳访问模式。
(2)使用满足以下“大小和对齐要求”部分中详细说明的大小和对齐要求的数据类型,
(3)在某些情况下,例如,当访问二维数组时,如下面的“二维数组”部分所述,Padding Data 。
Size and Alignment Requirement
全局内存指令支持读取,写入大小等于1、2、4、8或16个字节的字。 当且仅当数据类型的大小为1、2、4、8或16个字节并且自然对齐时(即,其地址是该大小的倍数),对全局内存中数据的任何访问(通过变量或指针)都会编译为单个全局内存指令。
如果未满足此大小和对齐要求,则该访问将编译为具有交错访问模式的多个指令,从而阻止这些指令完全合并。 因此,建议对驻留在全局内存中的数据使用符合此要求的类型。
内置向量类型会自动满足对齐要求。
对于结构,编译器可以使用对齐说明符__align __(8)或__align __(16)来强制执行大小和对齐要求,例如
读取非自然对齐的8字节或16字节字会产生错误的结果(偏移几个字),因此必须特别注意保持这些类型的任何值或值数组的起始地址对齐。 一个可能容易被忽略的典型情况是,使用某些自定义全局内存分配方案时,多个数组的分配(对cudaMalloc()或cuMemAlloc()的多次调用)被单个大内存块的分配所取代,划分为多个数组,在这种情况下,每个数组的起始地址都偏离块的起始地址。
Two-Dimensional Arrays
一种常见的全局内存访问模式是,当索引(tx,ty)的每个线程使用以下地址访问宽度为2D的2D数组的一个元素时,该元素位于类型type *的地址BaseAddress上(其中类型满足Maximize中描述的Maximize Utilization) :
为了使这些访问完全合并,线程块的宽度和数组的宽度都必须是warp大小的倍数。
特别是,这意味着,如果宽度实际不是warp大小倍数的数组,实际分配的宽度舍入为该大小的最接近倍数,并相应地填充其行,则将可以更有效地访问该数组。 参考手册中介绍的cudaMallocPitch()和cuMemAllocPitch()函数以及相关的内存复制函数使程序员能够编写与硬件无关的代码来分配符合这些约束的数组。
Local Memory
本地内存访问仅针对某些自动变量。 编译器可能放置在本地内存中的自动变量是:
(1)无法确定为其定序索引的数组,
(2)大型结构或阵列会占用过多的寄存器空间,
(3)任何如果内核使用的寄存器多于可用寄存器的变量,(这也称为寄存器溢出)。
检查PTX汇编代码(通过使用-ptx或-keep选项进行编译获得)可得知在第一个编译阶段是否已将变量放置在本地内存中,因为将使用.local助记符声明该变量并使用 .local和st.local助记符访问该变量。 即使没有,随后的编译阶段发现目标体系结构消耗了过多的寄存器空间,后续的编译阶段仍可能做出其他决定:使用cuobjdump对cubin对象进行检查将确定是否是这种情况。 另外,使用--ptxas-options = -v选项进行编译时,编译器会报告每个内核(lmem)的本地内存总使用量。 请注意,某些数学函数的实现路径可能会访问本地内存。
本地内存空间位于设备内存中,因此本地内存访问具有与全局内存访问相同的高延迟和低带宽,并且要遵守与设备内存访问中所述的内存合并相同的要求。本地内存以连续的32字节进行了组织,通过连续的线程ID进行访问。 因此,只要warp中的所有线程都访问相同的相对地址(例如,数组变量中的相同索引,结构变量中的相同成员),访问就会完全合并。
在某些具有计算能力3.x的设备上,本地内存访问总是以与全局内存访问相同的方式缓存在L1和L2中(请参阅计算能力3.x)。
在具有计算能力5.x和6.x的设备上,本地内存访问总是以与全局内存访问相同的方式缓存在L2中(请参阅计算能力5.x和计算能力6.x)。
Shared Memory
共享内存访问,讲的最明白的还是《Professional CUDA C Programming》,此节暂不翻译,尤其是bank confilct部分。
Constant Memory
常量内存空间驻留在设备内存中,并缓存在常量缓存中。
然后,将一个请求拆分为与初始请求中存在不同内存地址的数量一样多的单独请求,从而将吞吐量降低为等同于单独请求数量的倍数。
然后,在发生高速缓存命中的情况下,以恒定高速缓存的吞吐量最终结果,否则以设备内存的吞吐量为最终结果。
Texture and Surface Memory
纹理和表面存储空间驻留在设备内存中,并缓存在纹理缓存中,因此纹理获取或表面读取仅在一次高速缓存未命中时花费一个从设备内存读取的内存,否则仅花费一个从纹理缓存中读取的内存。 纹理缓存针对2D空间局部性进行了优化,因此读取2D中紧靠在一起的纹理或表面地址的同一warp线程将获得最佳性能。 此外,它还设计用于具有恒定延迟的流式获取。 高速缓存命中可以减少DRAM带宽需求,但不能减少获取延迟。
通过纹理或表面读取来读取设备内存具有一些好处,可以使其成为从全局或常量内存读取设备内存的有利替代方案:
(1)如果存储器读取不遵循全局或恒定存储器读取必须遵循的访问模式才能获得良好的性能,则可以提供更高的带宽,前提是纹理读取或表面读取中存在局部性;
(2)Addressing calculations are performed outside the kernel by dedicated units;
(3)打包的数据可以在单个操作中广播到分开的变量中;
(4)可以选择将8位和16位整数输入数据转换为[0.0,1.0]或[-1.0,1.0]范围内的32位浮点值(请参见纹理内存)。
为了使指令吞吐量最大化,应用程序应该:
(1)最少使用低吞吐量的算术指令; 这包括在不影响最终结果的情况下为了速度而进行的精度降低,例如使用内在函数而不是常规函数,单精度而不是双精度或将非正规数设置为零;
(2)Minimize divergent warps caused by control flow instructions as detailed in Control Flow Instructions
(3)减少指令的数量,例如,通过尽可能地优化同步点(如同步指令中所述)或使用受限指针(如__restrict__中所述)来减少指令数量。
在本节中,吞吐量以每个SM每个时钟周期的操作数给出。 对于32的warp,一条指令对应32个操作,因此,如果N是每个时钟周期的操作数,则指令吞吐量为每个时钟周期N / 32指令。
所有吞吐量都是针对一个SM的。 必须将它们乘以设备中多处理器的数量才能获得整个设备的吞吐量。
首先给出一张不同计算能力设备的内部指令表。
其他指令和函数在本机指令之上实现。 对于具有不同计算能力的设备,其实现方式可能会有所不同,并且编译后的本机指令数量可能会随每个编译器版本而变化。 对于复杂的功能,取决于输入,可以有多个代码路径。 cuobjdump可用于检查cubin对象中的特定实现。
一些功能的实现在CUDA头文件(math_functions.h,device_functions.h,...)上很容易获得。
通常,与-ftz = false编译的代码相比,使用-ftz = true编译的代码(将非规格化的数字刷新为零)具有更高的性能。 同样,使用-prec div = false(精度较低的除法)编译的代码往往比使用-prec div = true编译的代码具有更高的性能代码,而使用-prec-sqrt = false(精度较低的平方根)编译的代码往往具有更高的性能代码。 比使用-prec-sqrt = true编译的代码具有更高的性能。 nvcc用户手册更详细地描述了这些编译标志。
Single-Precision Floating-Point Division
__fdividef(x,y)(请参阅本征函数)比除算符提供了更快的单精度浮点除法。
Single-Precision Floating-Point Reciprocal Square Root
为了保留IEEE-754语义,仅当倒数和平方根都近似时(即-prec-div = false和-prec-sqrt = false),编译器才能将1.0 / sqrtf()优化为rsqrtf()。 因此,建议在需要的地方直接调用rsqrtf()。
Single-Precision Floating-Point Square Root
单精度浮点平方根的实现形式是倒数平方根,然后是倒数,而不是倒数平方根,然后是乘法,这样可以得出0和无穷大的正确结果。
Sine and Cosine
sinf(x),cosf(x),tanf(x),sincosf(x)和相应的双精度指令要昂贵得多,而且如果参数x的大小较大,则价格甚至更高。
更准确地说,自变量减少代码(请参见实现的数学函数)包括两个代码路径,分别称为快速路径和慢速路径。
快速路径用于大小足够小的自变量,并且基本上由一些乘法加法运算组成。 慢速路径用于幅度较大的自变量,由冗长的计算组成,以在整个自变量范围内获得正确的结果。
目前,用于三角函数的自变量约简代码为单精度函数的量值小于105615.0f,对于双精度函数的量值小于2147483648.0的变量选择快速路径。
由于慢速路径需要比快速路径更多的寄存器,因此尝试通过在本地存储器中存储一些中间变量来减少慢速路径中的寄存器压力,这可能会由于本地存储器高延迟和高带宽而影响性能(请参阅设备内存)。 访问)。 目前,单精度功能使用28字节的本地存储器,双精度功能使用44字节。 但是,确切的数量可能会发生变化。
由于冗长的计算和在慢速路径中使用本地内存,当需要慢速路径缩减而不是快速路径缩减时,这些三角函数的吞吐量降低了一个数量级。
Integer Arithmetic
整数除法和模运算的成本很高,因为它们最多可编译20条指令。 在某些情况下,可以将它们替换为按位运算:如果n为2的幂,则(i / n)等于(i >> log2(n)),并且(i%n)等于(i&(n- 1)); 如果n是立即数,则编译器将执行这些转换。
__brev和__popc映射到单个指令,而__brevll和__popcll映射到几个指令。
__ [u] mul24是旧有的内在函数,不再有任何理由要使用。
Half Precision Arithmetic
为了获得16位精度浮点加,乘或乘加的良好性能,建议将Half2数据类型用于半精度,而将__nv_bfloat162用于__nv_bfloat16精度。 然后可以使用向量内在函数(例如__hadd2,__ hsub2,__ hmul2,__ hfma2)在一条指令中执行两项操作。 使用half2或__nv_bfloat162代替使用half或__nv_bfloat16的两个调用也可能有助于其他内部函数的性能,例如扭曲混洗。
提供内在的__halves2half2可以将两个半精度值转换为half2数据类型。
提供固有的__halves2bfloat162可以将两个__nv_bfloat精度值转换为__nv_bfloat162数据类型。
Type Conversion
有时,编译器必须插入转换指令,从而引入其他执行周期。 这种情况适用于:
(1)对char或short类型的变量进行操作的函数,其操作数通常需要转换为int,
(2)双精度浮点常量(即那些没有任何类型后缀的常量)用作单精度浮点计算的输入(由C / C ++标准规定)。
(3)可以通过使用单精度浮点常量(用3.141592653589793f,1.0f,0.5f等后缀f定义)来避免最后一种情况。
任何流控制指令(if, switch, do, for, while)都会导致相同warp的线程发散(即遵循不同的执行路径),从而极大地影响有效指令吞吐量。 如果发生这种情况,则必须序列化不同的执行路径,从而增加了为此warp执行的指令总数。
为了在控制流取决于线程ID的情况下获得最佳性能,应编写控制条件,以最大程度地减少发散扭曲的数量。 这是可能的,因为在整个块中warp的分布是确定的,如SIMT体系结构中所述。 一个简单的例子是,控制条件仅取决于(threadIdx / warpSize),其中warpSize是warp曲大小。 在这种情况下,由于控制条件与warp完全对准,因此没有warp发散。
有时,编译器可能会展开循环,或者可能会改用分支谓词来优化short if或switch块,如下所述。 在这些情 况下,任何warp都不会发散。 程序员还可以使用#pragma unroll指令控制循环的展开(请参阅#pragma展开)。
使用分支谓词时,其执行取决于控制条件的指令均不会被跳过。 相反,它们中的每一个都与基于控制条件且根据执行条件排定的每个线程条件代码或谓词相关联,该代码或谓词基于控制条件而设置为true或false,并且尽管调度了这些指令中的每条指令以执行,但实际上仅执行具有真实谓词的指令。 带有错误谓词的指令不会写入结果,也不会求值地址或读取操作数。
__syncthreads()的吞吐量对于计算能力为3.x的设备是每个时钟周期128个操作,对于计算能力为6.0的设备是每个时钟周期32个操作,对于计算能力7.x和8的设备是每个时钟周期16个操作。 对于具有计算能力5.x,6.1和6.2的设备,每个时钟周期x和64个操作。
请注意,__ syncthreads()可能会通过强制多处理器进入空闲状态来影响性能,如设备内存访问中所述。