0
  • 聊天消息
  • 系统消息
  • 评论与回复
登录后你可以
  • 下载海量资料
  • 学习在线课程
  • 观看技术视频
  • 写文章/发帖/加入社区
会员中心
创作中心

完善资料让更多小伙伴认识你,还能领取20积分哦,立即完善>

3天内不再提示

使用NVIDIA CUDA流顺序内存分配器

星星科技指导员 ? 来源:NVIDIA ? 作者:NVIDIA ? 2022-04-21 15:39 ? 次阅读
加入交流群
微信小助手二维码

扫码添加小助手

加入工程师交流群

大多数 CUDA 开发人员都熟悉 cudaMalloc 和 cudaFree API 函数来分配 GPU 可访问内存。然而,这些 API 函数长期以来一直存在一个障碍:它们不是按流排序的。在本文中,我们将介绍新的 API 函数 cudaMallocAsync 和 cudaFreeAsync ,它们使内存分配和释放成为流式有序操作。

在 本系列的第 2 部分 中,我们通过共享一些大数据基准测试结果来强调这一新功能的好处,并为修改现有应用程序提供代码 MIG 定量指南。我们还介绍了在多 GPU 访问和 IPC 使用环境中利用流顺序内存分配的高级主题。这一切都有助于提高现有应用程序的性能。

流排序效率

下面左边的代码示例效率低下,因为第一个 cudaFree 调用必须等待 kernelA 完成,所以它会在释放内存之前同步设备。为了提高运行效率,可以预先分配内存,并将其调整为两种大小中的较大值,如右图所示。

cudaMalloc(&ptrA, sizeA);
kernelA<<<..., stream>>>(ptrA);
cudaFree(ptrA); // Synchronizes the
device before freeing memory
cudaMalloc(&ptrB, sizeB);
kernelB<<<..., stream>>>(ptrB);
cudaFree(ptrB);
cudaMalloc(&ptr,   max(sizeA, sizeB));
kernelA<<<...,   stream>>>(ptr);
kernelB<<<...,   stream>>>(ptr);
cudaFree(ptr); 

这增加了应用程序中的代码复杂性,因为内存管理代码与业务逻辑分离。当涉及到其他图书馆时,问题就更加严重了。例如,考虑kernelA由库函数启动的情况,而不是:

libraryFuncA(stream);
cudaMalloc(&ptrB, sizeB);
kernelB<<<..., stream>>>(ptrB);
cudaFree(ptrB);
  
void libraryFuncA(cudaStream_t stream) {
    cudaMalloc(&ptrA, sizeA);
    kernelA<<<..., stream>>>(ptrA);
    cudaFree(ptrA);
 } 

这对于应用程序来说要提高效率要困难得多,因为它可能无法完全查看或控制库正在执行的操作。为了避免这个问题,库必须在第一次调用该函数时分配内存,并且在库被取消初始化之前永远不会释放内存。这不仅增加了代码的复杂性,而且还会导致库占用内存的时间超过需要的时间,从而可能会阻止应用程序的另一部分使用该内存。

有些应用程序通过实现自己的自定义分配器,进一步提前分配内存。这为应用程序开发增加了大量复杂性。 CUDA 旨在提供一种低工作量、高性能的替代方案。

CUDA 11 。 2 引入了流式有序内存分配器来解决这些类型的问题,并添加了 cudaMallocAsync 和 cudaFreeAsync 。这些新的 API 函数将内存分配从同步整个设备的全局作用域操作转移到流顺序操作,从而使您能够将内存管理与 GPU 工作提交结合起来。这消除了同步未完成 GPU 工作的需要,并有助于将分配的生命周期限制为访问它的 GPU 工作。考虑下面的代码示例:

cudaMallocAsync(&ptrA, sizeA, stream);
kernelA<<<..., stream>>>(ptrA);
cudaFreeAsync(ptrA, stream); // No synchronization necessary
cudaMallocAsync(&ptrB, sizeB, stream); // Can reuse the memory freed previously
kernelB<<<..., stream>>>(ptrB);
cudaFreeAsync(ptrB, stream); 

现在可以在函数范围内管理内存,如下面启动kernelA的库函数示例所示。

libraryFuncA(stream);
cudaMallocAsync(&ptrB, sizeB, stream); // Can reuse the memory freed by the library call
kernelB<<<..., stream>>>(ptrB);
cudaFreeAsync(ptrB, stream);
  
void libraryFuncA(cudaStream_t stream) {
    cudaMallocAsync(&ptrA, sizeA, stream);
    kernelA<<<..., stream>>>(ptrA);
    cudaFreeAsync(ptrA, stream); // No synchronization necessary
} 

流有序分配语义

所有常用的流排序规则都适用于 cudaMallocAsync 和 cudaFreeAsync 。从 cudaMallocAsync 返回的内存可以被任何内核或 memcpy 操作访问,只要内核或 memcpy 被命令在分配操作之后和解除分配操作之前以流顺序执行。解除分配可以在任何流中执行,只要命令在分配操作之后以及在 GPU 上对该内存的所有流进行所有访问之后执行。

实际上,流顺序分配的行为就像分配和自由是内核一样。如果 kernelA 在流上生成有效缓冲区,并且 kernelB 在同一流上使其无效,则应用程序可以按照适当的流顺序在 kernelA 之后和 kernelB 之前自由访问缓冲区。

下面的示例显示了各种有效用法。

auto err = cudaMallocAsync(&ptr, size, streamA);
// If cudaMallocAsync completes successfully, ptr is guaranteed to be
// a valid pointer to memory that can be accessed in stream order
  
assert(err == cudaSuccess);
  
// Work launched in the same stream can access the memory because
// operations within a stream are serialized by definition
  
kernel<<<..., streamA>>>(ptr);
  
// Work launched in another stream can access the memory as long as
// the appropriate dependencies are added
  
cudaEventRecord(event, streamA);
cudaStreamWaitEvent(streamB, event, 0);
kernel<<<..., streamB>>>(ptr);


// Synchronizing the stream at a point beyond the allocation operation
// also enables any stream to access the memory
  
cudaEventSynchronize(event);
kernel<<<..., streamC>>>(ptr);
  
// Deallocation requires joining all the accessing streams. Here,
// streamD will be deallocating.
// Adding an event dependency on streamB ensures that all accesses in
// streamB will be done before the deallocation
  
cudaEventRecord(event, streamB);
cudaStreamWaitEvent(streamD, event, 0);
  
// Synchronizing streamC also ensures that all its accesses are done before
// the deallocation
  
cudaStreamSynchronize(streamC);
cudaFreeAsync(ptr, streamD); 

图 1 显示了在前面的代码示例中指定的各种依赖关系。如您所见,所有内核都被命令在分配操作之后执行,并在释放操作之前完成。

Figure showing how to correctly access memory allocated using cudaMallocAsync.

图 1 在流之间插入依赖关系的各种方法,以确保访问使用 cudaMallocAsync.

内存分配和释放不能异步失败。由于调用 cudaMallocAsync 或 cudaFreeAsync (例如,内存不足)而发生的内存错误会通过调用返回的错误代码立即报告。如果 cudaMallocAsync 成功完成,则返回的指针将保证是指向内存的有效指针,可以按照适当的流顺序安全访问。

err = cudaMallocAsync(&ptr, size, stream);
if (err != cudaSuccess) {
    return err;
}
// Now you’re guaranteed that ‘ptr’ is valid when the kernel executes on stream
kernel<<<..., stream>>>(ptr);
cudaFreeAsync(ptr, stream); 

CUDA 驱动程序使用内存池实现立即返回指针的行为。

内存池

流顺序内存分配器将 存储池 的概念引入 CUDA 。内存池是以前分配的内存的集合,可以重新用于将来的分配。在 CUDA 中,池由 cudaMemPool_t 句柄表示。每个设备都有一个默认池的概念,可以使用 cudaDeviceGetDefaultMemPool 查询其句柄。

您还可以显式创建自己的池,直接使用它们,或者将它们设置为设备的当前池,并间接使用它们。创建显式池的原因包括自定义配置,如本文后面所述。当没有显式创建的池被设置为设备的当前池时,默认池将充当当前池。

在没有显式池参数的情况下调用 cudaMallocAsync 时,每次调用都会从指定的流推断设备,并尝试从该设备的当前池分配内存。如果池内存不足, CUDA 驱动程序将调用操作系统以分配更多内存。对 cudaFreeAsync 的每次调用都会将内存返回到池中,然后可在后续 cudaMallocAsync 请求中重新使用该内存。池由 CUDA 驱动程序管理,这意味着应用程序可以在多个库之间实现池共享,而无需这些库相互协调。

如果使用 cudaMallocAsync 发出的内存分配请求由于相应内存池的碎片而无法提供服务, CUDA 驱动程序通过将池中未使用的内存重新映射到 GPU 虚拟地址空间的连续部分来对池进行碎片整理。重新映射现有池内存而不是从操作系统分配新内存也有助于降低应用程序的内存占用。

默认情况下,在事件、流或设备上的下一次同步操作期间,池中累积的未使用内存将返回到操作系统,如下面的代码示例所示。

cudaMallocAsync(ptr1, size1, stream); // Allocates new memory into the pool
kernel<<<..., stream>>>(ptr);
cudaFreeAsync(ptr1, stream); // Frees memory back to the pool
cudaMallocAsync(ptr2, size2, stream); // Allocates existing memory from the pool
kernel<<<..., stream>>>(ptr2);
cudaFreeAsync(ptr2, stream); // Frees memory back to the pool
cudaDeviceSynchronize(); // Frees unused memory accumulated in the pool back to the OS
// Note: cudaStreamSynchronize(stream) achieves the same effect here 

在池中保留内存

在某些情况下,将内存从池返回到系统可能会影响性能。考虑下面的代码示例:

for (int i = 0; i < 100; i++) {
    cudaMallocAsync(&ptr, size, stream);
    kernel<<<..., stream>>>(ptr);
    cudaFreeAsync(ptr, stream);
    cudaStreamSynchronize(stream);
}

默认情况下,流同步会导致与该流的设备关联的任何池将所有未使用的内存释放回系统。在本例中,这将在每次迭代结束时发生。因此,没有内存可供下次 cudaMallocAsync 调用重用,而必须通过昂贵的系统调用来分配内存。

为了避免这种昂贵的重新分配,应用程序可以配置一个释放阈值,以使未使用的内存在同步操作之后保持不变。释放阈值指定池缓存的最大内存量。在同步操作期间,它会将所有多余的内存释放回操作系统。

默认情况下,池的释放阈值为零。这意味着池中使用的内存在每次同步操作期间都会释放回操作系统。下面的代码示例演示如何更改释放阈值。

cudaMemPool_t mempool;
cudaDeviceGetDefaultMemPool(&mempool, device);
uint64_t threshold = UINT64_MAX;
cudaMemPoolSetAttribute(mempool, cudaMemPoolAttrReleaseThreshold, &threshold);
for (int i = 0; i < 100; i++) {
    cudaMallocAsync(&ptr, size, stream);
    kernel<<<..., stream>>>(ptr);
    cudaFreeAsync(ptr, stream);
    cudaStreamSynchronize(stream);    // Only releases memory down to “threshold” bytes
} 

使用非零释放阈值可以从一个迭代到下一个迭代重用内存。这只需要简单的簿记,并使 cudaMallocAsync 的性能独立于分配的大小,从而显著提高了内存分配性能(图 2 )。

Figure showing differences in cost of memory allocation with and without a release threshold.

图 2 使用 cudaMallocAsync 设置和不设置释放阈值(与 0 。 4MB 性能相关的所有值,阈值分配) 。

池阈值只是一个提示。在相同的内存池中[0]可以隐式释放内存分配,以使内存分配成功。例如,对 cudaMalloc 或 cuMemCreate 的调用可能会导致 CUDA 从与同一进程中的设备关联的任何内存池中释放未使用的内存来为请求提供服务

这在应用程序使用多个库的情况下尤其有用,其中一些库使用 cudaMallocAsync ,而另一些库不使用 cudaMallocAsync 。通过自动释放未使用的池内存,这些库不必相互协调以使各自的分配请求成功。

CUDA 驱动程序自动将内存从池重新分配给不相关的分配请求时存在限制。例如,应用程序可能使用不同的接口(如 Vulkan 或 DirectX )来访问 GPU ,或者可能有多个进程同时使用 GPU 。这些上下文中的内存分配请求不会自动释放未使用的池内存。在这种情况下,应用程序可能必须通过调用 cudaMemPoolTrimTo 显式释放池中未使用的内存。

size_t bytesToKeep = 0;
cudaMemPoolTrimTo(mempool, bytesToKeep); 

bytesToKeep 参数告诉 CUDA 驱动程序它可以在池中保留多少字节。任何超过该大小的未使用内存都会释放回操作系统。

通过内存重用提高性能

cudaMallocAsync 和 cudaFreeAsync 的 stream 参数有助于 CUDA 高效地重用内存,避免对操作系统进行昂贵的调用。考虑下面的琐碎代码示例。

cudaMallocAsync(&ptr1, size1, stream);
kernelA<<<..., stream>>>(ptr1);
cudaFreeAsync(ptr1, stream);
cudaMallocAsync(&ptr2, size2, stream);
kernelB<<<..., stream>>>(ptr2); 

Figure showing how memory can be reused within a stream.

图 3 同一流中的内存重用 。

在这个代码示例中, ptr2 是在 ptr1 被释放后按流顺序分配的。 ptr2 分配可以重用用于 ptr1 的部分或全部内存,而无需任何同步,因为 kernelA 和 kernelB 在同一个流中启动。因此,流排序语义保证 kernelB 在 kernelA 完成之前不能开始执行和访问内存。通过这种方式, CUDA 驱动程序可以帮助降低应用程序的内存占用,同时提高分配性能。

CUDA 驱动程序还可以跟踪通过 CUDA 事件插入的流之间的依赖关系,如以下代码示例所示:

cudaMallocAsync(&ptr1, size1, streamA);
kernelA<<<..., streamA>>>(ptr1);
cudaFreeAsync(ptr1, streamA);
cudaEventRecord(event, streamA);
cudaStreamWaitEvent(streamB, event, 0);
cudaMallocAsync(&ptr2, size2, streamB);
kernelB<<<..., streamB>>>(ptr2); 

Figure showing how memory can be reused across dependent streams.

图 4 跨流的内存重用,它们之间有事件依赖关系 。

由于 CUDA 驱动程序知道流 A 和 B 之间的依赖关系,因此它可以重用 ptr1 为 ptr2 使用的内存。流 A 和 B 之间的依赖关系链可以包含任意数量的流,如下面的代码示例所示。

cudaMallocAsync(&ptr1, size1, streamA);
kernelA<<<..., streamA>>>(ptr1);
cudaFreeAsync(ptr1, streamA);
cudaEventRecord(event, streamA);
for (int i = 0; i < 100; i++) {
    cudaStreamWaitEvent(streams[i], event, 0);       // streams[] is a previously created array of streams
    cudaEventRecord(event, streams[i]);
}
cudaStreamWaitEvent(streamB, event, 0);
cudaMallocAsync(&ptr2, size2, streamB);
kernelB<<<..., streamB>>>(ptr2); 

如有必要,应用程序可以基于每个池禁用此功能:

int enable = 0;
cudaMemPoolSetAttribute(mempool, cudaMemPoolReuseFollowEventDependencies, &enable); 

CUDA 驱动程序还可以在没有应用程序指定的显式依赖项的情况下,有机会重用内存。虽然这种启发式方法可能有助于提高性能或避免内存分配失败,但它们会给应用程序增加不确定性,因此可以在每个池的基础上禁用。考虑下面的代码示例:

cudaMallocAsync(&ptr1, size1, streamA);
kernelA<<<..., streamA>>>(ptr1);
cudaFreeAsync(ptr1);
cudaMallocAsync(&ptr2, size2, streamB);
kernelB<<<..., streamB>>>(ptr2);
cudaFreeAsync(ptr2); 

在此场景中, streamA 和 streamB 之间没有明确的依赖关系。但是, CUDA 驱动程序知道每个流执行了多远。如果在第二次调用 streamB 中的 cudaMallocAsync 时, CUDA 驱动程序确定 kernelA 已在 GPU 上完成执行,则它可以重用 ptr1 用于 ptr2 的部分或全部内存。

Figure showing how memory can be reused opportunistically across streams.

图 5 跨流的机会主义内存重用。

如果 kernelA 尚未完成执行, CUDA 驱动程序可以在两个流之间添加隐式依赖项,以便 kernelB 在 kernelA 完成之前不会开始执行。

Figure showing how memory can be reused across streams through implicit dependencies added by the CUDA driver.

图 6 通过内部依赖关系重用内存 。

应用程序可以按如下方式禁用这些启发式:

int enable = 0;
cudaMemPoolSetAttribute(mempool, cudaMemPoolReuseAllowOpportunistic, &enable);
cudaMemPoolSetAttribute(mempool, cudaMemPoolReuseAllowInternalDependencies, &enable); 

概括

在本系列的第 1 部分中,我们介绍了新的 API 函数 cudaMallocAsync 和 cudaFreeAsync ,这两个函数使内存分配和释放成为流顺序操作。使用它们可以避免通过 CUDA 驱动程序维护的内存池对操作系统进行昂贵的调用。

在 本系列的第 2 部分 中,我们分享了一些基准测试结果,以展示流顺序内存分配的好处。我们还提供了一个逐步修改现有应用程序的方法,以充分利用此高级 CUDA 功能。

关于作者

Vivek Kini 是 NVIDIA 的高级系统软件工程师。他致力于 CUDA 驱动程序,特别关注内存管理功能。他旨在简化 CUDA 应用程序的内存管理,而不牺牲它们所需的性能。

Jake Hemstad 是一个高级开发工程师 NVIDIA ,他在开发高性能 CUDA C ++软件加速数据分析。他同样关心开发高质量的软件,正如他实现最佳的 GPU 性能一样,也是现代 C ++设计的倡导者。在 NVIDIA 之前,他参加了明尼苏达大学的研究生院,在那里他与桑迪亚国家实验室在任务并行 HPC 运行时间和稀疏线性求解器上工作。

审核编辑:郭婷

声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉
  • NVIDIA
    +关注

    关注

    14

    文章

    5363

    浏览量

    106908
  • CUDA
    +关注

    关注

    0

    文章

    124

    浏览量

    14189
收藏 人收藏
加入交流群
微信小助手二维码

扫码添加小助手

加入工程师交流群

    评论

    相关推荐
    热点推荐

    低损耗双向功率分配器/合路器 2.2–2.8 GHz skyworksinc

    电子发烧友网为你提供()低损耗双向功率分配器/合路器 2.2–2.8 GHz相关产品参数、数据手册,更有低损耗双向功率分配器/合路器 2.2–2.8 GHz的引脚图、接线图、封装手册、中文资料、英文
    发表于 07-30 18:34
    低损耗双向功率<b class='flag-5'>分配器</b>/合路器 2.2–2.8 GHz skyworksinc

    五路有源功率分配器 skyworksinc

    电子发烧友网为你提供()五路有源功率分配器相关产品参数、数据手册,更有五路有源功率分配器的引脚图、接线图、封装手册、中文资料、英文资料,五路有源功率分配器真值表,五路有源功率分配器管脚
    发表于 07-30 18:33
    五路有源功率<b class='flag-5'>分配器</b> skyworksinc

    九航星达KS-DVI0104型4通道DVI分配器使用手册

    电子发烧友网站提供《九航星达KS-DVI0104型4通道DVI分配器使用手册.doc》资料免费下载
    发表于 07-21 14:45 ?0次下载

    九航星达KS-DVI0102型2通道DVI分配器使用手册

    电子发烧友网站提供《九航星达KS-DVI0102型2通道DVI分配器使用手册.doc》资料免费下载
    发表于 07-16 17:35 ?0次下载

    802-4-0.252,N型母头功率分配器/合路器MECA

    802-4-0.252,N型母头功率分配器/合路器MECA802-4-0.252是一款由MECA生产的N型母头射频功率分配器/合路器,802-4-0.252功率分配器/合路器平均额定功率为2瓦,频率
    发表于 05-27 08:51

    MAX9174/MAX9175 670MHz、LVDS至LVDS和任意逻辑至LVDS 1:2分配器中文手册

    MAX9174/MAX9175是670MHz低抖动、低扭曲的1:2分配器,尤其适合于保护切换、环回、时钟和数据分配。这些器件具有1.0ps~(RMS)~ (最大)的超低随机抖动,保证在那些定时误差极为敏感的高速链路中可靠工作。
    的头像 发表于 05-19 09:23 ?331次阅读
    MAX9174/MAX9175 670MHz、LVDS至LVDS和任意逻辑至LVDS 1:2<b class='flag-5'>分配器</b>中文手册

    golang内存分配

    作者:钱文 Go 的分配采用了类似 tcmalloc 的结构.特点: 使用一小块一小块的连续内存页, 进行分配某个范围大小的内存需求. 比如某个连续 8KB 专门用于
    的头像 发表于 03-31 15:00 ?245次阅读
    golang<b class='flag-5'>内存</b><b class='flag-5'>分配</b>

    使用NVIDIA CUDA-X库加速科学和工程发展

    NVIDIA GTC 全球 AI 大会上宣布,开发者现在可以通过 CUDA-X 与新一代超级芯片架构的协同,实现 CPU 和 GPU 资源间深度自动化整合与调度,相较于传统加速计算架构,该技术可使计算工程工具运行速度提升至原来的 11 倍,计算规模增加至 5 倍。
    的头像 发表于 03-25 15:11 ?822次阅读

    PS2-88,PS2-88/NF功率分配器MCLI

    PS2-88,PS2-88/NF功率分配器MCLIPS2-88功率分配器是MCLI品牌推出的一款高性能射频微波器件,属于PS2系列2路功率分配器。PS2-88功率分配器是一款高性能的射
    发表于 03-20 09:31

    PS2-185/NF带状线2路电源分配器

    PS2-185/NF带状线2路电源分配器PS2-185/NF带状线2路电源分配器具备高可靠性,通过不同种类的结构(如带状线、微带和集总器件方式)来适合各种需求和应用。主要特性电气性能频率范围
    发表于 01-08 09:23

    英迈质谱分配器:精准控制,引领质谱分析新高度

    在质谱分析这一精密科学领域,流体的精准输送对于获取高质量数据至关重要。为了满足这一严苛需求,Instrumax(英迈仪器)凭借其在流体控制领域的深厚积累,推出了全新的质谱分配器。 这款质谱
    的头像 发表于 12-26 14:14 ?538次阅读

    画面分割器和视频分配器有何区别

    画面分割器和视频分配器是两种不同的视频处理设备,它们在视频监控系统中扮演着不同的角色。 1. 画面分割器 画面分割器,又称为视频分割器或多画面处理器,是一种可以将多个视频信号合并到一个显示设备上
    的头像 发表于 10-17 09:27 ?1654次阅读

    HDMI分配器与转换器的选择与配置:连接多个设备或延长信号距离的必备工具

    HDMI分配器和转换器是两种常用的视频信号管理设备,它们在家庭影院、会议室、监控系统等多种场合中发挥着重要作用。了解它们的特点和配置方法可以帮助用户更好地选择合适的设备,以满足特定的视频传输需求
    的头像 发表于 09-26 10:12 ?1107次阅读

    转载 golang内存分配

    Go 的分配采用了类似 tcmalloc 的结构.特点: 使用一小块一小块的连续内存页, 进行分配某个范围大小的内存需求. 比如某个连续 8KB 专门用于
    的头像 发表于 09-05 14:12 ?555次阅读
    转载 golang<b class='flag-5'>内存</b><b class='flag-5'>分配</b>

    CDCL1810A 1.8V、10 输出高性能时钟分配器数据表

    电子发烧友网站提供《CDCL1810A 1.8V、10 输出高性能时钟分配器数据表.pdf》资料免费下载
    发表于 08-23 10:08 ?0次下载
    CDCL1810A 1.8V、10 输出高性能时钟<b class='flag-5'>分配器</b>数据表