注意:文中采用设备版本较老,重在设计思想
在本篇中,我们将开始关于何有效地在主机和设备之间传输数据的讨论。 设备内存和GPU之间的峰值带宽(例如,在NVIDIA Tesla C2050上为144 GB / s)比主机内存和设备内存之间的峰值带宽(在PCIe x16 Gen2上为8 GB / s)高得多。 这种差异意味着在主机和GPU设备之间进行数据传输的实现可能会影响或破坏整体应用程序性能。 让我们从有关主机设备数据传输的一些一般准则开始。
1.尽可能减少在主机和设备之间传输的数据量,即使这意味着与在主机CPU上运行相比,在GPU上运行内核的速度不快或没有提高。
2.使用页面锁定(或“固定”)内存时,主机和设备之间可能会有更高的带宽。将许多小型传输分批成一个较大的传输的性能要好得多,因为它消除了大多数每次传输的开销。
3.主机和设备之间的数据传输有时可能与内核函数执行和其他数据传输的时间重叠。
我们在这篇文章中研究了上面的前三个指南,并且在下一篇文章中专门讨论重叠的数据传输。 首先,我想谈谈如何在不修改源代码的情况下测量花费在数据传输上的时间。
使用nvprof测量数据传输时间
为了测量每次数据传输所花费的时间,我们可以记录每次传输之前和之后的CUDA事件,并使用cudaEventElapsedTime(),如我们在上一篇文章中所述。 但是,通过使用nvprof(CUDA工具包附带的命令行CUDA探查器)(从CUDA 5开始),无需使用CUDA事件来对源代码进行检测,就可以获得经过的传输时间。 让我们用下面的代码示例进行尝试。
int main()
{
const unsigned int N = 1048576;
const unsigned int bytes = N * sizeof(int);
int *h_a = (int*)malloc(bytes);
int *d_a;
cudaMalloc((int**)&d_a, bytes);
memset(h_a, 0, bytes);
cudaMemcpy(d_a, h_a, bytes, cudaMemcpyHostToDevice);
cudaMemcpy(h_a, d_a, bytes, cudaMemcpyDeviceToHost);
return 0;
}
要分析此代码,我们仅使用nvcc对其进行编译,然后使用程序文件名作为参数运行nvprof。
$ nvcc profile.cu -o profile_test
$ nvprof ./profile_test
当我在具有GeForce GTX 680(GK104 GPU,类似于Tesla K10)的台式机上运行时,得到以下输出:
$ nvprof ./a.out
======== NVPROF is profiling a.out...
======== Command: a.out
======== Profiling result:
Time(%) Time Calls Avg Min Max Name
50.08 718.11us 1 718.11us 718.11us 718.11us [CUDA memcpy DtoH]
49.92 715.94us 1 715.94us 715.94us 715.94us [CUDA memcpy HtoD]
上边为nvprof测量每个CUDA memcpy调用花费的时间。 它报告每次调用的平均时间,最短时间和最长时间(由于我们每个副本只运行一次,所以所有时间都相同)。 nvprof是一个非常灵活的工具,因此请确保您查看了相关文档。
nvprof是CUDA 5中的新增功能。如果使用的是CUDA的早期版本,则可以使用较旧的“命令行分析器”,如Greg Ruetsch在他的文章“如何在CUDA Fortran中优化数据传输”中所述。
最小化数据传输
我们不应该仅使用内核的GPU执行时间(相对于其CPU实现的执行时间)来决定采取GPU还是CPU版本的程序。 我们还需要考虑通过PCI-e总线移动数据的成本,特别是当我们最初将代码移植到CUDA时。 由于CUDA的异构编程模型同时使用CPU和GPU,因此可以一次将代码移植到CUDA一个内核。 在移植的初始阶段,数据传输可能会占据整个执行时间。 值得分别注意花费在数据传输上的时间和花费在内核执行上的时间。 正如我们已经演示的,使用命令行事件探查器很容易。 随着我们移植更多代码,我们将删除中间传输并相应地减少总体执行时间。
如图所示,固定内存用作从设备到主机传输的暂存区。 通过直接在固定内存中分配主机阵列,我们可以避免在可分页和固定主机阵列之间进行传输的开销。 使用cudaMallocHost()或cudaHostAlloc()在CUDA C / C ++中分配固定的主机内存,并使用cudaFreeHost()取消内存的分配。 固定的内存分配可能会失败,因此您应始终检查错误。 以下代码摘录演示了使用错误检查分配固定内存的方法。
cudaError_t status = cudaMallocHost((void**)&h_aPinned, bytes);
if (status != cudaSuccess)
printf("Error allocating pinned host memory\n");
使用主机固定内存进行的数据传输使用与可分页内存进行的传输相同的cudaMemcpy()语法。 我们可以使用下面的“ bandwidthtest”程序(在Github上也可以使用)比较可分页和固定的传输速率。
#include <stdio.h>
#include <assert.h>
// Convenience function for checking CUDA runtime API results
// can be wrapped around any runtime API call. No-op in release builds.
inline
cudaError_t checkCuda(cudaError_t result)
{
#if defined(DEBUG) || defined(_DEBUG)
if (result != cudaSuccess) {
fprintf(stderr, "CUDA Runtime Error: %s\n",
cudaGetErrorString(result));
assert(result == cudaSuccess);
}
#endif
return result;
}
void profileCopies(float *h_a,
float *h_b,
float *d,
unsigned int n,
char *desc)
{
printf("\n%s transfers\n", desc);
unsigned int bytes = n * sizeof(float);
// events for timing
cudaEvent_t startEvent, stopEvent;
checkCuda( cudaEventCreate(&startEvent) );
checkCuda( cudaEventCreate(&stopEvent) );
checkCuda( cudaEventRecord(startEvent, 0) );
checkCuda( cudaMemcpy(d, h_a, bytes, cudaMemcpyHostToDevice) );
checkCuda( cudaEventRecord(stopEvent, 0) );
checkCuda( cudaEventSynchronize(stopEvent) );
float time;
checkCuda( cudaEventElapsedTime(&time, startEvent, stopEvent) );
printf(" Host to Device bandwidth (GB/s): %f\n", bytes * 1e-6 / time);
checkCuda( cudaEventRecord(startEvent, 0) );
checkCuda( cudaMemcpy(h_b, d, bytes, cudaMemcpyDeviceToHost) );
checkCuda( cudaEventRecord(stopEvent, 0) );
checkCuda( cudaEventSynchronize(stopEvent) );
checkCuda( cudaEventElapsedTime(&time, startEvent, stopEvent) );
printf(" Device to Host bandwidth (GB/s): %f\n", bytes * 1e-6 / time);
for (int i = 0; i < n; ++i) {
if (h_a[i] != h_b[i]) {
printf("*** %s transfers failed ***\n", desc);
break;
}
}
// clean up events
checkCuda( cudaEventDestroy(startEvent) );
checkCuda( cudaEventDestroy(stopEvent) );
}
int main()
{
unsigned int nElements = 4*1024*1024;
const unsigned int bytes = nElements * sizeof(float);
// host arrays
float *h_aPageable, *h_bPageable;
float *h_aPinned, *h_bPinned;
// device array
float *d_a;
// allocate and initialize
数据传输速率取决于主机系统(主板,CPU和芯片组)以及GPU的类型。 在我的笔记本电脑上,该笔记本电脑具有Intel Core i7-2620M CPU(2.7GHz,2个Sandy Bridge内核,4MB L3缓存)和NVIDIA NVS 4200M GPU(1个Fermi SM,Compute Capability 2.1,PCI-e Gen2 x16),并运行BandwidthTest 以下结果。 如您所见,固定传输的速度是可分页传输速度的两倍以上。
Device: NVS 4200M
Transfer size (MB): 16
Pageable transfers
Host to Device bandwidth (GB/s): 2.308439
Device to Host bandwidth (GB/s): 2.316220
Pinned transfers
Host to Device bandwidth (GB/s): 5.774224
Device to Host bandwidth (GB/s): 5.958834
在具有更快Intel Core i7-3930K CPU(3.2 GHz,6 Sandy Bridge内核,12MB L3缓存)和NVIDIA GeForce GTX 680 GPU(8 Kepler SM,Compute Capability 3.0)的台式机上,我们看到可分页的传输要快得多, 如以下输出所示。 据推测这是因为更快的CPU(和芯片组)降低了主机端内存复制成本。
Device: GeForce GTX 680
Transfer size (MB): 16
Pageable transfers
Host to Device bandwidth (GB/s): 5.368503
Device to Host bandwidth (GB/s): 5.627219
Pinned transfers
Host to Device bandwidth (GB/s): 6.186581
Device to Host bandwidth (GB/s): 6.670246
您不应过多分配固定的内存。 这样做会降低整体系统性能,因为它会减少可用于操作系统和其他程序的物理内存量。 内存空间的花销是很难事先告知的,因此,对于所有优化,请测试您的应用程序和它们在其上运行的系统以获得最佳性能参数。
批量传输数据
由于与每个传输相关的开销,因此最好将许多小传输分批处理成单个传输。 这可以通过使用临时阵列(最好是固定的)并将其与要传输的数据打包在一起来轻松实现。
对于二维数组传输,可以使用cudaMemcpy2D()。
cudaMemcpy2D(dest, dest_pitch, src, src_pitch, w, h, cudaMemcpyHostToDevice)
函数的参数包括第一个目标元素和目标数组的间距的指针,一个指向第一个源元素和源数组的间距,要传递的子矩阵的宽度和高度以及memcpy类型的指针。 同时还可以使用cudaMemcpy3D()函数用于传输三维数组。
总结
主机和设备之间的传输是GPU计算中涉及的数据移动的最慢地方,因此您应注意尽量减少设备间的数据传输。遵循这篇文章中的指导原则可以帮助您确保什么是必要的传输。当修改或编写新的CUDA C / C ++代码时,建议您从现有主机指针的可分页传输开始。正如我之前提到的,随着编写更多设备代码,您将消除一些中间传输,因此在移植早期花在优化传输上的所有精力都可能会浪费掉。另外,建议您不要使用带有CUDA事件或其他计时器的仪器代码来测量每次传输所花费的时间,我建议您使用nvprof,命令行CUDA分析器或一种视觉分析工具,例如NVIDIA Visual Profiler(还包括CUDA工具包)。