2023年的深度学习入门指南(10) - CUDA编程基础

本文介绍了CUDA编程的基础,包括获取CUDA设备信息、理解线程块和线程网格的概念,以及如何使用GPU计算正弦值。文章还展示了如何使用cuBLAS库进行矩阵乘法,强调了GPU编程中的并发特性和适合GPU的算法选择。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

2023年的深度学习入门指南(10) - CUDA编程基础

上一篇我们走马观花地看了下SIMD和GPGPU的编程。不过线条太粗了,在开发大模型时遇到问题了肯定还会晕。
所以我们还是需要深入到CUDA中去探险一下。

获取CUDA设备信息

在使用CUDA设备之前,首先我们得获取是否支持CUDA,有几个设备。这个可以通过cudaGetDeviceCount

    int deviceCount;
    cudaError_t cudaError;
    cudaError = cudaGetDeviceCount(&deviceCount);

    if (cudaError == cudaSuccess) {
        cout << "There are " << deviceCount << " cuda devices." << endl;
    }

获取了支持多少个设备了之后,我们就可以遍历设备去用cudaGetDeviceProperties函数去查看设备信息了。

    for (int i = 0; i < deviceCount; i++)
    {
        cudaError = cudaGetDeviceProperties(&props, i);

        if (cudaError == cudaSuccess) {
            cout << "Device Name: " << props.name << endl;
            cout << "Compute Capability version: " << props.major << "." << props.minor << endl;
        }
    }

这是我在我的电脑上输出的结果:

There are 1 cuda devices.
Device Name: NVIDIA GeForce RTX 3060
Compute Capability version: 8.6
struct cudaDeviceProp {
              char name[256];
              cudaUUID_t uuid;
              size_t totalGlobalMem;
              size_t sharedMemPerBlock;
              int regsPerBlock;
              int warpSize;
              size_t memPitch;
              int maxThreadsPerBlock;
              int maxThreadsDim[3];
              int maxGridSize[3];
              int clockRate;
              size_t totalConstMem;
              int major;
              int minor;
              size_t textureAlignment;
              size_t texturePitchAlignment;
              int deviceOverlap;
              int multiProcessorCount;
              int kernelExecTimeoutEnabled;
              int integrated;
              int canMapHostMemory;
              int computeMode;
              int maxTexture1D;
              int maxTexture1DMipmap;
              int maxTexture1DLinear;
              int maxTexture2D[2];
              int maxTexture2DMipmap[2];
              int maxTexture2DLinear[3];
              int maxTexture2DGather[2];
              int maxTexture3D[3];
              int maxTexture3DAlt[3];
              int maxTextureCubemap;
              int maxTexture1DLayered[2];
              int maxTexture2DLayered[3];
              int maxTextureCubemapLayered[2];
              int maxSurface1D;
              int maxSurface2D[2];
              int maxSurface3D[3];
              int maxSurface1DLayered[2];
              int maxSurface2DLayered[3];
              int maxSurfaceCubemap;
              int maxSurfaceCubemapLayered[2];
              size_t surfaceAlignment;
              int concurrentKernels;
              int ECCEnabled;
              int pciBusID;
              int pciDeviceID;
              int pciDomainID;
              int tccDriver;
              int asyncEngineCount;
              int unifiedAddressing;
              int memoryClockRate;
              int memoryBusWidth;
              int l2CacheSize;
              int persistingL2CacheMaxSize;
              int maxThreadsPerMultiProcessor;
              int streamPrioritiesSupported;
              int globalL1CacheSupported;
              int localL1CacheSupported;
              size_t sharedMemPerMultiprocessor;
              int regsPerMultiprocessor;
              int managedMemory;
              int isMultiGpuBoard;
              int multiGpuBoardGroupID;
              int singleToDoublePrecisionPerfRatio;
              int pageableMemoryAccess;
              int concurrentManagedAccess;
              int computePreemptionSupported;
              int canUseHostPointerForRegisteredMem;
              int cooperativeLaunch;
              int cooperativeMultiDeviceLaunch;
              int pageableMemoryAccessUsesHostPageTables;
              int directManagedMemAccessFromHost;
              int accessPolicyMaxWindowSize;
          }

我们择其要者介绍几个吧:

  • totalGlobalMem是设备上可用的全局内存总量,以字节为单位。
  • sharedMemPerBlock是一个线程块可用的最大共享内存量,以字节为单位。
  • regsPerBlock是一个线程块可用的最大32位寄存器数量。
  • warpSize是线程束的大小,以线程为单位。
  • memPitch是涉及通过cudaMallocPitch()分配的内存区域的内存复制函数允许的最大间距,以字节为单位。
  • maxThreadsPerBlock是每个块的最大线程数。
  • maxThreadsDim[3]包含了一个块的每个维度的最大尺寸。
  • maxGridSize[3]包含了一个网格的每个维度的最大尺寸。
  • clockRate是时钟频率,以千赫为单位。
  • totalConstMem是设备上可用的常量内存总量,以字节为单位。
  • major, minor是定义设备计算能力的主要和次要修订号。
  • multiProcessorCount是设备上多处理器的数量。
  • memoryClockRate是峰值内存时钟频率,以千赫为单位。
  • memoryBusWidth是内存总线宽度,以位为单位。
  • memoryPoolsSupported 是 1,如果设备支持使用 cudaMallocAsync 和 cudaMemPool 系列 API,否则为 0
  • gpuDirectRDMASupported 是 1,如果设备支持 GPUDirect RDMA API,否则为 0
  • gpuDirectRDMAFlushWritesOptions 是一个按照 cudaFlushGPUDirectRDMAWritesOptions 枚举解释的位掩码
  • gpuDirectRDMAWritesOrdering 参见 cudaGPUDirectRDMAWritesOrdering 枚举的数值
  • memoryPoolSupportedHandleTypes 是一个支持与 mempool-based IPC 的句柄类型的位掩码
  • deferredMappingCudaArraySupported 是 1,如果设备支持延迟映射 CUDA 数组和 CUDA mipmapped 数组
  • ipcEventSupported 是 1,如果设备支持 IPC 事件,否则为 0
  • unifiedFunctionPointers 是 1,如果设备支持统一指针,否则为 0

有了更多的信息,我们输出一些看看:

    for (int i = 0; i < deviceCount; i++)
    {
        cudaError = cudaGetDeviceProperties(&props, i);

        if (cudaError == cudaSuccess) {
            cout << "Device Name: " << props.name << endl;
            cout << "Compute Capability version: " << props.major << "." << props.minor << endl;
            cout << "设备上可用的全局内存总量:(G字节)" << props.totalGlobalMem / 1024 / 1024 / 1024 << endl;
            cout << "时钟频率(以MHz为单位):" << props.clockRate / 1000 << endl;
            cout << "设备上多处理器的数量:" << props.multiProcessorCount << endl;
            cout << "每个块的最大线程数:" << props.maxThreadsPerBlock <<endl;
            cout << "内存总线宽度(位)" << props.memoryBusWidth << endl;
            cout << "一个块的每个维度的最大尺寸:" << props.maxThreadsDim[0] << ","<< props.maxThreadsDim[1] << "," << props.maxThreadsDim[2] << endl;
            cout << "一个网格的每个维度的最大尺寸:" << props.maxGridSize[0] << "," << props.maxGridSize[1] << "," << props.maxGridSize[2] <<endl;
        }
    }

在我的3060显卡上运行的结果:

Device Name: NVIDIA GeForce RTX 3060
Compute Capability version: 8.6
设备上可用的全局内存总量:(G字节)11
时钟频率(以MHz为单位):1777
设备上多处理器的数量:28
每个块的最大线程数:1024
内存总线宽度(位)192
一个块的每个维度的最大尺寸:1024,1024,64
一个网格的每个维度的最大尺寸:2147483647,65535,65535

线程块和线程网格

在CUDA中,线程块(block)和线程网格(grid)是两个非常重要的概念,它们用于描述GPU执行并行任务时的线程组织方式。线程块是由若干个线程(thread)组成的,它们可以在同一个GPU多处理器(multiprocessor)上并行执行。线程网格则是由若干个线程块组成的,它们可以在整个GPU设备上并行执行。每个线程块和线程网格都有一个唯一的索引,用于在CUDA C/C++的GPU核函数中对线程进行标识和控制。

在CUDA中,使用dim3结构体来表示线程块和线程网格的维度。例如,dim3(2,2)表示一个2D线程网格,其中有2x2=4个线程块;dim3(2,2,2)表示一个3D线程块,其中有2x2x2=8个线程。在启动GPU核函数时,可以使用<<< >>>的语法来指定线程网格和线程块的大小,例如:

dim3 dimGrid(2, 2);
dim3 dimBlock(2, 2, 2);
myKernel<<<dimGrid, dimBlock>>>(...);

这里使用dimGrid和dimBlock指定了线程网格和线程块的大小,然后调用myKernel函数,并传递必要的参数。在执行GPU核函数时,CUDA会按照指定的线程网格和线程块的大小启动对应的线程,并对它们进行分配和协作,从而完成任务的并行执行。线程块和线程网格的组织方式和大小都可以根据具体的应用场景和硬件环境进行调整和优化,以实现最优的性能和效率。

我们再看下在核函数中如何使用线程网格和线程块。

__global__ void testKernel(int val) {
    printf("[%d, %d]:\t\tValue is:%d\n", blockIdx.y * gridDim.x + blockIdx.x,
        threadIdx.z * blockDim.x * blockDim.y + threadIdx.y * blockDim.x +
        threadIdx.x,
        val);
}

上面有几个点我们需要解释一下:

  • __global__:并不是表明这是一个全局函数,而是表明这是一个GPU核函数。
  • blockIdx:是一个内置的变量,表示当前线程所在的块(block)的索引。它是一个结构体类型,包含了三个成员变量,分别表示当前块在x、y、z三个维度上的索引值。
  • threadIdx:也是一个内置的变量,表示当前线程在所在的块中的索引。它也同样是一个结构体类型,包含了三个成员变量,分别表示当前线程在x、y、z三个维度上的索引值。
  • blockDim:同样是一个内置的变量,表示每个块(block)的维度(dimension),包括x、y、z三个维度。

在CUDA中,每个核函数(kernel function)被分配到一个或多个块(block)中执行,每个块包含若干个线程(thread),它们可以在GPU上并行执行。通过访问blockIdx的成员变量,可以确定当前线程所在的块在哪个位置,从而在核函数中进行特定的计算。例如,可以使用blockIdx.x表示当前线程所在的块在x轴上的索引值。在CUDA编程中,通常需要使用blockIdx和threadIdx来确定每个线程在整个GPU并行执行中的唯一标识,以便进行任务的分配和协作。

然后将dimGrid和dimBlock传给testKernel.

    // Kernel configuration, where a two-dimensional grid and
    // three-dimensional blocks are configured.
    dim3 dimGrid(2, 2);
    dim3 dimBlock(2, 2, 2);
    testKernel << <dimGrid, dimBlock >> > (10);

将下面的文件保存为kernel.cu,然后通过nvcc命令编译,最后运行生成的可执行文件就可以了。

// System includes
#include <stdio.h>
#include <assert.h>
#include <iostream>

// CUDA runtime
#include <cuda_runtime.h>

using namespace std;

__global__ void testKernel(int val) {
    printf("[%d, %d]:\t\tValue is:%d\n", blockIdx.y * gridDim.x + blockIdx.x,
        threadIdx.z * blockDim.x * blockDim.y + threadIdx.y * blockDim.x +
        threadIdx.x,
        val);
}

int main(int argc, char** argv) {
    int devID;
    cudaDeviceProp props;

    int deviceCount;
    cudaError_t cudaError;
    cudaError = cudaGetDeviceCount(&deviceCount);

    if (cudaError == cudaSuccess) {
        cout << "There are " << deviceCount << " cuda devices." << endl;
    }

    for (int i = 0; i < deviceCount; i++)
    {
        cudaError = cudaGetDeviceProperties(&props, i);

        if (cudaError == cudaSuccess) {
            cout << "Device Name: " << props.name << endl;
            cout << "Compute Capability version: " << props.major << "." << props.minor << endl;
            cout << "设备上可用的全局内存总量:(G字节)" << props.totalGlobalMem / 1024 / 1024 / 1024 << endl;
            cout << "时钟频率(以MHz为单位):" << props.clockRate / 1000 << endl;
            cout << "设备上多处理器的数量:" << props.multiProcessorCount << endl;
            cout << "每个块的最大线程数:" << props.maxThreadsPerBlock <<endl;
            cout << "内存总线宽度(位)" << props.memoryBusWidth << endl;
            cout << "一个块的每个维度的最大尺寸:" << props.maxThreadsDim[0] << ","<< props.maxThreadsDim[1] << "," << props.maxThreadsDim[2] << endl;
            cout << "一个网格的每个维度的最大尺寸:" << props.maxGridSize[0] << "," << props.maxGridSize[1] << "," << props.maxGridSize[2] <<endl;
        }
    }

    // Kernel configuration, where a two-dimensional grid and
    // three-dimensional blocks are configured.
    dim3 dimGrid(2, 2);
    dim3 dimBlock(2, 2, 2);
    testKernel << <dimGrid, dimBlock >> > (10);
    cudaDeviceSynchronize();

    return EXIT_SUCCESS;
}

前面输出的不管,我们只看后面32个线程的结果:

[1, 0]:         Value is:10
[1, 1]:         Value is:10
[1, 2]:         Value is:10
[1, 3]:         Value is:10
[1, 4]:         Value is:10
[1, 5]:         Value is:10
[1, 6]:         Value is:10
[1, 7]:         Value is:10
[0, 0]:         Value is:10
[0, 1]:         Value is:10
[0, 2]:         Value is:10
[0, 3]:         Value is:10
[0, 4]:         Value is:10
[0, 5]:         Value is:10
[0, 6]:         Value is:10
[0, 7]:         Value is:10
[3, 0]:         Value is:10
[3, 1]:         Value is:10
[3, 2]:         Value is:10
[3, 3]:         Value is:10
[3, 4]:         Value is:10
[3, 5]:         Value is:10
[3, 6]:         Value is:10
[3, 7]:         Value is:10
[2, 0]:         Value is:10
[2, 1]:         Value is:10
[2, 2]:         Value is:10
[2, 3]:         Value is:10
[2, 4]:         Value is:10
[2, 5]:         Value is:10
[2, 6]:         Value is:10
[2, 7]:         Value is:10

前面表示线程块,后面表示线程。

大家第一次搞GPU编程的话很容易被绕晕。我来解释一下这个计算方法。其实就是跟用一维数组来模拟多维数组是一个算法。

blockIdx.y * gridDim.x + blockIdx.x表示当前线程所在的线程块在二维线程网格中的唯一标识。其中,gridDim.x表示线程网格在x方向上的线程块数量,blockIdx.x表示当前线程块在x方向上的索引值,blockIdx.y表示当前线程块在y方向上的索引值。

threadIdx.z * blockDim.x * blockDim.y表示当前线程在z方向上的偏移量,即前面所有线程所占用的空间大小。然后,threadIdx.y * blockDim.x表示当前线程在y方向上的偏移量,即当前线程在所在z平面上的偏移量。最后,threadIdx.x表示当前线程在x方向上的偏移量,即当前线程在所在z平面的某一行上的偏移量。

明白这一点之后,我们尝试将每个线程块从8个线程改成12个:

    dim3 dimGrid(2, 2);
    dim3 dimBlock(2, 2, 3);
    testKernel << <dimGrid, dimBlock >> > (12);

运行结果如下:

[0, 0]:         Value is:12
[0, 1]:         Value is:12
[0, 2]:         Value is:12
[0, 3]:         Value is:12
[0, 4]:         Value is:12
[0, 5]:         Value is:12
[0, 6]:         Value is:12
[0, 7]:         Value is:12
[0, 8]:         Value is:12
[0, 9]:         Value is:12
[0, 10]:                Value is:12
[0, 11]:                Value is:12
[1, 0]:         Value is:12
[1, 1]:         Value is:12
[1, 2]:         Value is:12
[1, 3]:         Value is:12
[1, 4]:         Value is:12
[1, 5]:         Value is:12
[1, 6]:         Value is:12
[1, 7]:         Value is:12
[1, 8]:         Value is:12
[1, 9]:         Value is:12
[1, 10]:                Value is:12
[1, 11]:                Value is:12
[3, 0]:         Value is:12
[3, 1]:         Value is:12
[3, 2]:         Value is:12
[3, 3]:         Value is:12
[3, 4]:         Value is:12
[3, 5]:         Value is:12
[3, 6]:         Value is:12
[3, 7]:         Value is:12
[3, 8]:         Value is:12
[3, 9]:         Value is:12
[3, 10]:                Value is:12
[3, 11]:                Value is:12
[2, 0]:         Value is:12
[2, 1]:         Value is:12
[2, 2]:         Value is:12
[2, 3]:         Value is:12
[2, 4]:         Value is:12
[2, 5]:         Value is:12
[2, 6]:         Value is:12
[2, 7]:         Value is:12
[2, 8]:         Value is:12
[2, 9]:         Value is:12
[2, 10]:                Value is:12
[2, 11]:                Value is:12

下面我们正式开启真并发之旅,在上面的48个线程里同时计算正弦。
在GPU里计算,我们CPU上原来的数学库不顶用了,我们要用GPU自己的,在CUDA中我们用__sinf:

__global__ void testKernel(float val) {
    printf("[%d, %d]:\t\tValue is:%f\n", blockIdx.y * gridDim.x + blockIdx.x,
        threadIdx.z * blockDim.x * blockDim.y + threadIdx.y * blockDim.x +
        threadIdx.x,
        __sinf(val* threadIdx.z * blockDim.x * blockDim.y + threadIdx.y * blockDim.x +
            threadIdx.x));
}

main函数里也随便改一个:

    dim3 dimGrid(2, 2);
    dim3 dimBlock(2, 2, 3);
    testKernel << <dimGrid, dimBlock >> > (0.5);

运行结果如下:

[0, 0]:         Value is:0.000000
[0, 1]:         Value is:0.841471
[0, 2]:         Value is:0.909297
[0, 3]:         Value is:0.141120
[0, 4]:         Value is:0.909297
[0, 5]:         Value is:0.141120
[0, 6]:         Value is:-0.756802
[0, 7]:         Value is:-0.958924
[0, 8]:         Value is:-0.756802
[0, 9]:         Value is:-0.958924
[0, 10]:                Value is:-0.279416
[0, 11]:                Value is:0.656986
[1, 0]:         Value is:0.000000
[1, 1]:         Value is:0.841471
[1, 2]:         Value is:0.909297
[1, 3]:         Value is:0.141120
[1, 4]:         Value is:0.909297
[1, 5]:         Value is:0.141120
[1, 6]:         Value is:-0.756802
[1, 7]:         Value is:-0.958924
[1, 8]:         Value is:-0.756802
[1, 9]:         Value is:-0.958924
[1, 10]:                Value is:-0.279416
[1, 11]:                Value is:0.656986
[3, 0]:         Value is:0.000000
[3, 1]:         Value is:0.841471
[3, 2]:         Value is:0.909297
[3, 3]:         Value is:0.141120
[3, 4]:         Value is:0.909297
[3, 5]:         Value is:0.141120
[3, 6]:         Value is:-0.756802
[3, 7]:         Value is:-0.958924
[3, 8]:         Value is:-0.756802
[3, 9]:         Value is:-0.958924
[3, 10]:                Value is:-0.279416
[3, 11]:                Value is:0.656986
[2, 0]:         Value is:0.000000
[2, 1]:         Value is:0.841471
[2, 2]:         Value is:0.909297
[2, 3]:         Value is:0.141120
[2, 4]:         Value is:0.909297
[2, 5]:         Value is:0.141120
[2, 6]:         Value is:-0.756802
[2, 7]:         Value is:-0.958924
[2, 8]:         Value is:-0.756802
[2, 9]:         Value is:-0.958924
[2, 10]:                Value is:-0.279416
[2, 11]:                Value is:0.656986

内存与显存间的数据交换

上面我们是传了一个立即数到GPU核函数。我们距离正式能使用GPU进行CUDA编程,就差分配GPU显存和在显存和内存之间复制了。

同malloc类似,CUDA使用cudaMalloc来分配GPU内存,其原型为:

cudaError_t cudaMalloc(void **devPtr, size_t size);

参数解释:

  • devPtr: 返回分配的设备内存的指针。
  • size: 要分配的内存大小,以字节为单位。

返回值:

  • cudaSuccess: 分配成功。
  • cudaErrorInvalidValue: size为零或devPtr为NULL。
  • cudaErrorMemoryAllocation: 内存分配失败。

一般的用法,记得用完了用cudaFree释放掉:

float* devPtr;
cudaMalloc(&devPtr, size * sizeof(float));
...
cudaFree(devPtr);

分配完内存了,然后就是从内存复制到显存了。同样类似于memcpy,通过cudaMemcpy来完成。

cudaError_t cudaMemcpy(void* dst, const void* src, size_t count, cudaMemcpyKind kind);

参数解释:

  • dst: 目标内存的指针。
  • src: 源内存的指针。
  • count: 要拷贝的内存大小,以字节为单位。
  • kind: 拷贝的类型,可以是:
    • cudaMemcpyHostToHost
    • cudaMemcpyHostToDevice
    • cudaMemcpyDeviceToHost
    • cudaMemcpyDeviceToDevice

返回值:

  • cudaSuccess: 拷贝成功。
  • cudaErrorInvalidValue: count或dst或src为NULL。
  • cudaErrorMemoryAllocation: 内存分配失败。

下面我们来写一个用CUDA计算平方根的例子:

    const int n = 1024;
    size_t size = n * sizeof(float);
    float* h_in = (float*)malloc(size);
    float* h_out = (float*)malloc(size);
    float* d_in, * d_out;

    // Initialize input array
    for (int i = 0; i < n; ++i) {
        h_in[i] = (float)i;
    }

    // Allocate device memory
    cudaMalloc(&d_in, size);
    cudaMalloc(&d_out, size);

    // Copy input data to device
    cudaMemcpy(d_in, h_in, size, cudaMemcpyHostToDevice);

    // Launch kernel
    int threadsPerBlock = 256;
    int blocksPerGrid = (n + threadsPerBlock - 1) / threadsPerBlock;
    sqrtKernel << <blocksPerGrid, threadsPerBlock >> > (d_in, d_out, n);

    // Copy output data to host
    cudaMemcpy(h_out, d_out, size, cudaMemcpyDeviceToHost);

    // Verify results
    for (int i = 0; i < n; ++i) {
        if (fabsf(h_out[i] - sqrtf(h_in[i])) > 1e-5) {
            printf("Error: h_out[%d] = %f, sqrtf(h_in[%d]) = %f\n", i, h_out[i], i, sqrtf(h_in[i]));
        }
    }

    printf("Success!\n");

    // Free memory
    free(h_in);
    free(h_out);
    cudaFree(d_in);
    cudaFree(d_out);

大家关注线程块数和线程数这两个,我们这里没有用多维,就是用两个整数计算的:

    int threadsPerBlock = 256;
    int blocksPerGrid = (n + threadsPerBlock - 1) / threadsPerBlock;
    sqrtKernel << <blocksPerGrid, threadsPerBlock >> > (d_in, d_out, n);

我们用4个块,每个块有256个线程。

此时,就不用计算y和z了,只计算x维度就可以:

int i = blockIdx.x * blockDim.x + threadIdx.x;

但是要注意,blockIdx和threadIdx仍然是三维的,y和z维仍然是有效的,只不过它们变成0了。

我们的核函数这样写:

__global__ void sqrtKernel(float* in, float* out, int n) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i < n) {
        out[i] = sqrtf(in[i]);
        printf("[%d, %d]:\t\tValue is:%f\n", blockIdx.y * gridDim.x + blockIdx.x,
            threadIdx.z * blockDim.x * blockDim.y + threadIdx.y * blockDim.x +
            threadIdx.x, out[i]);
    }
}

当然了,因为block和thread的y和z都是0,跟只写x是没啥区别的:

__global__ void sqrtKernel(float* in, float* out, int n) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i < n) {
        out[i] = sqrtf(in[i]);
        printf("[%d, %d]:\t\tValue is:%f\n", blockIdx.x, threadIdx.x, out[i]);
    }
}

使用封装好的库

除了CUDA运行时之外,针对主要的应用场景,NVidia也提供了很多专门的库。

比如针对矩阵运算,就有cuBLAS库。有的库是跟随CUDA工具包一起安装的,比如cuBLAS, cuFFT。也有的库需要专门下载安装,比如cudnn库。

这里强调一下,所谓的库,不是在核函数中要调用的模块,而是将整个需要在核函数里面要实现的功能全封装好了。所以在使用封装库的时候,并不需要nvcc,就是引用一个库就好了。

我们来看一个使用cuBLAS库来计算矩阵乘法的例子。

cuBLAS库来计算矩阵乘法要用到的主要的函数有4个:

  • cublasCreate: 创建cublas句柄
  • cublasDestroy:释放cublas句柄
  • cublasSetVector:在CPU和GPU内存间复制数据
  • cublasSgemm:矩阵乘法运算
cublasStatus_t cublasSetVector(int n, int elemSize, const void *x, int incx, void *y, int incy)

其中:

  • n 是要拷贝的元素个数
  • elemSize是每个元素的大小(以字节为单位)
  • x是主机端(CPU)内存中的数据起始地址
  • incx是x中相邻元素之间的跨度
  • y是GPU设备内存中的数据起始地址
  • incy是y中相邻元素之间的跨度
cublasStatus_t cublasSgemm(cublasHandle_t handle,
                           cublasOperation_t transa, cublasOperation_t transb,
                           int m, int n, int k,
                           const float *alpha, const float *A, int lda,
                           const float *B, int ldb, const float *beta,
                           float *C, int ldc)

其中:

  • handle是cuBLAS句柄;
  • transa是A矩阵的转置选项,取值为CUBLAS_OP_N或CUBLAS_OP_T,分别表示不转置和转置;
  • transb是B矩阵的转置选项;m、n、k分别是A、B、C矩阵的维度;
  • alpha是一个标量值,用于将A和B矩阵的乘积缩放到C矩阵中;
  • A是A矩阵的起始地址;
  • lda是A矩阵中相邻列之间的跨度;
  • B是B矩阵的起始地址;
  • ldb是B矩阵中相邻列之间的跨度;
  • beta是一个标量值,用于将C矩阵中的值缩放;
  • C是C矩阵的起始地址;
  • ldc是C矩阵中相邻列之间的跨度。

我们简化写一个例子,主要说明函数的用法:

#include <stdio.h>
#include <cuda_runtime.h>
#include <cublas_v2.h>

int main() {
    int m = 1024, n = 1024, k = 1024;
    float* h_A = (float*)malloc(m * k * sizeof(float));
    float* h_B = (float*)malloc(k * n * sizeof(float));
    float* h_C = (float*)malloc(m * n * sizeof(float));
    for (int i = 0; i < m * k; ++i) {
        h_A[i] = (float)i;
    }
    for (int i = 0; i < k * n; ++i) {
        h_B[i] = (float)i;
    }

    float* d_A, * d_B, * d_C;
    cudaMalloc(&d_A, m * k * sizeof(float));
    cudaMalloc(&d_B, k * n * sizeof(float));
    cudaMalloc(&d_C, m * n * sizeof(float));

    // Copy data from host to device
    cublasSetVector(m * k, sizeof(float), h_A, 1, d_A, 1);
    cublasSetVector(k * n, sizeof(float), h_B, 1, d_B, 1);

    // Initialize cuBLAS
    cublasHandle_t handle;
    cublasCreate(&handle);

    // Do matrix multiplication
    const float alpha = 1.0f, beta = 0.0f;
    cublasSgemm(handle, CUBLAS_OP_N, CUBLAS_OP_N, m, n, k,
        &alpha, d_A, m, d_B, k, &beta, d_C, m);

    // Copy data from device to host
    cublasGetVector(m * n, sizeof(float), d_C, 1, h_C, 1);

    // Free memory
    free(h_A);
    free(h_B);
    free(h_C);
    cudaFree(d_A);
    cudaFree(d_B);
    cudaFree(d_C);

    // Destroy cuBLAS handle
    cublasDestroy(handle);

    return 0;
}

当然,上面的只是个例子,没有做错误处理,这样是不对的。
我们参考官方的例子:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/* Includes, cuda */
#include <cublas_v2.h>
#include <cuda_runtime.h>
#include <helper_cuda.h>

/* Matrix size */
#define N (275)

/* Host implementation of a simple version of sgemm */
static void simple_sgemm(int n, float alpha, const float *A, const float *B,
                         float beta, float *C) {
  int i;
  int j;
  int k;

  for (i = 0; i < n; ++i) {
    for (j = 0; j < n; ++j) {
      float prod = 0;

      for (k = 0; k < n; ++k) {
        prod += A[k * n + i] * B[j * n + k];
      }

      C[j * n + i] = alpha * prod + beta * C[j * n + i];
    }
  }
}

/* Main */
int main(int argc, char **argv) {
  cublasStatus_t status;
  float *h_A;
  float *h_B;
  float *h_C;
  float *h_C_ref;
  float *d_A = 0;
  float *d_B = 0;
  float *d_C = 0;
  float alpha = 1.0f;
  float beta = 0.0f;
  int n2 = N * N;
  int i;
  float error_norm;
  float ref_norm;
  float diff;
  cublasHandle_t handle;

  /* Initialize CUBLAS */
  printf("simpleCUBLAS test running..\n");

  status = cublasCreate(&handle);

  if (status != CUBLAS_STATUS_SUCCESS) {
    fprintf(stderr, "!!!! CUBLAS initialization error\n");
    return EXIT_FAILURE;
  }

  /* Allocate host memory for the matrices */
  h_A = reinterpret_cast<float *>(malloc(n2 * sizeof(h_A[0])));

  if (h_A == 0) {
    fprintf(stderr, "!!!! host memory allocation error (A)\n");
    return EXIT_FAILURE;
  }

  h_B = reinterpret_cast<float *>(malloc(n2 * sizeof(h_B[0])));

  if (h_B == 0) {
    fprintf(stderr, "!!!! host memory allocation error (B)\n");
    return EXIT_FAILURE;
  }

  h_C = reinterpret_cast<float *>(malloc(n2 * sizeof(h_C[0])));

  if (h_C == 0) {
    fprintf(stderr, "!!!! host memory allocation error (C)\n");
    return EXIT_FAILURE;
  }

  /* Fill the matrices with test data */
  for (i = 0; i < n2; i++) {
    h_A[i] = rand() / static_cast<float>(RAND_MAX);
    h_B[i] = rand() / static_cast<float>(RAND_MAX);
    h_C[i] = rand() / static_cast<float>(RAND_MAX);
  }

  /* Allocate device memory for the matrices */
  if (cudaMalloc(reinterpret_cast<void **>(&d_A), n2 * sizeof(d_A[0])) !=
      cudaSuccess) {
    fprintf(stderr, "!!!! device memory allocation error (allocate A)\n");
    return EXIT_FAILURE;
  }

  if (cudaMalloc(reinterpret_cast<void **>(&d_B), n2 * sizeof(d_B[0])) !=
      cudaSuccess) {
    fprintf(stderr, "!!!! device memory allocation error (allocate B)\n");
    return EXIT_FAILURE;
  }

  if (cudaMalloc(reinterpret_cast<void **>(&d_C), n2 * sizeof(d_C[0])) !=
      cudaSuccess) {
    fprintf(stderr, "!!!! device memory allocation error (allocate C)\n");
    return EXIT_FAILURE;
  }

  /* Initialize the device matrices with the host matrices */
  status = cublasSetVector(n2, sizeof(h_A[0]), h_A, 1, d_A, 1);

  if (status != CUBLAS_STATUS_SUCCESS) {
    fprintf(stderr, "!!!! device access error (write A)\n");
    return EXIT_FAILURE;
  }

  status = cublasSetVector(n2, sizeof(h_B[0]), h_B, 1, d_B, 1);

  if (status != CUBLAS_STATUS_SUCCESS) {
    fprintf(stderr, "!!!! device access error (write B)\n");
    return EXIT_FAILURE;
  }

  status = cublasSetVector(n2, sizeof(h_C[0]), h_C, 1, d_C, 1);

  if (status != CUBLAS_STATUS_SUCCESS) {
    fprintf(stderr, "!!!! device access error (write C)\n");
    return EXIT_FAILURE;
  }

  /* Performs operation using plain C code */
  simple_sgemm(N, alpha, h_A, h_B, beta, h_C);
  h_C_ref = h_C;

  /* Performs operation using cublas */
  status = cublasSgemm(handle, CUBLAS_OP_N, CUBLAS_OP_N, N, N, N, &alpha, d_A,
                       N, d_B, N, &beta, d_C, N);

  if (status != CUBLAS_STATUS_SUCCESS) {
    fprintf(stderr, "!!!! kernel execution error.\n");
    return EXIT_FAILURE;
  }

  /* Allocate host memory for reading back the result from device memory */
  h_C = reinterpret_cast<float *>(malloc(n2 * sizeof(h_C[0])));

  if (h_C == 0) {
    fprintf(stderr, "!!!! host memory allocation error (C)\n");
    return EXIT_FAILURE;
  }

  /* Read the result back */
  status = cublasGetVector(n2, sizeof(h_C[0]), d_C, 1, h_C, 1);

  if (status != CUBLAS_STATUS_SUCCESS) {
    fprintf(stderr, "!!!! device access error (read C)\n");
    return EXIT_FAILURE;
  }

  /* Check result against reference */
  error_norm = 0;
  ref_norm = 0;

  for (i = 0; i < n2; ++i) {
    diff = h_C_ref[i] - h_C[i];
    error_norm += diff * diff;
    ref_norm += h_C_ref[i] * h_C_ref[i];
  }

  error_norm = static_cast<float>(sqrt(static_cast<double>(error_norm)));
  ref_norm = static_cast<float>(sqrt(static_cast<double>(ref_norm)));

  if (fabs(ref_norm) < 1e-7) {
    fprintf(stderr, "!!!! reference norm is 0\n");
    return EXIT_FAILURE;
  }

  /* Memory clean up */
  free(h_A);
  free(h_B);
  free(h_C);
  free(h_C_ref);

  if (cudaFree(d_A) != cudaSuccess) {
    fprintf(stderr, "!!!! memory free error (A)\n");
    return EXIT_FAILURE;
  }

  if (cudaFree(d_B) != cudaSuccess) {
    fprintf(stderr, "!!!! memory free error (B)\n");
    return EXIT_FAILURE;
  }

  if (cudaFree(d_C) != cudaSuccess) {
    fprintf(stderr, "!!!! memory free error (C)\n");
    return EXIT_FAILURE;
  }

  /* Shutdown */
  status = cublasDestroy(handle);

  if (status != CUBLAS_STATUS_SUCCESS) {
    fprintf(stderr, "!!!! shutdown error (A)\n");
    return EXIT_FAILURE;
  }

  if (error_norm / ref_norm < 1e-6f) {
    printf("simpleCUBLAS test passed.\n");
    exit(EXIT_SUCCESS);
  } else {
    printf("simpleCUBLAS test failed.\n");
    exit(EXIT_FAILURE);
  }
}

一些更高级的特性

有了上面的基础,我们就可以写一些可以运行在GPU上的代码了。

结束之前,我们再看几个稍微高级一点的特性。

__device__关键字

之前我们学习核函数的__global__关键字。核函数既可以被CPU端调用,也可以被GPU调用。

如果我们想编写只能在GPU上运行的函数,我们就可以使用__device__.

使用__device__定义的函数或变量只能在设备代码中使用,无法在主机端代码中使用。在CUDA程序中,通常使用__host____device__关键字来指定函数或变量在主机端和设备端的执行位置。使用__device__定义的函数或变量可以在设备代码中被其他函数调用,也可以在主机端使用CUDA API将数据从主机内存传输到设备内存后,由设备上的函数处理。

GPU函数的内联

与CPU函数一样,GPU上的函数也可以内联,使用__forceinline__关键字。

并发的"?:"三目运算符

在C语言中,"?:"三目运算符只能做一次判断。
现在来到了GPU的世界,并发能力变强了,可以做多次判断了。

我们来看个例子:

__device__ __forceinline__ int qcompare(unsigned &val1, unsigned &val2) {
  return (val1 > val2) ? 1 : (val1 == val2) ? 0 : -1;
}

PTX汇编

在上一篇我们学习SIMD指令的时候,我们基本都要内联汇编。那么在CUDA里面是不是有汇编呢?
答案是肯定的,既然要做性能优化,那么肯定要挖掘一切潜力。
不过,为了避免跟架构过于相关,NVidia给我们提供的是一种中间指令格式PTX(Parallel Thread Execution)。
PTX assembly是CUDA的一种中间汇编语言,它是一种与机器无关的指令集架构(ISA),用于描述GPU上的并行线程执行。PTX assembly可以被编译成特定GPU家族的实际执行的机器码。使用PTX assembly可以实现跨GPU的兼容性和性能优化。

我们来看一段内嵌汇编:

static __device__ __forceinline__ unsigned int __qsflo(unsigned int word) {
  unsigned int ret;
  asm volatile("bfind.u32 %0, %1;" : "=r"(ret) : "r"(word));
  return ret;
}

其中用到的bfind.u32指令用于查找一个无符号整数中最右边的非零位(即最低有效位),并返回其位位置。该指令将无符号整数作为操作数输入,并将最低有效位的位位置输出到目的操作数中。
“=r”(ret)表示输出寄存器,返回结果保存在ret中。
“r”(word)表示输入寄存器,将参数word作为输入。

GPU特有的算法

最后一点要强调的时,很多时候将代码并行化,并不是简简单单的从CPU转到GPU,而很有可能是要改变算法。

比如,quicksort是一个(nlog(n))的算法,而bitonic sort是个 ( n l o g 2 ( n ) ) (nlog^2(n)) (nlog2(n))的算法。但是,bitonic sort更适合于在GPU加速。所以我们在CPU上的quicksort改成bitonic sort算法会更好一些。

Bitonic Sort

小结

在Intel CPU还是8+4核20线程的时候,GTX 1060显卡做到1280个CUDA核,3060是3584个CUDA核,3090是10496个CUDA核,4090有16384个CUDA核。每个CUDA核上可以起比如1024个线程。
所以,如果有大量可以并发的任务,应该毫不犹豫地将其写成核函数放到GPU上去运行。

GPU编程既没有那么复杂,完全可以快速上手像写CPU程序一样去写。但也不是那么简单,适合GPU可能需要改用特殊的算法。

而基于大量简单Transformers组成的大模型,恰恰是适合高并发的计算。

第一章导论 1 1.1 从图形处理到通用并行计算 1 1.2 CUDATM:一种通用并行计算架构 3 1.3 一种可扩展的编程模型 3 1.4 文档结构 4 第二章编程模型 7 2.1 内核 7 2.2 线程层次 8 2.3 存储器层次 11 2.4 异构编程 11 2.5 计算能力 11 第三章编程接口 15 3.1 用nvcc编译 15 3.1.1 编译流程 16 3.1.1.1 离线编译 16 3.1.1.2 即时编译 16 3.1.2 二进制兼容性 17 3.1.3 PTX兼容性 17 3.1.4 应用兼容性 18 3.1.5 C/C++兼容性 19 3.1.6 64位兼容性 19 3.2 CUDA C运行时 3.2.1 初始化 20 3.2.2 设备存储器 20 3.2.3 共享存储器 24 3.2.4 分页锁定主机存储器 32 3.2.4.1 可分享存储器(portable memory) 34 3.2.4.2 写结合存储器 34 3.2.4.3 被映射存储器 34 3.2.5 异步并发执行 35 3.2.5.1 主机和设备间异步执行 35 3.2.5.2 数据传输和内核执行重叠 36 3.2.5.3 并发内核执行 36 3.2.5.4 并发数据传输 36 3.2.5.5 流 37 3.2.5.6 事件 41 3.2.5.7 同步调用 42 3.2.6 多设备系统 42 3.2.6.1 枚举设备 42 3.2.6.2 设备指定 42 3.2.6.3 流和事件行为 43 3.2.6.4 p2p存储器访问 44 3.2.6.5 p2p存储器复制 45 3.2.6.6 统一虚拟地址空间 45 3.2.6.7 错误检查 46 3.2.7 调用栈 47 3.2.8 纹理和表面存储器 47 3.2.8.1 纹理存储器 47 3.2.8.2 表面存储器(surface) 60 3.2.8.3 CUDA 数组 65 目录iii 3.2.8.4 读写一致性 66 3.2.9 图形学互操作性 66 3.2.9.1 OpenGL互操作性 67 3.2.9.2 Direct3D互操作性 70 3.2.9.3 SLI(速力)互操作性 82 3.3 版本和兼容性 82 3.4 计算模式 83 3.5 模式切换 84 3.6 Windows上的Tesla计算集群模式 85 第四章硬件实现 87 4.1 SIMT 架构 87 4.2 硬件多线程 88 第五章性能指南 91 5.1 总体性能优化策略 91 5.2 最大化利用率 91 5.2.1 应用层次 91 5.2.2 设备层次 92 5.2.3 多处理器层次 92 5.3 最大化存储器吞吐量 94 5.3.1 主机和设备的数据传输 95 5.3.2 设备存储器访问 96 5.3.2.1 全局存储器 96 5.3.2.2 本地存储器 98 5.3.2.3 共享存储器 99 5.3.2.4 常量存储器 100 5.3.2.5 纹理和表面存储器 100 5.4 最大化指令吞吐量 100 iv CUDA编程指南5.0中文版 5.4.1 算术指令 101 5.4.2 控制流指令 104 5.4.3 同步指令 105 附录A 支持CUDA的GPU 107 附录B C语言扩展 109 B.1 函数类型限定符 109 B.1.1 device 109 B.1.2 global 109 B.1.3 host 109 B.1.4 noinline 和forceinline 110 B.2 变量类型限定符 110 B.2.1 device 111 B.2.2 constant 111 B.2.3 shared 112 B.2.4 restrict 113 B.3 内置变量类型 115 B.3.1 char1、uchar1、char2、uchar2、char3、uchar3、char4、 uchar4、short1、ushort1、short2、ushort2、short3、ushort3、 short4、ushort4、int1、uint1、int2、uint2、int3、uint3、 int4、uint4、long1、ulong1、long2、ulong2、long3、ulong3、 long4、ulong4、float1、float2、float3、float4、double2 115 B.3.2 dim3类型 115 B.4 内置变量 115 B.4.1 gridDim 115 B.4.2 blockIdx 115 B.4.3 blockDim 117 B.4.4 threadIdx 117 B.4.5 warpSize 117 目录v B.5 存储器栅栏函数 117 B.6 同步函数 119 B.7 数学函数 120 B.8 纹理函数 120 B.8.1 纹理对象函数 120 B.8.1.1 tex1Dfetch() 120 B.8.1.2 tex1D() 121 B.8.1.3 tex2D() 121 B.8.1.4 tex3D() 121 B.8.1.5 tex1DLayered() 121 B.8.1.6 tex2DLayered() 122 B.8.1.7 texCubemap() 122 B.8.1.8 texCubemapLayered() 122 B.8.1.9 tex2Dgather() 123 B.8.2 纹理参考函数 123 B.8.2.1 tex1Dfetch() 123 B.8.2.2 tex1D() 124 B.8.2.3 tex2D() 124 B.8.2.4 tex3D() 125 B.8.2.5 tex1DLayered() 125 B.8.2.6 tex2DLayered() 125 B.8.2.7 texCubemap() 125 B.8.2.8 texCubemapLayered() 126 B.8.2.9 tex2Dgather() 126 B.9 表面函数(surface) 126 B.9.1 表面对象函数 127 B.9.1.1 surf1Dread() 127 B.9.1.2 surf1Dwrite() 127 vi CUDA编程指南5.0中文版 B.9.1.3 surf2Dread() 127 B.9.1.4 surf2Dwrite() 128 B.9.1.5 surf3Dread() 128 B.9.1.6 surf3Dwrite() 128 B.9.1.7 surf1DLayeredread() 129 B.9.1.8 surf1DLayeredwrite() 129 B.9.1.9 surf2DLayeredread() 129 B.9.1.10 surf2DLayeredwrite() 130 B.9.1.11 surfCubemapread() 130 B.9.1.12 surfCubemapwrite() 131 B.9.1.13 surfCubemapLayeredread() 131 B.9.1.14 surfCubemapLayeredwrite() 131 B.9.2 表面引用API 132 B.9.2.1 surf1Dread() 132 B.9.2.2 surf1Dwrite() 132 B.9.2.3 surf2Dread() 132 B.9.2.4 surf2Dwrite() 133 B.9.2.5 surf3Dread() 133 B.9.2.6 surf3Dwrite() 133 B.9.2.7 surf1DLayeredread() 134 B.9.2.8 surf1DLayeredwrite() 134 B.9.2.9 surf2DLayeredread() 135 B.9.2.10 surf2DLayeredwrite() 135 B.9.2.11 surfCubemapread() 135 B.9.2.12 surfCubemapwrite() 136 B.9.2.13 surfCubemapLayeredread() 136 B.9.2.14 surfCubemapLayeredwrite() 137 B.10 时间函数 137 目录vii B.11 原子函数 137 B.11.1 数学函数 138 B.11.1.1 atomicAdd() 138 B.11.1.2 atomicSub() 139 B.11.1.3 atomicExch() 139 B.11.1.4 atomicMin() 140 B.11.1.5 atomicMax() 140 B.11.1.6 atomicInc() 140 B.11.1.7 atomicDec() 141 B.11.1.8 atomicCAS() 141 B.11.2 位逻辑函数 141 B.11.2.1 atomicAnd() 141 B.11.2.2 atomicOr() 142 B.11.2.3 atomicXor() 142 B.12 束表决(warp vote)函数 142 B.13 束洗牌函数 143 B.13.1 概览 143 B.13.2 在束内广播一个值 144 B.13.3 计算8个线程的前缀和 145 B.13.4 束内求和 146 B.14 取样计数器函数 146 B.15 断言 147 B.16 格式化输出 148 B.16.1 格式化符号 149 B.16.2 限制 149 B.16.3 相关的主机端API 150 B.16.4 例程 151 B.17 动态全局存储器分配 152 viii CUDA编程指南5.0中文版 B.17.1 堆存储器分配 153 B.17.2 与设备存储器API的互操作 154 B.17.3 例程 154 B.17.3.1 每个线程的分配 154 B.17.3.2 每个线程块的分配 155 B.17.3.3 在内核启动之间持久的分配 156 B.18 执行配置 159 B.19 启动绑定 160 B.20 #pragma unroll 162 B.21 SIMD 视频指令 163 附录C 数学函数 165 C.1 标准函数 165 C.1.1 单精度浮点函数 165 C.1.2 双精度浮点函数 168 C.2 内置函数 171 C.2.1 单精度浮点函数 172 C.2.2 双精度浮点函数 172 附录D C++语言支持 175 D.1 代码例子 175 D.1.1 数据类 175 D.1.2 派生类 176 D.1.3 类模板 177 D.1.4 函数模板 178 D.1.5 函子类 178 D.2 限制 180 D.2.1 预处理符号 180 D.2.2 限定符 180 目录ix D.2.2.1 设备存储器限定符 180 D.2.2.2 Volatile限定符 182 D.2.3 指针 182 D.2.4 运算符 183 D.2.4.1 赋值运算符 183 D.2.4.2 地址运算符 183 D.2.5 函数 183 D.2.5.1 编译器生成的函数 183 D.2.5.2 函数参数 184 D.2.5.3 函数内静态变量 184 D.2.5.4 函数指针 184 D.2.5.5 函数递归 185 D.2.6 类 185 D.2.6.1 数据成员 185 D.2.6.2 函数成员 185 D.2.6.3 虚函数 185 D.2.6.4 虚基类 185 D.2.6.5 Windows相关 185 D.2.7 模板 186 附录E 纹理获取 187 E.1 最近点取样 187 E.2 线性滤波 187 E.3 查找表 189 附录F 计算能力 191 F.1 特性和技术规范 191 F.2 浮点标准 195 F.3 计算能力1.x 198 x CUDA编程指南5.0中文版 F.3.1 架构 198 F.3.2 全局存储器 199 F.3.2.1 计算能力1.0和1.1的设备 199 F.3.2.2 计算能力1.2和1.3的设备 199 F.3.3 共享存储器 201 F.3.3.1 32位步长访问 201 F.3.3.2 32位广播访问 202 F.3.3.3 8位和16位访问 205 F.3.3.4 大于32位访问 205 F.4 计算能力2.x 206 F.4.1 架构 206 F.4.2 全局存储器 208 F.4.3 共享存储器 209 F.4.3.1 32位步长访问 209 F.4.3.2 大于32位访问 210 F.4.4 常量存储器 211 F.5 计算能力3.x 211 F.5.1 架构 211 F.5.2 全局存储器访问 212 F.5.3 共享存储器 213 F.5.3.1 64位模式 213 F.5.3.2 32位模式 213 附录G 驱动API 215 G.1 上下文 218 G.2 模块 219 G.3 内核执行 220 G.4 运行时API和驱动API的互操作性 222 G.5 注意 223
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Jtag特工

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值