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),吞吐量会严重下降。
-
全局内存原子操作延迟大。
-
__global__ void histogram_atomic(const int *data,int *hist,int data_size,int numBins){//data是原数据,hist是直方图频率数组,data_size的数据大小,numBins是bin的个数;这里对10取余,所以numBins传10int tid = blockDim.x * blockIdx.x + threadIdx.x;//为了简单,只用一维的block和threadif (tid < data_size) {int bin = data[tid] % numBins;//计算本线程是数据属于哪个binatomicAdd(&hist[bin], 1);//原子加}
}
CUB 方法
HistogramEven — 等宽区间直方图(单通道)
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)
分为等宽区间,并对每个样本执行:
bin = floor((sample - lower_level) * (num_levels-1) / (upper_level - lower_level))
注意:若类型为整数,则进行整数除法处
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
-
num_levels = numBins + 1 = 11
-
这个参数指示 分割点数量,不是 bin 数量。
-
分割点个数 = bin 数 + 1,所以实际生成 10 个 bin。
-
-
min = 0, max = numBins = 10
-
数据落在
[0, 10)
区间,最大值 10 是上界,不包含 10 本身。
-
-
区间计算公式(每个 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 | 空或部分数据落入? |
对比测试:
#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);
}
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规约代码
__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,对整体进行切割,计算局部再规约
__global__ void histogram_warp_shared(const int* data, int* hist, int data_size, int numBins) {extern __shared__ unsigned int sharedHist[];//通过<<<>>>里动态分配;动态分配必须加externint 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 unrollfor (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 unrollfor (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]);}
}
调用
histogram_warp_shared<<<10,10 , 10 * sizeof(int) >>>(d_data, d_hist, DATA_SIZE, 10);
测试100个数据的平均分布的结果
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还是加速了许多的