【CUDA】Reduce归约求和(下)

目录

    • 前言
    • 1. 优化技巧4:展开最后一个warp减少同步
    • 2. 优化技巧5:完全展开循环
    • 3. 优化技巧6:调节GridSize和BlockSize
    • 4. 优化技巧7:使用shuffle指令
    • 5. 拓展—CUDA工具链的使用
    • 结语
    • 下载链接
    • 参考

前言

学习 UP 主 比飞鸟贵重的多_HKL 的 【CUDA】Reduce规约求和(已完结~) 视频,记录下个人学习笔记,仅供自己参考😄

refer1:【CUDA】Reduce规约求和(已完结~)

refer2:Optimizing Parallel Reduction in CUDA

refer3:https://github.com/BBuf/how-to-optim-algorithm-in-cuda/tree/master/reduce

refer4:深入浅出GPU优化系列:reduce优化

refer5:https://chatgpt.com/

1. 优化技巧4:展开最后一个warp减少同步

我们接着上篇文章来讲解 reduce 的优化,上篇文章最后我们提到 reduce_v3 中最后几轮迭代时对于空闲的线程还在进行 __syncthreads() 同步,这造成了浪费

为了减少线程同步带来的指令开销,NVIDIA 博客中给出的优化技巧 4 的代码如下:

__device__ void warpReduce(volatile float* sdata, unsigned int tid){
    sdata[tid] += sdata[tid + 32];
    sdata[tid] += sdata[tid + 16];
    sdata[tid] += sdata[tid + 8];
    sdata[tid] += sdata[tid + 4];
    sdata[tid] += sdata[tid + 2];
    sdata[tid] += sdata[tid + 1];
}

__global__ void reduce_v4(float* g_idata, float* g_odata){
    extern __shared__ float sdata[];

    // each thread loads one element from global to shared mem
    unsigned int tid = threadIdx.x;
    unsigned int i = blockIdx.x * (blockDim.x * 2) + threadIdx.x;
    sdata[tid] = g_idata[i] + g_idata[i + blockDim.x];
    __syncthreads();

    // do reduction in shared mem
    for(unsigned int s = blockDim.x / 2; s > 32; s >>= 1){
        if(tid < s){
            sdata[tid] += sdata[tid + s];
        }
        __syncthreads();
    }

    // write result for this block to global mem
    if(tid < 32){
        warpReduce(sdata, tid);
    }
    if(tid == 0){
        g_odata[blockIdx.x] = sdata[0];
    }
}

相比于 reduce_v3 的做法,reduce_v4 主要是将原来的:

// do reduction in shared mem
for(unsigned int s = blockDim.x / 2; s > 0; s >>= 1){
    if(tid < s){
        sdata[tid] += sdata[tid + s];
    }
    __syncthreads();
}

替换为了:

// do reduction in shared mem
for(unsigned int s = blockDim.x / 2; s > 32; s >>= 1){
    if(tid < s){
        sdata[tid] += sdata[tid + s];
    }
    __syncthreads();
}

// write result for this block to global mem
if(tid < 32){
    warpReduce(sdata, tid);
}

其他部分的代码保持不变,下面我们来具体分析下 NVIDIA 的优化思路:

1. 缩短归约循环范围

  • 原来的归约循环一直执行到 s > 0,每一次迭代都调用 __syncthreads() 进行全局同步
  • reduce_v4 中,将归约循环的条件修改为 s > 32,即只有还有超过一个 warp 的活跃线程时进行同步归约

2. 利用 warp 内同步特性

  • s <= 32 时,活跃的线程全部位于同一 warp 中,而同一 warp 内的线程是同步执行的(位于一个 SIMD 单元上),不再需要显式调用 __syncthreads()
  • 这里可以避免在最后几轮归约中多余的同步操作,从而节省了大量的指令开销

3. 使用 unrolled 的 warpReduce 函数

  • 在归约循环结束后,对于最后 32 个线程,使用一个专门的 warpReduce 函数来完成剩余的归约操作
  • 该函数通过直接对共享内存中的值进行累加,实现了高效的无同步归约

总的来说,在 reduce_v4 中 NVIDIA 通过缩短归约循环范围并利用 warp 内同步,避免了最后几轮归约中空闲线程的同步开销,从而提升了整体性能

此外我们还发现 warpReduce 函数中对于 sdata 参数还加入了一个 volatile 修饰符,它的作用是什么呢?为什么要加入呢?🤔

warpReduce 函数中,volatile 修饰符的作用是告诉编译器,该内存区域可能会在不同线程间发生变化,因此不要对它的读写操作进行缓存或重排序优化

具体来说,volatile 修饰符包含两个功能:

  • 保证内存可见性:在没有显式同步操作(__syncthreads())的 warp 内部归约中,所有线程处于同步状态,但编译器仍可能优化内存访问。使用 volatile 可以确保每次对 sdata 的读写都直接从共享内存中完成,这样线程可以看到其他线程最新更新的值
  • 避免编译器优化问题:在 warp 内进行连续的归约操作时,编译器可能会将数据保存在寄存器中,而不是每次都重新加载共享内存。volatile 强制每次都访问共享内存,从而避免由于优化导致的错误累加或数据不一致问题。

假设没有 volatile 修饰符,编译器为了优化可能会将共享内存变化缓存到寄存器中,例如在第一轮归约时,线程 0 将 sdata[0]sdata[32] 相加,并将结果存入寄存器中,但这个更新可能没有立即写回共享内存,而在进行第二轮归约时,线程 0 再次读取 sdata[0] 时,由于编译器优化,重读共享内存时未能获取刚才更新后的值,而是读取的旧值,从而导致使用了错误的数值,具体大家可以参考:CUDA: In warp reduction and volatile keyword

博主测试了如果不加 volatile 修饰符时输出如下:

output_host[0] = 112.00
output_host[1] = 112.00
output_host[2] = 112.00
output_host[3] = 112.00
output_host[4] = 112.00

性能和带宽的测试情况如下:

优化手段耗时(us)Memory throughout(%)DRAM throughout(%)加速比
reduce_baseline2490us62.01%15.44%~
reduce_v1_interleaved_addressing1800us85.78%21.35%1.38
reduce_v2_bank_conflict_free1730us89.24%22.01%1.44
reduce_v3_idle_threads_free896.19us89.48%42.89%2.78
reduce_v4_unroll_last_warp507.71us76.08%76.08%4.90

2. 优化技巧5:完全展开循环

其实到了 reduce_v4 这一步,reduce 的效率已经足够高了,再进一步优化其实已经非常困难了。为了探索极致的性能表现,NVIDIA 博客中给出了对 for 循环进行完全展开的方案

代码如下:

template <unsigned int blockSize>
__device__ void warpReduce(volatile float* sdata, int tid){
    if(blockSize >= 64) sdata[tid] += sdata[tid + 32];
    if(blockSize >= 32) sdata[tid] += sdata[tid + 16];
    if(blockSize >= 16) sdata[tid] += sdata[tid + 8];
    if(blockSize >= 8) sdata[tid] += sdata[tid + 4];
    if(blockSize >= 4) sdata[tid] += sdata[tid + 2];
    if(blockSize >= 2) sdata[tid] += sdata[tid + 1];
}

template <unsigned int blockSize>
__global__ void reduce_v5(float* g_idata, float* g_odata){
    extern __shared__ float sdata[];

    // each thread loads one element from global to shared mem
    unsigned int tid = threadIdx.x;
    unsigned int i = blockIdx.x * (blockDim.x * 2) + threadIdx.x;
    sdata[tid] = g_idata[i] + g_idata[i + blockDim.x];
    __syncthreads();

    // do reduction in shared mem
    if(blockSize >= 512){
        if(tid < 256){
            sdata[tid] += sdata[tid + 256];
        }
        __syncthreads();
    }

    if(blockSize >= 256){
        if(tid < 128){
            sdata[tid] += sdata[tid + 128];
        }
        __syncthreads();
    }

    if(blockSize >= 128){
        if(tid < 64){
            sdata[tid] += sdata[tid + 64];
        }
        __syncthreads();
    }

    // write result for this block to global mem
    if(tid < 32){
        warpReduce<blockSize>(sdata, tid);
    }
    if(tid == 0){
        g_odata[blockIdx.x] = sdata[0];
    }
}

reduce_v5 中,NVIDIA 采用了模板和完全展开技术,将归约过程中的循环和条件判断在编译时就“摊平”,从而极大地减少了运行时的开销,详细来说:

1. 使用模板参数实现编译时常量

  • 通过模板参数 <unsigned int blockSize> 使得 blockSize 成为编译时常量。这样所有关于 blockSize 的 if 条件(例如 if(blockSize >= 512))都会在编译期间确定,从而使得代码可以完全展开,无需在运行时进行判断

2. 分阶段归约,逐步减少数据量

  • 初始阶段,每个线程从全局内存加载两个元素并预归约,然后对于较大的 blockSize,先进行大步归约,当归约到 32 个线程以内时,调用 unrolled 的 warpReduce 函数完成最后的归约,而这时由于所有线程都在同一个 warp 内,因此不需要 __syncthreads()

3. 完全展开消除循环和同步开销

  • 采用 if 语句和模板展开后,每一步归约操作都显式写出,而不是使用 for 循环。这样不仅减少了循环控制的开销,还避免了在归约末尾额外的分支和同步指令
  • 最终,这种方式利用编译时展开将“硬编码”成一系列连续的加法操作,从而让编译器可以生成更加高效的汇编代码

性能和带宽的测试情况如下:

优化手段耗时(us)Memory throughout(%)DRAM throughout(%)加速比
reduce_baseline2490us62.01%15.44%~
reduce_v1_interleaved_addressing1800us85.78%21.35%1.38
reduce_v2_bank_conflict_free1730us89.24%22.01%1.44
reduce_v3_idle_threads_free896.19us89.48%42.89%2.78
reduce_v4_unroll_last_warp507.71us76.08%76.08%4.90
reduce_v5_completely_unroll485.54us79.48%79.48%5.13

从测试情况可以看到 reduce_v5 相比 reduce_v4 收益较少,这主要是因为 NVIDIA 博客中测试的设备比较老,而目前的 GPU 硬件架构在不断发展,另外 NVIDIA 在编译器上也做了较多的优化工作,所以这里测试出的加速比没有博客中的那么明显

3. 优化技巧6:调节GridSize和BlockSize

当走到这一步的时候,能调的东西基本上已经调完了,最后让我们把目光放在 block 和 thread 数量的设置上,之前在 reduce_v5 中我们默认一个 block 开启 256 个线程,负责 512 个元素的 reduce 操作,那我们可以让每个 block 处理的数量更多一点,这样开启的 block 数量就会少一些,以此对 block 设置进行调整,获得最优的 block 取值,这样或许能够带来一些性能上的收益

NVIDIA 博客中给出的优化技巧 6 的代码如下:

#include <cuda.h>
#include <cuda_runtime.h>
#include <time.h>
#include <stdio.h>

#define N 32*1024*1024
#define BLOCK_SIZE 256

template <unsigned int blockSize>
__device__ void warpReduce(volatile float* sdata, int tid){
    if(blockSize >= 64) sdata[tid] += sdata[tid + 32];
    if(blockSize >= 32) sdata[tid] += sdata[tid + 16];
    if(blockSize >= 16) sdata[tid] += sdata[tid + 8];
    if(blockSize >= 8) sdata[tid] += sdata[tid + 4];
    if(blockSize >= 4) sdata[tid] += sdata[tid + 2];
    if(blockSize >= 2) sdata[tid] += sdata[tid + 1];    
}

template <unsigned int blockSize, int NUM_PER_THREAD>
__global__ void reduce_v6(float* g_idata, float* g_odata){
    __shared__ float sdata[BLOCK_SIZE];

    // each thread loads one element from global to shared mem
    unsigned int tid = threadIdx.x;
    unsigned int i = blockIdx.x * (blockDim.x * NUM_PER_THREAD) + threadIdx.x;
    sdata[tid] = 0;
    #pragma unroll
    for(int iter = 0; iter < NUM_PER_THREAD; iter++){
        sdata[tid] += g_idata[i + iter * blockSize];
    }
    __syncthreads();

    // do reduction in shared mem
    if(blockSize >= 512){
        if(tid < 256){
            sdata[tid] += sdata[tid + 256];
        }
        __syncthreads();
    }

    if(blockSize >= 256){
        if(tid < 128){
            sdata[tid] += sdata[tid + 128];
        }
        __syncthreads();
    }

    if(blockSize >= 128){
        if(tid < 64){
            sdata[tid] += sdata[tid + 64];
        }
        __syncthreads();
    }

    // write result for this block to global mem
    if(tid < 32){
        warpReduce<blockSize>(sdata, tid);
    }
    if(tid == 0){
        g_odata[blockIdx.x] = sdata[0];
    }    
}

int main(){

    // input host
    float* input_host = (float*)malloc(N * sizeof(float));
    for(int i = 0; i < N; ++i){
        input_host[i] = 2.0;
    }

    // input device
    float* input_device;
    cudaMalloc((void**)&input_device, N * sizeof(float));

    // copy data from host to device
    cudaMemcpy(input_device, input_host, N * sizeof(float), cudaMemcpyHostToDevice);
    
    const int block_num = 1024;
    // 每个 block 要处理的数据量
    const int NUM_PER_BLOCK = N / block_num;
    // 每个 thread 要处理的数据量
    const int NUM_PER_THREAD = NUM_PER_BLOCK / BLOCK_SIZE;
    float* output_host = (float*)malloc(block_num * sizeof(float));

    // output device
    float* output_device;
    cudaMalloc((void**)&output_device, block_num * sizeof(float));

    // kernel launch
    dim3 grid(block_num, 1);
    dim3 block(BLOCK_SIZE, 1);
    reduce_v6<BLOCK_SIZE, NUM_PER_THREAD><<<grid, block>>>(input_device, output_device);

    // copy result from device to host
    cudaMemcpy(output_host, output_device, block_num * sizeof(float), cudaMemcpyDeviceToHost);

    // print some result
    for(int i = 0; i < 5; ++i){
        float data = output_host[i];
        printf("output_host[%d] = %.2f\n", i, data);
    }

    return 0;
}

reduce_v6 中,NVIDIA 的优化重点在于通过调整每个 block 处理的数据量来提高硬件资源的利用率,具体思路包括:

1. 增加每个 block 处理的数据量

  • 原来版本(reduce_v5):每个 block 处理 512 个元素(每个线程处理 2 个元素,加载两个数据相加)
  • 当前优化(reduce_v6):每个 block 处理的数据量更大,由 NUM_PER_BLOCK 定义,每个线程也不再只处理一个数据对,而是通过一个循环加载 NUM_PER_THREAD 个数据块,每次以 blockSize 为步长访问全局内存

2. 利用循环展开(Loop Unrolling)

  • 使用 #pragma unroll 告诉编译器展开 for 循环,这样循环内的指令可以在编译时直接生成连续的加法指令,减少了循环控制开销,提高执行效率

3. 调整 block 和 thread 数量的权衡

reduce_v6 通过让每个线程加载并预处理多个数据元素,使用循环展开以及合理调整 block 和 thread 配置,在降低全局内存访问次数的同时减少了 kernel 启动开销,从而进一步提升了归约操作的整体性能。

性能和带宽的测试情况如下:

优化手段耗时(us)Memory throughout(%)DRAM throughout(%)加速比
reduce_baseline2490us62.01%15.44%~
reduce_v1_interleaved_addressing1800us85.78%21.35%1.38
reduce_v2_bank_conflict_free1730us89.24%22.01%1.44
reduce_v3_idle_threads_free896.19us89.48%42.89%2.78
reduce_v4_unroll_last_warp507.71us76.08%76.08%4.90
reduce_v5_completely_unroll485.54us79.48%79.48%5.13
reduce_v6_multi_add400.06us96.94%96.94%6.22

对于 block 的取值理论上来说取 SM 数量的倍数会比较合理,这里博主没有详细测试,表中的数据设置的 block 数量为 1024,可以看到此时带宽利用率得到了较大的提升

4. 优化技巧7:使用shuffle指令

其实对于 reduce 的优化 NVIDIA 的博客到这里就结束了,但是 NVIDIA 后面出了 shuffle 指令,对于 reduce 的优化有着非常好的效果。目前绝大多数访存类算子像 softmax、batchnorm、reduce 等都是用 shuffle 实现的,所以这里简单聊下怎么把 shuffle 指令用在 reduce 的优化上

Note:之前博主在学习 llama.cpp 中 RMSNorm 的实现时 warp 内的归约求和就是通过 shuffle 指令实现的,大家感兴趣的可以看看:RMSNorm算子的CUDA实现

shuffle 指令是一组针对 warp 的指令,它最重要的特性就是可以让 warp 内的寄存器相互访问,在没有 shuffle 指令的时候,各个线程在进行通信时只能通过 shared memory 来访问彼此的寄存器。而采用了 shuffle 指令之后,warp 内的线程可以直接对其他线程的寄存器进行访存,通过这种方式可以减少访存的延时

代码实现如下:

#include <cuda.h>
#include <cuda_runtime.h>
#include <time.h>
#include <stdio.h>

#define N 32*1024*1024
#define BLOCK_SIZE 256
#define WARP_SIZE 32

template <unsigned int blockSize>
__device__ __forceinline__ float warpReduceSum(float sum){
    if(blockSize >= 32) sum += __shfl_down_sync(0xffffffff, sum, 16);
    if(blockSize >= 16) sum += __shfl_down_sync(0xffffffff, sum, 8);
    if(blockSize >= 8) sum += __shfl_down_sync(0xffffffff, sum, 4);
    if(blockSize >= 4) sum += __shfl_down_sync(0xffffffff, sum, 2);
    if(blockSize >= 2) sum += __shfl_down_sync(0xffffffff, sum, 1);
    return sum;
}

template <unsigned int blockSize, int NUM_PER_THREAD>
__global__ void reduce_v7(float* g_idata, float* g_odata){
    float sum = 0;

    // each thread loads NUM_PER_THREAD element from global
    unsigned int tid = threadIdx.x;
    unsigned int i = blockIdx.x * (blockSize * NUM_PER_THREAD) + threadIdx.x;
    #pragma unroll
    for(int iter = 0; iter < NUM_PER_THREAD; iter++){
        sum += g_idata[i + iter * blockSize];
    }
    
    // shared memory for partial sums (one per warp in the block)
    static __shared__ float warpLevelSum[WARP_SIZE];
    const int warpId = threadIdx.x / WARP_SIZE;
    const int laneId = threadIdx.x % WARP_SIZE;

    sum = warpReduceSum<blockSize>(sum);

    if(laneId == 0){
        warpLevelSum[warpId] = sum;
    }
    __syncthreads();

    // read from shared memory only if that warp existed
    if(threadIdx.x < blockDim.x / WARP_SIZE){
        sum = warpLevelSum[laneId];
    }else{
        sum = 0;
    }

    // final reduce using first warp
    if(warpId == 0){
        sum = warpReduceSum<blockSize / WARP_SIZE>(sum);
    }

    // write result for this block to global memory
    if(tid == 0){
        g_odata[blockIdx.x] = sum;
    }
}

int main(){

    // input host
    float* input_host = (float*)malloc(N * sizeof(float));
    for(int i = 0; i < N; ++i){
        input_host[i] = 2.0;
    }

    // input device
    float* input_device;
    cudaMalloc((void**)&input_device, N * sizeof(float));

    // copy data from host to device
    cudaMemcpy(input_device, input_host, N * sizeof(float), cudaMemcpyHostToDevice);
    
    const int block_num = 1024;
    // 每个 block 要处理的数据量
    const int NUM_PER_BLOCK = N / block_num;
    // 每个 thread 要处理的数据量
    const int NUM_PER_THREAD = NUM_PER_BLOCK / BLOCK_SIZE;
    float* output_host = (float*)malloc(block_num * sizeof(float));

    // output device
    float* output_device;
    cudaMalloc((void**)&output_device, block_num * sizeof(float));

    // kernel launch
    dim3 grid(block_num, 1);
    dim3 block(BLOCK_SIZE, 1);
    reduce_v7<BLOCK_SIZE, NUM_PER_THREAD><<<grid, block>>>(input_device, output_device);

    // copy result from device to host
    cudaMemcpy(output_host, output_device, block_num * sizeof(float), cudaMemcpyDeviceToHost);

    // print some result
    for(int i = 0; i < 5; ++i){
        float data = output_host[i];
        printf("output_host[%d] = %.2f\n", i, data);
    }

    return 0;
}

reduce_v7 的优化思路在于利用 shuffle 指令在 warp 内高效完成归约,减少共享内存的访问和同步开销,下面是具体流程的说明:

1. 数据加载与局部求和

  • 每个线程负责加载多个数据元素:每个线程根据 blockIdx 和线程索引计算出起始全局内存地址,然后在一个展开的循环中累加 NUM_PER_THREAD 个数据:
unsigned int tid = threadIdx.x;
unsigned int i = blockIdx.x * (blockSize * NUM_PER_THREAD) + threadIdx.x;
#pragma unroll
for(int iter = 0; iter < NUM_PER_THREAD; iter++){
    sum += g_idata[i + iter * blockSize];
}

这样,每个线程得到了自己局部的和,保存在变量 sum

2. 使用 shuffle 指令进行 warp 内归约

  • warpReduceSum 函数:该 device 函数利用 __shfl_down_sync 指令在 warp 内归约,具体实现我们后续再分析

3. 将 warp 级归约结果存入共享内存

  • 写入共享内存:定义了一个大小为 WARP_SIZE(32) 的共享内存数组 warpLevelSum,每个 warp 内的第一个线程(即 laneId == 0)将其 warp 内的归约结果写入:
if(laneId == 0){
    warpLevelSum[warpId] = sum;
}

这里,warpId = threadIdx.x / WARP_SIZE 表示线程所属的 warp 编号

  • 同步与读取:调用 __syncthreads() 保证所有 warp 内的部分结果都写入共享内存。接着,只有前 blockDim.x / WARP_SIZE 个线程(对应每个 warp 的归约结果)参与下一步:
if(threadIdx.x < blockDim.x / WARP_SIZE){
    sum = warpLevelSum[laneId];
} else {
    sum = 0;
}

4. 最终的 block 级归约

  • 利用第一个 warp(warp0)进行归约:此时,归约结果已经在共享内存中存储为一个数组,每个元素代表一个 warp 的部分和。只有第一个 warp 中的线程会执行最终归约(if(warpId == 0)),再次调用 warpReduceSum
if(warpId == 0){
    sum = warpReduceSum<blockSize / WARP_SIZE>(sum);
}

这一步骤将所有 warp 的部分和归并到一起,最终 threadIdx.x = 0 的线程得到整个 block 的归约结果

  • 写回全局内存:最后,将整个 block 的归约结果写入全局内存:
if(tid == 0){
    g_odata[blockIdx.x] = sum;
}

reduce_v7 通过让每个线程预先累加多个数据,再利用 shuffle 指令在 warp 内高效归约、将部分结果写入共享内存后在第一个 warp 内完成最终归约,从而最大程度地减少了同步和内存访问开销,实现了极高的性能。

博主绘制了一张草图来帮助理解:

在这里插入图片描述

reduce_v7 核函数的整体思路还是比较清晰的,以一个 block 为例,reduce_v7 会先对 warp0~warp7 进行归约计算,并将其结果保存在共享内存数组 warpLevelSum 中,然后通过线程同步确保所有 warp 结果均已写入 warpLevelSum 中,接着将共享内存数组中的前 8 个数分别赋值给 warp0 的前 8 个线程的 sum 变量,而 warp0 的其它线程中的 sum 变量会赋值 0,最后对 warp0 的前 8 个线程做一次 warp 归约得到整个 block 的归约结果

下面我们来看下 warp 内归约函数 warpReduceSum 具体是如何做的

我们知道,一个 warp 中有 32 个线程,归约的目标是把这 32 个线程中的值累加到一个线程(通常是 landId = 0)中。__shfl_down_sync 指令允许同一 warp 内的线程直接交换寄存器中的数据,而无需借助共享内存或同步调用

具体来说,__shfl_down_sync(mask, value, delta) 的参数含义如下:

  • mask:表示参与交换的线程掩码,通常设置为 0xffffffff 表示整个 warp 的 32 个线程都参与
  • value:当前线程中要传递的数据
  • delta:表示向下(即向更高的 lane 编号方向)移动多少个线程的数据。例如 delta 为 16 时,每个线程会获得它自己所在 lane 号加 16 的线程中的值

warpReduceSum 中采用了依次使用 delta 为 16、8、4、2、1 的调用,形成了一棵二叉树结构的归约流程。举个例子,假设一个 warp 中线程的编号是 0~31,初始时每个线程保存一个数值,例如:

lane:   0  1  2  3  ... 15 | 16 17 18 19 ... 31
value:  a  b  c  d  ... p  | q  r  s  t  ... z

归约过程如下:

1. 第一步(delta = 16)

每个线程执行:

sum += __shfl_down_sync(0xffffffff, sum, 16);
  • 线程 0~15:
  • 线程 0:a = a + q
  • 线程 1:b = b + r
  • 线程 15:p = p + z
  • 线程 16~31 的返回值未被用于累加,因为它们不需要再累加其他数据

结果:前 16 个线程保存了 16 个部分和

2. 第二步(delta = 8)

接下来,对于线程 0~7:

  • 线程 0:之前的 a + q 再加上来自线程 8 的值
  • 线程 1:同理,累加线程 9 的值

这一步将 16 个数归约为 8 个数

3. 第三步(delta = 4)

对线程 0~3,分别加上各自向下 4 个位置的数,归约到 4 个数

4. 第四步(delta = 2)

对线程 0~1,分别加上自己下面 2 个位置的数,归约到 2 个数

5. 第五步(delta = 1)

最后,线程 0 加上线程 1 的值,得到整个 warp 的累加和

__shfl_down_sync 允许在 warp 内直接从相对位置“下移”数据。通过连续使用 delta 值 16、8、4、2、1,我们形成了一棵二叉树归约结构,确保所有线程中的数据最终都被归约到线程 0 中,从而实现高效的 warp 内归约

那到这里博主其实有个困惑,在第二步(delta = 8)的时候线程 0 会累加线程 8 的值,线程 8 会累加线程 16 的值,那会不会出现这么一种情况,那就是线程 0 读取线程 8 寄存器的值累加的时候,恰好这时候线程 8 又在读取线程 16 寄存器的值进行累加,这个累加值会不会更新到线程 8 的寄存器中,导致线程 0 在取线程 8 寄存器值的时候其实是线程 8 在第二步中已经累加的结果,最终导致归约的结果出错🤔

其实在 CUDA 的 SIMT 模型中,warp 中所有线程是以锁步lockstep)的方式执行指令的,__shfl_down_sync 指令就是在这种锁步机制下进行数据交换的,具体解释如下:

1. 锁步执行保证同时读取

当 warp 内的所有线程执行第二步的 __shfl_down_sync(sum, 8) 时,所有线程同时进入这一指令。此时,每个线程都会从目标线程(例如 landId = 8)的寄存器中读取值。

硬件保证这个读取操作是在所有线程已经完成第一步的更新后统一开始的,而不是某个线程先更新、另一个线程再读取。这意味着线程 0 读取线程 8 时,拿到的正是线程 8 在第一步结束时的值,而不会看到线程 8 在第二步中“更新一半”的状态

2. __shfl_down_sync 的工作机制

__shfl_down_sync(maks, value, delta) 指令在执行时,所有线程在同一时刻根据各自寄存器中的 value,读取各自 laneId + delta 线程的 value。

这个读取是原子进行的,所有线程都在同一个指令周期内完成读取和加法操作,因此不存在线程 8 读取线程 16 的值“抢先更新”导致线程 0 读到第二步结果的问题

性能和带宽的测试情况如下:

优化手段耗时(us)Memory throughout(%)DRAM throughout(%)加速比
reduce_baseline2490us62.01%15.44%~
reduce_v1_interleaved_addressing1800us85.78%21.35%1.38
reduce_v2_bank_conflict_free1730us89.24%22.01%1.44
reduce_v3_idle_threads_free896.19us89.48%42.89%2.78
reduce_v4_unroll_last_warp507.71us76.08%76.08%4.90
reduce_v5_completely_unroll485.54us79.48%79.48%5.13
reduce_v6_multi_add400.06us96.94%96.94%6.22
reduce_v7_shufl_down_sync400.80us96.88%96.88%6.21

可以看到基于 warp 原语 __shfl_down_sync 进行优化之后,耗时和带宽利用率和 reduce_v6 基本没有什么区别,这可能是 GPU 硬件架构迭代后内部更新的原因

不同优化技巧带来的性能表现如下:

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

通过上面一系列的优化技巧,我们对 reduce 进行了不断地优化,可以看到其实在 reduce_v6 的时候就已经很难再提升了,而这个数据跟 NVIDIA 博客中的数据还是有比较大的出入,主要原因可能是因为 GPU 硬件架构已经更新了好几代。总而言之,我们通过一系列的优化已经把 reduce 优化到一个非常好的程度了,还是非常 nice 的🤗

5. 拓展—CUDA工具链的使用

我们平时主要关注两个 CUDA 工具链的使用:NVIDIA Nsight Compute 和 NVIDIA Nsight Systems

NVIDIA Nsight Compute 和 NVIDIA Nsight Systems 都是 NVIDIA 提供的性能分析工具,但它们关注的层次和用途有所不同:

NVIDIA Nsight Compute

  • 用途:专注于单个 CUDA 内核(kernel)的详细性能分析
  • 功能:提供指令级别的计数、内存吞吐量、线程执行、寄存器使用、共享内存利用率等详细指标

NVIDIA Nsight Systems

  • 用途:面向整个系统级别的性能分析,捕捉应用程序从 CPU 到 GPU 的整体行为
  • 功能:提供时间轴视图,展示 CPU 线程、GPU 活动、驱动程序调用以及系统其他组件之间的交互与同步情况

Note:在本文的 reduce 核函数分析中,我们只需要用 Nsight Compute 即可

Nsight Compute 其实在安装完 CUDA 之后就已经安装了,具体安装位置在 /usr/local/cuda/bin,如下图所示:

在这里插入图片描述

Nsight Compute 提供两种界面:

1. ncu(命令行工具):

  • 用途:通过命令行运行 Nsight Compute,直接在终端中对 GPU 内核进行采样和分析
  • 特点:适合自动化脚本、批量分析和生成报告文件(如 .ncu-rep),便于嵌入到开发和 CI/CD 流程中
  • 使用方式:通过传递参数(例如 --launch-count)运行可执行文件,并将分析结果输出到终端或指定的报告文件中

2. ncu-ui(图形用户界面工具)

  • 用途:提供直观的图形界面,用于加载和浏览由 ncu 生成的报告文件
  • 特点:界面中会显示各种图表和 Breakdown 表格,如 GPU Throughput 图、Compute 和 Memory 的各项细分指标,便于交互地查看和分析性能数据
  • 使用方式:启动 ncu-ui 后,通过菜单加载 .ncu-rep 报告文件,或直接配置目标程序进行交互式分析

关于 Nisight Compute 的使用大家可以参考:【模型分析】Nsight Compute使用入门,大家如果感兴趣的话也可以看看 UP 的视频:【CUDA调优指南】合并访存

另外一个工具 Nsight System 博主目前也暂未使用过,只是进行了安装,Nsight System 安装比较简单,大家可以参考:【cuda】Nsight System 下载,安装与使用,软件包外网访问下载可能比较慢,大家可以点击 here 下载(注意下载的 Nsight System 是 2025.1.1 版本,如果有其它版本需求可以自行下载

关于服务器上的 Nsight System 安装大家可以参考:Nsight System的安装和使用

由于博主对这两个工具的使用也不熟练,自己也还在摸索中,所以这里只教大家如何利用 Nsight Compute 简单的查看 kernel 的耗时和带宽利用率😄

我们以 redece_v7 核函数为例,首先我们需要编写代码,新建一个 .cu 文件 reduce_v7_shfl_down_sync.cu,其内容如下:

#include <cuda.h>
#include <cuda_runtime.h>
#include <time.h>
#include <stdio.h>

#define N 32*1024*1024
#define BLOCK_SIZE 256
#define WARP_SIZE 32

template <unsigned int blockSize>
__device__ __forceinline__ float warpReduceSum(float sum){
    if(blockSize >= 32) sum += __shfl_down_sync(0xffffffff, sum, 16);
    if(blockSize >= 16) sum += __shfl_down_sync(0xffffffff, sum, 8);
    if(blockSize >= 8) sum += __shfl_down_sync(0xffffffff, sum, 4);
    if(blockSize >= 4) sum += __shfl_down_sync(0xffffffff, sum, 2);
    if(blockSize >= 2) sum += __shfl_down_sync(0xffffffff, sum, 1);
    return sum;
}

template <unsigned int blockSize, int NUM_PER_THREAD>
__global__ void reduce_v7(float* g_idata, float* g_odata){
    float sum = 0;

    // each thread loads NUM_PER_THREAD element from global
    unsigned int tid = threadIdx.x;
    unsigned int i = blockIdx.x * (blockSize * NUM_PER_THREAD) + threadIdx.x;
    #pragma unroll
    for(int iter = 0; iter < NUM_PER_THREAD; iter++){
        sum += g_idata[i + iter * blockSize];
    }
    
    // shared memory for partial sums (one per warp in the block)
    static __shared__ float warpLevelSum[WARP_SIZE];
    const int warpId = threadIdx.x / WARP_SIZE;
    const int laneId = threadIdx.x % WARP_SIZE;

    sum = warpReduceSum<blockSize>(sum);

    if(laneId == 0){
        warpLevelSum[warpId] = sum;
    }
    __syncthreads();

    // read from shared memory only if that warp existed
    if(threadIdx.x < blockDim.x / WARP_SIZE){
        sum = warpLevelSum[laneId];
    }else{
        sum = 0;
    }

    // final reduce using first warp
    if(warpId == 0){
        sum = warpReduceSum<blockSize / WARP_SIZE>(sum);
    }

    // write result for this block to global memory
    if(tid == 0){
        g_odata[blockIdx.x] = sum;
    }
}

int main(){

    // input host
    float* input_host = (float*)malloc(N * sizeof(float));
    for(int i = 0; i < N; ++i){
        input_host[i] = 2.0;
    }

    // input device
    float* input_device;
    cudaMalloc((void**)&input_device, N * sizeof(float));

    // copy data from host to device
    cudaMemcpy(input_device, input_host, N * sizeof(float), cudaMemcpyHostToDevice);
    
    const int block_num = 1024;
    // 每个 block 要处理的数据量
    const int NUM_PER_BLOCK = N / block_num;
    // 每个 thread 要处理的数据量
    const int NUM_PER_THREAD = NUM_PER_BLOCK / BLOCK_SIZE;
    float* output_host = (float*)malloc(block_num * sizeof(float));

    // output device
    float* output_device;
    cudaMalloc((void**)&output_device, block_num * sizeof(float));

    // kernel launch
    dim3 grid(block_num, 1);
    dim3 block(BLOCK_SIZE, 1);
    reduce_v7<BLOCK_SIZE, NUM_PER_THREAD><<<grid, block>>>(input_device, output_device);

    // copy result from device to host
    cudaMemcpy(output_host, output_device, block_num * sizeof(float), cudaMemcpyDeviceToHost);

    // print some result
    for(int i = 0; i < 5; ++i){
        float data = output_host[i];
        printf("output_host[%d] = %.2f\n", i, data);
    }

    return 0;
}

接着我们利用 nvcc 工具生成一个可执行文件,指令如下:

mkdir bin
/usr/local/cuda/bin/nvcc -o bin/reduce_v7 reduce_v7_shfl_down_sync.cu

执行成功后我们会在 bin 文件夹下看到一个可执行文件 reduce_v7

接着我们可以利用 ncu 命令行工具分析核函数

sudo /usr/local/cuda/bin/ncu --launch-count 2000 ./bin/reduce_v7

Note--launch-conut 参数会告诉 Nsight Compute 对目标程序中检测到的 kernel 调用重复采样 N 次,在这里是 2000 次

输出如下图所示:

在这里插入图片描述

可以看到执行指令后在终端输出了一系列的信息,我们依次来看下:

==PROF== Connected to process 805261 (/home/jarvis/Downloads/reduce/bin/reduce_v7)
==PROF== Profiling "reduce_v7" - 1 of 2000: 0%....50%....100% - 9 passes
==PROF== Disconnected from process 805261
[805261] reduce_v7@127.0.0.1
  void reduce_v7<(unsigned int)256, (int)128>(float *, float *), 2025-Mar-08 13:50:55, Context 1, Stream 7

首先显示的这部分基本信息记录了当前采样 kernel 的一些基本上下文信息,包括 kernel 名称、模板参数、执行时间戳、CUDA Contex、CUDA Stream 等等,这些信息可以帮助我们确认正在分析的是哪一个 kernel 以及运行时的上下文环境

接下来报告中主要有三个 Section,每个 Section 的作用如下:

1. GPU Speed Of Light Throughput

功能:

这部分反映了 kernel 在执行时各个硬件单元(例如 DRAM、SM、缓存)的吞吐能力和利用情况,基本上说明了内核的“极限速度情况”,因此对本文来说我们主要关注这个 Section 即可

主要指标:

  • Duration:表示 kernel 执行的时间(如 400.42 us),直接反映 kernel 的运行耗时
  • Memory [%] 和 DRAM Throughout:显示 kernel 在访问全局内存(DRAM)时所达到的利用率,当数值接近 100% 时,表明内存带宽已经接近瓶颈
  • L1/TEX Cache Throughput 和 L2 Cache Throughout:表示各级缓存的访问情况
  • Compute (SM) [%]:表示 SM 中的计算单元的利用率

作用:

通过这一节的数据,我们可以判断 kernel 是受内存带宽还是计算能力限制,进而为优化方向提供指令

2. Launch Statistics

功能:

这一部分展示了 kernel 的启动配置和资源分配情况,主要涉及 kernel 是如何在 GPU 上分布的,以及使用了多少硬件资源

主要指标:

  • Grid Size 和 Block Size:显示了启动时设置的 grid 和 block 的维度
  • Register Per Thread:每个线程使用的寄存器数,反映了寄存器资源的消耗
  • Shared Memory Configuration:包括静态和动态共享内存的分配情况
  • Threads 和 Waves Per SM:表示总线程数以及每个 SM 中的 warp 数量

作用:

这些信息帮助我们了解 kernel 的配置是否合理,是否存在资源使用过多或不足的问题,从而影响性能。

3. Occupancy

功能:

Occupancy 部分描述了 kernel 在 GPU 上的实际活跃程度,即 GPU 多大程度上被我们的 kernel 利用起来了

主要指标:

  • Theroretical Active Warps per SM:理论上每个 SM 能同时激活的 warp 数量
  • Achieved Occupancy:实际达到的占用率,通常以百分比表示
  • Achieved Active Warps Per SM:每个 SM 实际激活的 warp 数量
  • Block/Registers/Shared Memory Limit:表示由于硬件资源限制(如寄存器或共享内存)而可能限制 kernel 并行度的部分

作用:

Occupancy 数据可以帮助我们判断 kernel 的并行度是否达到预期。如果占用率低,可能意味着存在资源瓶颈(例如寄存器或共享内存分配不合理),这时可能需要优化 kernel 的资源使用或调整启动参数

这三个部分合起来为我们提供了一个从硬件资源利用、启动配置到实际执行效率的全面视角,从而帮助我们找出性能优化的重点。

在这些信息中我们关注的 reduce_v7 核函数的耗时和带宽利用率分别是 400.42 us 和 96.98 %

除了使用 ncu 直接在终端查看 kernel 性能分析外,我们还可以将其导出保存为 .ncu-rep 的报告,接着在 nuc-ui 图形化界面中查看,更加直观

具体流程如下:

首先我们需要利用 ncu 工具生成一份 .ncu-rep 报告,指令如下:

sudo /usr/local/cuda/bin/ncu --launch-count 2000 -o reduce_v7 ./bin/reduce_v7

执行后输出如下:

在这里插入图片描述

可以看到性能分析报告保存到了 reduce_v7.ncu-rep

接着我们启动 ncu-ui,在终端直接输入指令:

ncu-ui

启动完成后我们要将刚才导出的报告加载到界面中,依次点击 File->Open Files,接着选择我们刚才导出的 reduce_v7.ncu-rep 文件,最后点击 Open,接着我们就能在 UI 界面中看到相关的性能分析信息了,如下图所示:

在这里插入图片描述

可以看到 UI 界面展示的内容和终端输出的没什么差异,只是以界面的方式展示更加的直观

OK,以上就是使用 Nsight Compute 简单查看 kernel 耗时和带宽分析的内容了

结语

这篇文章我们继续学习了 reduce 的一些优化,包括展开最后一个 warp 减少同步带来的浪费、完全展开循环、调节 grid 和 block 参数以及使用 shuffle 指令,此外博主还简单介绍了 Nsight Compute 的使用。

OK,以上就是整篇文章的全部内容了

总的来说,整个 reduce 的优化过程还是挺有意思的,以解决问题的角度出发去优化 reduce sum,相比于直接枯燥的学习语法要有意思得多,大家感兴趣的可以看看 up 主的视频,还是很有收获的🤗

下载链接

  • Nsight Systems软件包下载链接【提取码:1234】
  • Reduce归约求和代码下载链接【提取码:1234】

参考

  • 【CUDA】Reduce规约求和(已完结~)
  • Optimizing Parallel Reduction in CUDA
  • https://github.com/BBuf/how-to-optim-algorithm-in-cuda/tree/master/reduce
  • 深入浅出GPU优化系列:reduce优化
  • https://chatgpt.com/

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/983543.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

IDE集成开发环境MyEclipse中安装SVN

打开Myeclipse的help菜单----install from site 点击add弹出对话框 在输入框中输入对应内容 http://subclipse.tigris.org/update_1.10.x 点击OK之后&#xff0c;会刷新出两个选项&#xff0c;需要选中的 点击next&#xff0c;出现许可的时候选中同意&#xff0c;一直结束等…

如何计算两个向量的余弦相似度

参考笔记&#xff1a; https://zhuanlan.zhihu.com/p/677639498 日常学习之&#xff1a;如何计算两个向量或者矩阵的余弦相似度-CSDN博客 1.余弦相似度定理 百度的解释&#xff1a;余弦相似度&#xff0c;又称为余弦相似性&#xff0c;是通过计算两个向量的夹角余弦值来评估…

国产编辑器EverEdit - 宏功能介绍

1 宏 1.1 应用场景 宏是一种重复执行简单工作的利器&#xff0c;可以让用户愉快的从繁琐的工作中解放出来&#xff0c;其本质是对键盘和菜单的操作序列的录制&#xff0c;并不会识别文件的内容&#xff0c;属于无差别无脑执行。 特别是对一些有规律的重复按键动作&#xff0c;…

vue安装stylelint

执行 npm install -D stylelint postcss-html stylelint-config-recommended-vue stylelint-config-standard stylelint-order stylelint-prettier postcss-less stylelint-config-property-sort-order-smacss 安装依赖&#xff0c;这里是less&#xff0c;sass换成postcss-scss…

(最新教程)Cursor Pro订阅升级开通教程,使用支付宝订阅Cursor Pro Plus

一、如何使用Cursor &#xff1f; 目前要使用Cursor - The AI Code Editor&#xff0c;直接去下载安装就可以了&#xff0c;不过基础版只能用两周&#xff0c;如果需要继续使用&#xff0c;就要订阅pro plus或者企业版了。 二、如何订阅Cursor Pro Plus &#xff1f; 因为基础…

Cursor 使用经验,一个需求开发全流程

软件开发中 Cursor 的使用经验成为关注焦点&#xff0c;尤其是处理大型数据集的需求。用户提到“Cursor 使用经验&#xff0c;一个需求开发全流程”&#xff0c;但“Cursor”可能指数据库游标&#xff0c;涉及逐行处理数据。本文将详细探讨开发一个需求的完整流程&#xff0c;包…

vue2实现组件库的自动按需引入,unplugin-auto-import,unplugin-vue-components

1.使用ant-design-vue或者element-ui时&#xff0c;如何每个组件都去import导入组件&#xff0c;大大降低了开发效率&#xff0c;如果全局一次性注册会增加项目体积&#xff0c;那么如何实现既不局部引入&#xff0c;也不全局注册&#xff1f; 2.在element-plus官网看到有说明…

蓝桥杯备赛:一道数学题(练思维(同余的应用))

题目&#xff1a;请问由1-8组成的8位数中有多少个数字可以被1111整除&#xff1f; 首先这道题目看着很难&#xff0c;如果我们直接用代码做的话&#xff0c;也要跑很久&#xff0c;那能不呢想想有什么样的思路可以巧妙一点解开这道题目呢&#xff1f; 有的兄弟有的 这道题目的…

[Lc7_分治-快排] 快速选择排序 | 数组中的第K个最大元素 | 库存管理 III

目录 1. 数组中的第K个最大元素 题解 代码 2.库存管理 III 代码 1. 数组中的第K个最大元素 题目链接&#xff1a;215. 数组中的第K个最大元素 题目分析&#xff1a; 给定整数数组 nums 和整数 k&#xff0c;请返回数组中第 k 个最大的元素。 请注意&#xff0c;你需要…

Unity引擎使用HybridCLR(华佗)热更新

大家好&#xff0c;我是阿赵。   阿赵我做手机游戏已经有十几年时间了。记得刚开始从做页游的公司转到去做手游的公司&#xff0c;在面试的时候很重要的一个点&#xff0c;就是会不会用Lua。使用Lua的原因很简单&#xff0c;就是为了热更新。   热更新游戏内容很重要。如果…

【神经网络】python实现神经网络(一)——数据集获取

一.概述 在文章【机器学习】一个例子带你了解神经网络是什么中&#xff0c;我们大致了解神经网络的正向信息传导、反向传导以及学习过程的大致流程&#xff0c;现在我们正式开始进行代码的实现&#xff0c;首先我们来实现第一步的运算过程模拟讲解&#xff1a;正向传导。本次代…

【Linux】冯诺依曼体系与操作系统理解

&#x1f31f;&#x1f31f;作者主页&#xff1a;ephemerals__ &#x1f31f;&#x1f31f;所属专栏&#xff1a;Linux 目录 前言 一、冯诺依曼体系结构 二、操作系统 1. 操作系统的概念 2. 操作系统存在的意义 3. 操作系统的管理方式 4. 补充&#xff1a;理解系统调用…

HTML-网页介绍

一、网页 1.什么是网页&#xff1a; 网站是指在因特网上根据一定的规则&#xff0c;使用 HTML 等制作的用于展示特定内容相关的网页集合。 网页是网站中的一“页”&#xff0c;通常是 HTML 格式的文件&#xff0c;它要通过浏览器来阅读。 网页是构成网站的基本元素&#xf…

STM32——GPIO介绍

GPIO(General-Purpose IO ports,通用输入/输出接口)模块是STM32的外设接口的核心部分,用于感知外界信号(输入模式)和控制外部设备(输出模式),支持多种工作模式和配置选项。 1、GPIO 基本结构 STM32F407 的每个 GPIO 引脚均可独立配置,主要特性包括: 9 组 GPIO 端口…

字节码是由什么组成的?

Java字节码是Java程序编译后的中间产物&#xff0c;它是一种二进制格式的代码&#xff0c;可以在Java虚拟机&#xff08;JVM&#xff09;上运行。理解字节码的组成有助于我们更好地理解Java程序的运行机制。 1. Java字节码是什么&#xff1f; 定义 Java字节码是Java源代码经过…

链表算法题目

1.两数相加 两个非空链表&#xff0c;分别表示两个整数&#xff0c;只不过是反着存储的&#xff0c;即先存储低位在存储高位。要求计算这两个链表所表示数的和&#xff0c;然后再以相同的表示方式将结果表示出来。如示例一&#xff1a;两个数分别是342和465&#xff0c;和为807…

blender学习25.3.8

【04-进阶篇】Blender材质及灯光Cycle渲染&后期_哔哩哔哩_bilibili 注意的问题 这一节有一个大重点就是你得打开显卡的渲染&#xff0c;否则cpu直接跑满然后渲染的还十分慢 在这里你要打开GPU计算&#xff0c;但是这还不够 左上角编辑&#xff0c;偏好设置&#xff0c;系…

【godot4.4】布局函数库Layouts

概述 为了方便编写一些自定义容器和控件、节点时方便元素布局&#xff0c;所以编写了一套布局的求取函数&#xff0c;统一放置在一个名为Layouts的静态函数库中。 本文介绍我自定义的一些布局计算和实现以及函数编写的思路&#xff0c;并提供完整的函数库代码&#xff08;持续…

Windows下配置Conda环境路径

问题描述&#xff1a; 安装好Conda之后&#xff0c;创建好自己的虚拟环境&#xff0c;同时下载并安装了Pycharm&#xff0c;但在Pycharm中找不到自己使用Conda创建好的虚拟环境。显示“Conda executable is not found” 解决办法&#xff08;依次尝试以下&#xff09; 起初怀…

OpenHarmony子系统开发编译构建指导

OpenHarmony子系统开发编译构建指导 概述 OpenHarmony编译子系统是以GN和Ninja构建为基座&#xff0c;对构建和配置粒度进行部件化抽象、对内建模块进行功能增强、对业务模块进行功能扩展的系统&#xff0c;该系统提供以下基本功能&#xff1a; 以部件为最小粒度拼装产品和独…