这一讲的内容对应于讲座的 lec 32 (b 大上的参考课程为 [Summer 20])这一部分引入了并行计算的概念,并深入讲解其中最简单、最基础的一种形式:SIMD,并为接下来的关于线程级并行的三讲打下坚实基础。
整体上这部分的思路是非常清晰的,经典的”发现问题” $\rightarrow$ “分析问题” $\rightarrow$ “解决问题”三部曲,具体而言:
$$\text{Parallelism}\rightarrow \text{Flynn’s Taxonomy}\rightarrow\text{SIMD}\rightarrow\text{For RISC – V}$$
Why Parallelism?
随着计算机的日新月异,一道看不见的墙逐渐显现在硬件发展的道路上——功率墙,这使得无限提高单核主频来提升性能的时代已经看到了终点,我们不得不换一个思路来提升性能,那就是增加并行度。
计算机中并行体现在很多层次,包括:
- 请求级并行(RLP): 多个独立任务(如处理多个搜索请求)在多个计算机上并行。
- 线程级并行(TLP): 一个任务的多个线程在多个CPU核心上并行。
- 指令级并行(ILP): 处理器流水线、多发射、乱序执行等技术,在单个核心上同时执行多条指令。
- 数据级并行(DLP): 对多条数据执行相同的操作。这也是本讲的重点
- 门级并行: 数字逻辑电路中,所有门电路在通电后同时工作。
在简要提及并行的必要性后,课件提及了对并行硬件的分类标准,也就是弗林分类法(Flynn’s Taxonomy):
类型 | 指令流 | 数据流 | 描述 | 常见例子 |
---|---|---|---|---|
SISD | 单 | 单 | 传统单核处理器 | 早期的CPU 前面设计的单周期/流水线 CPU |
SIMD | 单 | 多 | 一条指令同时处理多个数据 本讲核心 | CPU的SIMD指令扩展(SSE, AVX),GPU |
MISD | 多 | 单 | 多个指令处理同一个数据 | 极少见,主要用于容错系统 |
MIMD | 多 | 多 | 多个处理器独立执行不同指令处理不同数据 | 多核CPU,分布式计算机集群 |
数据级并行(DLP)和SIMD
在上表中我们看到了 SIMD 的意义,即 如果我们要对一个数组或向量中的所有元素执行相同的、独立的操作,那么就可以用一条指令来完成。
为什么需要 SIMD?
SIMD 能显著减少指令开销(因为只进行一次取指、译码),并行地完成内存访问和计算,并且花费的时钟和指令更少,这意味着 MISD 能更好的利用硬件资源。
如何实现 SIMD?
实现 SIMD 的基础是引入比普通数据位更宽的寄存器,比如:
- SSE: 引入128位的
XMM
寄存器。 - AVX: 扩展至256位的
YMM
寄存器。 - AVX-512: 进一步扩展至512位的
ZMM
寄存器。
以及对这些寄存器的相关打包指令(Packed Instructions):
ps
(packed single): 操作打包的单精度浮点数。pd
(packed double): 操作打包的双精度浮点数。
如何使用 SIMD?
我们需要通过 C 语言中的一个特殊概念 —— “内在函数(Intrinsics)”使用 SIMD。
内在函数是C语言的函数,它们由编译器直接映射到特定的汇编指令(如SSE指令)。它让我们可以用C语言的语法来调用底层硬件指令,无需直接编写汇编
它的实现包括<emmintrin.h>等一系列相关头文件
想要使用这些头文件,我们需要声明一些特殊的向量数据(比如 __m128d
用于存放 2 个 double
类型数据),并调用内在函数进行加载、储存和运算,比如:
_mm_load_pd()
: 从对齐的内存地址加载数据到寄存器。_mm_mul_pd()
: 两个寄存器中的packed double相乘。_mm_add_pd()
: 两个寄存器中的packed double相加。_mm_store_pd()
: 将寄存器内容存回对齐的内存地址。
注意到这些函数都是要求 内存对齐 的,这是为了高效访问,在 C 中我们可以通过 __attribute__ ((aligned (16)))
进行实现。
#include <stdio.h>
#include <emmintrin.h> // 包含SSE内在函数头文件
#define SIZE 1024 // 定义数组大小,注意尽量选择可被4整除的大小以简化SIMD处理
int main() {
// 初始化输入数组a和b,以及输出数组c
float a[SIZE], b[SIZE], c[SIZE];
for (int i = 0; i < SIZE; i++) {
a[i] = i * 1.0f; // 数组a初始化为[0.0, 1.0, 2.0, ...]
b[i] = i * 2.0f; // 数组b初始化为[0.0, 2.0, 4.0, ...]
}
// 普通循环版本:逐元素相加,无并行优化
for (int i = 0; i < SIZE; i++) {
c[i] = a[i] + b[i];
}
// 使用SSE内在函数进行并行计算版本
// SSE指令集允许一次处理4个单精度浮点数(128位寄存器)
for (int i = 0; i < SIZE; i += 4) {
// 从内存加载4个浮点数到SSE寄存器
__m128 vec_a = _mm_loadu_ps(&a[i]); // 加载a[i]到a[i+3]
__m128 vec_b = _mm_loadu_ps(&b[i]); // 加载b[i]到b[i+3]
// 执行SIMD加法:一次性计算4个浮点数的和
__m128 vec_c = _mm_add_ps(vec_a, vec_b);
// 将结果从SSE寄存器存储回内存
_mm_storeu_ps(&c[i], vec_c);
}
// 验证结果:打印前几个元素以确保正确性
for (int i = 0; i < 10; i++) {
printf("c[%d] = %f\n", i, c[i]);
}
return 0;
}
这一讲的最后,讲座提到了 RISC – V 的 V Extend,RISC – V 采用了更灵活的可变长度向量寄存器设计,而不是x86的固定长度SIMD寄存器,这使得其在理论上更具可扩展性。
这一讲其实只是入门,但它至少为我们揭开了 高性能计算的神秘面纱,虽然有时编译器可以进行自动向量化,但为了必要场景下的性能考虑,手动使用内在函数来引导编译器是必不可少的
发表回复