第11章 超级并行

本章关注如何充分发挥计算机系统多层次硬件的计算能力,称之为超级并行。

现实的超级并行一般分为三层
        (0.流水线:将计算和访存并行;)
        1.多机:通常使用网络将多个计算机连接起来。基于进程的MPI天生适合此类系统。编程时,通常需要大粒度;
        2.(多芯)多核:基于线程机制的OpenMP和pthreads是首选的;
        3.GPU(warp)或SIMD向量:X86架构支持SSE/AVX指令,在这一层次需要利用生产商提供的汇编指令接口编程,但是通常编译器已经包装它们,以内置函数的方式提供,以方便使用。

11.1 超级并行方式编程

尽管不同级别、类型、组织的硬件上有不同的适合的编程方式,创造或设计一个能够在多种类型、平台上运行的编程环境并非难事。但是它们通常不能发挥不同硬件的优势,因此超级并行方式通常涉及多种编程环境的混合。这种混合通常对应着硬件的某些特点,这种混合编程增加了编程的难度,但是却提供了灵活性和获得更好性能的方式。

由于使用了不同的函数库和编程语言,构建超级并行程序并不像普通的程序那样简单。解决这个问题的基本思想是分段编译,然后连接。

超级并行引入了更多复杂性,封装通常可用减少程序的复杂性,但是封装也会减弱开发人员的控制力,潜在地降低性能,因此实践中应根据实际情况均衡。

11.1.1 进程 + 线程

此类编程方式适合“多机 + 多核”的硬件组织模式,在这类系统上有多个计算机,可以为每个计算机分配一个进程,MPI是首选。每个计算机上有多个核心,为了发挥所有核心的计算能力,需要使用多线程机制在每个进程内分配多个线程,通常OpenMP是首选。

这要求首选将应用算法划分为大粒度的任务,以将每个任务分配给一个进程处理。然后将每个进程的任务划分为更小的任务,然后使用多个线程处理这些任务。由于进程间通信代价的高昂,应尽量将通信限制在一个计算机内的线程间。

在矩阵向量相乘时,可以使用一个进程计算矩阵多行与向量乘积,而每个线程计算矩阵一行和向量乘积,或者多个线程计算矩阵一行与向量乘积。

// 进程 + 线程计算矩阵向量乘
template <tyepeame T>
void mvx(size_t numRow, size_t numColumns, const T* matrix, const T* v, T* r)
{
#pragma omp parallel for
    for (int i = 0; i < numRows; i++)
    {
        T sum = (T)0;
        for (int j = 0; j < numColumns; j++)
            sum += matrix[i * numColumns + j] * v[j];

        r[i] = sum;
    }
}


int main()
{
    checkMPIError(MPI_Init(&argc, argv);
    int size, rank;
    checkMPIError(MPI_Comm_size(MPI_COMM_WORLD, &size));
    checkMPIError(MPI_Comm_rank(MPI_COMM_WORLD, &rank));

    float matrix[NUM_ROWS * NUM_COLUMNS];
    float v[NUM_COLUMNS];
    float r[NUM_ROWS];
    float* all;
    
    // init matrix, v and malloc space for all
...
    mvx(NUM_ROWS, NUM_COLUMNS, matrix, v, r);
    checkMPIError(MPI_Gather(r, NUM_ROWS, MPI_FLOAT, all, NUM_ROWS, MPI_FLOAT, 0, MPI_COMM_WORLD));
    // post process
...
    checkMPIError(MPI_Finalize());
    return 0;
}

由于两者可以用同一种编译器编译,因此只需要在编译的时候链接上对应的动态库或编译选项即可:

        mpic++ mpiOpenenmxv.cpp -DNUM_ROWS=1024 -DNUM_COLUMNS=1024 -fopenmp -lgomp

11.1.2 进程+GPU线程

实际中适合这种模式的硬件组织有两种情况:

        1.多机+GPU。在此类集群系统上编程首选MPI+CUDA/OpenCL;

        2.单机多核+多GPU,这更适合线程+GPU线程;

通常使用一个进程控制一个GPU。实际上由于三层模型增加了复杂性,很多时候将之降级为二层模型,在集群里的每个GPU分配一个进程,每个进程控制一个GPU。

MPI编译多GPU程序的思想是:将CUDA程序用C封装,在编译成目标文件;然后在mpicc编译时链接,最好保证GPU数和进程数相等,因为现有的一些老的GPU还不支持在多GPU上同时运行CUDA kernel函数。

void __global__ mvxBlock(int rowSize, int columnSize, int pitchItem, const float* __restrict__ d_matrix, const float* __restrict__ d_vec, float* __restrict__ d_r)
{
    unsigned int tid = threadIdx.x;
    extern __shared__ volatile float s_r[];
    float temp = 0.0f;

    for (int i = tid; i < columnSize; i += blockDim.x)
        temp += d_matrix[blockIdx.x * pitchItem + i] * d_vec[i];

    s_r[tid] = temp;
    __syncthreads();

    for (int i = (blockDim.x >> 1); i > 32; i >>= 1)
    {
        if (tid < i)
            s_r[tid] += s_r[tid + i];

        syncthreads();
    }

    if (tid < 32) { s_r[tid] += s_r[tid + 32]; }
    if (tid < 16) { s_r[tid] += s_r[tid + 16]; }
    if (tid < 8) { s_r[tid] += s_r[tid + 8]; }
    if (tid < 4) { s_r[tid] += s_r[tid + 4]; }
    if (tid < 2) { s_r[tid] += s_r[tid + 2]; }
    if (tid < 1) { s_r[tid] += s_r[tid + 1]; }

    if (0 == tid) d_r[blockIdx.x] = s_r[0];
}

void runmvxBlockGPU(int rowSize, int columnSize, const float* matrix, const float* v, float* r)
{
    float* d_matrix;
    size_t pitch;
    cutiSafeCall(cudaMallocPitch((void**)&d_matrix, &pitch, columnSize * sizeof(float), rowSize));
    cutilSafeCall(cudaMemcpy2DAsync(d_matrix, pitch, matrix, columnSize * sizeof(float), columnSize(float), rowSize, cudaMemcpyHostToDevice, 0);
    
    float* d_v;
    cutilSafeCall(cudaMalloc(void**)&d_v, columnSize * sizeof(float), cudaMemcpyHostToDevice, 0);

    float* d_r;
    cutilSafeCall(cudaMalloc(void**)&d_r, RowSize * sizeof(float))

    int blockSize = 256;
    mvxBlock<<<rowSize, blockSize, blockSize * sizeof(float)>>>(rowSize, columnSize, pitch / sizeof(float), d_matrix, d_v, d_r);
    cutilSafeCall(cudaThreadSynchronize());
    cutilSafeCall(cudaMemcpyAsync(r, d_r, matrix, rowSize * sizeof(float), cudaMemcpyDeviceToHost, 0);

    cutilSafeCall(cudaFree(d_v));
    cutilSafeCall(cudaFree(d_r));
    cutilSafeCall(cudaFree(d_matrix));
    cutilSafeCall(cudaDeviceReset());
}

使用下面命令将其编译成.o文件:

        nvcc -c cudamxv.cu

void runmvxBlockGPU(int rowSize, int columnSize, const float* matrix, const float* v, float* r);

int main()
{
    checkMPIError(MPI_Init(&argc, argv);
    int size, rank;
    checkMPIError(MPI_Comm_size(MPI_COMM_WORLD, &size));
    checkMPIError(MPI_Comm_rank(MPI_COMM_WORLD, &rank));

    float matrix[NUM_ROWS * NUM_COLUMNS];
    float v[NUM_COLUMNS];
    float r[NUM_ROWS];
    float* all;
    
    // init matrix, v and malloc space for all
...
    int num;
    cudaGetDeviceCount(&num);
    cudaSetDevice(rank % num);
    runmxvBlockGPU(NUM_ROWS, NUM_COLUMNS, matrix, v, r);
    checkMPIError(MPI_Gather(r, NUM_ROWS, MPI_FLOAT, all, NUM_ROWS, MPI_FLOAT, 0, MPI_COMM_WORLD));
    // post process
...
    checkMPIError(MPI_Finalize());
    return 0;
}

使用下面的命令将其编译并链接CUDA内核函数;

        mpic++ mpiCUDAmvx.cpp -DNUM_ROW=1024 -DNUM_COLUMNS=1024 cudamxv.o -lcudart -L/usr/local/cuda-5.0/lib64

有时在运行时,程序找不到相关的动态库,此时只需要将动态链接所在路径写入/etc/lb/so/conf文件中,然后运行ldconfig命令即可。

11.1.3 线程+GPU线程

这种模式比较常见,硬件组织通常是多核+多GPU,使用的编程环境OpenMP+CUDA/OpenCL。此时通常使用的模型是一个线程控制一个GPU。

编译OpenMP+CUDA/OpenCL程序的思想是:首先将CUDA/OpenCL包装成C函数,在编译成目标文件或动态链接库,然后在OpenMP程序中调用它即可。

void runmxcBlockGPU(int rowSize, int columnSize, const float* matrix, const float* v, float* r);

int main(int argc, char* argv)
{
    float matrix[NUM_COLUMNS * NUM_ROWS];
    float v[NUM_COLUMNS];
    float r[NUM_ROWS];
    int deviceNum;
    cudaGetDeviceCount(&deviceNum);
#pragma omp parallel
{
    cudaSetDevice(omp_get_thread_num() % deviceNum);
#pragma omp for schedules(dynamic, 1)
    for (int i = 0; i < NUM_ROWS; i += PATCH)
    {
        runmxcBlockGPU(PATCH, NUM_COLUMNS, matrix + NUM_COLUMNS * i, v, r + i);
    }
}

    return 0;
}

可以使用如下命令编译并链接CUDA内核函数:

        g++ openCUDAmxv.cpp -DNUM_ROWS=1024 -DNUM_COLUMNS=1024 -DPATCH=256 cudamvx.o -lcudart -L/usr/local/cuda-5.0/lib64 -fopenmp

11.1.4 线程 + 向量指令

这种模式适合在多个X86和ARM处理器上使用,线程可以发挥多核的计算能力,而向量指令有可能发挥核心内向量指令的计算能力,编程方式首选OpenMP+SSE/AVX/NEON。

void mxVSSEOpenmp(int rowSize, int columnSize, const float* matrix, const float* v, float* r)
{
    __m128* mv = (__m128*)v;
    __m128* mm = (__m128*)matrix;

#pragma omp parallel for
    for (int i = 0; i < rowSize; i++)
    {
        __m128 re = __mm_set_ps(0.0f, 0.0f, 0.0f, 0.0f);
        for (int j = 0; j < columnSize / 4; j++)
            re = __mm_add_ps(re, __mm_mul_ps(mm[i * columnSize / 4 + j], mv[j]))
    
        float __attribute((aligned(16))) a[4];

        __mm_store_ps(a, re);

        r[0] = a[0] + a[1] + a[2] + a[3]; 
    }
}

编译指令和编译普通的OpenMP程序一致:

        g++ openmpSSEmxv.cpp -lgopm -fopennmp -03

11.1.5 进程 + 线程 + 向量指令

这种模式适合在X86集群上使用,为每个计算机分配一个进程,进程中再为每个核心分配一个线程,每个线程中使用向量指令以充分发挥集群的计算能力。通常使用MPI+OpenMP+SSE/AVX环境编程。

template<typename T>
void mxv(int rowSize, int columnSize, const T* matrix, const T* v, T* r)
{
    __m128* mv = (__m128*)v;
    __m128* mm = (__m128*)matrix;

#pragma omp parallel for
    for (int i = 0; i < rowSize; i++)
    {
        __m128 re = __mm_set_ps(0.0f, 0.0f, 0.0f, 0.0f);
        for (int j = 0; j < columnSize / 4; j++)
            re = __mm_add_ps(re, __mm_mul_ps(mm[i * columnSize / 4 + j], mv[j]))
    
        float __attribute((aligned(16))) a[4];

        __mm_store_ps(a, re);

        r[0] = a[0] + a[1] + a[2] + a[3]; 
    }
}

int main(int argc, char* argv[])
{
    float __attribute((aligned(16))) matrix[NUM_ROWS * NUM_COLUMNS]
    float __attribute((aligned(16))) v[NUM_COLUMNS], r[NUM_ROWS];

    checkMPIError(MPI_Init(&argc, argv);

    int size, rank;

    checkMPIError(MPI_Comm_size(MPI_COMM_WORLD, &size));
    checkMPIError(MPI_Comm_rank(MPI_COMM_WORLD, &rank));
    float* all;
    // init matrix, v and malloc space for all
...
    mxv(NUM_ROWS, NUM_COLUMNS, matrix, v, r);
    checkMPIError(MPI_Gather(r, NUM_ROWS, MPI_FLOAT, all, NUM_ROWS, MPI_FLOAT, 0, MPI_COMM_WORLD));
    // post process
...
    checkMPIError(MPI_Finalize());
    return 0;
}

其编译命令为:        

        mpic++ mpiOpenmpSSEmxv.cpp -DNUM_ROWS=1024 -DNUM_COLUMNS=1024 -fopenmp

11.1.6 进程+线程+GPU线程

一种三层模式是使用GPU取代向量指令,相比于向量指令,GPU要求更大的并行粒度。编程环境是MPI+OpenMP+CUDA/OpenCL。

在此类系统上编程时,可采用每个计算节点机上分配一个进程,每个进程中有多个线程,每个线程控制一个GPU的模式。

runmxvBlockGPU(int NUM_ROWS, int NUM_COLUMNS, const float* matrix, const float* v, float* r);

int main(int argc, char* argv[])
{
    checkMPIError(MPI_Init(&argc, argv);
    int size, rank;
    checkMPIError(MPI_Comm_size(MPI_COMM_WORLD, &size));
    checkMPIError(MPI_Comm_rank(MPI_COMM_WORLD, &rank));

    float matrix[NUM_ROWS * NUM_COLUMNS];
    float v[NUM_COLUMNS];
    float r[NUM_ROWS];
    float* all;
    
    // init matrix, v and malloc space for all

    int num;
    cudaGetDeviceCount(&num);
#pragma omp parallel
{


    cudaSetDevice(rank % num);
    #pragma omp for schedule(dynamic, 1);
    for (int i = 0; i < NUM_ROWS; i += PATCH)
        runmxvBlockGPU(PATCH, NUM_COLUMNS, matrix + i * NUM_COLUMNS, v, r + i);

}    
    checkMPIError(MPI_Gather(r, NUM_ROWS, MPI_FLOAT, all, NUM_ROWS, MPI_FLOAT, 0, MPI_COMM_WORLD));
    // post process

    checkMPIError(MPI_Finalize());
    return 0;
}

其编译命令为;

mpic++ mpiOpenmpCUDAmxv.cpp -DNUM_ROWS=1024 -DNUM_COLUMNS=1024 -DPATCH=256 -fopenmp cudamxv.o -lcidart -L/usr/local/cuda-5.0/lib64

11.2 矩阵乘法

11.2.1 多机CPU矩阵乘法

在基于MPI的多机环境中,卡诺算法是应用的最广泛的矩阵乘法实现。其中函数getXYUpDownProcess返回二维进程拓扑中与当前进程相聚disp的4个进程编号。目前的算法保证了所有通信进程都是二维相邻的。

#define NUM_DIMS 2

int main(int argc, char* argv[])
{
    checkMPIError(MPI_Init(&argc, &argv));
    int M = 1024;
    int N = 1024;
    int K = 1024;

    int rank;
    checkMPIError(MPI_Comm_rank(MPI_COMM_WORLD, &rank));
    int numProcesses;  
    checkMPIError(MPI_Comm_size(MPI_COMM_WORLD, &numProcesses));

    int dims[NUM_DIMS] = {0, 0};
    checkMPIError(MPI_Dims_Create(numProcesses, NUM_DIMS, dims));
    MPI_Comm my_comm;
    int periods[NUM_DIMS] = {1, 1};
    checkMPIError(MPI_Cart_create(MPI_COMM_WORLD, NUM_DIMS, dims, periods, 0, &my_comm));

    // Malloc sapce for A, B, C, bufA, bufB, bufC, and init them
    int numPartitions = dims[0];
    int myid[NUM_DIMS];
    int xidup, xiddown, yidup, yiddown;

    matrixMul<float, false>(A, B, C, M, K, N);
    for (int disp = 1; disp < numsPartitionsl disp++)
    {
        getCUpDownProcess(2, rank, my_comm, disp, myid, &xidup, &xiddown, &yidup, &yiddown);
        checkMPIError(MPI_Sendrecv(A, M * K, MPI_FLOAT, xiddown, rank, bufA, M * K, MPI_FLOAT, xidup, xidup, MPI_COMM_WORLD, MPI_STATUS_IGNORE));
        checkMPIError(MPI_Sendrecv(B, K * N, MPI_FLOAT, yiddown, rank, bufb, K * N, MPI_FLOAT, yidup, yidup, MPI_COMM_WORLD, MPI_STATUS_IGNORE));
        matrixMul<float, true>(bufA, bufB, C, M, K, N);
    }
    checkMPIError(MPI_Finalize());
    return 0;
}

多机CPU矩阵乘法版本2核心代码如下;

float* sendBufB, sendBufA, *recvBufA, * recvBufB;
for (int disp = 1; disp < numPartitions; disp++)
{
    sendBufA = (disp % 2) ? A : bufA;
    sendBufA = (disp % 2) ? bufA : A;
    recvBufB = (disp % 2) ? B : bufB;
    recvBufB = (disp % 2) ? bufB : B;


    if (1 == disp)
        matrix<float, false>(A, B, C, M, K, N);
    else
        matrix<float, true>(sendBufA, sendBufB, C, M, K, N);

    MPI_Sendrecv(sendBufA, M * K, MPI_FLOAT, xiddown, rank, recvBufA, M * K, MPI_FLOAT, xidup, xidup, MPI_COMM_WORLD, MPI_STATUS_IGNORE);
    MPI_Sendrecv(sendBufB, K * N, MPI_FLOAT, yiddown, rank, recvBufB, K * N, MPI_FLOAT, yidup, yidup, MPI_COMM_WORLD, MPI_STATUS_IGNORE);

    chekMPIError(MPI_Barrier(MPI_COMM_WORLD));
}

matrixMul<float, true>(recvBufA, recvBufB, C, M, K, N);

笔者使用了MPI_Sendrecv在进程中交换数据,但是这个函数是阻塞的,一次计算和通信是串行的。要解决这个问题的两个办法:

        1. 使用异步通信MPI_Isend、MPI_Irecv,此时需要特别安排避免死锁。认为MPI的异步通信不会出现死锁,这是严重错误的。

        2. 使用线程,将计算交给线程,通信和计算自然就并行了。

多机CPU矩阵乘法版本3

pthread_t thread;
float* sendBufB, sendBufA, *recvBufA, * recvBufB;
for (int disp = 1; disp < numPartitions; disp++)
{
    sendBufA = (disp % 2) ? A : bufA;
    sendBufA = (disp % 2) ? bufA : A;
    recvBufB = (disp % 2) ? B : bufB;
    recvBufB = (disp % 2) ? bufB : B;

    DataArg arg;
    arg.A = sendBufA;
    arg.B = sendBufB;
    arg.C = C;
    arg.M = M;
    arg.K = K;
    arg.N = N;
    assert(0 == pthread_create(&thread, NULL, thread_matrixMul, &arg);

    checkMPIError(MPI_Sendrecv(sendBufA, M * K, MPI_FLOAT, xiddown, rank, recvBufA, M * K, MPI_FLOAT, xidup, xidup, MPI_COMM_WORLD, MPI_STATUS_IGNORE));
    checkMPIError(MPI_Sendrecv(sendBufB, K * N, MPI_FLOAT, yiddown, rank, recvBufB, K * N, MPI_FLOAT, yidup, yidup, MPI_COMM_WORLD, MPI_STATUS_IGNORE));

    pthread_join(thread, NULL);
    chekMPIError(MPI_Barrier(MPI_COMM_WORLD));
}

matrixMul<float, true>(recvBufA, recvBufB, C, M, K, N);

需要提醒的是,有些MPI实现不支持直接在MPI进程中使用线程,目前OpenMPI实现一键提供了支持。

11.2.2 单机多GPU矩阵乘法

float alpha = 1.0f;
float beta = 0.0f;
int lda = k;
int ldb = n / numGPU;
int ldc = n;

for (int id = 0; id < numGpu; id++)
{
    cudaSetDevice(id);
    cublasSetStream(handle[id], streams[id][0]);
    cublasSgemm(handle[id], CUBLAS_OP_N, CUBLAS_OP_N, n / numGPU, m / numGPU, k, &alpha, d_bp[id][0], ldb, d_a[id], lda, &beta, c[id] + id * n / numGPU, ldc);
    
    for (int offset = l; offset < numGPU; offset++)
    {
        int srcDevice = (id + offset) % numGPU;
        checkCUDAError(cudaMemcpyPeerAsync(d_b[id][offset], id, d_b[srcDevice][0], srcDevice, n / numGPU * sizeof(float), streams[id][offset]));
    }

    for (int offset = l; offset < numGPU; offset++)
    {
        int srcDevice = (id + offset) % numGPU;
        cublasSetStream(handle[id], streams[id][offset]);
        cublasSgemm(handle[id], CUBLAS_OP_N, CUBLAS_OP_N, n / numGPU, m / numGPU, k, &alpha, d_bp[id][offset], ldb, d_a[id], lda, &beta, d_c[id] + srcDevice * n / numGPU, ldc);
    }
}

for (int id = 0; id < numGpu; id++)
{
    cudaSetDevice(id);
    checkCUDAError(cudaDeviceSynchronize());
}

此算法的主要核心是双层循环,外层循环遍历每个GPU,内层循环从其他GPU获得B数据。由于每个GPU只保存了一部分A和一部分B,因此需要在计算的时候从其他GPU获得B数据。

为了保证GPU开始计算时,其需要的数据已经传输到了,及计算和数据传输存在依赖关系,笔者而是用了CUDA流来保证这种依赖。

11.2.3 多机多GPU矩阵乘法

由于CUDA内核运行是异步的,因此其天然可以和MPI通信并行,为了保证正确性,需要在MPI通信完成后等待GPU计算完成。

float* sendBufB, sendBufA, *recvBufA, * recvBufB;
for (int disp = 1; disp < numPartitions; disp++)
{
    sendBufA = (disp % 2) ? A : bufA;
    sendBufA = (disp % 2) ? bufA : A;
    recvBufB = (disp % 2) ? B : bufB;
    recvBufB = (disp % 2) ? bufB : B;

    cublasSgemm(handle[id], CUBLAS_OP_N, CUBLAS_OP_N, N, M, K, &alpha, sendBufB, ldb, sendBufA, lda, &beta, d_C, ldc);
    checkMPIError(MPI_Sendrecv(sendBufA, M * K, MPI_FLOAT, xiddown, rank, recvBufA, M * K, MPI_FLOAT, xidup, xidup, MPI_COMM_WORLD, MPI_STATUS_IGNORE));
    checkMPIError(MPI_Sendrecv(sendBufB, K * N, MPI_FLOAT, yiddown, rank, recvBufB, K * N, MPI_FLOAT, yidup, yidup, MPI_COMM_WORLD, MPI_STATUS_IGNORE));
    checkCUDAError(cudaDeviceSynchronize());
    checkMPIError(MPI_Barrier(MPI_COMM_WORLD));
}
    cublasSgemm(handle[id], CUBLAS_OP_N, CUBLAS_OP_N, N, M, K, &alpha, recvBufB, ldb, recvBufA, lda, &beta, d_C, ldc);

和单机多GPU矩阵乘法不相同的是:为了减少临时空间的大小(只适用两倍分块大小的空间),采用在一块数据上计算的同时从其他进程上获得下一次计算需要的数据,同时也把直接的数据传给需要它的进程,故需要在循环内部调用cuadDeviceSynchronize和MPI_Barrier来保证在执行下一次计算前,当前的计算和传输都完成了。

11.3 本章小结

常见的超级并行方式:

        1.进程 + 线程;
        2.进程 + GPU;
        3.线程 + GPU;
        4.线程 + SIMD向量指令;
        5.进程 + 线程 + SIMD向量指令;
        6.进程 + 线程 + GPU;

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值