CppCon 2014 学习第6天:WRITING DATA PARALLEL ALGORITHMS ON GPUs

article/2025/7/2 9:57:55

什么是 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 (并行算法家族树)

  1. 并行基础控制结构

    • parallel_for_each
      (并行执行循环的核心操作)
  2. 数据选择与过滤

    • remove_if
      (条件删除元素)
    • copy_if
      (条件复制元素)
    • count_if
      (条件计数)
  3. 排序与排列

    • radix_sort
      (基数排序,适合整数或字符的并行排序)
  4. 序列性质检测

    • is_sorted
      (判断序列是否已排序)
    • adjacent_find
      (寻找相邻满足条件的元素)
  5. 数据填充与生成

    • fill
      (填充序列)
    • generate
      (生成序列)
  6. 扫描与前缀和

    • scan
      (前缀和操作,常用于并行归约)
  7. 转换与映射

    • transform (出现两次,可能代表多种用法)
      (对序列每个元素应用操作,映射转换)
    • map-reduce
      (映射 + 归约的组合)
  8. 归约与汇总

    • reduce (出现两次)
      (将序列归约为单个值,比如求和、最大值)

你可以把它理解为:

  • 控制结构(如parallel_for_each)是执行算法的框架。
  • 选择过滤类算法remove_ifcopy_ifcount_if)负责筛选元素。
  • 排序算法radix_sort)负责重排元素。
  • 性质检测类is_sortedadjacent_find)负责检查序列状态。
  • 填充生成类fillgenerate)用于初始化或生成数据。
  • 扫描/前缀和类scan)是并行算法中的关键技术。
  • 转换映射类transformmap-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 并行化的原因:
  • 每个输入元素独立处理,彼此之间没有依赖
  • 所以每个线程可以处理一个元素,非常高效!

举例解释:

在这里插入图片描述

你列出了以下映射:

idxinput (a)F(a) = 2 × aoutput
[0]122
[1]244
[2]366
[3]488
[4]51010
[15]163232
  • 每个线程读一个输入元素(蓝色框表示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_ifreduce 组合构建 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_viewindex
内核函数写法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 或并行系统中,我们不能顺序加,需要使用如下方式:

  1. 分组数据
  2. 每组并行计算小和
  3. 然后对这些小和再做归约

例如:

数据: [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]
Value12345678910111213141516

Top-Down Reduce:stride 从大到小(8 → 4 → 2 → 1)


Stride = 8:

每个线程 iii + 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 从大到小

步骤操作剩余元素
1stride = 810 12 14 16 18 20 22 24
2stride = 428 32 36 40
3stride = 264 72
4stride = 1136

这种方式适用于 共享内存归约,特别是当你从一个 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

注意事项

  1. 这个版本是简化版(Simple Reduction),并没有使用共享内存(tile memory),效率不是最高。
  2. 在 AMP 中进行原地修改时,要确保 array_view 是写权限的;否则需要 array<T>
  3. 实际部署中可能需要避免数据竞争,使用临时缓冲或 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 会以 warptile 为单位调度计算

例子: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)

总结对比

特性简单 ReduceTile-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工作流程,具体流程如下:


工作流程总结

  1. 每个线程把自己的数据从全局内存复制到 tile_static(共享内存)

    • 这是为了利用高速共享内存,避免后续访问全局内存的高延迟。
    • tidx.barrier.wait(); 等待所有线程完成这一步。
  2. 在 tile_static 内部进行分阶段归约,线程两两合并邻居数据

    • 通过 stride 控制间隔,逐步合并。
    • 每一步结束都用 tidx.barrier.wait_with_tile_static_memory_fence(); 保证共享内存的更新对所有线程可见。
  3. 归约完成后,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=0iak

  • 你给出的代码是串行版的简单实现:

std::vector<int> data(1024);
for (size_t i = 1; i < data.size(); ++i)
{data[i] += data[i - 1];
}

关键点

  • 从第二个元素开始,每个元素都加上它前面一个元素的值,累积到当前位置。
  • 最终 data[i] 就是 [0..i] 位置所有元素的和。

备注

  • Inclusive ScanExclusive 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=0i1aka0=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 ScanExclusive Scan
0110
121 + 2 = 31
231 + 2 + 3 = 61 + 2 = 3
34106


使用 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、并行调度、同步等),你只关注算法逻辑,效率更高、代码更清晰。


http://www.hkcw.cn/article/XUFgyViHPH.shtml

相关文章

AAAI 2025论文分享│STD-PLM:基于预训练语言模型的时空数据预测与补全方法

本文详细介绍了一篇发表于人工智能顶级会议AAAI 2025的论文《STD-PLM: Understanding Both Spatial and Temporal Properties of Spatial-Temporal Data with PLM》。该论文提出了一种基于预训练语言模型&#xff08;Pre-trained Language Model‌&#xff0c;PLM&#xff09;的…

抗辐照加固CANFD芯片:以车规级设计提升商业航天系统可靠性

摘要 商业航天领域的发展对电子系统的可靠性和抗辐照能力提出了更高要求。本文深入探讨了抗辐照加固CANFD芯片如何借助车规级设计&#xff0c;增强商业航天系统的可靠性。本文以国科安芯CANFD芯片ASM1042为例&#xff0c;通过对芯片单粒子效应脉冲激光试验报告、数据手册及芯片…

曼联亚洲行第二站3-1战胜中国香港队 逆转取胜展现实力

曼联队在5月30日于香港大球场进行的一场友谊赛中,以3:1逆转战胜中国香港队。此前,曼联在马来西亚吉隆坡的首场比赛中以0:1不敌东盟全明星队。本次亚洲之行,曼联最终以一胜一负的成绩结束。比赛开始后不久,曼联便陷入被动,第19分钟被儒尼奥尔左路突破防守抽射入网,比分变为…

高考人数下降 上好大学会更容易吗 报名人数减少竞争减小

2025年全国高考将于6月7日至8日举行。教育部官网消息显示,2025年全国高考报名人数为1335万人,这是自2018年以来首次出现下降。过去几年,全国高考报名人数经历了先降后升的过程。2008年达到顶峰1061万人,随后逐年减少,至2013年降至912万人。2014年至2017年间,报考人数相对…

耿爽敦促美方停止无聊的指责游戏 聚焦外交努力

当地时间5月30日,中国常驻联合国副代表耿爽在安理会审议向乌克兰提供武器问题时发言指出,战场上武器数量不断增加只会加剧对抗、延长战火,敦促美方聚焦当前的外交努力,停止无聊的指责游戏。自战争爆发以来,中方一直呼吁冲突当事方尽快停火止战、开启谈判、恢复和平。遗憾的…

国际油价5月30日小幅下跌 新华社发布图表

新华社图表,北京,2025年5月31日新华社发责任编辑:zhangxiaohua

湖南一溶洞清出垃圾3000斤 排污问题引关注

近日,有网友反映湖南省张家界市慈利县一处天然溶洞遭到人为排污,导致洞内被污染,引起广泛关注。该溶洞位于通津铺镇长峪铺村杨家坡,属于喀斯特地貌,垂直深度约150米。洞内存有陈年垃圾和污水,近期因暴雨导致洞内污水上涨并外溢至溇水。初步调查显示,污染是由当地部分养殖…

暴雨黄警发布 10省份有大到暴雨 警惕次生灾害

据中央气象台消息,5月27日,强降雨主要出现在贵州中东部、湖南中西部、重庆东南部、四川南部、云南北部、海南岛南部等地。预计今天,强降雨落区东移南压至江西南部、福建、广西东部、广东等地,公众需及时关注预报预警信息,警惕强降雨可能引发的次生灾害,注意出行安全。今晨…

Postgres Checkpointer 实战:优化数据持久化性能的策略

如果你正在经历一段格外艰难的人生&#xff0c;请允许我祝福你可以从今天开始&#xff0c;邂逅最棒的一年。 ——莉兹克里莫 本文将使用 langgraph-checkpoint-postgres 库&#xff0c;将 Postgres 作为后端来持久化 checkpoint 状态。温馨提示&#xff1a;本文搭配 jupyter-la…

【清晰教程】利用Git工具将本地项目push上传至GitHub仓库中

Git 是一个分布式版本控制系统&#xff0c;由 Linus Torvalds 创建&#xff0c;用于有效、高速地处理从小到大的项目版本管理。GitHub 是一个基于 Git 的代码托管平台&#xff0c;提供了额外的协作和社交功能&#xff0c;使项目管理更加高效。它们为项目代码管理、团队协作和持…

雷达目标起伏特性简析

目录 一、五种起伏模型辨析 二、数学模型 一、五种起伏模型辨析 在《雷达搜索状态下的脉冲积累雷达方程-CSDN博客》中提到雷达方程模型是假定是非起伏目标&#xff0c;即目标RCS是稳定的&#xff0c;然而在真实的雷达搜索目标的过程中&#xff0c;目标的RCS总是变化的&#xf…

差分与共模的相互转化:

差分与共模的相互转化: 理想情况下&#xff0c;如果在差分对接收端&#xff0c;两个单端信号完全对称&#xff0c;即幅度相同、翻转方向相反、边沿对齐&#xff0c;那么共模信号将是恒定的电平&#xff0c;如图8-21a所示。但是实际中两个单端信号不可能完全对称&#xff0c;比如…

python-pptx去除形状默认的阴影

文章目录 效果原理1. 阴影继承机制解析2. XML层操作细节3. 注意事项 扩展应用1. 批量去除阴影2. 复合效果控制 效果 右边这个是直接添加一个形状。可以看到它会默认被赋予一个阴影。 然而&#xff0c;这个东西在特定的场合&#xff0c;其实是我们所不需要的。 那怎么把这个阴…

使用pydantic-i18n将fastapi的接口返回消息翻译成中文

使用fastapi时&#xff0c;发现接口报错的结果是英文的&#xff0c;自己的英语水平不太行&#xff0c;就想能不能翻译成中文&#xff0c;发现果然有办法。 先看结果&#xff1a; 实现方法&#xff1a; 参考链接pydantic-i18n PyPI 首先执行如下代码获取原有的英文翻译字典 …

说一说SAP系统从Non-Unicode到Unicode的演化

当前的 SAP 系统基本上都是 Unicode 系统。然而&#xff0c;在 SAP 的发展过程中&#xff0c;最初并不是 Unicode 系统&#xff0c;而是 Non-Unicode 系统。 1. 什么是 Non-Unicode 和 Unicode&#xff1f; Non-Unicode 系统&#xff1a; 在 Non-Unicode 系统中&#xff0c;字符…

特朗普马斯克发表“分手感言” 称赞成就与未来合作

5月30日,美国总统特朗普与美国企业家、政府效率部负责人埃隆马斯克在白宫召开新闻发布会。会上,特朗普对马斯克领导的“政府效率部”所取得的成绩表示赞赏,称该部门聘请了计算机领域的杰出人才。特朗普还提到,政府将致力于推动“政府效率部”削减政府开支政策的永久化。他指…

特朗普称将把进口钢铁关税提高至50% 加强行业保护

当地时间5月30日,美国总统特朗普宣布将进口钢铁的关税从25%提高至50%。美国白宫当天在社交媒体上发布公告,表示此举是为了进一步保护美国钢铁行业免受外国和不公平竞争的影响,并指出新的关税政策将从下周开始实施。特朗普曾于2月10日签署行政命令,对所有进口至美国的钢铁和…

警方:跳进兵马俑坑男子有精神疾病 警情通报发布

5月31日凌晨3点15分,新浪微博账号@平安临潼发布了一则警情通报。责任编辑:zhangxiaohua

5月全国各地经济社会发展观察 高质量发展新图景绘就

5月,万物繁茂。消费新动能持续涌动、夏粮生产力保丰收、就业岗位挖潜扩容、城市更新提升民生福祉……一派充满生机的高质量发展新图景在神州大地不断绘就。临近端午小长假,北京的宋女士这几天正忙着订火车票和酒店,准备利用假期去河南游玩。她说:“想去看石窟、吃水席,还要…

白皮精读:70页 2025 基于数据空间的金融数据可信流通研究报告【附全文阅读】

该报告聚焦金融数据可信流通&#xff0c;基于数据空间理念&#xff0c;分析全球及我国金融数据流通现状与挑战&#xff0c;提出包含典型场景、关键角色、流通模式及技术方案的可信流通框架&#xff0c;并通过中信银行、工银金租等多个实践案例验证其可行性。报告指出当前面临法…