什么是 Data-Parallel Algorithms?
数据并行(Data Parallelism) 是一种并行计算模型:
- 多个线程同时对不同的数据元素执行相同的操作。
- 与之相对的是“任务并行”,即多个线程执行不同任务。
为什么适合 GPU?
GPU(图形处理器)具有以下特点,非常适合数据并行:
特性 | 说明 |
---|---|
上千个线程核心 | 可同时运行大量线程 |
高吞吐设计 | 适合运行简单、相同的计算任务 |
SIMD 模型 | 多个线程执行相同指令(Single Instruction Multiple Data) |
所以,数据并行算法能更好地利用 GPU 的结构,如:CUDA、OpenCL、Metal、DirectCompute。
写 GPU 上的数据并行算法,核心思路:
1. 数据分配策略
- 把数组、矩阵、图等结构切成 每个线程负责一小块。
- 例如:图像处理中,每个线程负责处理一个像素。
2. 核心算法结构
__global__ void vector_add(float* A, float* B, float* C, int N) {int i = blockIdx.x * blockDim.x + threadIdx.x;if (i < N) {C[i] = A[i] + B[i];}
}
并行处理 A[i] + B[i],每个线程负责一个 i。
3. 避免线程间依赖
-
数据并行算法应尽量 避免线程之间的数据共享或依赖。
-
若必须同步,使用:
- shared memory + sync
- 或拆成多个 kernel 阶段(map/reduce 等)
4. 常见数据并行模式
模式 | 示例 | 用途 |
---|---|---|
Map | 每个元素独立转换 | 图像处理、标量操作 |
Reduce | 所有元素归并成一个结果 | 求和、最大值 |
Scan (Prefix Sum) | 计算前缀累积和 | 排序、压缩 |
Gather/Scatter | 不规则内存访问 | 稀疏矩阵 |
Sort (Radix/Bucket) | 排序大量数据 | 数据准备 |
写数据并行算法需注意:
问题 | 说明 |
---|---|
Memory Coalescing | 线程要尽量顺序访问内存,提升带宽效率 |
Warp Divergence | 避免同个 warp 中线程走不同分支 |
Shared Memory Bank Conflict | 合理布局 shared memory |
Load Balancing | 数据分配要均匀,避免部分线程空转 |
总结
写数据并行算法在 GPU 上的核心理念是:
“以统一的操作处理大规模数据,每个线程只负责处理一小块。”
- 找出 可并行、不依赖的操作。
- 用 每个线程处理一小片数据 的方式设计程序。
- 避免线程之间的复杂同步。
摘要(ABSTRACT)的核心意思是:掌握 GPU 编程的关键,是换一种思维方式 —— 从“任务并行”转向“数据并行”。
如今的大多数 PC、平板、手机都配备了多核处理器,程序员也普遍熟悉“任务并行”编程(task parallelism)。
- 传统多核 CPU 上的并行通常是 任务并行:多个线程执行不同任务,协调运行。
- 比如:前端处理 UI,后端加载数据。
这些设备也配备了 GPU,但人们常认为写 GPU 程序更难。事实真是如此吗?
- GPU 编程有学习曲线,很多开发者觉得“GPU 代码 = 专业 + 难”。
- 本讲座试图打破这个刻板印象。
真正掌握 GPU 编程,关键是学会“数据并行”的思维方式。
- 不要把 GPU 编程当作“写另一种语言”那么复杂。
- 转换思维:不是“每个线程执行不同的任务”,而是“每个线程做相同的操作,处理不同的数据”。
将用 C++ AMP 算法库的例子,展示一些在现代 GPU 上实现算法的常见模式。
- C++ AMP 是微软推出的用于 GPU 并行计算的 C++ 扩展。
- 演示内容包括:如何用数据并行方法重写常见算法(如排序、归约、扫描等)。
同时也会介绍 GPU 编程的一些特殊之处,并与传统 CPU 编程进行对比。
- 比如:
- GPU 上如何管理线程块(threads, warps, blocks)
- 内存访问模式(global memory vs. shared memory)
- 并发模型上的差异(SIMD vs. MIMD)
总结核心思想:
主题 | 内容 |
---|---|
传统并行 | 程序员熟悉多核 CPU 的“任务并行” |
GPU 并行 | 更高效的是“数据并行”:相同操作应用于大量数据 |
教学方式 | 用 C++ AMP 展示 GPU 编程的常见算法模式 |
差异点 | GPU 有自己的线程模型、内存结构和限制,需理解其独特性 |
目标 | 消除 GPU 编程的“神秘感”,让你学会用“数据并行”思维来写高性能代码 |
如果你对 C++ AMP、CUDA、OpenCL 这类 GPU 编程平台感兴趣,我可以进一步帮你:
- 举例说明 常见数据并行算法 在 GPU 上的实现方式
- 对比 CPU 和 GPU 的编程模型差异
- 讲解“GPU 编程中最容易踩的坑”
你将学到的内容:
- 如何更好地理解GPU编程: 帮助你理解如何为GPU编写高效代码。
- 更多关于C++ AMP的知识: 深入了解这个针对GPU的并行编程模型。
- 分享作者的经验与心得: 传授一些实用的技巧和经验。
- 书中的案例研究和示例代码: 通过实际案例帮助理解和应用。
- 开发C++ AMP算法库(AAL): 学习如何构建可复用的GPU算法库。
- 成为性能优化专家: 学会如何分析和提升代码性能,成为“性能大师”。
你不会学到的内容:
- GPU的“深层秘密”:比如非常底层的硬件细节或者架构原理,这些内容不会深入讲解。
你这组算法看起来是并行计算和GPU编程中常用的算法,尤其是在C++ AMP或类似的并行库里会用到。它们大多属于**并行算法(Parallel Algorithms)**范畴。
我帮你整理成一个“算法家族树”文本结构,分类并列出这些算法的关系和类别:
Algorithm Family Tree (并行算法家族树)
-
并行基础控制结构
parallel_for_each
(并行执行循环的核心操作)
-
数据选择与过滤
remove_if
(条件删除元素)copy_if
(条件复制元素)count_if
(条件计数)
-
排序与排列
radix_sort
(基数排序,适合整数或字符的并行排序)
-
序列性质检测
is_sorted
(判断序列是否已排序)adjacent_find
(寻找相邻满足条件的元素)
-
数据填充与生成
fill
(填充序列)generate
(生成序列)
-
扫描与前缀和
scan
(前缀和操作,常用于并行归约)
-
转换与映射
transform
(出现两次,可能代表多种用法)
(对序列每个元素应用操作,映射转换)map-reduce
(映射 + 归约的组合)
-
归约与汇总
reduce
(出现两次)
(将序列归约为单个值,比如求和、最大值)
你可以把它理解为:
- 控制结构(如
parallel_for_each
)是执行算法的框架。 - 选择过滤类算法(
remove_if
、copy_if
、count_if
)负责筛选元素。 - 排序算法(
radix_sort
)负责重排元素。 - 性质检测类(
is_sorted
、adjacent_find
)负责检查序列状态。 - 填充生成类(
fill
、generate
)用于初始化或生成数据。 - 扫描/前缀和类(
scan
)是并行算法中的关键技术。 - 转换映射类(
transform
、map-reduce
)负责元素间的映射和组合。 - 归约类(
reduce
)负责将多个值合并成一个。
“TRANSFORM / MAP” 在 GPU 编程基础(GPU Programming 101) 中是非常核心的概念
TRANSFORM / MAP 的含义
-
Transform(变换)
这是一个对序列中每个元素应用某个函数(操作),产生一个新的序列的过程。
例如:对数组中的每个数字加1,或者把每个数平方。 -
Map
Map是函数式编程里的概念,意思和Transform非常接近,就是把一个函数“映射”到数据集合的每个元素上,得到新的集合。
在GPU编程里,map通常就是使用并行的方式对每个元素执行同样的操作。
GPU编程里的 Transform / Map
-
为什么重要?
GPU擅长处理大量并行计算,Transform/Map 操作天然并行,每个元素的处理彼此独立,非常适合GPU加速。 -
具体表现
在GPU编程中,比如C++ AMP、CUDA或OpenCL,你通常会写一个Kernel(内核函数),这个函数描述对单个元素的操作,然后GPU同时运行这个Kernel来处理整个数据集合。 -
示例
假设有个数组A
,你想得到一个数组B
,其中B[i] = A[i] * 2
,这个就是一个典型的transform/map操作。
总结
- Transform/Map = 并行执行相同操作,产生新数据集合
- 是GPU编程中最基础、最常用的模式之一
- 它是并行算法和数据并行思想的核心体现
经典的 transform/map 用法示例,体现了函数作用于输入序列每个元素并生成输出序列的过程。
代码说明
struct doubler_functor
{int operator()(const int& x) const { return x * 2; }
};
- 这是一个函数对象(functor),重载了
operator()
,表示“传入一个整数,返回它的两倍”。 - 这是 transform 中应用的“映射函数”。
std::vector<int> input(1024);
std::iota(begin(input), end(input), 1);
- 创建一个大小为1024的整型向量
input
。 std::iota
用来给input
填充连续数字,从1开始(即1, 2, 3, …, 1024)。
std::vector<int> output(1024);
- 创建大小相同的输出向量,用来存放变换结果。
std::transform(begin(input), end(input), begin(output), doubler_functor());
std::transform
是标准库的变换算法。它遍历[begin(input), end(input))
的每个元素,用doubler_functor()
函数对每个元素做操作,结果写入到output
对应位置。- 结果是
output[i] = input[i] * 2
。
总结
- 这是顺序执行的transform示例,作用就是对输入序列每个元素应用一个函数,输出新序列。
- 在GPU编程中,这样的操作会变成并行执行,多个元素同时调用这个函数,大大加速处理。
- 你的这个例子正是“map/transform”的基础模型。
关于 Transform/Map 操作在GPU上如何执行 的图示说明,描述了:
- 如何将每个输入元素(1对1)通过函数变换处理
- 每个线程执行一次运算
- 所有运算是并行进行的(GPU并行计算模型)
核心思想:Transform 是“1 对 1”的映射操作
适合 GPU 并行化的原因:
- 每个输入元素独立处理,彼此之间没有依赖
- 所以每个线程可以处理一个元素,非常高效!
举例解释:
你列出了以下映射:
idx | input (a) | F(a) = 2 × a | output |
---|---|---|---|
[0] | 1 | 2 | 2 |
[1] | 2 | 4 | 4 |
[2] | 3 | 6 | 6 |
[3] | 4 | 8 | 8 |
[4] | 5 | 10 | 10 |
… | … | … | … |
[15] | 16 | 32 | 32 |
- 每个线程读一个输入元素(蓝色框表示global memory的读取)
- 对这个值执行操作:
F(a) = 2 × a
(红色圆圈表示线程中的计算) - 写回结果到输出数组(可能还是 global memory,或 tile memory)
内存层次说明:
-
Blue boxes:全局内存(global memory)
所有线程都可以访问,通常延迟较高。 -
Green boxes:瓦片内存 / 共享内存(tile memory / shared memory)
是GPU内部多个线程块共享的小块快速内存,用于优化访问速度。 -
Red circles:GPU线程执行的操作
每个线程独立运行,因此这类操作天生适合并行化。
每个线程负责一件事:
int idx = ...; // 每个线程自己知道的索引
output[idx] = input[idx] * 2;
- 没有循环依赖
- 不需要同步
- 高度并行!
总结:你要理解的重点是——
特性 | 为什么重要 |
---|---|
1对1映射 | 每个输入元素只需要一个线程处理,结构简单 |
数据独立 | 没有跨线程依赖,所以易于并行 |
可并行优化 | 可以用shared memory优化访问速度 |
GPU友好 | 正是GPU的最佳使用场景之一 |
struct doubler_functor {int operator()(const int& x) const restrict(amp, cpu) { return x * 2; }
};
std::vector<int> input(1024);
std::iota(begin(input), end(input), 1);
std::vector<int> output(1024);
concurrency::array_view<const int> input_av(input);
concurrency::array_view<int> output_av(output);
output_av.discard_data();
amp_stl_algorithms::transform(begin(input_av), end(input_av), begin(output_av), doubler_functor());
给出的代码展示了使用 AAL(AMP STL Algorithms)在 GPU 上执行 transform
操作,这是在 C++ AMP 环境下并行编程的一个经典示例。
总体目标
将输入数组 input
中的每个元素 x
,通过 GPU 并行执行函数 x * 2
,写入输出数组 output
中。
逐行讲解
1. 定义 GPU + CPU 通用的函数对象
struct doubler_functor {int operator()(const int& x) const restrict(amp, cpu) {return x * 2;}
};
- 这是一个函数对象(functor),用于对每个元素执行操作。
restrict(amp, cpu)
表示这个函数可以同时在 GPU(AMP) 和 CPU 上运行(这是 AAL 的要求,增加通用性)。operator()
接收一个整数x
,返回其两倍x * 2
。
2. 准备数据
std::vector<int> input(1024);
std::iota(begin(input), end(input), 1); // 填入 1 ~ 1024std::vector<int> output(1024); // 结果数组
input
是 CPU 上的 std::vector,内容是 1 到 1024。output
是预留的存储结果的容器。
3. 映射到 GPU 上(array_view)
concurrency::array_view<const int> input_av(input);
concurrency::array_view<int> output_av(output);
output_av.discard_data(); // 告诉系统 output 初始数据不用保留
array_view
是 AMP 用来在 CPU 和 GPU 之间传递数据的机制。input_av
是只读视图,output_av
是可写视图。discard_data()
表示我们不关心 output 的初始值,优化内存传输。
4. 执行 transform 操作(在 GPU 上并行运行)
amp_stl_algorithms::transform(begin(input_av), end(input_av), begin(output_av), doubler_functor());
- 这是真正的并行计算。
- 所有的
input_av[i]
会同时(由 GPU 的线程)执行doubler_functor()(input_av[i])
。 - 结果写入到
output_av[i]
。 - 这是 transform/map 的标准 GPU 并行实现。
核心 GPU 并行特性
- 每个 GPU 线程处理一个元素。
- 运算完全独立 → 不需要同步 → 非常高效!
- 使用 functor 可以让代码更模块化,也便于测试和重用。
总结:这段代码的意义
部分 | 含义 |
---|---|
doubler_functor | 对每个元素执行乘2操作 |
array_view | 连接 CPU 与 GPU 的数据视图 |
transform | 并行调用 GPU 执行函数操作 |
restrict(amp, cpu) | 同时兼容 GPU 和 CPU 执行环境 |
如果你愿意,我还可以进一步说明如何扩展这个 transform:
- 改成多个输入源(binary transform)
- 与
count_if
、reduce
组合构建 map-reduce 流程 - 或者展示性能测试方法
在 C++ AMP 中 transform
操作的底层实现方式(under the hood),也就是:使用 parallel_for_each
手动实现 transform
的原理。
concurrency::array_view<const int> input_av(input);
concurrency::array_view<int> output_av(output);
output_av.discard_data();
auto doubler_func = doubler_functor();
concurrency::parallel_for_each(output_av.extent, [=](concurrency::index<1> idx) restrict(amp) {output_av[idx] = doubler_func(input_av[idx]);});
``
---一步一步理解这段代码:---## 本质目的:对 `input` 数组中的每个元素 `x`,计算 `x * 2`,并将结果存入 `output`,**使用 GPU 并行执行(不是CPU for循环!)**---## 逐行解释### 1. 创建 array\_view(GPU视图)```cpp
concurrency::array_view<const int> input_av(input);
concurrency::array_view<int> output_av(output);
output_av.discard_data();
array_view
:将std::vector
映射为 GPU 可访问的数组。input_av
是只读的(const),output_av
是可写的。discard_data()
告诉 GPU 不需要保留output
的初始值,优化数据传输。
2. 函数对象(Functor)
auto doubler_func = doubler_functor();
- 创建一个函数对象
doubler_func
,里面定义了operator()
,即return x * 2
。 - 它支持
restrict(amp, cpu)
,所以能在 GPU 内核中被调用。
3. 手动实现 transform(并行执行)
concurrency::parallel_for_each(output_av.extent,
[=](concurrency::index<1> idx) restrict(amp)
{output_av[idx] = doubler_func(input_av[idx]);
});
-
parallel_for_each
是 AMP 中最核心的并行执行机制。 -
output_av.extent
表示我们启动多少个 GPU 线程 —— 每个元素一个线程。 -
每个线程负责处理一个索引
idx
。 -
内核函数体
[=](index<1> idx)
是 GPU 执行的代码:output_av[idx] = doubler_func(input_av[idx]);
也就是:从输入中读元素 → 调用函数 → 写到输出中
整体理解:这是 transform 的本质!
这个手动写法:
parallel_for_each(..., lambda);
与之前 AAL 封装的:
amp_stl_algorithms::transform(...);
本质上是一样的。AAL 只是帮你写好上面这些底层调用而已,让你用更像 STL 的方式写代码。
为什么这段代码体现了 “Under the Hood”:
你手动实现的内容 | AAL 内部自动做的 |
---|---|
并行线程调度 | AAL 自动调用 parallel_for_each |
数据访问与索引管理 | AAL 内部处理 array_view 和 index |
内核函数写法 | AAL 封装 lambda + functor |
总结一句话:
你这段代码就是 transform 在 C++ AMP 中的底层实现方式 ——
用 parallel_for_each
来启动 GPU 线程,对每个元素进行操作。
“Reduce(归约)”操作的理解
什么是 Reduce?
Reduce(归约) 是一种聚合操作,将一个序列的数据“归约”为一个值,比如求和、求积、最大值、最小值等。
示例 1:CPU 顺序归约
std::vector<int> data(1024);
int s = 0;
for (int i : data)
{s += i;
}
- 顺序执行,将
data
中的所有元素一个个加到s
上。
示例 2:标准库版本
int s = std::accumulate(cbegin(data), cend(data), 0);
std::accumulate
是 STL 中做 reduce 的函数。- 也是顺序执行。
- 注意:使用的是
+
运算,从起始值0
开始。
并行 Reduce 怎么做?
在 GPU 或并行系统中,我们不能顺序加,需要使用如下方式:
- 分组数据
- 每组并行计算小和
- 然后对这些小和再做归约
例如:
数据: [a, b, c, d, e, f, g, h]阶段1:
线程0: a + b
线程1: c + d
线程2: e + f
线程3: g + h阶段2:
线程0: (a+b) + (c+d)
线程1: (e+f) + (g+h)阶段3:
线程0: 最终结果 = 所有小和之和
这个过程是树状归约(tree reduction)。
所以你要理解的几点是:
关键词 | 理解重点 |
---|---|
Reduce | 把序列变成一个值 |
accumulate | 顺序 reduce(CPU) |
并行 reduce | 多线程同时归约 |
浮点误差 | 相加顺序可能影响最终结果 |
不确定性 | GPU 上 reduce 的结果可能和 CPU 不完全一样 |
并行 Reduce(归约)中,stride 从大到小:stride = 8 → 4 → 2 → 1 的方式,这是一种 GPU 中典型的 自上而下的归约方式(Top-down reduction)。
我们来详细解释这个过程:
🎯 目标:
将这组数据归约成一个总和:
1 + 2 + 3 + ... + 16 = 136
初始数组(每个数字在对应的索引位):
Index i | [0] | [1] | [2] | [3] | [4] | [5] | [6] | [7] | [8] | [9] | [10] | [11] | [12] | [13] | [14] | [15] |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Value | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
Top-Down Reduce:stride 从大到小(8 → 4 → 2 → 1)
Stride = 8:
每个线程 i
从 i
和 i + 8
位置做相加(前一半加后一半):
[0] = [0] + [8] = 1 + 9 = 10
[1] = [1] + [9] = 2 + 10 = 12
[2] = [2] + [10] = 3 + 11 = 14
[3] = [3] + [11] = 4 + 12 = 16
[4] = [4] + [12] = 5 + 13 = 18
[5] = [5] + [13] = 6 + 14 = 20
[6] = [6] + [14] = 7 + 15 = 22
[7] = [7] + [15] = 8 + 16 = 24
更新后的数组(前8项):
[0-7] = 10 12 14 16 18 20 22 24
Stride = 4:
再归约:
[0] = [0] + [4] = 10 + 18 = 28
[1] = [1] + [5] = 12 + 20 = 32
[2] = [2] + [6] = 14 + 22 = 36
[3] = [3] + [7] = 16 + 24 = 40
更新后的前 4 项:
[0-3] = 28 32 36 40
Stride = 2:
[0] = [0] + [2] = 28 + 36 = 64
[1] = [1] + [3] = 32 + 40 = 72
变成:
[0-1] = 64 72
Stride = 1:
[0] = [0] + [1] = 64 + 72 = 136
总结:你理解的是 树状归约(Top-down),stride 从大到小
步骤 | 操作 | 剩余元素 |
---|---|---|
1 | stride = 8 | 10 12 14 16 18 20 22 24 |
2 | stride = 4 | 28 32 36 40 |
3 | stride = 2 | 64 72 |
4 | stride = 1 | 136 |
这种方式适用于 共享内存归约,特别是当你从一个 tile/block 中读取数据时,可以用这种方式分阶段聚合。
C++ AMP 实现的简单并行归约(reduction)函数。
template <typename T>
int reduce_simple(const concurrency::array_view<T>& source) const {int element_count = source.extent.size();std::vector<T> result(1);for (int stride = (element_count / 2); stride > 0; stride /= 2) {concurrency::parallel_for_each(concurrency::extent<1>(stride), [=](concurrency::index<1> idx) restrict(amp) {source[idx] += source[idx + stride];});}concurrency::copy(source.section(0, 1), result.begin());return result[0];
}
函数签名
template <typename T>
int reduce_simple(const concurrency::array_view<T>& source) const
- 泛型函数:适用于任意类型
T
(比如int
,float
)。 - 接收一个 AMP 的
array_view<T>
作为输入,这表示这段数据是要在 GPU 上并行处理的。 - 函数返回整个数组的归约(reduce)结果(总和、乘积等 —— 此例为加法)。
初始化
int element_count = source.extent.size();
std::vector<T> result(1);
element_count
是输入数组的大小。result
是一个只存一个值的向量,用来存放最终归约结果。
并行归约过程(核心)
for (int stride = (element_count / 2); stride > 0; stride /= 2)
{concurrency::parallel_for_each(concurrency::extent<1>(stride), [=](concurrency::index<1> idx) restrict(amp){source[idx] += source[idx + stride];});
}
这是归约的关键步骤,解释如下:
每一轮:
-
设置
stride
:从数组长度的一半开始,每轮除以 2,直到为 1。 -
parallel_for_each(...)
会在 GPU 上启动stride
个线程并行执行。 -
每个线程执行:
source[idx] += source[idx + stride];
把当前位置的元素与其 “stride 距离” 的元素加在一起,保存在当前位置。
例子:假设数组是 [1, 2, 3, 4, 5, 6, 7, 8]
stride = 4
[0] = 1 + 5 = 6
[1] = 2 + 6 = 8
[2] = 3 + 7 = 10
[3] = 4 + 8 = 12
中间变成:
[6, 8, 10, 12, ?, ?, ?, ?]
stride = 2
[0] = 6 + 10 = 16
[1] = 8 + 12 = 20
中间变成:
[16, 20, ?, ?, ?, ?, ?, ?]
stride = 1
[0] = 16 + 20 = 36
最终结果读取
concurrency::copy(source.section(0, 1), result.begin());
return result[0];
- 把 GPU 上的
[0]
拷贝到 CPU 上的result[0]
,返回作为最终归约结果。
总结
部分 | 功能 |
---|---|
for + stride | 分阶段归约,stride 每次减半 |
parallel_for_each | 在 GPU 上每轮并行加法 |
array_view | 封装 CPU 内存与 GPU 的连接 |
copy(...) | 把结果从 GPU 拷贝到 CPU |
注意事项
- 这个版本是简化版(Simple Reduction),并没有使用共享内存(tile memory),效率不是最高。
- 在 AMP 中进行原地修改时,要确保
array_view
是写权限的;否则需要array<T>
。 - 实际部署中可能需要避免数据竞争,使用临时缓冲或 tile-based 方法来提高性能和准确性。
“简单 Reduce 存在的问题” 以及 为什么使用 Tiles 和 Tile Memory 能带来更高性能。
简单 Reduce 的问题
1. 没有使用 Tile Static Memory
- 每一次加法操作都直接读写 全局内存(global memory)
- 全局内存访问速度慢(延迟高、带宽低)
- 不使用 tile_static 就错过了高速缓存(shared memory)的优化机会
2. 所有读写都发生在全局内存(Global Memory)
- 全局内存访问对性能影响很大
- 缺乏共享 memory 导致线程之间协作成本高
- 线程不能高效共享中间计算结果
3. 大部分线程大部分时间是“闲置”的(线程浪费)
举例来说:
- 第一次迭代(stride = N/2):用了 N/2 个线程
- 第二次 stride = N/4,只用了 N/4 个线程…
- 最后只剩下 1 个线程工作,其余都在等
- 这叫做 线程活跃率低(low occupancy) —— 性能浪费严重
使用 Tiles 和 Tile Memory 的优势
什么是 Tile?
- Tile 是 GPU 上的一块 线程分组(thread group)
- 在 C++ AMP 中,Tiles 是被
tiled_extent
创建的子空间 - 一个 Tile 中的线程可以通过
tile_static
内存共享数据 - 每个 Tile 对应于 GPU 上的一个工作单元,比如 CUDA 中的一个 thread block
为什么用 Tiles?
优点 | 原因 |
---|---|
Tile Static Memory | 速度快:在 shared memory 中,延迟远低于 global |
线程协作更高效 | 使用 tile_barrier::wait() 同步中间结果 |
更高线程活跃率 | 所有线程都可以并行参与每一步计算 |
更接近 GPU 的调度机制 | GPU 会以 warp 或 tile 为单位调度计算 |
例子:Tile-based Reduce 的调度方式
- GPU 中有多个 SM(Streaming Multiprocessor)
- 每个 SM 会执行多个 Tile(Block)
- 每个 Tile 又分成多个 Warp(32个线程,NVIDIA架构)
- Warp 内部线程共享指令流,但数据不同(SIMT 模型)
架构理解:
Application└── Kernel└── Tiles (Blocks)└── Warps (Thread groups)└── Threads (executing instructions)
总结对比
特性 | 简单 Reduce | Tile-based Reduce |
---|---|---|
Memory 类型 | 全局内存(慢) | Tile memory(共享内存,快) |
线程利用率 | 逐步下降 | 全程高利用 |
同步机制 | 无显式同步 | 使用 tile_barrier 同步 |
性能 | 差 | 优 |
是否可扩展 | 不太适合大规模数据 | 可适用于大数据高并发场景 |
Streaming Multiprocessor (SM) 与 Tile Memory 的关系,
GPU 计算单元架构要点
1. Streaming Multiprocessor (SM)
- GPU 中的核心计算单元,每个 SM 负责调度和执行多个线程组(tiles 或 blocks)。
- 一个 SM 内部有自己的 tile memory(共享内存) 和 寄存器。
2. Tile Memory(共享内存)
- Tile memory 是每个 SM 内部的高速片上内存(一般是几十 KB 级别)。
- 速度远快于全局内存(GB 级别)。
- 线程组(tile)内的线程可以共享 tile memory,实现快速数据交换和协同计算。
3. 寄存器(Registers)
- 每个线程有自己的寄存器,速度最快。
- 用于存放线程私有变量和临时数据。
4. 全局内存(Global Memory)
- 存储容量大(GB 级别),但是访问延迟高,速度慢。
- 所有 tiles 和 SM 都可以访问全局内存,但访问时的同步和冲突处理较复杂(如原子操作)。
关键点理解
概念 | 作用 |
---|---|
Tiles 访问全局内存 | 可以读取写入全局内存,但同步能力有限,只能通过原子操作实现线程安全。 |
Tiles 访问 tile memory | 可以高效共享数据,利用同步原语(如 tile_barrier::wait() )协调访问。 |
Registers | 线程私有,极快,存储临时变量。 |
资源调度 | 应用需合理使用 tile memory 和寄存器,避免资源浪费,提高线程占用率(occupancy)。 |
为什么要平衡资源使用?
- 占用率(Occupancy):表示 GPU 中活跃线程与最大线程数的比例。
- 如果占用率太低,硬件资源没充分利用,性能下降。
- 但占用率太高,可能导致每线程可用资源(寄存器、共享内存)减少,影响性能。
- 所以要在 tile memory,寄存器,线程数量 之间做权衡。
用 tile memory(共享内存) 优化归约(reduce)操作的过程,通过分阶段合并元素,减少全局内存访问,提高性能。
使用 Tile Memory 的归约步骤理解
假设你有数组:
[1] 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
1. 从全局内存加载数据到 Tile Memory
- 线程组(tile)里的每个线程负责将全局内存(global memory)对应元素复制到本地的 tile memory(local memory,速度快很多)。
- 示例中
[0]
到[7]
是 tile 内部的索引。
global: 1, 2, ..., 8, ...
local tile: 1, 2, ..., 8
2. 分阶段归约(Reduce)在 Tile Memory 中进行
归约使用的 stride 从较大到 1 缩小,类似:
- stride = 8: 把第0个线程的元素和第8个元素相加,存回第0个元素
- stride = 4: 把第0个线程的元素和第4个元素相加
- stride = 2: 把第0个线程的元素和第2个元素相加
- stride = 1: 把第0个线程的元素和第1个元素相加
每一步所有线程并行工作,但只访问快速的 tile memory。
3. 分阶段结果示意
比如:
阶段 | Tile Memory 值举例 |
---|---|
初始 | [1, 2, 3, 4, 5, 6, 7, 8] |
stride=4 | [1+5=6, 2+6=8, 3+7=10, 4+8=12, ...] |
stride=2 | [6+10=16, 8+12=20, ...] |
stride=1 | [16+20=36, ...] |
最终 [0]
位置是本 tile 的部分和。
4. 将 tile memory 的归约结果写回全局内存
- 归约结束后,tile 的结果可以写回全局内存中,再由其他 tile 继续合并,或者最终由主线程读取。
总结:
优点 | 说明 |
---|---|
减少全局内存访问 | 全局内存访问只发生一次(加载和写回) |
利用快速 tile memory | 在共享内存中做归约操作,加速计算 |
并行执行 | 所有线程并行参与每轮合并,利用率高 |
一个典型的 tiled reduce(分块归约) 实现,用到了 C++ AMP 的 tile memory(共享内存)和同步机制。
代码详细解析
template <typename T, int tile_size = 64>
int reduce_tiled(concurrency::array_view<T>& source) const
{int element_count = source.extent.size();// 1. 创建一个全局数组存储每个 tile 的部分归约结果concurrency::array<int, 1> per_tile_results(element_count / tile_size);concurrency::array_view<int, 1> per_tile_results_av(per_tile_results);per_tile_results_av.discard_data();// 2. 循环归约,直到元素数小于 tile_sizewhile (element_count >= tile_size){concurrency::extent<1> ext(element_count);concurrency::parallel_for_each(ext.tile<tile_size>(),[=](tiled_index<tile_size> tidx) restrict(amp){int tid = tidx.local[0]; // tile 内线程id// 3. 在 tile static(共享内存)中分配空间tile_static int tile_data[tile_size];// 4. 把数据从全局内存拷贝到 tile memorytile_data[tid] = source[tidx.global[0]];tidx.barrier.wait(); // 等待所有线程完成加载// 5. 分阶段归约for (int stride = 1; stride < tile_size; stride *= 2){if (tid % (2 * stride) == 0)tile_data[tid] += tile_data[tid + stride]; // 合并相邻元素// 同步,并且保证 tile static 内存一致性tidx.barrier.wait_with_tile_static_memory_fence();}// 6. tile 内线程0 写入部分归约结果到全局数组if (tid == 0)per_tile_results_av[tidx.tile[0]] = tile_data[0];});// 7. 缩减元素数量,换用部分归约结果继续归约element_count /= tile_size;// 8. 交换数组视图,下一轮用上次结果继续归约std::swap(per_tile_results_av, source);per_tile_results_av.discard_data();}// 9. 最终结果较小,拷贝回 CPU 端并用 std::accumulate 完成最后归约std::vector<int> partialResult(element_count);concurrency::copy(source.section(0, element_count), partialResult.begin());source.discard_data();return std::accumulate(partialResult.cbegin(), partialResult.cend(), 0);
}
关键点总结
阶段 | 说明 |
---|---|
1. 全局数组存结果 | 每个 tile 执行完归约后写一个部分和 |
2. 循环直到数据规模变小 | 每轮把数据量缩小 tile_size 倍 |
3. 使用 tile_static 内存 | 高速共享内存,避免频繁全局内存读写 |
4. 同步 barrier | 保证数据安全,所有线程同步进行归约 |
5. 线程0 写回归约结果 | tile 内唯一线程负责写结果,避免写冲突 |
6. 交换数组视图 | 让下轮归约用上次的部分结果 |
7. CPU 端完成最后归约 | 小数据量时用 CPU 做最终归约更高效 |
为什么这样设计?
- 减少全局内存访问:大部分计算在快速的 tile memory 中完成。
- 充分利用并行:每个 tile 内所有线程都并行工作,归约效率高。
- 多轮归约合并结果:层层合并,直到数据小到适合 CPU 端处理。
concurrency::extent<1> ext(element_count);
concurrency::parallel_for_each(ext.tile<tile_size>(), [=](tiled_index<tile_size> tidx) restrict(amp) {int tid = tidx.local[0];tile_static int tile_data[tile_size];tile_data[tid] = source[tidx.global[0]];tidx.barrier.wait();for (int stride = 1; stride < tile_size; stride *= 2) {if (tid % (2 * stride) == 0) tile_data[tid] += tile_data[tid + stride];tidx.barrier.wait_with_tile_static_memory_fence();}if (tid == 0) per_tile_results_av[tidx.tile[0]] = tile_data[0];});
典型的 tiled kernel工作流程,具体流程如下:
工作流程总结
-
每个线程把自己的数据从全局内存复制到 tile_static(共享内存)
- 这是为了利用高速共享内存,避免后续访问全局内存的高延迟。
tidx.barrier.wait();
等待所有线程完成这一步。
-
在 tile_static 内部进行分阶段归约,线程两两合并邻居数据
- 通过
stride
控制间隔,逐步合并。 - 每一步结束都用
tidx.barrier.wait_with_tile_static_memory_fence();
保证共享内存的更新对所有线程可见。
- 通过
-
归约完成后,tile 内第0个线程把最终结果写回全局内存
- 避免写冲突。
- 写回结果是每个 tile 的部分和。
这就是 使用 tile memory 优化归约操作的典型范式,充分利用了 GPU 的并行能力和高速共享内存,极大提升了性能。
代码片段展示了一种减少归约中线程空闲时间的优化策略
代码重点解析
concurrency::extent<1> ext(element_count / 2);
concurrency::parallel_for_each(ext.tile<tile_size>(),
[=](tiled_index<tile_size> tidx) restrict(amp)
{int tid = tidx.local[0];tile_static int tile_data[tile_size];// 这里每个线程一次性加两个全局内存元素,减少了后续线程空闲int rel_idx = tidx.tile[0] * (tile_size * 2) + tid;tile_data[tid] = source[rel_idx] + source[rel_idx + tile_size];tidx.barrier.wait();// 之后的归约循环进行 tile 内部归约...
});
优化意图(减少空闲线程)
- 原始的归约中,元素数量会减半,线程数量也跟着减半,导致一半线程空闲。
- 这个方案先让每个线程把两个元素相加,减少一半的元素数量,这样下一轮归约可以使用和当前线程数相等的线程。
- 也就是说,每个线程在开始归约之前,先做一次预归约操作。
优点
- 提高线程利用率:每个线程初始就做两次元素的加法,充分利用 GPU 线程资源。
- 减少归约循环中空闲线程数量:避免“线程浪费”,提升并行效率。
简单总结
- 通过预先合并两个元素,线程数对应减少一半数据量,接下来归约操作能用满所有线程。
- 这是一种经典的归约性能优化手法,适合 GPU 并行计算。
使用 AAL(C++ AMP Algorithms Library) 的 reduce
函数可以极大简化归约操作:
concurrency::array_view<int> input_av(input_av);
int result = amp_algorithms::reduce(input_av, amp_algorithms::plus<int>());
重点说明:
- 封装好复杂细节:库函数内部已经帮你做好了优化,比如使用 tile memory、减少空闲线程、同步等。
- 提高开发效率:不用重复造轮子,避免细节错误。
- 性能也能有保障:开源库或官方库通常经过多轮调优和测试。
总结:
能用库就用库,省时省力还靠谱!让专业的库帮你做复杂的 GPU 优化吧。
Inclusive Scan(包含扫描) 的基本概念和实现。
Inclusive Scan 解释
-
给定一个数组
data
,每个元素data[i]
被替换成从data[0]
到data[i]
所有元素的累加和。 -
数学表示:
a i = ∑ k = 0 i a k a_i = \sum_{k=0}^i a_k ai=k=0∑iak
-
你给出的代码是串行版的简单实现:
std::vector<int> data(1024);
for (size_t i = 1; i < data.size(); ++i)
{data[i] += data[i - 1];
}
关键点
- 从第二个元素开始,每个元素都加上它前面一个元素的值,累积到当前位置。
- 最终
data[i]
就是[0..i]
位置所有元素的和。
备注
-
Inclusive Scan 和 Exclusive Scan 的区别是:
- Inclusive Scan 包含当前位置元素
- Exclusive Scan 不包含当前位置元素,只累加之前的元素。
Exclusive Scan(排除自身的前缀和) 的概念和实现,非常清晰。
什么是 Exclusive Scan?
给定一个输入数组 input
,输出数组 output[i]
是 input[0] + input[1] + ... + input[i-1]
,不包括 input[i]
本身。
数学表达式:
a i = ∑ k = 0 i − 1 a k ( a 0 = 0 ) a_i = \sum_{k=0}^{i-1} a_k \quad(a_0 = 0) ai=k=0∑i−1ak(a0=0)
代码解释:
std::vector<int> input(1024);
std::vector<int> output(1024);
output[0] = 0;
for (size_t i = 1; i < output.size(); ++i)
{output[i] = output[i - 1] + input[i - 1];
}
行为分析:
output[0] = 0
:前缀为空,自然是 0。output[i] = output[i - 1] + input[i - 1]
:加的是 前一个 input 元素,从而排除了当前元素input[i]
。
对比 Inclusive Scan:
位置 | input[i] | Inclusive Scan | Exclusive Scan |
---|---|---|---|
0 | 1 | 1 | 0 |
1 | 2 | 1 + 2 = 3 | 1 |
2 | 3 | 1 + 2 + 3 = 6 | 1 + 2 = 3 |
3 | 4 | 10 | 6 |
使用 AAL 实现 Exclusive Scan:
concurrency::array_view<int> input_av(input_vw);
scan<warp_size, scan_mode::exclusive>(input_vw, input_vw, amp_algorithms::plus<int>());
分析说明:
-
scan<warp_size, scan_mode::exclusive>
:-
模板参数:
warp_size
: 每个线程组(warp)的大小,通常设为 32 或 64。scan_mode::exclusive
: 表示使用 exclusive scan(排除当前位置的前缀和)。
-
-
input_vw
:array_view
对象,既是输入又是输出。 -
amp_algorithms::plus<int>()
: 二元操作函数,用于前缀“累加”。
AAL 还提供了基于 DirectX 的封装支持,在某些平台或图形应用中可以直接用 GPU 执行 scan 操作,适合图形/游戏/图像处理中常见的大规模并行数据前缀处理。
总结:
使用 AAL 来做
scan
可以避免写复杂的并行代码 —— 让库来做优化(例如 tile memory、并行调度、同步等),你只关注算法逻辑,效率更高、代码更清晰。