cuda编程笔记(15)--使用 CUB 和 atomicAdd 实现 histogram

什么是histogram?

直方图(histogram )是一种统计分布的表示方法。

它的核心思想:

  • 把数据按数值范围分成若干个 区间(bin)。

  • 统计每个区间里有多少个数据。

  • 最终得到一个数组,表示"每个区间的数量"。

假设我有 10 个数字:

1, 2, 2, 3, 5, 5, 5, 8, 9, 10

如果我按下面的区间划分:

  • Bin 0: 0--2

  • Bin 1: 3--5

  • Bin 2: 6--8

  • Bin 3: 9--10

那么 histogram 结果就是:

复制代码
Bin 0: 3   (1, 2, 2)
Bin 1: 4   (3, 5, 5, 5)
Bin 2: 2   (8)
Bin 3: 1   (9, 10)

如果画出来,就是典型的直方图柱状图。

在 GPU 上计算 histogram,就是并行统计每个数据属于哪个 bin,然后更新计数。

  • 常见场景:图像灰度统计(0--255)、点云密度分析、特征分布统计等。

  • 例如一张 8-bit 灰度图的 histogram 有 256 个 bin,每个 bin 统计有多少像素是该灰度值。

cuda编程笔记(5)--原子操作_cuda atomiccas-CSDN博客

实际上,上文中的示例代码:统计字符流里的字符出现频率,就是一个典型的histogram

难点

在 GPU 上计算 histogram 的主要挑战是:

  • 数据竞争:多个线程可能同时更新同一个 bin(原子冲突)。

  • 访存效率:全局内存的写入延迟很高,如果每次更新都直接写全局内存,就会很慢。

  • 因此就有了 atomicAdd 方法 (直接原子更新) 和 CUB 方法(先分块私有化,再合并) 的不同优化思路。

我们用"10以内的统计分布"来举例,这是一个非常简单的 histogram 问题。

atomicAdd 方法

每个线程直接计算自己数据的 bin 索引,然后对全局 histogram 数组执行:

  • 优点:实现简单,几乎一行代码搞定。

  • 缺点:

    • 同一个 bin 高并发时会产生原子冲突(atomic contention)。

    • 对于热点分布(hot-spot distribution),吞吐量会严重下降。

    • 全局内存原子操作延迟大。

cpp 复制代码
__global__ void histogram_atomic(const int *data,int *hist,int data_size,int numBins){
    //data是原数据,hist是直方图频率数组,data_size的数据大小,numBins是bin的个数;这里对10取余,所以numBins传10
    int tid = blockDim.x * blockIdx.x + threadIdx.x;//为了简单,只用一维的block和thread
    if (tid < data_size) {
        int bin = data[tid] % numBins;//计算本线程是数据属于哪个bin
        atomicAdd(&hist[bin], 1);//原子加
    }
}

CUB 方法

HistogramEven --- 等宽区间直方图(单通道)

cpp 复制代码
template<
    typename SampleIteratorT,
    typename CounterT,
    typename LevelT,
    typename OffsetT>
static cudaError_t HistogramEven(
    void* d_temp_storage,
    size_t& temp_storage_bytes,
    SampleIteratorT d_samples,
    CounterT* d_histogram,
    int num_levels,
    LevelT lower_level,
    LevelT upper_level,
    OffsetT num_samples,
    cudaStream_t stream = 0,
    bool debug_synchronous = false
);
  • d_temp_storage :设备上用于中间计算的临时缓冲区指针。如果设为 nullptr,函数只负责返回所需缓冲区大小,不执行计算。

  • temp_storage_bytes:输入输出参数,用于读取或写入临时缓冲区大小(Byte 单位)。

  • d_samples:输入数据指针(device memory),可以是指针或迭代器。

  • d_histogram :输出直方图数组指针(device memory),长度应为 num_levels - 1

  • num_levels:区间边界数量,因此输出 bin 数是 num_levels - 1

  • lower_level, upper_level :数据样本的取值范围 [lower_level, upper_level)。区间宽度为 (upper_level - lower_level) / (num_levels - 1)

  • num_samples:输入样本数量。

  • stream:可选 CUDA 流。

  • debug_synchronous:可选调试标志,帮助调试但会影响性能。

这个方法会把 [lower_level, upper_level) 分为等宽区间,并对每个样本执行:

cpp 复制代码
bin = floor((sample - lower_level) * (num_levels-1) / (upper_level - lower_level))

注意:若类型为整数,则进行整数除法处

cpp 复制代码
void histogram_cub(const int* data, int* hist, int data_size, int numBins) {
    void* d_temp_storage = nullptr;
    size_t temp_storage_bytes = 0;

    //注意numBins+1需要加1,因为numBins传进去是10,我们也确实有10个区间
    cub::DeviceHistogram::HistogramEven(d_temp_storage, temp_storage_bytes,
        data, hist,
        numBins+1, 0, numBins,
        data_size);

    cudaMalloc(&d_temp_storage, temp_storage_bytes);
    cub::DeviceHistogram::HistogramEven(d_temp_storage, temp_storage_bytes,
        data, hist,
        numBins+1, 0, numBins,
        data_size);

    cudaFree(d_temp_storage);
}

这里主要探讨一下num_levels 这个参数:

如果传递11,也即numBins+1

  1. num_levels = numBins + 1 = 11

    • 这个参数指示 分割点数量,不是 bin 数量。

    • 分割点个数 = bin 数 + 1,所以实际生成 10 个 bin。

  2. min = 0, max = numBins = 10

    • 数据落在 [0, 10) 区间,最大值 10 是上界,不包含 10 本身。
  3. 区间计算公式(每个 bin 区间宽度):

width=(max−min)/numBins​​=1

所以每个 bin 的范围是:

Bin index 区间
0 [0, 1)
1 [1, 2)
2 [2, 3)
3 [3, 4)
4 [4, 5)
5 [5, 6)
6 [6, 7)
7 [7, 8)
8 [8, 9)
9 [9, 10)

这样整数 0~9 恰好落在 10 个 bin 中,每个整数对应一个 bin。

如果传了10

width=(max−min)/(numLevels - 1)​​=10/(9)=1.111

CUB 会自动计算 bin 宽度:1.111

区间划分(错误的情况)

Bin index 区间范围
0 [0, 1.111)
1 [1.111, 2.222)
2 [2.222, 3.333)
3 [3.333, 4.444)
4 [4.444, 5.555)
5 [5.555, 6.666)
6 [6.666, 7.777)
7 [7.777, 8.888)
8 [8.888, 10]
9 空或部分数据落入?

对比测试:

cpp 复制代码
#ifndef __CUDACC__
#define __CUDACC__
#endif
#include <cuda_runtime.h>
#include <device_launch_parameters.h>
#include <cublas_v2.h>
#include<cub/cub.cuh>
#include <iostream>
#include<cstdio>
#include <random>

#include <vector>
const int DATA_SIZE = 100;

void error_handling(cudaError_t res) {
    if (res !=cudaSuccess) {
        std::cout << "error!" << std::endl;
    }
}
__global__ void histogram_atomic(const int* data, int* hist, int data_size, int numBins) {
    int tid = blockDim.x * blockIdx.x + threadIdx.x;
    if (tid < data_size) {
        int bin = data[tid]; // 数据已经在 [0,numBins-1]
        if(bin>=0&&bin<10)
        atomicAdd(&hist[bin], 1);
    }
}

void histogram_cub(const int* data, int* hist, int data_size, int numBins) {
    void* d_temp_storage = nullptr;
    size_t temp_storage_bytes = 0;

    //注意numBins+1需要加1,因为numBins传进去是10,我们也确实有10个区间
    cub::DeviceHistogram::HistogramEven(d_temp_storage, temp_storage_bytes,
        data, hist,
        numBins+1, 0, numBins,
        data_size);

    cudaMalloc(&d_temp_storage, temp_storage_bytes);
    cub::DeviceHistogram::HistogramEven(d_temp_storage, temp_storage_bytes,
        data, hist,
        numBins+1, 0, numBins,
        data_size);

    cudaFree(d_temp_storage);
}

void get_uniform_data(int* h_data, int data_size) {
    std::mt19937 gen(std::random_device{}()); // 梅森旋转算法引擎,种子来自硬件随机数
    std::uniform_int_distribution<int> dist(0, 9); // 均匀分布,范围 [0, 9]
    for (int i = 0; i < data_size; i++) {
        h_data[i] =dist(gen);
    }
}
void gen_hotspot_data(int* h_data, int data_size) {
    for (int i = 0; i < data_size; i++) {
        h_data[i] = 0; // 所有数据落到 bin=0
    }
}
void gen_skewed_data(int* h_data, int data_size) {
    std::mt19937 gen(std::random_device{}());
    std::uniform_int_distribution<int> dist(0, 9);
    for (int i = 0; i < data_size; i++) {
        int temp = dist(gen);
        if (temp  < 8) { // 80% 概率
            h_data[i] = 0; // 热点 bin
        }
        else {
            h_data[i] = temp;
        }
    }
}

int main() {
    int h_data[DATA_SIZE];
    int* d_data;
    cudaMalloc(&d_data, DATA_SIZE * sizeof(int));
    int h_hist[10];
    int* d_hist;
    cudaMalloc(&d_hist, 10 * sizeof(int));

    cudaEvent_t start, stop;
    cudaEventCreate(&start);
    cudaEventCreate(&stop);
    float atomicTime, cubTime;

    //均匀分布
    memset(h_hist, 0, 10 * sizeof(int));
    cudaMemset(d_hist, 0, 10 * sizeof(int));
    get_uniform_data(h_data, DATA_SIZE);
    cudaMemcpy(d_data, h_data, DATA_SIZE *sizeof(int), cudaMemcpyHostToDevice);

    cudaEventRecord(start);
    histogram_atomic<<<10,10>>>(d_data, d_hist, DATA_SIZE, 10);
    cudaEventRecord(stop);
    cudaEventSynchronize(stop);
    cudaEventElapsedTime(&atomicTime, start, stop);
    cudaMemcpy(h_hist, d_hist, 10 * sizeof(int), cudaMemcpyDeviceToHost);
    for (int i = 0; i < 10; i++)
        std::cout << h_hist[i] << " ";
    std::cout << std::endl;

    memset(h_hist, 0, 10 * sizeof(int));
    cudaMemset(d_hist, 0, 10 * sizeof(int));

    cudaEventRecord(start);
    histogram_cub(d_data, d_hist, DATA_SIZE, 10);
    cudaEventRecord(stop);
    cudaEventSynchronize(stop);
    cudaEventElapsedTime(&cubTime, start, stop);
    cudaMemcpy(h_hist, d_hist, 10 * sizeof(int), cudaMemcpyDeviceToHost);
    for (int i = 0; i < 10; i++)
        std::cout << h_hist[i] << " ";
    std::cout << std::endl;

    std::cout << "对于平均分布 atomic的时间是:" << atomicTime << "ms cub的时间是:" << cubTime << "ms" << std::endl;

    memset(h_data, -1, DATA_SIZE * sizeof(int));
    //热点分布
    memset(h_hist, 0, 10 * sizeof(int));
    cudaMemset(d_hist, 0, 10 * sizeof(int));
    gen_hotspot_data(h_data, DATA_SIZE);
    cudaMemcpy(d_data, h_data, DATA_SIZE * sizeof(int), cudaMemcpyHostToDevice);

    cudaEventRecord(start);
    histogram_atomic << <10, 10 >> > (d_data, d_hist, DATA_SIZE, 10);
    cudaEventRecord(stop);
    cudaEventSynchronize(stop);
    cudaEventElapsedTime(&atomicTime, start, stop);
    cudaMemcpy(h_hist, d_hist, 10 * sizeof(int), cudaMemcpyDeviceToHost);
    for (int i = 0; i < 10; i++)
        std::cout << h_hist[i] << " ";
    std::cout << std::endl;

    memset(h_hist, 0, 10 * sizeof(int));
    cudaMemset(d_hist, 0, 10 * sizeof(int));

    cudaEventRecord(start);
    histogram_cub(d_data, d_hist, DATA_SIZE, 10);
    cudaEventRecord(stop);
    cudaEventSynchronize(stop);
    cudaEventElapsedTime(&cubTime, start, stop);
    cudaMemcpy(h_hist, d_hist, 10 * sizeof(int), cudaMemcpyDeviceToHost);
    for (int i = 0; i < 10; i++)
        std::cout << h_hist[i] << " ";
    std::cout << std::endl;

    std::cout << "对于热点分布 atomic的时间是:" << atomicTime << "ms cub的时间是:" << cubTime << "ms" << std::endl;


    cudaFree(d_data);
    cudaFree(d_hist);
    cudaEventDestroy(start);
    cudaEventDestroy(stop);
}
cpp 复制代码
5 8 13 12 10 11 7 10 9 15
5 8 13 12 10 11 7 10 9 15
对于平均分布 atomic的时间是:4.31718ms cub的时间是:3.11603ms
100 0 0 0 0 0 0 0 0 0
100 0 0 0 0 0 0 0 0 0
对于热点分布 atomic的时间是:0.014336ms cub的时间是:0.206144ms

平均分布情况

  • 数据分布在 0~9 的各个 bin 上,atomicAdd 被 相对均匀地分布到各个线程

  • GPU 上的 atomicAdd 竞争较少,每个 block/warp 访问不同的 hist 元素

  • CUB 内部实现 先做 block-local hist,再全局归并,减少了全局 atomic 冲突

  • 因此对于平均分布,CUB 会比直接 global atomicAdd 略快(测试结果也是 atomic: 4.24ms,CUB: 3.22ms)


热点分布情况

  • 数据大部分都落在 bin 0,atomicAdd 全部线程都要写 hist[0]

  • 直接 global atomicAdd

    • 虽然冲突多,但因为数据量很小(100 个样本),atomicAdd 冲突不严重

    • GPU 的 kernel launch 和 CUB 的临时存储 malloc 等开销甚至比 atomicAdd 执行时间更大

    • 因此 atomicAdd 时间非常短(0.015ms)

  • CUB

    • 内部需要先分配 temp storage,block-local hist,最后全局归并

    • 即便数据量小,CUB 的 kernel launch、临时存储和 block 内 hist 合并操作开销明显

    • 因此对于小数据量或高度集中数据,CUB 会慢于直接 atomicAdd(你测试是 0.164ms)

尝试用warp对atomicAdd进行优化

首先是标准的warp规约代码

cpp 复制代码
__inline__ __device__
int warpReduceSum(int val) {
    unsigned mask = __activemask();
    for (int offset = warpSize / 2; offset > 0; offset >>= 1) {
        val += __shfl_down_sync(mask, val, offset);
    }
    return val;
}
  • warp 大小始终是 32,永远不会变小。

  • 如果最后一组线程不足 32 个,CUDA 仍然分配一个 warp,但其中未使用的线程是 inactive,不会执行你的指令。

  • 当使用 __shfl_sync 等 warp 内通信时,如果包含了 inactive 线程,就必须通过 mask 来保证正确性。

__activemask() 函数

获取当前 warp 活跃线程掩码

然后通过共享内存+warp,对整体进行切割,计算局部再规约

cpp 复制代码
__global__ void histogram_warp_shared(const int* data, int* hist, int data_size, int numBins) {
    extern __shared__ unsigned int sharedHist[];//通过<<<>>>里动态分配;动态分配必须加extern
    int tid = blockDim.x * blockIdx.x + threadIdx.x;
    int lane = threadIdx.x % warpSize;
    int warpId = threadIdx.x / warpSize;

    //初始化,可能块内线程数不够numBins,所以用了循环
    for (int i = threadIdx.x; i < numBins; i += blockDim.x) {
        sharedHist[i] = 0;
    }
    __syncthreads();

    //warp内累加;localBin是本线程存储的hist数组
    unsigned int localBin[10];//这里设置numBins的大小,这里确定是10了
#pragma unroll
    for (int i = 0; i < 10; i++) localBin[i] = 0;
    
    //遍历所有数据(可能线程总数不够data_size),处理本线程负责的区域
    for (int i = tid; i < data_size; i += gridDim.x * blockDim.x) {
        int d = data[i];
        if (d >= 0 && d < numBins) localBin[d]++;
    }
    //warp内累加
#pragma unroll
    
    for (int i = 0; i < numBins; i++) {
        unsigned int val = warpReduceSum(localBin[i]);
        if (lane == 0) atomicAdd(&sharedHist[i], val);//只有lane 0会拿到最终的和
    }
    __syncthreads();

    //共享内存写回全局
    for (int i = threadIdx.x; i < numBins; i += blockDim.x) {
        atomicAdd(&hist[i], sharedHist[i]);
    }
}

调用

cpp 复制代码
histogram_warp_shared<<<10,10 , 10 * sizeof(int) >>>(d_data, d_hist, DATA_SIZE, 10);

测试100个数据的平均分布的结果

cpp 复制代码
10 16 8 12 6 9 14 8 7 10
10 16 8 12 6 9 14 8 7 10
10 16 8 12 6 9 14 8 7 10
对于平均分布 atomic的时间是:4.55786ms cub的时间是:4.25782ms warp的时间是:0.113664ms

最起码warp相比atomic还是加速了许多的