Linux 桌面玩家指南:16. 使用 CUDA 发挥显卡的计算性能

原文: Linux 桌面玩家指南:16. 使用 CUDA 发挥显卡的计算性能

特别说明:要在个人随笔后写评论的小伙伴们请注意了,个人博客开启了 MathJax 数学公式支持,MathJax 使用$标记数学公式的开始和结束。若是某条评论中出现了两个$,MathJax 会将两个$之间的内容按照数学公式进行排版,从而致使评论区格式混乱。若是你们的评论中用到了$,可是又不是为了使用数学公式,就请使用\$转义一下,谢谢。html

想从头阅读该系列吗?下面是传送门:python

前言

科学计算碰到数据量很大的时候,每每很是消耗时间。使用多核进行并行计算是加快计算速度的主要方法,而显卡天生具备成百上千个计算核心,因此使用 GPU 进行计算也就愈来愈流行。得益于 Nvidia 提供的 CUDA,咱们编写利用 GPU 进行计算的程序愈来愈方便。那么,在 Linux 系统下,使用 CUDA 究竟效果如何呢?这一篇将为你揭晓答案。编程

我测试的是两个 2000×2000 矩阵相乘所耗费的时间,测试环境是个人 Dell XPS 15,这是一款 2015 年的产品了,CPU 是 Intel 的 6700H,4 核 8 线程,显卡是 Nvidia 的 GTX 960M,和如今 2019 年的主流比起来,已经落后几代了。前段时间“核弹”厂的老黄又发布了新的甜点级的显卡,RTX 2060,流处理器又多了几倍,还支持光线追踪,害得我内心又长了草。数组

继续说回矩阵相乘,我先测试了一下本身用 C 语言写一个简单的利用三重循环计算矩阵相乘的程序,而后使用 gcc 编译,运行,进行计时。其次,我本身写一段 CUDA 代码,利用显卡计算两个矩阵相乘,使用 nvcc 编译运行,进行计时。最后,使用 Nvidia 提供的 cuBLAS 库中的函数直接计算两个矩阵相乘,并进行计时。其中我本身用 C 语言写的三重循环是彻底没有优化的,因此计算时间确定比较慢,若是进行充分优化,利用 CPU 的 SSE、AVX 等向量化指令,并优化内存访问以充分利用 CPU 的缓存,能够将性能提高大约 10 倍。在雷锋网上有一篇 OpenBLAS 项目创始人和主要维护者张先轶介绍的OpenBLAS项目与矩阵乘法优化,能够一看。我用 Python 的 NumPy 测试了一下,确实可提速 8 到 10 倍,由于 NumPy 的底层调用了 OpenBLAS 库。并且这还只是利用了 CPU 的一个核,若是要利用 CPU 进行并行计算,能够考虑 OpenMP 或 MPI。缓存

先贴代码和结论

完整的 C 语言代码我放在cublas_test.cu文件中,大家没看错,扩展名为.cu,由于 CUDA 的编译器 nvcc 要求这样,nvcc 对代码进行初步处理后,仍是调用的 gcc 进行编译链接生成可执行文件。完整代码以下:安全

#include <stdio.h>
#include <stdlib.h>
#include <memory.h>
#include <sys/time.h>
#include <cuda_runtime.h>
#include <cublas_v2.h>

#define M 2000
#define N 2000
#define K 2000

#define idx(i, j, m) ((j) * (m) + (i))

typedef struct _matrix {
      size_t height;
      size_t width;
      float *elements;
} Matrix;

Matrix empty(size_t m, size_t n){
    Matrix temp = {m, n, NULL};
    temp.elements = (float*)malloc( m*n*sizeof(float) );
    return temp;
}

Matrix zeros(size_t m, size_t n){
    Matrix temp = empty(m, n);
    memset(temp.elements, 0, sizeof( m*n*sizeof(float) ));
    return temp;
}

Matrix rands(size_t m, size_t n){
    Matrix temp = empty(m, n);
    for(size_t i=0; i<m*n; i++){
        temp.elements[i] = (float)rand()/RAND_MAX;
    }
    return temp;
}

void deleteMatrix(Matrix *mat){
    mat->height = 0;
    mat->width = 0;
    free(mat->elements);
    mat->elements = NULL;
}


void printRow(Matrix mat, size_t i){
    size_t m = mat.height;
    size_t n = mat.width;
    if(n < 7){
        for(size_t j=0; j<n; j++){
            printf(" %7.4f ", mat.elements[idx(i, j, m)]);
        }
    }else{
        for(size_t j=0; j<3; j++){
            printf(" %7.4f ", mat.elements[idx(i, j, m)]);
        }
        printf("   ...   ");
        for(size_t j=n-3; j<n; j++){
            printf(" %7.4f ", mat.elements[idx(i, j, m)]);
        }
    }
    printf("\n");
}

void printMatrix(Matrix mat){
    size_t m = mat.height;
    if(m < 7){
        for(size_t i=0; i<m; i++){
            printRow(mat, i);
        }
    }else{
        for(size_t i=0; i<3; i++){
            printRow(mat, i);
        }
        printf("   ...   \n");
        for(size_t i=m-3; i<m; i++){
            printRow(mat, i);
        }
    }
    printf("\n");
}

Matrix matMul(Matrix A, Matrix B){
    size_t m, n, k;
    Matrix C = {0, 0, NULL};
    if(A.width == B.height){
        m = A.height;
        n = B.width;
        k = B.height;
        C = zeros(m, n);
        for(size_t i=0; i<m; i++){
            for(size_t j=0; j<n; j++){
                float sum = 0;
                for(size_t p=0; p<k; p++){
                    sum += A.elements[idx(i, p, m)] * B.elements[idx(p, j, k)];
                }
                C.elements[idx(i, j, m)] = sum;
            }
        }
    }else{
        printf("Matrix shape error!\n");
    }
    return C;
}

long getTimer(struct timeval start, struct timeval end){
  return (end.tv_sec - start.tv_sec)*1000000 + (end.tv_usec - start.tv_usec);
}

void printTimer(long timer){
  printf("%ld,%ld,%ld us\n", timer/1000000, (timer/1000)%1000, timer%1000);
}
    

bool initCUBLAS(cublasHandle_t *handle){
    int count;
    cudaGetDeviceCount(&count);
    if(count == 0){
        printf("There is no device.\n");
        return false;
    }else{
        printf("There is %d device.\n", count);
    }
    int i;
    for(i=0; i<count; i++){
        cudaDeviceProp prop;
        if(cudaGetDeviceProperties(&prop, i) == cudaSuccess){
            if(prop.major >= 1){
                break;
            }
        }
    }

    if(i == count){
        printf("There is no device supporting CUDA 1.x.\n");
        return false;
    }
    cudaSetDevice(i);

    cublasStatus_t stat;
    stat = cublasCreate(handle);
    if(stat!=CUBLAS_STATUS_SUCCESS){
        printf("CUBLAS initialization failed\n");
        return false;
    }
    printf("CUBLAS initialized.\n");

    return true;
}

__global__ void cudaMatMul(Matrix devA, Matrix devB, Matrix devC){
  size_t m = devA.height;
  size_t k = devB.height;
  size_t n = devB.width;

  size_t j = blockIdx.y * blockDim.y + threadIdx.y;
  size_t i = blockIdx.x * blockDim.x + threadIdx.x;

  if(i<m && j<n){
    float sum = 0.0;
    for(size_t p=0; p<k; p++){
      sum += devA.elements[idx(i, p, m)] * devB.elements[idx(p, j, k)];
    }
    devC.elements[idx(i, j, m)] = sum;
  }
}


int main(int argc, char** argv){
    struct timeval start;
    struct timeval end;

    //第一步,建立两个随机矩阵,使用 CPU 计算它们相乘,并计时
    gettimeofday(&start, NULL);
    Matrix A = rands(M, K);
    printf("Matrix A, shape: %ldx%ld, address in memory:%ld\n", A.height, A.width, (size_t)A.elements);
    printMatrix(A);

    Matrix B = rands(K, N);
    printf("Matrix B, shape: %ldx%ld, address in memory:%ld\n", B.height, B.width, (size_t)B.elements);
    printMatrix(B);

    Matrix C = matMul(A, B);
    printf("Matrix C, shape: %ldx%ld, address in memory:%ld\n", C.height, C.width, (size_t)C.elements);
    printMatrix(C);
    gettimeofday(&end, NULL);
    printf("CPU 计算矩阵相乘,用时:");
    long timer1 = getTimer(start, end);
    printTimer(timer1);

    //第二步,将矩阵 A 和 B 拷贝到显卡中,利用显卡计算矩阵乘法,再将结果考回 C 中,并计时
    cublasHandle_t handle;
    if(!initCUBLAS(&handle)){
        return EXIT_FAILURE;
    }
    gettimeofday(&start, NULL);
    size_t m = A.height;
    size_t n = B.width;
    size_t k = B.height;
    Matrix devA = {m, k, NULL};
    Matrix devB = {k, n, NULL};
    Matrix devC = {m, n, NULL};
    cudaMalloc(&devA.elements, m*k*sizeof(float));
    cudaMemcpy(devA.elements, A.elements, m*k*sizeof(float), cudaMemcpyHostToDevice);
    cudaMalloc(&devB.elements, k*n*sizeof(float));
    cudaMemcpy(devB.elements, B.elements, k*n*sizeof(float), cudaMemcpyHostToDevice);
    cudaMalloc(&devC.elements, m*n*sizeof(float));
    cudaMemset(devC.elements, 0, m*n*sizeof(float));
    //调用 GPU 进行计算
    size_t blockSize = 32;
    size_t gridWidth = (n + blockSize -1)/blockSize;
    size_t gridHeight = (m + blockSize -1)/blockSize;
    cudaMatMul<<<dim3(gridHeight, gridWidth), dim3(blockSize, blockSize)>>>(devA, devB, devC);
    //从 GPU 取回数据
    deleteMatrix(&C);
    C = zeros(m, n);
    printf("从显卡取回数据以前的矩阵 C\n");
    printf("Matrix C, shape: %ldx%ld, address in memory:%ld\n", C.height, C.width, (size_t)C.elements);
    printMatrix(C);
    cudaMemcpy(C.elements, devC.elements, m*n*sizeof(float), cudaMemcpyDeviceToHost);
    printf("从显卡取回数据以后的矩阵 C\n");
    printf("Matrix C, shape: %ldx%ld, address in memory:%ld\n", C.height, C.width, (size_t)C.elements);
    printMatrix(C);

    gettimeofday(&end, NULL);
    printf("本身写 CUDA 代码,利用 GPU 计算矩阵相乘,用时:");
    long timer2 = getTimer(start, end);
    printTimer(timer2);

    //第三步,直接利用 cublas 库计算矩阵乘法,并计时
    gettimeofday(&start, NULL);
    float scalar = 1.0;
    Matrix devA2 = {m, k, NULL};
    Matrix devB2 = {k, n, NULL};
    Matrix devC2 = {m, n, NULL};
    cudaMalloc(&devA2.elements, m*k*sizeof(float));
    cudaMalloc(&devB2.elements, k*n*sizeof(float));
    cudaMalloc(&devC2.elements, m*n*sizeof(float));

    Matrix C2 = zeros(m, n);

    cublasSetMatrix(m, k, sizeof(float), A.elements, m, devA2.elements, m);
    cublasSetMatrix(k, n, sizeof(float), B.elements, k, devB2.elements, k);
    cublasSetMatrix(m, n, sizeof(float), C2.elements, m, devC2.elements, m);

    cublasSgemm(handle, CUBLAS_OP_N, CUBLAS_OP_N, m, n, k, &scalar, devA2.elements, m, devB2.elements, k, &scalar, devC2.elements, m);

    printf("从显卡取回数据以前的矩阵 C2\n");
    printf("Matrix C2, shape: %ldx%ld, address in memory:%ld\n", C2.height, C2.width, (size_t)C2.elements);
    printMatrix(C2);

    cublasGetMatrix(m, n, sizeof(float), devC2.elements, m, C2.elements, m);

    printf("从显卡取回数据以后的矩阵 C2\n");
    printf("Matrix C2, shape: %ldx%ld, address in memory:%ld\n", C2.height, C2.width, (size_t)C2.elements);
    printMatrix(C2);
    gettimeofday(&end, NULL);
    long timer3 = getTimer(start, end);
    printf("直接使用 Nvidia 提供的 cuBlas 库进行矩阵乘法计算,用时:");
    printTimer(timer3);

    printf("\n");
    printf("本身写 CUDA 代码利用 GPU 计算矩阵乘法,速度是 CPU 的 %f 倍,利用 cuBlass 库进行计算,速度是 CPU 的 %f 倍。\n",
            (float)timer1/timer2, (float)timer1/timer3);
    return 0;
}

而后,使用nvcc cublas_test.cu -lcublas -o cublas_test编译,使用./cublas_test运行,就能够看到结果了,以下两图:

网络

能够看到,C 语言的三重循环,用时 47.763 秒,而本身写 CUDA 代码用显卡进行计算,用时 222 毫秒,是 CPU 的 214 倍。固然,我本身写的 CUDA 代码确定是没有优化的,若是调用 Nvidia 官方实现的 cuBLAS 库,则只用时 32 毫秒,是 CPU 的 1489 倍。这计算速度的提高,仍是至关巨大的。dom

我使用 Python 的 NumPy 测试了一下 OpenBLAS 优化后的性能,以下图:
iphone

用时 6.23 秒,是 C 语言三重循环的 8 倍左右。 Python 的代码很是简单,只有四行,以下:编程语言

import numpy as np
A = np.random.randn(2000, 2000).astype('float32')
B = np.random.randn(2000, 2000).astype('float32')
%timeit np.dot(A, B)

其中,%timeit是 IPython 或 Jupyter notebook 的魔术指令,专用于测性能。

开发环境的安装

上面直接给出了代码和测试结果,那么在 Linux 下写 CUDA 程序方便吗?安装开发环境容易吗?个人回答是:至关的方便,至关的容易。

首先,须要一台带 Nvidia 显卡的电脑。其次,须要安装好 Nvidia 的显卡驱动,在 Ubuntu 中,这就是一条sudo apt-get install nvidia-384sudo apt-get install nvidia-390命令的事,我以前的随笔中有介绍。

最后,就是安装 CUDA 的开发环境了,也很简单,一条sudo apt-get install nvidia-cuda-toolkit命令就搞定。而后,就可使用nvcc命令了,nvcc背后调用的是gcc。而 Python 环境,在 Ubuntu 中是天生的方便,前面随笔中有介绍,这里就再也不罗嗦。

那用什么工具写代码和调试代码呢?因为这里用的是 C 语言,因此我首先推荐的是 JetBrains 全家桶中的 CLion,以下图:

只是有点小贵,一年 199 刀。我是不同意使用破解版的,我只能在它提供的 30 天试用期内进行使用。土豪请随意。

而 Nvidia 也提供了一款基于 Eclipse 的工具 Nsight。安装完nvidia-cuda-toolkit软件包后,该工具就有了,不须要另外下载。在命令行输入nsight命令就能够启动它,以下:

而我这里,就写一个文件而以,区区两百多行代码,要啥 IDE 啊,我用 Vim 足以,以下图:

关于 Vim 的配置,我前面的随笔也有介绍。

代码解释

首先,我定义了一些辅助的结构和函数。Matrix用来定义一个矩阵,而矩阵的元素我没有使用二维数组,只是在内存中分配一块连续空间,以一维数组的形式进行访问,为了和 Fortran 兼容,使用列优先存储,因此定义了宏#define idx(i, j, m) ((j) * (m) + (i))用来根据矩阵的行i和列j定位矩阵的元素在一维数组中的下标。另外,定义的empty(m, n)zeros(m, n)rands(m, n)函数分别用来初始化一个空矩阵、零矩阵和随机矩阵,deleteMatrix()用来删除一个矩阵并释放内存。printMatrix()函数用来打印矩阵,便于查看结果。而矩阵相乘的函数matMul(),里面就是三重循环,很少解释。

对程序的计时,使用的是 Linux 中的gettimeofday()函数,须要包含<sys/time.h>头文件。该函数计时的精度能够到微秒级别,够用了。同时,定义printTimer()辅助函数用于输出时间数据。

重点在于对 CUDA 代码的解释。

首先,CUDA 把代码和数据都分为两部分,一部分代码是在 CPU 上执行的,叫 Runtime 函数,一部分代码是在 GPU 上执行的,叫 Kernel 函数,一部分数据存储在内存中,咱们称之为 Host 上的数据,一部分数据储存在显存中,咱们称之为 Device 数据。因此,咱们编写 CUDA 的流程是这样的,先调用 Runtime 函数初始化 CUDA 环境,最重要的是检测系统中有没有 Nvidia 的显卡,并调用 setDevice()设置使用哪一个显卡进行计算。好羡慕那些有好几个显卡的人啊。而后,在 Host 中分配空间、初始化数据、在 Device 中分配空间,将 Host 中的数据拷贝到 Device 中。这个过程须要用到malloc()cudaMalloc()cudaMemcpy()函数,从 Host 中向 Device 中复制数据,须要用到cudaMemcpyHostToDevice常量。

其次,就是设计 Kernel 函数和调用 Kernel 函数了。在这方面,nvcc对 C 语言进行了扩展,提供了__global__关键字,用于定义 Kernel 函数。每个 Kernel 函数都在一个 GPU 流处理器上执行,而 GPU 每每有成百上千个流处理器,因此该 Kernel 函数至关于被成百上千个线程同时执行,进行并行计算。定义好 Kernel 函数后,就能够在 C 程序中调用,使用的是func_name<<<gridDim, blockDim>>>(args)这样的语法,而gridDimblockDim能够指定 GPU 中的线程组织成什么形状。多个 thread 能够组织成一个 block,多个 block 能够组织成一个 grid。而 grid 和 block 能够是一维的、二维的、三维的,这样组织,很是方便咱们设计程序。

最后,就是把显卡计算后的结果数据再从 Device 中拷贝到 Host 中,仍是用的cudaMemcpy函数,只不过用的是cudaMemcpyDeviceToHost常数。

若是把 GPU 中的线程组织成二维的,就是这样:

在本例中,咱们须要计算一个 M×K 的矩阵和一个 K×N 的矩阵相乘,结果是一个 M×N 的矩阵,咱们就能够启用 M×N 个线程,并把它组织成如上形状,每个线程只计算结果矩阵中的一个元素,以下图:

CUDA 中每一个 block 中的 thread 数是有上限的,在个人电脑上,该上限是 1024。因此我定义blockSize = 32,而后 block 的形状就是(blockSize, blockSize),也就是 block 的大小为 32×32。而后再把 grid 的形状定义为((m + blockSize -1)/blockSize, (n + blockSize -1)/blockSize),这样,这个 grid 中的总线程数就是 M×N 了,并且线程排列的形状和结果矩阵的形状彻底同样,因此每一个线程计算结果矩阵中的一个元素,极其方便。

下面来看看完整的流程。

先在 Host 上创建矩阵 A、B、C,并初始化:

Matrix A = rands(M, K);
    printf("Matrix A, shape: %ldx%ld, address in memory:%ld\n", A.height, A.width, (size_t)A.elements);
    printMatrix(A);

    Matrix B = rands(K, N);
    printf("Matrix B, shape: %ldx%ld, address in memory:%ld\n", B.height, B.width, (size_t)B.elements);
    printMatrix(B);

    Matrix C = matMul(A, B);
    printf("Matrix C, shape: %ldx%ld, address in memory:%ld\n", C.height, C.width, (size_t)C.elements);
    printMatrix(C);

而后建立三个变量用来保存在 Device 上的矩阵的形状和数据指针 devA、devB、devC,这三个变量刚开始其中的数据为空,以下:

size_t m = A.height;
    size_t n = B.width;
    size_t k = B.height;
    Matrix devA = {m, k, NULL};
    Matrix devB = {k, n, NULL};
    Matrix devC = {m, n, NULL};

而后在 Device 中分配空间,并把 Host 中的数据拷贝到 Device 中,这时,devA、devB、devC中的数据指针指向的是 Device 中的空间,以下:

cudaMalloc(&devA.elements, m*k*sizeof(float));
    cudaMemcpy(devA.elements, A.elements, m*k*sizeof(float), cudaMemcpyHostToDevice);
    cudaMalloc(&devB.elements, k*n*sizeof(float));
    cudaMemcpy(devB.elements, B.elements, k*n*sizeof(float), cudaMemcpyHostToDevice);
    cudaMalloc(&devC.elements, m*n*sizeof(float));
    cudaMemset(devC.elements, 0, m*n*sizeof(float));

再而后,调用 Kernel 函数cudaMatMul(),指定启用多少个线程,并指定 grid 和 block 的形状,并把 devA、devB、devC 传递给它作参数。以下:

size_t blockSize = 32;
    size_t gridWidth = (n + blockSize -1)/blockSize;
    size_t gridHeight = (m + blockSize -1)/blockSize;
    cudaMatMul<<<dim3(gridHeight, gridWidth), dim3(blockSize, blockSize)>>>(devA, devB, devC);

Kernel 函数cudaMatMul()是怎么定义的呢?以下:

__global__ void cudaMatMul(Matrix devA, Matrix devB, Matrix devC){
  size_t m = devA.height;
  size_t k = devB.height;
  size_t n = devB.width;

  size_t j = blockIdx.y * blockDim.y + threadIdx.y;
  size_t i = blockIdx.x * blockDim.x + threadIdx.x;

  if(i<m && j<n){
    float sum = 0.0;
    for(size_t p=0; p<k; p++){
      sum += devA.elements[idx(i, p, m)] * devB.elements[idx(p, j, k)];
    }
    devC.elements[idx(i, j, m)] = sum;
  }
}

能够看到,定义 Kernel 函数时,用到了__global__关键字,而且使用了threadIdx.xthreadIdx.yblockIdx.xblockIdx.yblockDim.xblockDim.y这些内建变量,根据前面的介绍,咱们很是容易猜到这些变量的意义。而后,很是容易算出当前线程计算的是结果矩阵的哪一个元素 C[i, j],再而后,三重循环变成一重循环,计算起来不要过轻松,效率主要取决于访问内存的速度。

最后,将结果矩阵从 Device 拷贝到 Host 并显示,都是常规操做。以下:

deleteMatrix(&C);
    C = zeros(m, n);
    printf("从显卡取回数据以前的矩阵 C\n");
    printf("Matrix C, shape: %ldx%ld, address in memory:%ld\n", C.height, C.width, (size_t)C.elements);
    printMatrix(C);
    cudaMemcpy(C.elements, devC.elements, m*n*sizeof(float), cudaMemcpyDeviceToHost);
    printf("从显卡取回数据以后的矩阵 C\n");
    printf("Matrix C, shape: %ldx%ld, address in memory:%ld\n", C.height, C.width, (size_t)C.elements);
    printMatrix(C);

若是不想本身写 CUDA 代码,可使用 Nvidia 提供的 cuBLAS 库。除了 cuBLAS 库以外,还有 cuSPARSE 用来计算稀疏矩阵、cuFFT 用来进行傅立叶变化、cuSOLVER 用来解线性方程组、cuDNN 用来加速神经网络的计算等等。这些库优化得好,速度比咱们本身写 CUDA 代码要快一些。另外,若是直接用 cuBLAS,则只须要用到 Runtime 函数,不须要本身定义 Kernel 函数,因此文件的扩展名能够不是.cu,编译器能够不用nvcc而直接用gcc,只须要包含正确的头文件和链接正确的库就能够了。固然,使用nvcc也能够。

cuBLAS 使用<cublas_v2.h>头文件,编译时使用-lcublas链接相应的库。为何头文件中有个v2呢?这一由于这个新版本是线程安全的,因此每一个 cuBLAS 函数都须要一个handle做为参数,这个handle可使用cublasCreate()函数建立,不一样的线程可使用不一样的handle。我把建立handle()的工做也放到 CUDA 初始化的代码里面了,在initCUBLAS()函数中。

分配内存和拷贝数据的过程是同样的,在 cuBLAS 中,同样使用cudaMalloc()cudaMemcpy()函数,可是推荐使用cublasSetMatrix()cublasGetMatrix()函数,由于这两个函数能够拷贝子矩阵的数据。

而后就是调用cublasSgemm()函数进行计算了,为何这个函数名这么奇怪呢?这是由于 BLAS 库中的函数名原本就极度简化,好比mm就表明两个矩阵相乘,mv就表明一个矩阵和一个向量相乘。ge可能指的就是普通矩阵,除此以外,还有带状矩阵、对称矩阵等,分别用bs指示。而中间的大写S,指的是数据类型,S是单精度浮点数,D是双精度浮点数,C为单精度复数,Z为双精度复数。

具体的函数,你们直接阅读 Nvidia 的官方文档吧。

总结

使用显卡进行科学计算,得益于 GPU 大量的流处理器,计算速度能够获得成百上千倍的提高。在 Linux 环境下,安装 Nvidia 的显卡驱动和 CUDA toolkit,也是极其方便的事,并且 Nvidia 提供完善的文档和库,真的是咱们的福音。

求打赏

我对此次写的这个系列要求是很是高的:首先内容要有意义、够充实,信息量要足够丰富;其次是每个知识点要讲透彻,不能模棱两可含糊不清;最后是包含丰富的截图,让那些不想装 Linux 系统的朋友们也能够领略到 Linux 桌面的风采。若是个人努力获得你们的承认,能够扫下面的二维码打赏一下:

版权申明

该随笔由京山游侠在2019年01月14日发布于博客园,引用请注明出处,转载或出版请联系博主。QQ邮箱:1841079@qq.com

相关文章
相关标签/搜索