最近在学CUDA编程,看的是NVIDIA官方编程指南5.0的中文版。此博客作为自己的一个简单的学习记录,方便后面自己对CUDA的知识进行回顾。
// 定义内核
__global void VecAdd(float* A, float* B, float* c) {
int i = threadIdx.x;
c[i] = A[i] + B[i];
}
int main() {
// 创建内核(每个线程都会执行)
VecAdd<<<1, N>>>(A, B, C);
}
(Dx,Dy)
的块(即块的尺寸为(Dx,Dy)
),线程索引为(x,y)
的线程ID为(x+y\*dx)
(把块想象成一个矩形);对于三位长度(Dx,Dy,Dz)
的块,索引为(x,y,z)
的线程ID为(x+y\*Dx+z\*Dx\*Dy)
(把块想象成一个立方体,很容易理解编号的由来)。// __global代表函数是在GPU上执行的
// blockIdx返回当前block在grid中的坐标,blockIdx.x为在grid中的横坐标,blockIdx.y为在grid中的纵坐标
// blockDim返回block的大小尺寸,即横向有多少线程,纵向有多少线程
// threadIdx返回当前线程在block中的坐标,threadIdx.x为在block中的横坐标
__global void MatAdd(float A[N][N], float B[N][N], float C[N][N]) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
int j = blockIdx.y * blockDim.y + threadIdx.y;
if (i < N && j < N)
C[i][j] = A[i][j] + B[i][j];
}
int main() {
// dim3是一个表示内核大小尺寸的结构体
dim3 threadsPerBlock(16, 16);
dim3 numBlocks(N / threadsPerBlock.x, N / threadsPerBlock.y);
// 使用dim3参数来设置内核的大小
MatAdd<<>>(A, B, C);
}
3. 块内线程可以通过共享存储器和同步执行协作。共享存储器为快内线程共享的存储器,存放共享的数据;__syncthreads()
指明块内线程的同步点。只有等到块内所有的线程都执行到__suncthreads()
位置时,线程才会继续执行下去。
CUDA线程在设备(GPU)上并行运行,C程序在主机上串行运行。
这个编译的方法是用来应对兼容性问题。代码会转换为PTX代码,然后PTX代码在运行时被加载,之后会被设备驱动进一步编译成二进制代码,这种编译方法使得代码的向后兼容成为了可能。具体可以参照2.1.3 PTX兼容性
二进制代码是由架构特定的,二进制兼容性保证向后兼容,但不保证向前兼容,也不保证跨越主修订号的向后兼容。
一些PTX指令只能被高计算能力的设备支持。为某些特殊计算能力生成的PTX代码始终能够被编译成相等或更高计算能力设备上的二进制代码。PTX保证完全的向后兼容,而二进制只保证主修订号相同的向后兼容。(主修订号相同指的是x.y与x.z)
为了能在将来更高计算能力的架构上执行,应用必须装载PTX代码并为哪些设备提供即时编译。
64位的nvcc使用-m64编译选项以64位模式编译设备代码。
32位的nvcc使用-m32编译选项以32位模式编译设备代码。
在初始化时,运行时为系统中的每个设备建立一个上下文。这个上下文作为设备的主要上下文,被应用中的主机线程共享。
cudaMalloc()
分配,通过cudaFree()
释放,使用cudaMemcpy()
在设备和主机间传输。// 设备代码
__global__ void VecAdd(float* A, float* B, float* C, int N) {
int i = blockDim.x * blockIdx.x + threadIdx.x;
if (i < N)
C[i] = A[i] + B[i];
}
// 主机代码
int main() {
int N = ...;
size_t size = N * sizeof(float);
// 分配主机内存
float* h_A = (float*) malloc(size);
float* h_B = (float*) malloc(size);
// 分配设备内存
float* d_A;
cuadMalloc(d_A, size);
float* d_B;
cudaMalloc(d_B, size);
float* d_C;
cudaMalloc(d_C, size);
// 将数据从主机内存复制到主机内存中
cudaMemcpy(d_A, h_A, size, cudaMemcpyHostToDevice);
cudaMemcpy(d_B,h_B, size, cudaMemcpyHostToDevice);
// 创建内核
int threadsPerBlock = 256;
int blocksPerGrid = (N + threadsPerBlock - 1) / threadsPerBlock;
VecAdd<<>>(d_A, d_B, d_C, N);
// 将结果从设备中复制回主机
cudaMemcpy(h_C, d_C, size, cudaMemcpyDeviceToHost);
// 释放设备内存空间
cudaFree(d_A);
cudaFree(d_B);
cudaFree(d_C);
// 释放主机内存空间
...
}
// 以下为使用cudaMallocPitch()在设备中分配二维数组
// 主机代码
int width = 64, height = 64;
float* devPtr;
size_t pitch;
// 分配一个二维数组的内存空间
cudaMallocPitch(&devPtr, &pitch, width * sizeof(float), height);
MyKernel<<<100, 512>>>(devPtr, pitch, width, height);
// 设备代码
// pitch为步长,使用步长遍历二维数组
__global__ void MyKernel(float* devPtr, size_t pitch, int width, int height) {
for (int r = 0; r < height; ++r) {
// 选定行
float* row = (float*) ((char*)devPtr + r*pitch);
// 在行中遍历所有元素
for (int c = 0; c < width; ++c) {
float element = row[c];
}
}
}
// 接下来是使用cudaMalloc3D()分配一个width*height*depth的三维浮点数组
// 主机代码
int width = 64, height = 64, depth = 64;
cudaExtent extent = make_cudaExtent(width * sizeof(float), height, depth);
cudaPitchedPtr devPitchedPtr;
// 分配一个三维数组空间
cudaMalloc3D(&devPitchedPtr, extent);
// 设备代码
Mykernel<<<100, 512>>>(devPitchedPtr, width, height, depth) {
char* devPtr = devPitchedPtr.ptr;
size_t pitch = devPitchedPtr.pitch;
size_t slicePitch = pitch * height;
// 遍历三维数组
for (int z = 0; z < depth; ++z) {
char* slice = devPtr + z * slicePitch;
for (int y = 0; y < height; ++y) {
float* row = (float*)(slice + y * pitch);
for (int x = 0; x < width; ++x) {
float element = row[x];
}
}
}
}
共享存储器使用__shared__限定词。
访问共享存储器比访问全局存储器(所有block共享)更快,因此尽量使用共享存储器来代替全局存储器。
// 利用共享存储器计算矩阵乘法
typedef struct {
int width;
int height;
int stride;
float* elements;
} Matrix;
// 访问矩阵元素
__device__ float GetElement(const Matrix A, int row, int col) {
return A.elements[row * A.stride + col];
}
// 为矩阵赋值
__device__ void setElement(Matrix A, int row, int col, float value) {
A.elements[row * A.stride + col] = value;
}
// 在矩阵中获取block_size*block_size大小的子矩阵
__device__ Matrix GetSubMatrix(Matrix A, int row, int col) {
Matrix Asub;
Asub.width = BLOCK_SIZE;
Asub.height = BLOCK_SIZE;
Asub.stride = A.stride;
Asub.elements = &A.elements[A.stride * BLOCK_SIZE * row + BLOCK_SIZE * col];
return Asub;
}
# define BLOCK_SIZE 16
__global void MatMulKernel(const Matrix, const Matrix, Matrix);
void MatMul(const Matrix A, const Matrix B, Matrix C) {
// 将A从主机中读入设备内存
Matrix d_A;
d_A.width = d_A.stride = A.width;
d_A.height = A.height;
size_t size = A.width * A.height * sizeof(float);
cudaMalloc(&d_A.elements, size);
cudaMemcpy(d_A.elements, A.elements, size, cudaMemcpyHostToDevice);
// 将B从主机中读入设备内存
Matrix d_B;
d_B.width = d_B.stride = B.width;
d_B.height = B.height;
size = B.width * B.height * sizeof(float);
cudaMalloc(&d_B.elements, size);
cudaMemcpy(d_B.elements, B.elements, size, cudaMemcpyHostToDevice);
// 在设备内存中为C分配空间
Matrix d_C;
d_C.width = d_C.stride = C.width;
d_C.height = C.height;
size = C.width * C.height * sizeof(float);
cudaMalloc(&d_C.elements, size);
// 创建内核,执行子矩阵的乘法,将结果保存在d_C中
dim3 dimBlock(BLOCK_SIZE, BLOCK_SIZE);
// 将结果矩阵当成一个大的grid,其中分成多个block,block内部再分成多个线程
// 总的线程个数等于结果矩阵中元素的个数,也就是一个线程对应一个元素
dim3 dimGrid(B.width / dimBlock.x, A.height / dimBlock.y);
MatMulKernel<<>>(d_A, d_B, d_C);
// 将矩阵C从设备内存中读入
cudaMemcpy(C.elements, d_C.elements, size, cudaMemcpyDeviceToHost);
// 释放空间
cudaFree(d_A.elements);
cudaFree(d_B.elements);
cudaFree(d_C.elements);
}
__global__ void MatMulKernel(Matrix A, Matrix B, Matrix C) {
// 获取block在grid中的坐标
int blockRow = blockIdx.y;
int blockCol = blockIdx.x;
Matrix Csub = GetSubMatrix(C, blockRow, blockCol);
float Cvalue = 0;
// 获取thread在block中的坐标
int row = threadIdx.y;
int col = threadIdx.x;
// 循环对所有block进行计算,每一次循环分别对A和B取block_size大小的子矩阵
for (int m = 0; m < (A.width / BLOCK_SIZE); ++m) {
// 获取A、B的下一个block对应的子矩阵,保持A的行索引和B的列索引不变
Matrix Asub = GetSubMatrix(A, blockRow, m);
Matrix Bsub = GetSubMatrix(B, m, blockCol);
// 创建共享存储器,临时存放A、B的子矩阵
__shared__ float As[BLOCK_SIZE][BLOCK_SIZE];
__shared__ float Bs[BLOCK_SIZE][BLOCK_SIZE];
// 每个线程将在A、B中的对应元素读进来,每个线程负责读入一个元素
As[row][col] = GetElement(Asub, row, col);
Bs[row][col] = GetElement(Bsub, row, col);
// 同步,使得所有线程都已经将对应元素读入子矩阵中,方便下一步进行计算
__syncthreads();
// 计算
for (int e = 0; e < BLOCK_SIZE; e++)
Cvalue += As[row][e] * Bs[e][col];
// 同步,使得所有线程都已经计算完毕
__syncthreads();
}
// 将计算的结果(每一个元素)添加到结果矩阵的对应位置中。每一个线程负责添加一个元素
// Cvalue为一个值,对应结果矩阵中的某一元素,将其设为结果矩阵的某一元素
setElement(Csub, row, col, Cvalue);
}
为了让所有线程可以使用分布锁定共享存储器的好处,可以在使用cudaHostAlloc()分配时传入cudaHostAllocPortable标签,或者在使用cudaHostRegister()分布锁定存储器时,传入cudaHostRegisterPortable标签。
可以在使用cudaHostAlloc()分配时传入cudaHostAllocWriteCombined标签使其被分配为写结合的。写结合在写访问时会加速,但是读取访问的速度会非常的慢。
在使用cudaHostAlloc()分配时传入cudaHostAllocMapped标签或者在使用cudaHostRegister()分布锁定一块主机存储器时使用cudaHostRegisterMapped标签,可以分配一块被映射到设备地址空间的分页锁定主机存储器。这块存储器上有两个地址:一个在主机存储器上,一个在设备存储器上。主机指针是从cudaHostAlloc()或malloc()返回的,设备指针可以通过cudaHostGetDevicePointer()函数检索到,可以使用这个设备指针在内核中访问这块存储器。
一些计算能力更高的设备可以在内核执行时,在分页锁定存储器和设备存储器之间拷贝数据。
在计算能力2.x的设备上,从主机分页锁定存储器复制数据到设备存储器和从设备存储器复制数据到主机分页锁定存储器,这两个操作可并发执行。
流是一系列顺序执行的命令。流内部是顺序执行命令的,不同流之间相对无序的或者并发的执行他们的命令。
流是有序的操作序列,既包含内存复制操作,也包含核函数调用,但是硬件中并没有流的概念,而是包含一个或多个引擎来执行内存复制操作和一个引擎来执行核函数。这些引擎彼此独立的对队列进行排队。具体流程可以看下面的执行表和图。
创建和销毁 可以通过创建流对象来定义流,且可指定他作为一系列内核发射和设备主机间存储器拷贝的流参数。
// 创建两个流对象
cudaStream_t stream[2];
for (int i = 0; i < 2; i++)
cudaStreamCreate(&stream[i]);
float* hostPtr;
// 在分页锁定存储器中分配一个浮点数组
cudaMallocHost(&hostPtr, 2 * size);
// 将流定义为一个由一次主机到设备的传输,一次内核发射,一次设备到主机的传输组成的系列
for (int i = 0; i < 2; i++) {
// 将分页锁定主机存储器中的数据以异步的方式转入设备中
cudaMemcpyAsync(inputDevPtr + i * size, hostPtr + i * size, size, cudaMemcpyHostToDevice, stream[i]);
MyKernel<<<100, 512, 0, stream[i]>>>(outputDevPtr + i * size, inputDevPtr + i * size, size);
cudaMemcpyAsync(hostPtr + i * size, outputDevPtr + i * size, size, cudaMemcpyDeviceToHost, stream[i]);
}
// 释放流
for (int i = 0; i < 2; i++)
cudaStreamDestroy(stream[i]);
默认流 没有使用流参数的内核启动和主机设备间数据拷贝,此时将会被发射到默认流。
显示同步 流之间的同步
隐式同步
如果不同流的两个命令执行的是下面的操作,则两个命令不能并发
重叠行为
两个流的重叠执行数量依赖于发射到每个流的命令的顺序和设备是否支持数据传输和内核执行重叠(3.2.5.2)、并发内核执行(3.2.5.3)、并发数据传输(参见3.2.5.4)。
例如,在不支持并发数据传输的设备上,3.2.5.5例程的两个流并没有重叠,因为发射到流1的从主机到设备的存储器拷贝在发射到流0的从设备到主机的存储器拷贝之后,因此只有发射到流0的设备到主机的存储器拷贝完成它才开始。具体如下表:
内存复制引擎 | 核函数执行引擎 |
---|---|
第0个流:Host->Device | |
第0个流:执行核函数 | |
第0个流:Device->Host | |
第1个流:Host->Device | |
第1个流:执行核函数 | |
第1个流:Device->Host |
如果代码重写成下面这样(同时假设设备支持数据传输和内核执行重叠):
// 先将从主机复制数据到设备中的命令加入流中
for (int i = 0; i < 2; i++)
cudaMemcpyAsync(inputDevPtr + i ∗ size, hostPtr + i ∗ size, size , cudaMemcpyHostToDevice, stream[i]);
// 接着将内核执行分别添加进流中
for (int i = 0; i < 2; ++i)
MyKernel<<<100, 512, 0, stream[i]>>>(outputDevPtr + i ∗ size, inputDevPtr + i ∗ size, size );
// 最后将数据从设备中存入主机
for (int i = 0; i < 2; ++i)
cudaMemcpyAsync(hostPtr + i ∗ size, outputDevPtr + i ∗ size, size , cudaMemcpyDeviceToHost, stream[i]);
具体如下表:
内存复制引擎 | 核函数执行引擎 |
---|---|
第0个流:Host->Device | |
第1个流:Host->Device | 第0个流:执行核函数 |
第2个流:Host->Device | 第1个流:执行核函数 |
第0个流:Device->Host | 第2个流:执行核函数 |
第1个流:Device->Host | |
第2个流:Device->Host |
两个表表明了使用不同的方式、顺序将任务添加进流中时,执行的并发性不同。
举另一个例子来说明:
由于第零个流将 c 复制回主机的操作要等到核函数执行完成后,所以第一个流中的将 a 和 b 复制到 GPU 的操作虽然是完全独立的却被阻塞了,这是因为 GPU 引擎是按照指定的顺序来执行的,所以用上面两个流却没有加速。
修改为更高效的代码,只需要改变分配到两个流的顺序,采用宽度优先而不是深度优先方式。就是说,不是先添加第零个流的所有四个操作(即a 的复制,b 的复制,核函数,c 的复制),然后不再添加第一个流的所有四个操作,而是将这两个流的操作交叉添加。首先,将 a 的复制操作加到第零个流,将 a 的复制操作加到第一个流,然后将 b 的复制分别复制到两个流,之后是核函数,在之后复制 c,流程如下:
这里第零个流对 c 的复制操作并不会阻碍第一个流对 a,b 的复制操作。这使得 GPU 可以并行的执行复制操作和核函数
回调
运行时通过cudaStreamAddCallback()提供了一种在任何执行点向流插入回调的方式。回调是一个函数,一旦在插入点之前发射到流的所有命令执行完成,回调就会在主机上执行。在流0中的回调,只能在插入点之前其它流的所有命令都完成后才能执行。
// 自定义的回调函数
void CUDART_CB MyCallback(void* data) {
printf("Inside callback %d\n", (int)data);
}
...
for (int i = 0; i < 2; i++) {
// 将数据拷贝至设备上
cudaMemcpyAsync(devPtrIn[i], hostPtr[i], size, cudaMemcpyHostToDevice, stream[i]);
// 执行核函数
MyKernel<<<100, 512, 0, stream[i]>>>(devPtrOut[i], devPtrIn[i], size);
// 将数据拷贝回主机上
cudaMemcpyAsync(hostPtr[i], devPtrIn[i], size, cudaMemcpyDeviceToHost, stream[i]);
// 上面的指令执行完后,会在主机上执行自定义的MyCallback回调函数
cudaStreamAddCallback(stream[i], MyCallback, (void*)i, 0);
}
通过在应用的任意点上异步地记载事件和查询事件是否完成,运行时提供了精密地监测设备运行进度和精确计时。当事件记载点前面,事件指定的流中的所有任务或者指定流中的命令全部完成时,事件被记载。只有记载点之前所有的流中的任务/命令都已完成,0号流的事件才会记载。
创建和销毁
// 创建两个事件
cudaEvent_t start, stop;
cudaEventCreate(&start);
cudaEventCreate(&stop);
// 销毁
cudaEventDestroy(start);
cudaEventDestroy(stop);
将事件用于代码计时:
// 记录起始时间
cudaEventRecord(start, 0);
for (int i = 0; i < 2; i++) {
cudaMemcpyAsync(inputDev + i * size, inputHost + i * size, size, cudaMemcpyHostToDevice, stream[i]);
MyKernel<<<100, 512, 0, stream[i]>>>(outputDev + i * size, inputDev + i * size, size);
cudaMemcpyAsync(outputHost + i * size, outputDev + i * size, size, cudaMemcpyDeviceToHost, stream[i]);
}
// 记录结束时间
cudaEventRecord(stop, 0);
cudaEventSynchronize(stop);
float elapsedTime;
// 计算时间
cudaEventElapsedTime(&elapsedTime, start, stop);
直到设备真正完成任务,同步函数调用的控制权才会返回给主机线程。。在主机线程执行任何其它CUDA调用前,通过调用cudaSetDeviceFlags()并传入指定标签(参见参考手册)可以指定主机线程的让步,阻塞,或自旋状态。
主机上可以有多个设备
int deviceCount;
// 获取设备的数量
cudaGetDeviceCount(&deviceCount);
int device;
for (device = 0; device < deviceCount; device++) {
cudaDeviceProp deviceProp;
// 获取设备信息
cudaGetDeviceProperties(&deviceProp, device);
printf ("Device %d has compute capability %d.%d.\n", device, deviceProp.major, deviceProp.minor);
}
设备存储器分配和内核执行都作用在当前的设备上;流和事件关联当前设备。如果没有cudaSetDevice()调用,当前设备默认为0号设备。
size_t size = 1024 * sizeof(float);
// 将当前设备设置为0号设备
cudaSetDevice(0);
float* p0;
// 在0号设备上分配内存
cudaMalloc(&p0, size);
// 在0号设备上执行内核
MyKernel<<<100, 128>>>(p0);
// 将当前设备设置为1号设备
cudaSetDevice(1);
float* p1;
// 在1号设备上分配内存
cudaMalloc(&p1, size);
// 在1号设备上执行内核
MyKernel<<<1000, 128>>>(p1);
如果内核执行和存储器拷贝发射到非关联到当前设备的流,它们将会失败。
cudaSetDevice(0); // Set device 0 as current
cudaStream t s0;
cudaStreamCreate(&s0); // Create stream s0 on device 0
MyKernel<<<100, 64, 0, s0>>>(); // Launch kernel on device 0 in s0
cudaSetDevice(1); // Set device 1 as current
cudaStream t s1;
cudaStreamCreate(&s1); // Create stream s1 on device 1
MyKernel<<<100, 64, 0, s1>>>(); // Launch kernel on device 1 in s1
// This kernel launch will fail:
MyKernel<<<100, 64, 0, s0>>>(); // Launch kernel on device 1 in s0
当应用以64位进程运行时,以TCC模式在win7/Vista、 在win XP或者在Linux上,计算能力2.0或以上,Tesla系列设备能够访问彼此的存储器(即运行在一个设备上的内核可以解引用指向另一个设备存储器的指针)。只要两个设备上的cudaDeviceCanAccessPeer()返回true,这种p2p的存储器访问特性在它们间得到支持。
cudaSetDevice(0); // Set device 0 as current
float ∗ p0;
size t size = 1024 ∗ sizeof( float );
cudaMalloc(&p0, size); // Allocate memory on device 0
MyKernel<<<1000, 128>>>(p0); // Launch kernel on device 0
cudaSetDevice(1); // Set device 1 as current
// 这句话是关键,它使得当前设备(1号设备)可以访问0号设备的内存,即实现p2p存储器访问
cudaDeviceEnablePeerAccess(0, 0); // Enable peer-to-peer access with device 0
// Launch kernel on device 1
// This kernel launch can access memory on device 0 at address p0
MyKernel<<<1000, 128>>>(p0);
当两个设备使用统一存储器地址空间(参见3.2.6.6)时,使用设备存储器节提到的普通的存储器拷贝函数即可。 否则使用cudaMemcpyPeer()、cudaMemcpyPeerAsync()、cudaMemcpy3Dpeer()或者cudaMemcpy3DpeerAsync()
cudaSetDevice(0); // Set device 0 as current
float ∗ p0;
size t size = 1024 ∗ sizeof( float );
cudaMalloc(&p0, size); // Allocate memory on device 0
cudaSetDevice(1); // Set device 1 as current
float ∗ p1;
cudaMalloc(&p1, size); // Allocate memory on device 1
cudaSetDevice(0); // Set device 0 as current
MyKernel<<<1000, 128>>>(p0); // Launch kernel on device 0
cudaSetDevice(1); // Set device 1 as current
cudaMemcpyPeer(p1, 1, p0, 0, size); // Copy p0 to p1
MyKernel<<<1000, 128>>>(p1); // Launch kernel on device 1
对于计算能力2.0或以上的设备,当应用以64位进程运行时,以TCC模式在win7/Vista(只支持Tesla系列设备)、在win XP或者在Linux上,主机和设备使用单一的地址空间。 主机通过cudaHostAlloc()分配的存储器和使用cudaMalloc*()在任意设备上分配的存储器使用这个虚拟地址空间;指针指向哪个存储器空间(主机存储器或任意一个设备存储器)可以通过cudaPointerGetAttributes()确定。
运行时为每个主机线程维护着一个初始化为cudaSuccess的错误变量,每次错误发生(可以是参数不正确或异步错误)时,该变量会被错误码重写。cudaPeekAtLastError()返回这个变量,cudaGetLastError()会返回这个变量,并将它重新设置为cudaSuccess。
内核发射不返回任何错误码,所以应当在内核发射后立刻调用cudaGetLastError()或cudaPeekAtLastError()检测发射前错误。为保证cudaGetLastError()返回的错误值不是由于内核发射之前的错误导致的,必须保证运行时错误变量在内核发射前被设置为cudaSuccess,可以通过在内核发射前调用cudaGetLastError()实现。内核发射是异步的,因此为了检测异步错误,应用必须在内核发射和cudaGetLastError()或cudaPeekAtLastError()之间同步。
纹理存储器这一块我不是很理解,因此基本上都是照搬一位老哥的博客,以下是这位老哥的博客地址。
https://blog.csdn.net/zhangpinghao/article/details/16833489
纹理存储器(texture memory)是一种只读存储器,由GPU用于纹理渲染的的图形专用单元发展而来,因此也提供了一些特殊功能。纹理存储器中的数据位于显存,但可以通过纹理缓存加速读取。在纹理存储器中可以绑定的数据比在常量存储器可以声明的64K大很多,并且支持一维、二维或者三维纹理。在通用计算中,纹理存储器十分适合用于实现图像处理或查找表,并且对数据量较大时的随机数据访问或者非对齐访问也有良好的加速效果。
纹理存储器在硬件中并不对应一块专门的存储器,而实际上是牵涉到显存、两级纹理缓存、纹理抓取单元的纹理流水线。纹理存储器提供了地址映射、数据滤波、缓存等功能,这些功能都是围绕着纹理渲染的需求设计的。关于GPU纹理流水线的介绍可以参考本书3.3.3节。在CUDA编程模型中,纹理缓存是透明的,编程人员不用去了解它的实现机制。
从CUDA的内核函数访问纹理存储器的操作被称为纹理拾取(texture fetching)。纹理拾取使用的坐标与数据在显存中的地址可以不同,两者通过纹理参照系(texture reference)约定从数据的地址到纹理坐标的映射方式。将显存中的数据与纹理参照系关联的操作,称为将数据与纹理绑定(texture binding)。显存中可以绑定到纹理的数据有两种,分别是普通的线性内存(Linear Memroy)和CUDA数组(CUDA Array)。CUDA数组则为纹理访问进行了优化,并且在Device端中只能通过纹理拾取访问。
绑定到纹理的线性内存和数组中的元素被称为像元(texels),是texture elements的缩写。像元的数据类型可以是其中的元素可以是CUDA中规定的1,2或者4元组(不能是3元组)的有符号或者无符号8-,16-,32-bit整型或者16-bit(目前只能通过driver API支持)整型,以及32-bit浮点型数据。与CUDA数组绑定的纹理参照系中的元素使用的N-元组数据中的组件数量必须与CUDA数组相同。
纹理缓存有两个作用。首先,纹理缓存中的数据可以被重复利用,当一次访问需要的数据已经存在于纹理缓存中时,就可以避免对显存进行读取。数据重用过滤了一部分对显存的访问,节约了带宽,也不必按照显存对齐的要求读取。第二,纹理缓存可以缓存拾取坐标附近几个像元的数据,可以实现滤波模式,也能提高具有一定局部性的访问的效率。
纹理存储器是只读的,不需要关心缓存数据一致性问题。这意味着如果更改了绑定到纹理存储器的数据,纹理缓存中的数据可能并没有被更新,此时通过纹理拾取得到的数据可能是错误的。因此,在每次修改了绑定到纹理的数据以后,都需要对纹理进行重新绑定。由于不能从设备端修改CUDA数组,因此只有在对绑定到纹理的线性内存进行修改时才需要注意这一点。
线性内存中的数据只能与一维纹理绑定,并且纹理拾取坐标是定点型,坐标的值也与数据在线性内存中的偏移量相同;而CUDA数组可以与一维、二维或者三维纹理绑定,纹理拾取坐标是浮点型,并且支持许多特殊功能。纹理存储器的特殊功能有:
浮点型纹理拾取坐标:使用浮点型的纹理拾取坐标对纹理进行寻址,只对与CUDA数组绑定的存储器有效。地址映射的方式可以是归一化或者非归一化的:使用归一化纹理时,纹理在每个维度上的坐标被映射到浮点数[0.0, 1.0)范围内;使用非归一化纹理坐标时,各个维度上的坐标则被映射到浮点数[0.0, N)的范围内,其中N是纹理在该维度上像元的数量。由于在GPU中通常用浮点计算点的坐标,因此使用浮点数作为纹理拾取坐标更加自然;使用归一化的纹理拾取坐标可以不用关心纹理的实际尺寸,简化了渲染程序的编写。
寻址模式:寻址模式规定了纹理拾取的输入坐标超出纹理寻址范围时的行为,有钳位模式和循环模式两种。使用钳位模式时,当输入的坐标超出了寻址范围,输入的值将被“钳位”到寻址范围的最大值或者最小值;循环模式只对归一化坐标有效,此时要对超出寻址范围的纹理坐标作求模等处理。例如,对映射到[0.0, 1.0)的归一化纹理坐标,输入拾取坐标1.25,钳位模式会将输入按照0.99999…处理,而循环模式会将输入0.25处理。
类型转换:如果像元中的数据是8-bit或者16-bit定点型,类型转换功能对拾取的返回值进行类型转换,将其映射到归一化的浮点范围[0.0f, 1.0f](对无符号整型)或者[ -1.0f, 1.0f](对有符号整型)。
滤波:如果将返回类型是浮点型的CUDA数组与纹理绑定,那么就可以对返回的值进行滤波。滤波模式可以是最近点取样模式或者线性滤波模式两种。最近点模式返回与浮点型的纹理抓取坐标最近像元的值,而线性滤波模式则会先取出附近几个像元,然后按照抓取坐标与这几个像元的距离进行线性插值,返回线性插值得到的值。线性滤波可以使纹理渲染得到的画面更加平滑自然。线性滤波需要的插值计算不需要可编程单元参与,提供了额外的浮点处理能力,但精度较低。使用线性滤波模式返回的值经过了插值处理,适合用于图像处理;使用最近点取样模式的返回值不会改变纹理中像元的值,适合用于实现查找表。
关于纹理拾取模式的详细描述,可以参考附录F。
使用纹理存储器时,首先要在主机端声明需要绑定到纹理的线性存储器或CUDA数组,并设置好纹理参照系,然后将纹理参照系与线性内存或者CUDA数组绑定。在主机端完成配置工作后,就可以在内核函数中通过纹理抓取函数访问纹理存储器了。
在显存中可以分配的空间有两种:CUDA 数组和线性内存。此外,常数存储器中通过缓存加速读取的数据实际也存在于显存中。CUDA数组和线性内存都可以与纹理参照系绑定,但CUDA数组对纹理拾取访问进行了优化,在设备端也只能通过纹理拾取访问。
声明CUDA数组之前,必须先以结构体channelDesc
描述CUDA数组中的像元的数据类型。
struct cudaChannelFormatDesc {
int x, y, z, w;
enum cudaChannelFormatKind f;
}
其中,x, y, z和w
分别是每个返回值成员的位数,而f是一个枚举变量,可以取一下几个值:
cudaChannelFormatKindSigned
,如果这些成员是有符号整型;
cudaChannelFormatKindUnsigned
,如果这些成员是无符号整型;
cudaChannelFormatKindFloat
,如果这些成员是浮点型;
然后,我们要确定CUDA数组的维度和尺寸。CUDA数组可以通过cudaMalloc3DArray()
或cudaMallocArray()
函数分配。用cudaMalloc3DArray()
可以分配一维、二维或者三维的CUDA数组,而cudaMallocArray()
一般用于分配二维CUDA数组。在使用完CUDA数组后,要使用cudaFreeArray()
函数释放显存。
由cudaMalloc3DArray分配的CUDA数组使用cudaMemcpy3D()
完成与其他CUDA数组或者线性内存的数据传输。CUDA API中使用结构体cudaExtent描述3D Array和3D线性内存在三个维度上的尺寸,在描述一维、二维和三维数组分别用以下的形式:
cudaextent extent = make_cudaextent([1,8192],0,0);
cudaextent extent = make_cudaextent([1,65535],[1,32768],0);
cudaextent extent = make_cudaextent([1,2048],[1,2048],[1,2048]);
其中方括号[]内为允许的寻址范围。注意到二维CUDA数组的第一个维度的寻址范围大于一维CUDA数组的寻址范围,因此在一维CUDA数组的尺寸不够用时,将二维CUDA数组的第二个维度设为1代替一维CUDA数组,获得更大的寻址范围。
下面是声明一个数据类型为char2型,宽×高×深为64×32×16的CUDA 3D数组,对其初始化,最后释放数组的示例代码:
cudaChannelFormatDesc channelDesc = cudaCreateChannelDesc(8, 8, 0, 0,cudaChannelFormatKindunsigned); //每个像元由两个char构成
cudaExtent extent = make_cudaextent(64,32,16);//建立cudaExtent结构体,描述CUDA数组的维度和尺寸
cudaArray* cuArray;
// 使用extent来设定cuda数组的尺寸
cudaMalloc3DArray(&cuArray, &channelDesc, extent); //为cuArray开辟空间
缺
cudaFreeArray(cuArray);
下面则是使用cudaMallocArray声明一个由float型构成,尺寸为64×32的CUDA数组,对其赋值,并最后释放的示例代码:
// 设定cuda中每个像元的数据类型
cudaChannelFormatDesc channelDesc = cudaCreateChannelDesc(32, 0, 0, 0,cudaChannelFormatKindunsigned); //每个像元由一个float构成
cudaArray* cuArray;
// 直接使用参数来设定cuda数组的尺寸
cudaMallocArray(&cuArray, &channelDesc, 64, 32); //为cuArray开辟空间
cudaMemcpyToArray(cuArray, 0, 0, h_data, &channelDesc);//第二和第三个参数分别表示在宽度和高度上的偏移量,假设h_data中的数据已经初始化
cudaFreeArray(cuArray);
用于在CUDA数组和主机端或者设备端线性内存,以及在CUDA数组间传输数据的函数还有很多,这些还是还有一些异步调用版本,关于这些函数的具体使用方法请参考CUDA Reference mannual。
纹理参照系中的一些属性必须在编译时之前被显示声明。纹理参考只能被声明为全局静态变量,且不能作为函数的参数传递。纹理参照系通过一个作用范围为全文件的texture型变量声明:
texture texRef;
其中,Type
确定了由纹理拾取返回的数据类型;Type可以是B3.1节中描述的任意一种由基本整型或者单精度浮点型组成能的1-,2-或者4-元组向量类型。
Dim
确定了纹理参照系的维度,默认为1。
ReadMode
可以是cudaReadModeNormalizedFloat
或者cudaReadModeElementType
。如果ReadMode是cudaReadModeNomalizedFloat
,并且Type是16-或者8-bit整型,那么返回的值将是一个浮点数。此时,原来整形的值域会被映射到[0.0,1.0](对无符号整型),或者[-1.0,1.0](对有符号整型)。例如,一个值为0xff的8-bit无符号整型会被映射为1.0f。如果使用cudaReadModeElementType
,那么就不会对输出进行转换。ReadMode是一个可选参数,如果不写,那么默认就是cudaReadModeElementType
。
例如,下面的代码声明了一个二维,像元数据为unsigned char型,但将返回值转换为float型的纹理参照系:
texture texRef;
纹理参照系中的其它属性可以不必声明,并在运行时进行修改。这些参数规定了纹理的寻址模式,是否进行归一化,以及纹理滤波。runtimeAPI拥有底层的C风格和高层的C++风格两种接口。高层API中的texture类型是从底层的textureReference中派生(继承)而来的。TextureReference是一个下面的代码描述的结构体。
struct textureReference {
int normalized;
enum cudaTextureFilterMode filterMode;
enum cudaTextureAddressMode addressMode[3];
struct cudaChannelFormatDesc channelDesc;
};
normalized
设置是否对纹理坐标是否进行归一化。如果normalized是一个非零值,那么就会使用归一化到[0,1)的坐标进行寻址,否则对尺寸为width, height, depth的纹理使用坐标[0,width-1], [0,height-1], [0,depth-1]寻址。例如,一个尺寸为64×32的纹理可以通过x维度范围为[0,63],y维度范围[0,31]的坐标寻址。如果采用归一化方式对尺寸为64×32的纹理进行寻址,在x和y维度上的坐标就都是[0.0,1.0)。这样就可以保证纹理的坐标与纹理的尺寸无关。
filterMode
用于设置纹理的滤波模式,即如何根据坐标计算返回的纹理值。滤波模式可以是cudaFilterModePoint
或者cudaFilterModeLinear
。滤波模式为CudaFilterModePoint
时,返回值是与坐标最接近的像元的值。CudaFilterModeLinear
模式只能对返回值为浮点型的纹理使用,启用这一种模式时将拾取纹理坐标周围的像元,然后根据坐标与这些像元之间的距离进行插值计算。对一维纹理可以使用线性滤波,对二维纹理可以使用双线性滤波。返回值会是对最接近纹理坐标的两个像元(对一维纹理),四个像元(对二维纹理)或者八个像元(对三维纹理)进行插值后得到的值。
addressmode
说明了寻址模式,即如何处理超出寻址范围的纹理坐标;addressmode
是一个大小为3的数组,三个元素分别说明对第一、二、三个纹理坐标的取址模式;取址模式可以是cudaAddressModeClamp
或cudaAddressModeWrap
中的一种,前者将超出寻址范围的纹理坐标”钳位”到寻址范围内的最大或最小值,后者将超出寻址范围的纹理坐标“折叠”进合理范围。cudaAddressModeWrap
只支持归一化的纹理坐标。
对非归一化的坐标,如果寻址的坐标超过了范围[0,N],大于N的坐标将被钳位,设为N-1。
对归一化的坐标,有钳位和循环两种处理方式,在钳位方式下,超过[0.0,1.0)范围的坐标将被钳位到[0.0,1.0);循环方式一般用于周期循环纹理,它只使用了纹理坐标中有用的小数部分,例如1.25会被当作0.25处理,而-1.25则会被当成0.75处理。
channelDesc
描述纹理获取返回值类型(即像元的类型),我们已经在3.2.4.1小节讲解CUDA array时介绍过这个结构体。纹理参照系的返回值类型描述必须和与之绑定的CUDA array的数据类型描述相同(即两者返回的像元类型必然相同),或者和与之绑定的线性内存中的元素类型相同。
normalized
, addressMode和filterMode
可以直接在主机端代码中修改。它们只适用于与CUDA数组绑定的纹理参照系。
附录D中列出了关于纹理拾取的更多信息。
kernel能用纹理参照系从纹理内存中读数据前,纹理参照系必须通过cudaBindTexture()
或cudaBindTextureToArray()
绑定到纹理上。cudaUnbindTexture()
用于解除纹理参照系的绑定。
以下代码示例绑定一个纹理参照系到devPtr指向的线性内存:
使用低级API:
texture texRef;
// 纹理参照系
textureReference* texRefPtr;
// 通过texture获取纹理参考系
cudaGetTextureReference(&texRefPtr, “texRef”);
cudaChannelFormatDesc channelDesc = cudaCreateChannelDesc();
cudaBindTexture2D(0, texRefPtr, devPtr, &channelDesc, width, height, pitch);
使用高级API
// texture直接继承了textureReference
texture texRef;
cudaChannelFormatDesc channelDesc = cudaCreateChannelDesc();
cudaBindTexture2D(0, texRef, devPtr, &channelDesc, width, height, pitch);
以下代码示例绑定纹理参照系到一个CUDA数组cuArray:
使用低级API:
texture texRef;
textureReference* texRefPtr;
cudaGetTextureReference(&texRefPtr, “texRef”);
cudaChannelFormatDesc channelDesc;
cudaGetChannelDesc(&channelDesc, cuArray);
// 将纹理参照系与cuda数组绑定
cudaBindTextureToArray(texRef, cuArray, &channelDesc);
使用高级API:
texture texRef;
cudaBindTextureToArray(texRef, cuArray);
当绑定一个纹理到纹理参照系时,格式必须与声明纹理参照系时的参数匹配;否则,纹理获取的结果是undefined的。
纹理拾取函数采用纹理坐标对纹理存储器进行访问。
对与线性内存绑定的纹理,使用texfetch1D函数访问,采用的纹理坐标是整型。由cudaMallocPitch或者cudaMalloc3D分配的线性空间实际上仍然是经过填充、对齐的一维线性空间,因此也用texfetch1D()函数访问。
对与一维、二维和三维CUDA数组绑定的问哪里,分别使用tex1D()、tex2D()和tex3D()函数访问,并且使用浮点型纹理坐标。
关于纹理拾取函数的更多讨论,请见本书附录D.8
// 2D float texture
texture texRef;
// Simple transformation kernel
__global__ void transformKernel(float* output, int width, int height, float theta) {
// 根据tid bid计算归一化的拾取坐标
unsigned int x = blockIdx.x * blockDim.x + threadIdx.x;
unsigned int y = blockIdx.y * blockDim.y + threadIdx.y;
float u = x / (float)width;
float v = y / (float)height;
// 旋转拾取坐标
u -= 0.5f;
v -= 0.5f;
float tu = u * cosf(theta) – v * sinf(theta) + 0.5f;
float tv = v * cosf(theta) + u * sinf(theta) + 0.5f;
// 从纹理存储器中拾取数据,并写入显存
output[y * width + x] = tex2D(tex, tu, tv);
}
// Host code
int main() {
// 分配CUDA数组
// 指定cuda数组中的像元的数据类型
cudaChannelFormatDesc channelDesc = cudaCreateChannelDesc(32, 0, 0, 0,cudaChannelFormatKindFloat);
cudaArray* cuArray;
// 分配cuda数组
cudaMallocArray(&cuArray, &channelDesc, width, height);
// Copy to device memory some data located at address h_data in host memory
cudaMemcpyToArray(cuArray, 0, 0, h_data, size, cudaMemcpyHostToDevice);
// Set texture parameters
texRef.addressMode[0] = cudaAddressModeWrap; //循环寻址方式
texRef.addressMode[1] = cudaAddressModeWrap;
texRef.filterMode = cudaFilterModeLinear; //线性滤波,因为这里是一个图像。如果要保持原来的值则千万不要用线性滤波
texRef.normalized = true; //归一化坐标
// Bind the array to the texture (将纹理参照系与cuda数组进行绑定)
cudaBindTextureToArray(texRef, cuArray, channelDesc);
// Allocate result of transformation in device memory
float* output;
cudaMalloc((void**)&output, width * height * sizeof(float));
// Invoke kernel
dim3 dimBlock(16, 16);
dim3 dimGrid((width + dimBlock.x –1) / dimBlock.x,(height + dimBlock.y –1) / dimBlock.y);
// 执行内核
transformKernel<<>>(output, width, height,angle);
// Free device memory
cudaFreeArray(cuArray);
cudaFree(output);
}
当主机上的CUDA程序调用内核网格,网格内块枚举并分发到有可用执行资源的多处理器上。线程块内线程在一个多处理器上并发执行且多个块可在一个流多处理器上并发执行。线程块终止时,便在空闲多处理器上发射新块。
内核网格Grid(对应GPU)->线程块Block(一个Block对应一个多处理器)->线程Thread(一个线程对应一个标量处理器)
一个GPU->多个多处理器,一个多处理器->多个标量处理器
多处理器以32个为一组创建、管理、调度和执行并行线程,这32个线程称为束(warps)。束内包含的不同线程从同一程序地址开始,但它们有自己的指令地址计数器和寄存器状态,因此可自由分支和独立执行。
当多处理器得到一个或多个块执行,它会将块分割成束以执行,束被束调度器调度。块分割成束的方式总是相同的;束内线程是连续的,递增线程ID,第一个束包含线程0。
束每次执行一个相同的指令,所以如果束内所有32个线程在同一条路径上执行的话,会达到最高效率。如果由于数据依赖条件分支导致束分岔,因为SIMT(单指令多线程)的特性,束会顺序执行每个分支路径,而禁用不在此路径上的线程,直到所有路径完成,线程重新汇合到同一执行路径,这样子执行的速度会很慢。分支岔开只会在同一束内发生;不同的束独立执行不管它们是执行相同或不同的代码路径。
束内线程因为控制流导致分叉,会使束内线程的执行效率大大降低,所以应该尽量使束内线程执行相同的指令,此时的执行效率最高,不要因为控制指令导致束内线程执行分叉。
应当把每个处理器最擅长的任务分配给它:串行工作分配给主机;并行工作分配给设备。
对于并行工作,在算法中,由于某些线程为了与其它线程共享数据而同步导致并行性中断的点,有两种情况:
应用应当最大化设备内多处理器间的并行执行。
应用应当最大化多处理器内部的各种功能单元的并行执行。
在每次指令发射时,束调度器选择一个已准备好执行的束并将下个指令发射给束内的活动线程。束准备执行下一条指令花费的时钟周期数称为延迟,如果在延迟期间,每个时针周期内,束调度器有一些指令可在某些束未被发射就可获得完全的利用,或换句话说,每个束的延迟可被其它束完全隐藏。
因为多处理器是以束为单位进行管理,所以如果可能的话,每个块的线程数应当是束尺寸的整数倍以避免因为束内线程不足而浪费计算资源。
第一步是最小化低带宽的数据传输。这意味着要最小化主机存储器和设备存储器间的数据传输,也意味着通过最大化片上存储器使用以最小化全局存储器和设备间的数据传输:如共享存储器和缓存。
一个常用的编程模式是将来自设备存储器的数据存储到共享存储器,也就是让块内的每个线程:
当一个束执行一条访问全局存储器的指令时,它会合并束内线程的存储器的访问成一次或多次,这依赖于每个线程访问的字的尺寸和线程间存储器地址分布。
尺寸和对齐要求
全局存储器指令支持读写长度为1、2、4、8或16字节的字。任何对全局存储器的数据访问(通过变量或指针)编译成一次单独的全局存储器指令当且仅当数据类型的尺寸为1、2、4、8或16字节并且数据是天然对齐的(即地址是尺寸的倍数)。
如果尺寸和对齐要求没有满足,访问被编译成交叉访问的多条指令,这不能完全合并。因此建议对全局存储器中的数据使用满足要求的数据类型。
像float2或float4这样的内置数据类型自动满足对齐要求。
对于结构体,尺寸和对齐要求可用对齐修饰符__align__(8)或__align__(16)让编译器保证。
struct __align__(8) {
float x;
float y;
}
struct __align__(16) {
float x;
float y;
float z;
}
读没有天然对齐的8字节或16字节的字可能会产生错误的结果(偏离一些字),所以要特别注意保证任何值或数组的起始地址对齐。 一个典型的容易忽视的例子是使用一些自定义的全局存储器分配模式,如使用一次大的分配并为各数组划分分配的存储器以替代多个数组的分配(使用多次cudaMalloc()或cuMemAlloc()),这种情况下,每个数组的起始地址是偏离块的起始地址的。
二维数组
一个常见的全局存储器访问模式是各个索引为(tx,ty)的线程使用下面的地址访问类型为type*、位于地址BaseAddress、宽为width的二维数组的一个元素:
BaseAddress + width ∗ ty + tx
为了全部满足合并访问,width和线程块的宽度必须是束的整数倍(或对于计算能力1.x的设备是半束的整数倍)。
特别地,这意味着宽度不是束的整数倍的数组,在分配空间的时候行向上填充到最近的束的整数倍时,会更有效。
编译器可能放入本地存储器的自动变量是:
本地存储器存在于设备存储器空间,所有本地存储器访问延迟像全局存储器一样高,带宽和全局存储器一样低,且服从于5.3.2描述的存储器合并访问要求。本地存储器的组织使得连续的线程ID访问连续的32位字。只要束内所有线程访问同一相对地址(如数组变量的同一索引,结构体变量的同一成员)访问就可完全合并。
由于共享存储器位于芯片上,因而共享存储器空间比本地和全局存储器空间的速度都要快得多。
为了获得较高的存储器带宽,共享存储器被划分为多个大小相等的存储器模块,称为存储体(bank),存储体可同步被访问。因此,对落入n个不同存储体的n个地址的任何存储器读取或写入请求都可同时实现,整体带宽可达到单独一个模块的带宽的n倍。
但若一个存储器请求的两个地址落入同一个存储体内,就会出现存储体冲突(即bank冲突),访问必须序列化(即不能够并行访问)。硬件会在必要时将存在存储体冲突的存储器请求分割为多个不冲突的请求,此时有效带宽将降低为原带宽除以分离后的存储器请求的数量。如果分离后的存储器请求数量为n,就可以说初始存储器请求导致了n路存储体冲突。
可以使用CUDA内置的算术指令来代替原来的运算符,速度会更快。
任何流控制指令(if、switch、do、for、while)都会导致同一束的线程分支(即走向不同的执行路径),从而显著影响有效指令吞吐量。如果出现这种情况,不同的执行路径必须序列化,因而增加了为该束执行的指令总数,束的执行时间大大增加。当完成所有不同的执行路径时,线程将重新汇聚到同一执行路径。
为了在控制流依赖线程ID的情况下获得最佳性能,应控制条件以最小化分支束的数量。这是可行的,因为束在块内的分布情况是确定的,如4.1所提到的。一个简单的例子就是,当控制条件仅依赖于(threadIdx / warpSize)时,其中的warpSize是束的大小,这种情况下,不会出现任何束内分支,因为控制条件与束完美对齐。
有些时候,编译器可能会展开循环或使用分支谓词来优化if 或switch语句,下面将详细说明。在这些情况下,不会有任何warp分支。程序员还可使用#pragma unroll伪指令控制循环的展开(请参见B.20)。
对于计算能力1.x的设备, syncthreads()是每个时钟周期8个操作,对于计算能力2.x的设备每时钟周期16个操作,对于计算能力3.x的设备,每周期128个操作。
注意; syncthreads()能通过强制某些多处理器闲着从而影响性能,细节见4.3.2。