CS61C – 10 – Parallelism (1)

这一讲的内容对应于讲座的 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寄存器,这使得其在理论上更具可扩展性。

这一讲其实只是入门,但它至少为我们揭开了 高性能计算的神秘面纱,虽然有时编译器可以进行自动向量化,但为了必要场景下的性能考虑,手动使用内在函数来引导编译器是必不可少的

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注