内存形式
静态全局内存
#include<stdlib.h>
#include<stdio.h>
__device__ float devData; //- GPU静态变量(所有设备代码均可见,主机代码不允许直接访问)
__global__ void checkGlobalVariable()
{
printf("threadIdx.x=%d devData = %0.2f\n", threadIdx.x, devData);
devData += 2.0f;
}
int main(int argc, char **argv)
{
float init_value = 3.14f;
//- 注意传递参数是devData,而不是地址,不需要指定拷贝方向,因为函数名已经明确了
cudaMemcpyToSymbol(devData, &init_value, sizeof(float));
dim3 block(3);
dim3 grid(2);
checkGlobalVariable<<<grid, block>>>();
cudaDeviceSynchronize();
//- 注意传递参数是devData,而不是地址,不需要指定拷贝方向,因为函数名已经明确了
cudaMemcpyFromSymbol(&init_value, devData, sizeof(float));
printf("devData=%0.2f\n", init_value);
return 0;
}
[mmhe@k231 chapter4]$ ./test
threadIdx.x=0 devData = 3.14
threadIdx.x=1 devData = 3.14
threadIdx.x=2 devData = 3.14
threadIdx.x=0 devData = 3.14
threadIdx.x=1 devData = 3.14
threadIdx.x=2 devData = 3.14
devData=5.14
- 可以看到每个线程输出的结果都是3.14,而最终主机输出的结果是5.14,显示只加和了1次。这是因为访问冲突导致的,所有的thread首先加载到静态变量的值(均为3.14),然后执行加2操作之后(结果为5.14),将结果存储到静态变量中,因此最终结果为5.14.如果想要解决这个问题,需要调用原子操作atomicAdd。
- 另外需要注意的是,devData在主机代码中是被当做一个符号进行处理的,而非一个变量,因此在调用cudaMemcpyToSymbol和cudaMemcpyFromSymbol函数时,传递的是符号名字,而非地址。
- 书里面也介绍了一个获取全局变量地址的API——cudaGetSymbolAddress,这样就能使用cudaMemcpy来进行拷贝赋值了。
固定内存
在CUDA框架下,主机内存分为两种:(1)可分页内存——由malloc开辟,有free释放;(2)固定内存——由cudaMallocHost开辟,由cudaFreeHost释放,占用的内存更多,但是主机设备间的数据传递更快。
#include<stdlib.h>
#include<stdio.h>
int main(int argc, char **argv)
{
int *pin_arr, *page_arr;
int nElem = 1<<30;
cudaMallocHost((void **)&pin_arr, nElem * sizeof(int));
page_arr = (int *)malloc(nElem * sizeof(int));
int *d_arr;
cudaMalloc((void **)&d_arr, nElem * sizeof(int));
cudaMemcpy(d_arr, page_arr, nElem * sizeof(int), cudaMemcpyHostToDevice);
cudaMemcpy(page_arr, d_arr, nElem * sizeof(int), cudaMemcpyDeviceToHost);
// cudaMemcpy(d_arr, pin_arr, nElem * sizeof(int), cudaMemcpyHostToDevice);
// cudaMemcpy(pin_arr, d_arr, nElem * sizeof(int), cudaMemcpyDeviceToHost);
free(page_arr);
cudaFreeHost(pin_arr);
cudaDeviceReset();
return 0;
}
==45339== Profiling result:
Type Time(%) Time Calls Avg Min Max Name
GPU activities: 84.54% 3.21079s 1 3.21079s 3.21079s 3.21079s [CUDA memcpy DtoH]
15.46% 587.34ms 1 587.34ms 587.34ms 587.34ms [CUDA memcpy HtoD]
==48677== Profiling result:
Type Time(%) Time Calls Avg Min Max Name
GPU activities: 50.19% 574.67ms 1 574.67ms 574.67ms 574.67ms [CUDA memcpy DtoH]
49.81% 570.23ms 1 570.23ms 570.23ms 570.23ms [CUDA memcpy HtoD]
上半部分是使用可分页的内存,下半部分使用的是固定内存。可以发现,对于设备到主机的传输,固定内存要远快于可分页内存,但是对于主机到设备的传输,则性能差不多。
零拷贝内存
零拷贝内存是主机和设备都能直接访问的一片内存,主要用于显存不够时的一个补充。但是由于数据需要经过PCIe总线,因此如果频繁对这片区域的数据进行读写,那么会显著降低性能。
以矢量相加为例子,比较一下当A和B都位于GMEM和位于零拷贝内存上,核函数执行效率的区别。
#include<stdlib.h>
#include<stdio.h>
__host__ void init(int *arr, int nElem)
{
for (int i = 0; i < nElem; i++)
{
arr[i] = i;
}
}
__global__ void sumArr(int *arrA, int *arrB, int *arrC, int nElem)
{
int index = blockIdx.x * blockDim.x + threadIdx.x;
if (index < nElem)
{
arrC[index] = arrA[index] + arrB[index];
}
}
__host__ void check(int *arrA, int *arrB, int *arrC, int nElem)
{
for (int i = 0; i < nElem; i++)
{
if (arrC[i] != (arrA[i] + arrB[i]))
{
printf("error at i = %d\n", i);
return;
}
}
printf("result is correct\n");
}
int main(int argc, char **argv)
{
int nElem = 1<<24;
int *h_arrA, *h_arrB, *h_arrC, *d_arrA_G, *d_arrB_G, *d_arrC_G, *d_arrA_U, *d_arrB_U, *d_arrC_U;
h_arrA = (int *)malloc(nElem * sizeof(int));
h_arrB = (int *)malloc(nElem * sizeof(int));
h_arrC = (int *)malloc(nElem * sizeof(int));
cudaMalloc((void **)&d_arrA_G, nElem * sizeof(int));
cudaMalloc((void **)&d_arrB_G, nElem * sizeof(int));
cudaMalloc((void **)&d_arrC_G, nElem * sizeof(int));
cudaHostAlloc((void **)&d_arrA_U, nElem * sizeof(int), cudaHostAllocMapped);
cudaHostAlloc((void **)&d_arrB_U, nElem * sizeof(int), cudaHostAllocMapped);
cudaHostAlloc((void **)&d_arrC_U, nElem * sizeof(int), cudaHostAllocMapped);
init(h_arrA, nElem);
init(h_arrB, nElem);
init(d_arrA_U, nElem);
init(d_arrB_U, nElem);
cudaMemcpy(d_arrA_G, h_arrA, nElem * sizeof(int), cudaMemcpyHostToDevice);
cudaMemcpy(d_arrB_G, h_arrB, nElem * sizeof(int), cudaMemcpyHostToDevice);
dim3 block(512);
dim3 grid((nElem + block.x - 1) / block.x);
//- A和B都是全局内存,C是全局内存
sumArr<<<grid, block>>>(d_arrA_G, d_arrB_G, d_arrC_G, nElem);
cudaMemcpy(h_arrC, d_arrC_G, nElem * sizeof(int), cudaMemcpyDeviceToHost);
check(h_arrA, h_arrB, h_arrC, nElem);
// //- A和B都是零拷贝内存,C是全局内存
// sumArr<<<grid, block>>>(d_arrA_U, d_arrB_U, d_arrC_G, nElem);
// cudaMemcpy(h_arrC, d_arrC_G, nElem * sizeof(int), cudaMemcpyDeviceToHost);
// check(h_arrA, h_arrB, h_arrC, nElem);
// //- A和B都是零拷贝内存,C也是零拷贝内存
// sumArr<<<grid, block>>>(d_arrA_U, d_arrB_U, d_arrC_U, nElem);
// cudaDeviceSynchronize(); //- 这个地方一定要注意,鉴于d_arrC_U主机可以访问,就没有用调用cudaMemcpy来拷贝,因此一定要显式同步。
// check(d_arrA_U, d_arrB_U, d_arrC_U, nElem);
free(h_arrA);
free(h_arrB);
free(h_arrC);
cudaFree(d_arrA_G);
cudaFree(d_arrB_G);
cudaFree(d_arrC_G);
cudaFreeHost(d_arrA_U);
cudaFreeHost(d_arrB_U);
cudaFreeHost(d_arrC_U);
cudaDeviceReset();
return 0;
}
#- A和B都是全局内存,C是全局内存
Type Time(%) Time Calls Avg Min Max Name
GPU activities: 60.39% 42.203ms 1 42.203ms 42.203ms 42.203ms [CUDA memcpy DtoH]
27.05% 18.908ms 2 9.4539ms 9.3450ms 9.5628ms [CUDA memcpy HtoD]
12.56% 8.7768ms 1 8.7768ms 8.7768ms 8.7768ms sumArr(int*, int*, int*, int)
#- A和B都是零拷贝内存,C是全局内存
GPU activities: 54.52% 39.520ms 1 39.520ms 39.520ms 39.520ms [CUDA memcpy DtoH]
29.03% 21.047ms 2 10.523ms 10.407ms 10.639ms [CUDA memcpy HtoD]
16.45% 11.926ms 1 11.926ms 11.926ms 11.926ms sumArr(int*, int*, int*, int)
#- A和B都是零拷贝内存,C也是零拷贝内存
GPU activities: 66.12% 49.425ms 2 24.713ms 21.864ms 27.561ms [CUDA memcpy HtoD]
33.88% 25.330ms 1 25.330ms 25.330ms 25.330ms sumArr(int*, int*, int*, int)
可以看到,对性能的影响还是挺大的。零拷贝内存不适合大数据集,书上提到,随着处理的数据变大,减速越明显。
统一虚拟寻址
上面的零拷贝例子中,我们直接将零拷贝内存的指针传递给了核函数,让它在设备代码上被解析。其实这里隐藏了一个称为虚拟统一寻址(UVA)的机制。对于不支持这一特性的老版本cuda来说,我们不能这样用同一个指针来在设备和主机代码中进行解析,而是需要先通过API获取零拷贝内存的地址,然后传递给核函数,才能进行解析,如:
int *d_arrA_U_ref, *d_arrB_U_ref, *d_arrC_U_ref;
cudaHostGetDevicePointer((void **)&d_arrA_U_ref, d_arrA_U, 0);
cudaHostGetDevicePointer((void **)&d_arrB_U_ref, d_arrB_U, 0);
cudaHostGetDevicePointer((void **)&d_arrC_U_ref, d_arrC_U, 0);
sumArr<<<grid, block>>>(d_arrA_U_ref, d_arrB_U_ref, d_arrC_U_ref, nElem);
这两种做法性能是相同的,只是代码可读性和维护性更强。
内存访问模式
对齐访问
这里有一个推论概念:以L1缓存内存加载事务为例,粒度为128字节。首先会以数组的首地址为起始点,每间隔128字节为一个区间,如果一个warp中的32个thread请求的数据内存刚好都落在这一个区间内,那么只需要一个128字节内存加载事务就能满足需求;否则,如果散落在两个区间内,就需要两个加载事务。这里的关键要明确,划分区间的起始点是数组的首地址。
#include<stdlib.h>
#include<stdio.h>
__global__ void sumArrOffSet(int *arrA, int *arrB, int *arrC, int nElem, int offset)
{
int index = blockIdx.x * blockDim.x + threadIdx.x;
int k = index + offset;
if (index < nElem)
{
arrC[index] = arrA[k] + arrB[k];
}
}
int main(int argc, char **argv)
{
int nElem = 1<<14;
int *d_arrA, *d_arrB, *d_arrC;
cudaMalloc((void **)&d_arrA, nElem * sizeof(int));
cudaMalloc((void **)&d_arrB, nElem * sizeof(int));
cudaMalloc((void **)&d_arrC, nElem * sizeof(int));
dim3 block(512);
dim3 grid((nElem + block.x - 1) / block.x);
sumArrOffSet<<<grid, block>>>(d_arrA, d_arrB, d_arrC, nElem, atoi(argv[1]));
cudaDeviceSynchronize();
cudaFree(d_arrA);
cudaFree(d_arrB);
cudaFree(d_arrC);
cudaDeviceReset();
return 0;
}
[mmhe@k231 chapter4]$ nvprof --metrics gld_transactions,gst_transactions ./test 0
Invocations Metric Name Metric Description Min Max Avg
Device "Tesla K80 (0)"
Kernel: sumArrOffSet(int*, int*, int*, int, int)
1 gld_transactions Global Load Transactions 1024 1024 1024
1 gst_transactions Global Store Transactions 512 512 512
[mmhe@k231 chapter4]$ nvprof --metrics gld_transactions,gst_transactions ./test 1
Invocations Metric Name Metric Description Min Max Avg
Device "Tesla K80 (0)"
Kernel: sumArrOffSet(int*, int*, int*, int, int)
1 gld_transactions Global Load Transactions 2048 2048 2048
1 gst_transactions Global Store Transactions 512 512 512
[mmhe@k231 chapter4]$ nvprof --metrics gld_transactions,gst_transactions ./test 16
Invocations Metric Name Metric Description Min Max Avg
Device "Tesla K80 (0)"
Kernel: sumArrOffSet(int*, int*, int*, int, int)
1 gld_transactions Global Load Transactions 2048 2048 2048
1 gst_transactions Global Store Transactions 512 512 512
[mmhe@k231 chapter4]$ nvprof --metrics gld_transactions,gst_transactions ./test 32
Invocations Metric Name Metric Description Min Max Avg
Device "Tesla K80 (0)"
Kernel: sumArrOffSet(int*, int*, int*, int, int)
1 gld_transactions Global Load Transactions 1024 1024 1024
1 gst_transactions Global Store Transactions 512 512 512
本次处理的矢量长度为16384,blockDim.x=512,共有32个block,每个block有16个warp。
- 当偏移量=0时,第一个warp中请求的A和B元素各需要一个128字节的内存加载事务,因此共计1024个内存加载事务。
- 当偏移量=1时,第一个warp中32个thread加载A和B均落在字节索引4-131这128个字节内,但是由于前面推论中提到的分区是以数组的首地址开始的,因此0-127属于第一个区间,128-255属于第二个区间,因此4-131跨越了两个区间。对于其他的warp也是如此,因此一个warp需要4个内存事务来进行,共计2048个加载事务。
- 当偏移量=16时,第一个warp加载的字节索引落在64-191这128个字节内,同样跨越了两个区间,因此也上述情况一样;
- 当偏移量=32时,第一个warp加载的字节索引落在128-255字节区间内,刚好属于第二个区间,因此一个事务就能满足,因此又回到了1024这个结果。
注意,这里的程序只是为了验证对其访问,因此并不注重程序的正确性,程序中存在跨区域索引。
合并访问
全局内存写入
写入的粒度有多种,因此它能提供更高的利用率。
结构体数组和数组结构体
#include<stdlib.h>
#include<stdio.h>
struct point
{
int x;
int y;
};
__global__ void kernel(point *arr, int nElem)
{
int index = blockIdx.x * blockDim.x + threadIdx.x;
if (index < nElem)
{
arr[index].x = arr[index].x + 2;
arr[index].y = arr[index].y + 2;
}
}
int main(int argc, char **argv)
{
int nElem = 1<<14;
struct point *d_arr;
cudaMalloc((void **)&d_arr, nElem * sizeof(point));
dim3 block(512);
dim3 grid((nElem + block.x - 1)/block.x);
kernel<<<grid, block>>>(d_arr, nElem);
cudaDeviceSynchronize();
cudaDeviceReset();
return 0;
}
数组尺寸16384,block尺寸512,共有32个block,每个block有16个warp。
当结构体对象是数组的基本单元时,在这个数组的内存中,对象成员x和y是紧挨在一起的,即[x,y,x,y…x,y]。那么对于一个warp中对x的索引,会分散在以数组首地址为起始的两个128字节区间内,也就是说,加载x需要2个事务,那么总的核函数加载x和y共需要2048个内存加载事务,并且利用率只有50%。
#include<stdlib.h>
#include<stdio.h>
struct point
{
int *x;
int *y;
};
__global__ void init(point *A, int nElem)
{
int index = blockIdx.x * blockDim.x + threadIdx.x;
if (index < nElem)
{
A->x[index] = index;
A->y[index] = index;
}
}
__global__ void kernel(point *A, int nElem)
{
int index = blockIdx.x * blockDim.x + threadIdx.x;
if (index < nElem)
{
A->x[index] += 2;
A->y[index] += 2;
}
}
int main(int argc, char **argv)
{
int nElem = 32;
point *h_arr;
point *d_arr;
h_arr = (point *)malloc(sizeof(point));
cudaMalloc((void **)&(h_arr->x), nElem * sizeof(int));
cudaMalloc((void **)&(h_arr->y), nElem * sizeof(int));
cudaMalloc((void **)&d_arr, sizeof(point));
cudaMemcpy(d_arr, h_arr, sizeof(point), cudaMemcpyHostToDevice);
dim3 block(32);
dim3 grid((nElem + block.x - 1)/block.x);
init<<<grid, block>>>(d_arr, nElem);
cudaDeviceSynchronize();
kernel<<<grid, block>>>(d_arr, nElem);
cudaDeviceSynchronize();
cudaDeviceReset();
return 0;
}
Invocations Metric Name Metric Description Min Max Avg
Device "Tesla K80 (0)"
Kernel: init(point*, int)
1 gld_transactions Global Load Transactions 2 2 2
1 gst_transactions Global Store Transactions 2 2 2
1 gld_efficiency Global Memory Load Efficiency 25.00% 25.00% 25.00%
1 gst_efficiency Global Memory Store Efficiency 100.00% 100.00% 100.00%
Kernel: kernel(point*, int)
1 gld_transactions Global Load Transactions 4 4 4
1 gst_transactions Global Store Transactions 2 2 2
1 gld_efficiency Global Memory Load Efficiency 85.00% 85.00% 85.00%
1 gst_efficiency Global Memory Store Efficiency 100.00% 100.00% 100.00%
这个结果比较有意思,一步一步来分析:(32个数据,整体只有一个warp,便于分析)
- init核函数:出现了2个加载事务,这是因为A->x也是需要从GMEM中进行加载的,一个int*类型有8个字节,需要一个内存加载事务。整体需要两个内存加载事务。这里需要注意的是,nvcc会根据加载量自动优化缓存路径,这里它采用了非L1缓存的方式来加载,导致加载粒度为32,因此利用率为25%。
- kernel核函数:同样,对于数组地址的加载共计需要消耗2个32粒度的事务,其余元素的加载共需要消耗2个128粒度的事务,因此总的利用率为 8 × 2 + 32 × 4 × 2 32 × 2 + 128 × 2 = 85 % \frac{8\times2+32\times4\times2}{32\times2+128\times2} = 85\% 32×2+128×28×2+32×4×2=85%.
最大内存带宽
基本概念
峰值带宽
K80一块板上集成了2个GPU,内存总线宽度是384bit,内存时钟频率为2505Mhz,双通道,8bit为一个字节,则内存带宽峰值为:
2
×
384
b
i
t
×
2.505
G
H
z
×
2
8
b
i
t
s
/
b
y
t
e
=
480.96
G
B
/
s
\frac{2\times 384bit\times2.505GHz\times2}{8bits/byte} = 480.96GB/s
8bits/byte2×384bit×2.505GHz×2=480.96GB/s
有效带宽
effective bandwidth (GB/s) = ( byte read + byte written ) time × 1024 × 1024 × 1024 \text{effective bandwidth (GB/s)} = \frac{\left(\text{byte read + byte written}\right)}{\text{time}\times1024\times1024\times1024} effective bandwidth (GB/s)=time×1024×1024×1024(byte read + byte written)
矩阵转置
有效带宽上下限
- 上限:当读取和写入都是行主导的时候,内存可以被合并对其加载,此时的内存性能是最好的,因此这种情况作为有效带宽的上限
- 下限:当读取和写入都是列主导的时候,这是最坏的 情况,因此作为下限。
L1缓存是否加载
L1缓存是否加载会导致加载粒度不同。
#include<stdlib.h>
#include<stdio.h>
#include"../../CodeSamples/common/common.h"
__global__ void copyRow(int *arrA, int *arrB, int row, int col)
{
int x = blockIdx.x * blockDim.x + threadIdx.x;
int y = blockIdx.y * blockDim.y + threadIdx.y;
if (x < col && y < row)
{
arrB[y * col + x] = arrA[y * col + x];
}
}
__global__ void copyCol(int *arrA, int *arrB, int row, int col)
{
int x = blockIdx.x * blockDim.x + threadIdx.x;
int y = blockIdx.y * blockDim.y + threadIdx.y;
if (x < col && y < row)
{
arrB[x * col + y] = arrA[x * col + y];
}
}
__global__ void NaiveRow(int *arrA, int *arrB, int row, int col)
{
int x = blockIdx.x * blockDim.x + threadIdx.x;
int y = blockIdx.y * blockDim.y + threadIdx.y;
if (x < col && y < row)
{
arrB[x * col + y] = arrA[y * col + x];
}
}
__global__ void NaiveCol(int *arrA, int *arrB, int row, int col)
{
int x = blockIdx.x * blockDim.x + threadIdx.x;
int y = blockIdx.y * blockDim.y + threadIdx.y;
if (x < col && y < row)
{
arrB[y * col + x] = arrA[x * col + y];
}
}
int main(int argc, char **argv)
{
cudaSetDevice(5);
int row = 1<<14;
int col = 1<<14;
int nElem = row * col;
int *d_arrA, *d_arrB;
cudaMalloc((void **)&d_arrA, nElem * sizeof(int));
cudaMalloc((void **)&d_arrB, nElem * sizeof(int));
dim3 block(atoi(argv[1]), atoi(argv[2]));
dim3 grid((row + block.y - 1) / block.y, (col + block.x - 1) / block.x);
printf("gridDim:(%d,%d,%d) blockDim:(%d,%d,%d)\n", grid.x, grid.y, grid.z, block.x, block.y, block.z);
double iStart = seconds();
copyRow<<<grid, block>>>(d_arrA, d_arrB, row, col);
cudaDeviceSynchronize();
double iElaps = seconds() - iStart;
printf("copyRow time=%fs, brandwidth = %fGB/s\n", iElaps, 2.0*row*col*sizeof(int)/iElaps/1024/1024/1024);
iStart = seconds();
copyCol<<<grid, block>>>(d_arrA, d_arrB, row, col);
cudaDeviceSynchronize();
iElaps = seconds() - iStart;
printf("copyCol time=%fs, brandwidth = %fGB/s\n", iElaps, 2.0*row*col*sizeof(int)/iElaps/1024/1024/1024);
iStart = seconds();
NaiveRow<<<grid, block>>>(d_arrA, d_arrB, row, col);
cudaDeviceSynchronize();
iElaps = seconds() - iStart;
printf("NaiveRow time=%fs, brandwidth = %fGB/s\n", iElaps, 2.0*row*col*sizeof(int)/iElaps/1024/1024/1024);
iStart = seconds();
NaiveCol<<<grid, block>>>(d_arrA, d_arrB, row, col);
cudaDeviceSynchronize();
iElaps = seconds() - iStart;
printf("NaiveCol time=%fs, brandwidth = %fGB/s\n", iElaps, 2.0*row*col*sizeof(int)/iElaps/1024/1024/1024);
cudaFree(d_arrA);
cudaFree(d_arrB);
return 0;
}
L1缓存开启
Kernel BandWidth Notes 加载吞吐量 存储吞吐量 加载效率 存储效率
copyRow 41.340298GB/s Upper bound 23.154GB/s 23.154GB/s 100.00% 100.00%
copyCol 21.524815GB/s Lower bound 308.22GB/s 77.056GB/s 3.12% 12.50%
NaiveRow 30.102732GB/s 合并读/间隔写 15.946GB/s 127.57GB/s 100.00% 12.50%
NaiveCol 22.602213GB/s 间隔写/合并读 379.29GB/s 11.853GB/s 3.12% 100.00%
L1缓存关闭
Kernel BandWidth Notes 加载吞吐量 存储吞吐量 加载效率 存储效率
copyRow 41.362517GB/s Upper bound 23.460GB/s 23.460GB/s 100.00% 100.00%
copyCol 13.826092GB/s Lower bound 53.674GB/s 53.674GB/s 12.50% 12.50%
NaiveRow 29.522903GB/s 合并读/间隔写 15.419GB/s 123.35GB/s 100.00% 12.50%
NaiveCol 17.568648GB/s 间隔写/合并读 72.086GB/s 9.0108GB/s 12.50% 100.00%