5.1_主机内存
5.1 主机内存
在CUDA中,主机内存指的是系统中可被CPU访问的内存。默认来说,这个内存是可换页的,意味着操作系统可移动该内存或换出到磁盘。因为可换页内存的物理内存位置可能在不被察觉的情况下改变,它不可以被如GPU这样的外设访问。为了让硬件使用DMA,操作系统允许主机内存进行页锁定,并且因为性能原因,CUDA包含了开发者使用这些操作系统工具的API。页锁定后的且映射为CUDA直接访问的锁页内存允许以下几点:
·更快的传输性能。
· 异步内存复制(即:在必要的内存复制结束之前内存复制返回控制给调用者;GPU复制操作与CPU并行地执行。)。
映射锁页内存可以被CUDA内核直接访问。
因为可换页内存的虚拟地址到物理地址的映射可能变化无常,所以GPU根本不能访问可换页内存。CUDA为了能够复制可换页内存中数据,会使用一对中转缓冲区,这对缓冲区是锁页的,在CUDA上下文创建时由驱动程序分配。第6章介绍了手动编写的换页内存复制程序,其中使用CUDA事件进行为管理这个双缓冲区需要的同步。
5.1.1 分配锁页内存
锁页内存可以通过CUDA提供的特殊函数分配与释放:CUDA运行时中的CUDAHostAlloc()/CUDAFreeHost(),驱动程序API中的cuMemHostAlloc()/cuMemFreeHost()。这些函数同主机操作系统一同,分配锁页内存并对其进行映射使得GPU可以进行DMA操作。
CUDA会跟踪分配的内存并在内存复制中涉及使用cuMemHostAlloc()/cudaHostAlloc()分配的主机内存指针时,自动加速。除此之外,一些函数(尤其是异步内存复制函数)需要使用锁页内存。
SDK中的示例bandwithTest使开发者能够轻松的比较锁页内存与普通换页内存的性能。选项--memory=pinned使测试使用锁页内存替代可换页内存。表5-1列出了亚马逊EC2中一个cgl.4xlarge实例的bandwidthTest数值(单位MB/s),测试运行在Windows 7-x64上。分配锁页内存成本十分高昂,因为这项操作对主机来说包含了大量的工作,其中包含一个内核转换。
表5-1 锁页带宽与可换页带宽对比
CUDA 2.2添加了几个锁页内存的特性。可共享锁页内存可以被任何GPU访问;“映射锁页内存”被映射到CUDA地址空间中,以便CUDA内核直接访问;写结合锁页内存在一些系统上拥有更快的总线传输速度。CUDA 4.0同样添加了两个关于主机内存的重要的特性:已存在的主机内存区间可以使用主机内存注册操作将页面锁定,统一虚拟寻址(UVA)使所有的指针在进程内变得唯一,包括主机与设备指针。当UVA启用,系统可以通过地址区间推测出内存是设备内存还是主机内存。
5.1.2 可共享锁页内存
默认情况下,锁页内存分配只能由使用CUDAHostAlloc()或cuMemHostAlloc()的GPU访问。通过在函数CUDAHostAlloc()中指定CUDAHostAllocPortable标志,或在函数cuHostMemAlloc()中指定CU_MEMHOSTALLOC_PORTABLE,应用程序可以请求锁页分配映射给所有的GPU。可共享锁页内存能获利于前文描述的内存复制自动加速,并且可以参与到系统中任何GPU的异步内存复制操作中。对想要使用多GPU的应用程序,指定所有锁页分配为可共享的是一个非常好的方式。
注意 当统一虚拟寻址(UVA)有效,所有的锁页内存分配都是可共享的。
5.1.3 映射锁页内存
默认的,锁页内存分配被映射给CUDA地址空间外的GPU。它们可以被GPU直接访问,但仅仅是通过内存复制函数可以做到。CUDA内核不能直接读写主机内存。然而,在SM 1.2以及以上版本的GPU上,CUDA内核可以直接读写主机内存。只需分配的内存被映射到设备内存地址空间即可。
为了启用映射锁页内存分配,使用CUDA运行时的程序必须使用CUDADeviceMapHost标志调用CUDASetDeviceFlags()函数,这个调用必须在所有的初始化执行之前进行。驱动程序API则在函数cuCtxCreate()中指定CUCTX_MAP_HOST标志。
一旦映射锁页内存被启用,它就可以通过使用CUDAHostAllocMapped标志调用CUDAHostAlloc()函数,或者在函数cuMemHostAlloc()中使用标志CU_MEMALLOCHOST_DEVICEMAP来分配。除非UVA启用,应用程序随后必须使用函数CUDAHostGetDevicePointer()或cuMemHostGetDevicePointer()查询关联到所分配内存的设备指针。得到的设备指针可以传入到CUDA内核。使用映射锁页内存的最佳实践在本章“映射锁页内存用法”一节中描述。
5.1.4 写结合锁页内存
写结合内存,也称为非缓存预测的写结合(uncacheable speculative write combining,USWC)内存。创建写结合内存可以使CPU快速写入GPU帧缓冲区而不用污染CPU缓存[2]。为了实现这个目的,英特尔公司添加了新的页表种类,控制写入到特殊的写结合缓冲区而不是原来的主处理器缓存结构。之后,英特尔又添加了“非暂时”存储指令(即:MOVNTPS和MOVNTI),使应用程序以单个指令为单位控制写入到写结合缓冲区中。一般来说,需要使用内存栅栏指令(例如MFENCE)保持与WC内存的一致性。CUDA应用程序不需要这些操作,因为在CUDA驱动程序提交给硬件工作时这些操作都会被自动执行。
对CUDA来说,可以在调用CUDAHostAlloc()函数时使用CUDAHostWriteCombined标志,或者使用cuMemHostAlloc()函数和CU_MEMHOSTALLOC_WRITEECOMBINED标志请求写结合内存。除了设置页表入口绕过CPU缓存,这一内存同样不能在PCIe总线传输时被窥探(snoop)。在拥有前端总线的系统上(AMD皓龙处理器和英特尔微处理器),避免窥探提高了PCIe的传输性能。在NUMA(非一致内存访问)系统上写结合(WC)内存的性能优势微弱。
使用CPU读WC内存非常慢(大约比CPU读慢6倍),除非读操作由指令MOVNTDQA(在SSE4中新添加)完成。在英伟达集成GPU中,写结合内存与系统内存操作几乎同样快——这些系统内存在系统启动时间就被保留来为GPU使用,它们对CPU来说是不可用的。
尽管有这些前面提到的优势,只有很少的理由会使CUDA开发者选择写结合内存。指向写结合内存的主机内存指针很容易“泄露”到想要进行内存读操作的应用程序中。在缺少经验证据的情况下,这应当被避免。
注意 当统一虚拟寻址有效,写结合锁页内存分配不会被映射到统一地址空间中。
5.1.5 注册锁页内存
CUDA开发者不太有机会分配主机内存用于GPU直接访问。例如,一个大型的、可扩展应用程序可能有传递内存指针给CUDA插件的接口,或者应用程序可能出于与CUDA相同原因使用其他的API为其他的外设分配专用内存(特别是高速的网络)。为了应对这些使用情况,CUDA4.0添加了注册锁页内存能力。
锁页内存注册把内存分配与页锁定和主机内存映射分离开来。它可以操作一个已分配的虚拟地址范围,并页锁定它,然后,将其映射
给GPU。正如CUDAHostAlloc(),可根据需要让内存映射到CUDA地址空间或变成可共享的(所有GPU可访问)。
函数cuMemHostRegister()/cudaHostRegister()和cuMemHostUnregister()/cudaHostUnregister()分别实现把主机内存注册为锁页内存和移除注册的功能。注册的内存范围必须是页对齐的:换句话来说,无论是基地址还是大小,都必须是可被操作系统页面大小整除的。应用程序可以用两种方式分配页对齐地址范围:
·使用操作系统提供的工具遍历所有的页来分配内存,像Windows上的VirtualAlloc()或其他平台上的valloc()或mmap() [3]。
·给定一段任意的地址范围(例如,使用malloc()或操作符new[]分配内存),夹取内存范围到倒数第二页页边界并补齐下一个页面。
注意 即使启用UVA,已映射到CUDA地址空间的注册的锁页内存还是有着与主机指针不同的设备指针。应用程序必须调用CUDAHostGetDevicePointer()/cuMemHostGetDevice Pointer()以得到设备指针。
5.1.6 锁页内存与统一虚拟寻址
当UVA(统一虚拟寻址)有效,所有的锁页内存分配均是映射的和可共享的。这一规则的例外是写结合内存和注册的内存。对于这二者,设备指针可能不同于主机指针,并且应用程序仍然需要使用CUDAHostGetDevicePointer()/cuMemHostGetDevicePointer()函数查询其设备指针。
除了Windows Vista和Windows 7(Windows 8),UVA被所有的64位平台所支持。在Windows Vista和Windows 7上,只有TCC驱动程序(可以使用nvidia-smi启用或禁用)支持UVA。应用程序查询UVA是否启用有两种方法:一是可以使用函数cudaGetDeviceProperties()并检查cudaDeviceProp::unifiedAddressing结构体成员,二是调用函数cuDeviceAttributeValue()并使用参数CU_DEVICE_ATTRIBUTE_UNIFIED_ADDRESS。
5.1.7 映射锁页内存用法
对依赖于PCIe传输性能的应用程序,映射锁页内存是一个福音。由于GPU可以通过内核直接读写主机内存,它减少了一些内存复制的需求,减少了花销。这里是一些常见的使用映射锁页内存的情况:
·提交写入到主机内存:为了同其他的GPU进行交换,多GPU应用程序经常需要返回中转结果给系统内存;通过映射锁页内存写这些
结果避免了一次不必要的设备到主机间的内存复制。对主机内存的只写访问模式会很吸引人,因为它没有需要隐藏的延迟。
·使用流:这些工作负载在其他方面会使用CUDA流来协调,使设备与内存间的内存与内核的处理工作并发执行。
·“重叠复制”:有些负载会得益于在计算的同时通过PCIe传输数据。例如,GPU可以在为扫描而传输数据时执行子数组归约。
警示 映射锁页内存并不是万能的,这里有一些使用映射锁页内存应该小心的方面:
在映射锁页内存上进行纹理操作是可能的,但速度非常慢。
·使用合并内存事务访问映射锁页内存非常重要(参考5.2.9节)。未进行合并内存事务处理的访问时间会是处理后的 倍。但是即使是SM 2.x和之后的GPU,它们的缓存机制已经企图将合并处理替换掉,这项性能惩罚也是巨大的。
·不建议使用内核轮询主机内存(例如,进行CPU/GPU同步操作)。不要尝试在映射锁页内存上进行原子操作,对主机(locked compare-exchange,加锁的比较交换操作)或设备(atomicAdd())都是
如此。CPU中加锁操作执行互斥的工具,对PCIe总线上的外设来说,是不可见。反过来,对GPU,原子操作只在本地设备内存上工作,因为它们是使用GPU本地内存控制器实现的。
5.1.8 NUMA、线程亲和性与锁页内存
在AMD皓龙和英特尔系列处理器上,CPU内存控制器被直接集成进CPU。之前,内存被附加在芯片组中的北桥上的前端总线(FSB)上。在多CPU系统中,北桥可以为任何一个CPU的内存请求服务,并且CPU间的内存访问性能相当一致。随着集成内存控制器的引入,每一个CPU都拥有自己的专用内存池,专用内存池来自每一个直接附加在CPU上的“本地”物理内存。尽管任何CPU都可以访问任意其他的CPU内存——通过AMD的HyperTransport(HT)或Intel的QuickPath Interconnect(QPI),但这会招致延迟惩罚和带宽限制。相对于系统使用FSB的一致内存访问时间,这一系统架构称作NUMA,即非一致内存访问(nonuniform memory access)。
多线程应用程序的性能可能高度依赖于正在运行线程的CPU是否引用的是本地内存。无论如何,对大多数的应用程序,高额的非本地访问开销被CPU上的缓存所弥补。一旦非本地内存被读取进CPU,它会始终保留在缓存中,直到换出,或者其他CPU想要访问内存中的相同的页。事实上,启用系统BIOS选项,以便在CPU间交错物理内存,对NUMA
系统是很普遍的。当这个BIOS选项启用,内存在CPU上按缓存行大小(普遍为64位)为基础均匀划分。举个例子,在双CPU系统中,平均大约有 内存是非本地访问的。
对CUDA应用程序,PCIe传输性能依赖于内存引用是否为本地。如果系统有超过一个I/O集线器(IOH),给定IOH上附加的GPU在锁页内存是本地的情况下会有更好的性能表现,并且会减少QPI带宽的需求。因为一些高级的NUMA系统是分级的,但是没有严格的在CPU与内存带宽池间构成联系,NUMA API既适用于严格关联的节点,也适用于没有严格关联的节点上。
如果系统上的NUMA开启,对给定GPU在同一节点分配主机内存是一个好的做法,不幸的是,现在没有官方的CUDA API来为GPU连接一个给定的CPU。拥有系统设计经验的开发者可能会知道哪一个节点关联着哪一个GPU。之后,可以使用针对特定平台的NUMA感知的API(NUMA-aware API)分配内存,然后利用主机内存注册(参见5.1.5小节)锁定这些分配的虚拟内存,并且把它们映射给GPU。
代码清单5-1给出了Linux上的执行NUMA感知的内存分配的代码片段 [4],代码清单5-2给出了Windows上的代码片段 [5]。
代码清单5-1 NUMA感知的内存分配(Linux)
代码清单5-2 NUMA分配(Windows)
bool
numNodes(int \*p)
{ if(numa-available() $> = 0$ { \*p=numa_max_node()+1; return true; } return false;
}
void\*
pageAlignedNumaAlloc(size_t bytes,int node)
{ void \*ret; printf("Allocating on node %d\n",node); fflush(stdout); ret $\equiv$ numa_alloc_onnode(bytes,node); return ret;
}
void
pageAlignedNumaFree(void \*p,size_t bytes)
{ numa_free(p,bytes);bool
numNodes(int \*p)
{ ULONG maxNode; if(GetNumaHighestNodeNumber(&maxNode)) { \*p = (int) maxNode+1; return true; } return false;
}
void\* pageAlignedNumaAlloc(size_t bytes,int node)
{ void *ret; printf("Allocating on node %d\n",node); fflush(stdout); ret $=$ VirtualAllocExNumaGetCurrentProcess(), NULL, bytes, MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE, node); return ret;
}
void pageAlignedNumaFree(void \*p)
{ VirtualFreeEx(GetCurrentProcess(),p,0,MEM_RELEEASE);[1] 除了标记为写结合的内存之外。
[2] WC内存最初由英特尔公司在1997年提出,与加速图像端口(AGP)同时提出。在PCIe之前,AGP被使用在图像处理板卡上。
[3] 或者联合使用函数posix_memalign()和getpagesize()。
[4] http://bit.ly/USy4e7。
[5] http://bit.ly/XY1g8m。