• CUDA编程(六)进一步并行


    CUDA编程(六)

    进一步并行

    在之前我们使用Thread完毕了简单的并行加速,尽管我们的程序运行速度有了50甚至上百倍的提升,可是依据内存带宽来评估的话我们的程序还远远不够。在上一篇博客中给大家介绍了一个訪存方面非常重要的优化。我们通过使用连续的内存存取模式。取得了令人惬意的优化效果,终于内存带宽也达到了GB/s的级别。

    之前也已经提到过了,CUDA不仅提供了Thread。还提供了Grid和Block以及Share Memory这些非常重要的机制,我的显卡的Thread极限是1024,可是通过block和Grid。线程的数量还能成倍增长,甚至用几万个线程。所以本篇博客我们将再次回到线程和并行的角度,进一步的并行加速我们的程序。

    Thread AND Block AND Grid

    这里写图片描写叙述

    第一篇博客的时候就给大家说明过thread-block-grid 结构了。这里我们再复习一下。

    在 CUDA 架构下。显示芯片运行时的最小单位是thread。数个 thread 能够组成一个block。一个 block 中的 thread 能存取同一块共享的内存。并且能够高速进行同步的动作。

    每一个 block 所能包括的 thread 数目是有限的。只是,运行同样程序的 block。能够组成grid。不同 block 中的 thread 无法存取同一个共享的内存。因此无法直接互通或进行同步。

    因此,不同 block 中的 thread 能合作的程度是比較低的。只是,利用这个模式,能够让程序不用操心显示芯片实际上能同一时候运行的 thread 数目限制。

    比如。一个具有非常少量运行单元的显示芯片,可能会把各个 block 中的 thread 顺序运行。而非同一时候运行。不同的 grid 则能够运行不同的程序(即 kernel)。

    每一个 thread 都有自己的一份 register 和 local memory 的空间。

    同一个 block 中的每一个thread 则有共享的一份 share memory。此外,全部的 thread(包括不同 block 的 thread)都共享一份 global memory、constant memory、和 texture memory。不同的 grid 则有各自的 global memory、constant memory 和 texture memory。

    大家可能注意到不同block之间是无法进行同步工作的,只是,在我们的程序中。事实上不太须要进行 thread 的同步动作,因此我们能够使用多个 block 来进一步添加thread 的数目。

    通过多个block使用很多其它的线程

    以下我们就開始继续改动我们的程序:

    先贴一下之前的完整代码:

    #include <stdio.h>
    #include <stdlib.h>
    #include <time.h>
    
    //CUDA RunTime API
    #include <cuda_runtime.h>
    
    //1M
    #define DATA_SIZE 1048576
    
    #define THREAD_NUM 1024
    
    int data[DATA_SIZE];
    
    //产生大量0-9之间的随机数
    void GenerateNumbers(int *number, int size)
    {
        for (int i = 0; i < size; i++) {
            number[i] = rand() % 10;
        }
    }
    
    //打印设备信息
    void printDeviceProp(const cudaDeviceProp &prop)
    {
        printf("Device Name : %s.
    ", prop.name);
        printf("totalGlobalMem : %d.
    ", prop.totalGlobalMem);
        printf("sharedMemPerBlock : %d.
    ", prop.sharedMemPerBlock);
        printf("regsPerBlock : %d.
    ", prop.regsPerBlock);
        printf("warpSize : %d.
    ", prop.warpSize);
        printf("memPitch : %d.
    ", prop.memPitch);
        printf("maxThreadsPerBlock : %d.
    ", prop.maxThreadsPerBlock);
        printf("maxThreadsDim[0 - 2] : %d %d %d.
    ", prop.maxThreadsDim[0], prop.maxThreadsDim[1], prop.maxThreadsDim[2]);
        printf("maxGridSize[0 - 2] : %d %d %d.
    ", prop.maxGridSize[0], prop.maxGridSize[1], prop.maxGridSize[2]);
        printf("totalConstMem : %d.
    ", prop.totalConstMem);
        printf("major.minor : %d.%d.
    ", prop.major, prop.minor);
        printf("clockRate : %d.
    ", prop.clockRate);
        printf("textureAlignment : %d.
    ", prop.textureAlignment);
        printf("deviceOverlap : %d.
    ", prop.deviceOverlap);
        printf("multiProcessorCount : %d.
    ", prop.multiProcessorCount);
    }
    
    //CUDA 初始化
    bool InitCUDA()
    {
        int count;
    
        //取得支持Cuda的装置的数目
        cudaGetDeviceCount(&count);
    
        if (count == 0) {
            fprintf(stderr, "There is no device.
    ");
            return false;
        }
    
        int i;
    
        for (i = 0; i < count; i++) {
    
            cudaDeviceProp prop;
            cudaGetDeviceProperties(&prop, i);
            //打印设备信息
            printDeviceProp(prop);
    
            if (cudaGetDeviceProperties(&prop, i) == cudaSuccess) {
                if (prop.major >= 1) {
                    break;
                }
            }
        }
    
        if (i == count) {
            fprintf(stderr, "There is no device supporting CUDA 1.x.
    ");
            return false;
        }
    
        cudaSetDevice(i);
    
        return true;
    }
    
    
    // __global__ 函数 (GPU上运行) 计算立方和
    __global__ static void sumOfSquares(int *num, int* result, clock_t* time)
    {
    
        //表示眼下的 thread 是第几个 thread(由 0 開始计算)
        const int tid = threadIdx.x;
    
        int sum = 0;
    
        int i;
    
        //记录运算開始的时间
        clock_t start;
    
        //仅仅在 thread 0(即 threadIdx.x = 0 的时候)进行记录
        if (tid == 0) start = clock();
    
        for (i = tid; i < DATA_SIZE; i += THREAD_NUM) {
    
            sum += num[i] * num[i] * num[i];
    
        }
    
        result[tid] = sum;
    
        //计算时间的动作,仅仅在 thread 0(即 threadIdx.x = 0 的时候)进行
        if (tid == 0) *time = clock() - start;
    
    }
    
    
    
    
    
    int main()
    {
    
        //CUDA 初始化
        if (!InitCUDA()) {
            return 0;
        }
    
        //生成随机数
        GenerateNumbers(data, DATA_SIZE);
    
        /*把数据拷贝到显卡内存中*/
        int* gpudata, *result;
    
        clock_t* time;
    
        //cudaMalloc 取得一块显卡内存 ( 当中result用来存储计算结果,time用来存储运行时间 )
        cudaMalloc((void**)&gpudata, sizeof(int)* DATA_SIZE);
        cudaMalloc((void**)&result, sizeof(int)*THREAD_NUM);
        cudaMalloc((void**)&time, sizeof(clock_t));
    
        //cudaMemcpy 将产生的随机数拷贝到显卡内存中
        //cudaMemcpyHostToDevice - 从内存拷贝到显卡内存
        //cudaMemcpyDeviceToHost - 从显卡内存拷贝到内存
        cudaMemcpy(gpudata, data, sizeof(int)* DATA_SIZE, cudaMemcpyHostToDevice);
    
        // 在CUDA 中运行函数 语法:函数名称<<<block 数目, thread 数目, shared memory 大小>>>(參数...);
        sumOfSquares << < 1, THREAD_NUM, 0 >> >(gpudata, result, time);
    
    
        /*把结果从显示芯片复制回主内存*/
    
        int sum[THREAD_NUM];
    
        clock_t time_use;
    
        //cudaMemcpy 将结果从显存中复制回内存
        cudaMemcpy(&sum, result, sizeof(int)* THREAD_NUM, cudaMemcpyDeviceToHost);
        cudaMemcpy(&time_use, time, sizeof(clock_t), cudaMemcpyDeviceToHost);
    
        //Free
        cudaFree(gpudata);
        cudaFree(result);
        cudaFree(time);
    
        int final_sum = 0;
    
        for (int i = 0; i < THREAD_NUM; i++) {
    
            final_sum += sum[i];
    
        }
    
        printf("GPUsum: %d  gputime: %d
    ", final_sum, time_use);
    
        final_sum = 0;
    
        for (int i = 0; i < DATA_SIZE; i++) {
    
            final_sum += data[i] * data[i] * data[i];
    
        }
    
        printf("CPUsum: %d 
    ", final_sum);
    
        return 0;
    }

    我们要去添加多个block来继续添加我们的线程数量:

    首先define一个block的数目

    #define THREAD_NUM 256
    #define BLOCK_NUM 32
    

    我们准备建立 32 个 blocks。每一个 blocks 有 256个 threads,也就是说总共同拥有 32*256= 8192个threads,这里有一个问题。我们为什么不用极限的1024个线程呢?那样就是32*1024 = 32768 个线程,难道不是更好吗?事实上并非这种,从线程运行的原理来看,线程数量达到一定大小后,我们再一味的添加线程也不会取得性能提升了,反而有可能会让性能下降,感兴趣的同学能够改一下数量试一下。另外我们的加和部分是在CPU上进行的,越多的线程意味着越多的结果,而这也意味着CPU上的运算压力会越来越大。

    接着,我们须要改动kernel 部份,添加bid = blockIdx.x:

    
    // __global__ 函数 (GPU上运行) 计算立方和
    __global__ static void sumOfSquares(int *num, int* result, clock_t* time)
    {
    
        //表示眼下的 thread 是第几个 thread(由 0 開始计算)
        const int tid = threadIdx.x;
    
        //表示眼下的 thread 属于第几个 block(由 0 開始计算)
        const int bid = blockIdx.x;
    
    
        int sum = 0;
    
        int i;
    
        //记录运算開始的时间
        clock_t start;
    
        //仅仅在 thread 0(即 threadIdx.x = 0 的时候)进行记录。每一个 block 都会记录開始时间及结束时间
        if (tid == 0) time[bid]= clock();
    
        //thread须要同一时候通过tid和bid来确定,同一时候不要忘记保证内存连续性
        for (i =  bid * THREAD_NUM + tid; i < DATA_SIZE; i += BLOCK_NUM * THREAD_NUM) {
    
            sum += num[i] * num[i] * num[i];
    
        }
    
        //Result的数量随之添加
        result[bid * THREAD_NUM + tid] = sum;
    
        //计算时间的动作。仅仅在 thread 0(即 threadIdx.x = 0 的时候)进行,每一个 block 都会记录開始时间及结束时间
        if (tid == 0) time[bid + BLOCK_NUM] = clock();
    
    }
    

    关于改动凝视已经写得非常清楚了。

    blockIdx.x 和 threadIdx.x 一样是 CUDA 内建的变量。它表示的是眼下的 block 编号。

    另外我们把计算时间的方式改成每一个 block 都会记录開始时间及结束时间。

    因此我们result和time变量的长度须要进行更改:

    //cudaMalloc 取得一块显卡内存 ( 当中result用来存储计算结果,time用来存储运行时间 )
    cudaMalloc((void**) &result, sizeof(int) * THREAD_NUM * BLOCK_NUM);
    cudaMalloc((void**) &time, sizeof(clock_t) * BLOCK_NUM * 2); 
    

    然后在调用核函数的时候,把控制block数量的的參数改成我们的block数:

    
    sumOfSquares << < BLOCK_NUM, THREAD_NUM, 0 >> >(gpudata, result, time);
    

    注意从显存复制回内存的部分也须要改动(因为result和time长度的改变):

    
        /*把结果从显示芯片复制回主内存*/
    
        int sum[THREAD_NUM*BLOCK_NUM];
    
        clock_t time_use[BLOCK_NUM * 2];
    
        //cudaMemcpy 将结果从显存中复制回内存
        cudaMemcpy(&sum, result, sizeof(int)* THREAD_NUM*BLOCK_NUM, cudaMemcpyDeviceToHost);
        cudaMemcpy(&time_use, time, sizeof(clock_t)* BLOCK_NUM * 2, cudaMemcpyDeviceToHost);
    
        //Free
        cudaFree(gpudata);
        cudaFree(result);
        cudaFree(time);
    
        int final_sum = 0;
    
        for (int i = 0; i < THREAD_NUM*BLOCK_NUM; i++) {
    
            final_sum += sum[i];
    
        }
    

    此外,因为涉及到block,我们须要採取不同的计时方式,即把每一个 block 最早的開始时间,和最晚的结束时间相减,取得总运行时间。

        //採取新的计时策略 把每一个 block 最早的開始时间,和最晚的结束时间相减,取得总运行时间
        clock_t min_start, max_end;
    
        min_start = time_use[0];
    
        max_end = time_use[BLOCK_NUM];
    
        for (int i = 1; i < BLOCK_NUM; i++) {
            if (min_start > time_use[i])
                min_start = time_use[i];
            if (max_end < time_use[i + BLOCK_NUM])
                max_end = time_use[i + BLOCK_NUM];
        }
    
        printf("GPUsum: %d  gputime: %d
    ", final_sum, max_end - min_start);

    完整程序:

    #include <stdio.h>
    #include <stdlib.h>
    #include <time.h>
    
    //CUDA RunTime API
    #include <cuda_runtime.h>
    
    //1M
    #define DATA_SIZE 1048576
    
    #define THREAD_NUM 256
    
    #define BLOCK_NUM 32
    
    int data[DATA_SIZE];
    
    //产生大量0-9之间的随机数
    void GenerateNumbers(int *number, int size)
    {
        for (int i = 0; i < size; i++) {
            number[i] = rand() % 10;
        }
    }
    
    //打印设备信息
    void printDeviceProp(const cudaDeviceProp &prop)
    {
        printf("Device Name : %s.
    ", prop.name);
        printf("totalGlobalMem : %d.
    ", prop.totalGlobalMem);
        printf("sharedMemPerBlock : %d.
    ", prop.sharedMemPerBlock);
        printf("regsPerBlock : %d.
    ", prop.regsPerBlock);
        printf("warpSize : %d.
    ", prop.warpSize);
        printf("memPitch : %d.
    ", prop.memPitch);
        printf("maxThreadsPerBlock : %d.
    ", prop.maxThreadsPerBlock);
        printf("maxThreadsDim[0 - 2] : %d %d %d.
    ", prop.maxThreadsDim[0], prop.maxThreadsDim[1], prop.maxThreadsDim[2]);
        printf("maxGridSize[0 - 2] : %d %d %d.
    ", prop.maxGridSize[0], prop.maxGridSize[1], prop.maxGridSize[2]);
        printf("totalConstMem : %d.
    ", prop.totalConstMem);
        printf("major.minor : %d.%d.
    ", prop.major, prop.minor);
        printf("clockRate : %d.
    ", prop.clockRate);
        printf("textureAlignment : %d.
    ", prop.textureAlignment);
        printf("deviceOverlap : %d.
    ", prop.deviceOverlap);
        printf("multiProcessorCount : %d.
    ", prop.multiProcessorCount);
    }
    
    //CUDA 初始化
    bool InitCUDA()
    {
        int count;
    
        //取得支持Cuda的装置的数目
        cudaGetDeviceCount(&count);
    
        if (count == 0) {
            fprintf(stderr, "There is no device.
    ");
            return false;
        }
    
        int i;
    
        for (i = 0; i < count; i++) {
    
            cudaDeviceProp prop;
            cudaGetDeviceProperties(&prop, i);
            //打印设备信息
            printDeviceProp(prop);
    
            if (cudaGetDeviceProperties(&prop, i) == cudaSuccess) {
                if (prop.major >= 1) {
                    break;
                }
            }
        }
    
        if (i == count) {
            fprintf(stderr, "There is no device supporting CUDA 1.x.
    ");
            return false;
        }
    
        cudaSetDevice(i);
    
        return true;
    }
    
    
    // __global__ 函数 (GPU上运行) 计算立方和
    // __global__ 函数 (GPU上运行) 计算立方和
    __global__ static void sumOfSquares(int *num, int* result, clock_t* time)
    {
    
        //表示眼下的 thread 是第几个 thread(由 0 開始计算)
        const int tid = threadIdx.x;
    
        //表示眼下的 thread 属于第几个 block(由 0 開始计算)
        const int bid = blockIdx.x;
    
    
        int sum = 0;
    
        int i;
    
        //记录运算開始的时间
        clock_t start;
    
        //仅仅在 thread 0(即 threadIdx.x = 0 的时候)进行记录,每一个 block 都会记录開始时间及结束时间
        if (tid == 0) time[bid] = clock();
    
        //thread须要同一时候通过tid和bid来确定,同一时候不要忘记保证内存连续性
        for (i = bid * THREAD_NUM + tid; i < DATA_SIZE; i += BLOCK_NUM * THREAD_NUM) {
    
            sum += num[i] * num[i] * num[i];
    
        }
    
        //Result的数量随之添加
        result[bid * THREAD_NUM + tid] = sum;
    
        //计算时间的动作,仅仅在 thread 0(即 threadIdx.x = 0 的时候)进行。每一个 block 都会记录開始时间及结束时间
        if (tid == 0) time[bid + BLOCK_NUM] = clock();
    
    }
    
    
    
    
    
    int main()
    {
    
        //CUDA 初始化
        if (!InitCUDA()) {
            return 0;
        }
    
        //生成随机数
        GenerateNumbers(data, DATA_SIZE);
    
        /*把数据拷贝到显卡内存中*/
        int* gpudata, *result;
    
        clock_t* time;
    
        //cudaMalloc 取得一块显卡内存 ( 当中result用来存储计算结果。time用来存储运行时间 )
        cudaMalloc((void**)&gpudata, sizeof(int)* DATA_SIZE);
        cudaMalloc((void**)&result, sizeof(int)*THREAD_NUM* BLOCK_NUM);
        cudaMalloc((void**)&time, sizeof(clock_t)* BLOCK_NUM * 2);
    
        //cudaMemcpy 将产生的随机数拷贝到显卡内存中
        //cudaMemcpyHostToDevice - 从内存拷贝到显卡内存
        //cudaMemcpyDeviceToHost - 从显卡内存拷贝到内存
        cudaMemcpy(gpudata, data, sizeof(int)* DATA_SIZE, cudaMemcpyHostToDevice);
    
        // 在CUDA 中运行函数 语法:函数名称<<<block 数目, thread 数目, shared memory 大小>>>(參数...);
        sumOfSquares << < BLOCK_NUM, THREAD_NUM, 0 >> >(gpudata, result, time);
    
    
        /*把结果从显示芯片复制回主内存*/
    
        int sum[THREAD_NUM*BLOCK_NUM];
    
        clock_t time_use[BLOCK_NUM * 2];
    
        //cudaMemcpy 将结果从显存中复制回内存
        cudaMemcpy(&sum, result, sizeof(int)* THREAD_NUM*BLOCK_NUM, cudaMemcpyDeviceToHost);
        cudaMemcpy(&time_use, time, sizeof(clock_t)* BLOCK_NUM * 2, cudaMemcpyDeviceToHost);
    
        //Free
        cudaFree(gpudata);
        cudaFree(result);
        cudaFree(time);
    
        int final_sum = 0;
    
        for (int i = 0; i < THREAD_NUM*BLOCK_NUM; i++) {
    
            final_sum += sum[i];
    
        }
    
        //採取新的计时策略 把每一个 block 最早的開始时间,和最晚的结束时间相减。取得总运行时间
        clock_t min_start, max_end;
    
        min_start = time_use[0];
    
        max_end = time_use[BLOCK_NUM];
    
        for (int i = 1; i < BLOCK_NUM; i++) {
            if (min_start > time_use[i])
                min_start = time_use[i];
            if (max_end < time_use[i + BLOCK_NUM])
                max_end = time_use[i + BLOCK_NUM];
        }
    
        printf("GPUsum: %d  gputime: %d
    ", final_sum, max_end - min_start);
    
        final_sum = 0;
    
        for (int i = 0; i < DATA_SIZE; i++) {
    
            final_sum += data[i] * data[i] * data[i];
    
        }
    
        printf("CPUsum: %d 
    ", final_sum);
    
        return 0;
    }

    运行结果:

    这里写图片描写叙述

    为了对照我们把block改成1再运行一次:

    这里写图片描写叙述

    我们看到32block 256 thread 连续存取的情况下运行用了133133个时钟周期

    而在 1block 256 thread 连续存取的情况下运行用了3488971个时钟周期

    3488971/133133= 26.21倍
    

    能够看到我们的速度整整提升了26倍,这个版本号的程序。运行的时间降低非常多。

    我们还是从内存带宽的角度来进行一下评估:

    首先计算一下使用的时间:

    133133/ (797000 * 1000) = 1.67e-4S
    

    然后计算使用的带宽:

    数据量仍然没有变 DATA_SIZE 1048576,也就是1024*1024 也就是 1M

    1M 个 32 bits 数字的数据量是 4MB。

    因此。这个程序实际上使用的内存带宽约为:

    4MB / 1.67e-4S = 23945.9788MB/s = 23.38GB/s
    

    这对于我这块640,频率仅有797000。已经是一个非常不错的效果了。只是,这个程序尽管在GPU上节省了时间。可是在 CPU 上运行的部份,须要的时间加长了(因为 CPU 如今须要加总 8192 个数字)。

    为了避免这个问题,下一步我们能够让每一个 block 把自己的每一个 thread 的计算结果进行加总。

    关于很多其它线程的小实验,越多线程越好?

    之前中间提过我们为什么不用很多其它的线程,比方一个block 1024个。或者很多其它的block。

    这是因为从线程运行的原理来看。线程数量达到一定大小后,我们再一味的添加线程也不会取得性能提升了。反而有可能会让性能下降。

    我们能够试验一下:

    1024Thread *128block = 101372 个 Thread 够多了吧。我们看下运行结果:

    这里写图片描写叙述

    我们看到终于用了153292个时钟周期,劲爆的10万个线程真的变慢了。

    为什么会这样呢?以下我们从GPU的原理上来解说这个问题。

    从GPU结构理解线程:

    之前关于为什么线程不能这么多的问题,说的还是不是非常清楚。事实上从硬件角度分析,支持CUDA的NVIDIA 显卡,都是由多个multiprocessors 组成。每一个 multiprocessor 里包括了8个stream processors,其组成是四个四个一组,也就是两组4D的处理器。

    每一个 multiprocessor 还具有 非常多个(比方8192个)寄存器,一定的(比方16KB) share memory,以及 texture cache 和 constant cache

    这里写图片描写叙述

    在 CUDA 中,大部份主要的运算动作。都能够由 stream processor 进行。每一个 stream processor 都包括一个 FMA(fused-multiply-add)单元,能够进行一个乘法和一个加法。

    比較复杂的运算则会须要比較长的时间。

    在运行 CUDA 程序的时候。每一个 stream processor 就是相应一个 thread。每一个 multiprocessor 则相应一个 block。可是我们一个block往往有非常大量的线程,之前我们用到了256个和1024个。远超一个 multiprocessor 全部的8个 stream processor 。

    实际上。尽管一个 multiprocessor 仅仅有八个 stream processor,可是因为 stream processor 进行各种运算都有 latency,更不用提内存存取的 latency,因此 CUDA 在运行程序的时候,是以warp 为单位。

    比方一个 warp 里面有 32 个 threads。分成两组 16 threads 的 half-warp。

    因为 stream processor 的运算至少有 4 cycles 的 latency,因此对一个 4D 的stream processors 来说。一次至少运行 16 个 threads(即 half-warp)才干有效隐藏各种运算的 latency。

    也因此。线程数达到隐藏各种latency的程度后。之后数量的提升就没有太大的作用了。

    另一个重要的原因是,因为 multiprocessor 中并没有太多别的内存,因此每一个 thread 的状态都是直接保存在multiprocessor 的寄存器中。所以,假设一个 multiprocessor 同一时候有愈多的 thread 要运行,就会须要愈多的寄存器空间。比如,假设一个 block 里面有 256 个 threads。每一个 thread 用到20 个寄存器,那么总共就须要 256x20 = 5,120 个寄存器才干保存每一个 thread 的状态。

    而一般每一个 multiprocessor 仅仅有 8,192 个寄存器。因此。假设每一个 thread 使用到16 个寄存器,那就表示一个 multiprocessor 的寄存器同一时候最多仅仅能维持 512 个 thread 的运行。假设同一时候进行的 thread 数目超过这个数字。那么就会须要把一部份的数据储存在显卡内存中,就会降低运行的效率了。

    总结:

    这篇博客主要使用block进行了进一步增大了线程数,进行进一步的并行,终于的结果还是比較令人惬意的,至少对于我这块显卡来说已经非常不错了,因为我的显卡主频比較低,假设用一块1.5Ghz的显卡。用的时间就会是我的一半,而这时候内存带宽也就基本达到45GB/s左右了。

    同一时候也回答了非常多人都会有的疑问,即为什么我们不搞几万个线程。

    可是我们也看到了新的问题,我们在CPU端的加和压力变得非常大。那么我们能不能从GPU上直接完毕这个工作呢?我们知道每一个block内部的Thread之间是能够同步和通讯的,下一步我们将让每一个block把每一个thread的计算结果进行加和。

    希望我的博客能帮助到大家~

    參考资料:《深入浅出谈CUDA》

  • 相关阅读:
    javaScript常用运算符和操作符总结
    JavaScript-基本语法和数据类型
    javascript基本特点,组成和应用
    常用布局-列宽度是固定宽度还是自适应
    web设计之无懈可击
    CSS布局定位基础-盒模型和定位机制
    Ubuntu(Linux)系统WPS文字不能输入中文如何解决
    ggplot2点图+线性趋势+公式+R2+p值
    GTEx数据库-TCGA数据挖掘的好帮手
    limma, edgeR, deseq2,genefilter计算差异R包的foldchange方法差别
  • 原文地址:https://www.cnblogs.com/zsychanpin/p/7241338.html
Copyright © 2020-2023  润新知