并行计算:线程级并行、指令级并行、数据并行、模型并行

并行计算,说白了就是「多个人干一件事」。但怎么干、谁干什么、怎么配合,这里面的门道很深。我做了这么多年算子优化,发现很多同学一上来就想着开多线程,结果性能反而更差。为什么?因为没搞清楚这四种并行方式的区别。

今天咱们就把这四种并行方式掰开揉碎了讲。你想想看,一个计算任务从指令到线程,再到数据和模型,每个层面都有不同的并行策略。咱们一个一个来。

线程级并行:最直观的加速方式

线程级并行,就是让多个CPU核心同时干活。我刚开始做优化时,觉得这还不简单?开一堆线程就完事了。结果呢?线程间通信开销比计算本身还大,得不偿失。

线程级并行的核心在于「任务拆分」。你得把一个大任务拆成多个独立的小任务,每个线程负责一块。这里有个关键点:任务之间要尽量独立,不然锁竞争会让你怀疑人生。

关键指标:加速比 = 串行时间 / 并行时间。理论上,N个核心能加速N倍。但现实中,受Amdahl定律限制,串行部分会拖后腿。

举个例子,矩阵乘法。一个1024x1024的矩阵乘法,你可以拆成4个512x512的子任务,每个线程算一块。我在项目中遇到过,这样搞比单线程快了3.8倍左右。为什么不是4倍?因为线程创建、同步、数据搬运都有开销。

// 伪代码:线程级并行矩阵乘法
void matmul_thread(float* A, float* B, float* C, int N, int num_threads) {
    #pragma omp parallel for num_threads(num_threads)
    for (int i = 0; i < N; i++) {
        for (int j = 0; j < N; j++) {
            float sum = 0.0f;
            for (int k = 0; k < N; k++) {
                sum += A[i * N + k] * B[k * N + j];
            }
            C[i * N + j] = sum;
        }
    }
}

我的经验:线程数不是越多越好。一般设置为CPU核心数或核心数-1(留一个给系统)。我曾经试过开32个线程跑8核的机器,结果上下文切换把性能吃光了。

指令级并行:CPU的隐藏技能

指令级并行,你可能不太熟悉。但它就在你身边——CPU自动帮你做了。现代CPU有流水线、超标量、乱序执行这些技术,能同时执行多条不相关的指令。

说白了,你写的一行C代码,CPU会把它拆成多条微指令,然后尽量让它们并行执行。比如:

// 指令级并行的例子
float a = b + c;  // 指令1
float d = e * f;  // 指令2,和指令1没有数据依赖,可以并行

这两条指令没有数据依赖,CPU可以同时执行。但如果是这样:

float a = b + c;  // 指令1
float d = a * f;  // 指令2,依赖指令1的结果,必须等指令1完成

那就没法并行。所以,写代码时尽量让指令之间没有数据依赖,CPU就能更好地利用指令级并行。

注意:指令级并行是CPU硬件自动完成的,你没法直接控制。但你可以通过代码优化来「暗示」CPU。比如循环展开、减少分支预测失败等。我曾经在图像处理算法里做循环展开,性能提升了15%,就是因为让CPU更好地利用了指令级并行。

数据并行:GPU的拿手好戏

数据并行,就是把数据拆成多份,每份用相同的计算逻辑处理。GPU就是干这个的。你想想看,一张图片有上百万个像素,每个像素做同样的滤镜操作——这不就是天然的数据并行吗?

数据并行的核心是「单指令多数据」(SIMD)。一条指令,同时操作多个数据。CPU也有SIMD指令集(如AVX、SSE),但GPU的SIMT(单指令多线程)更夸张,一个指令能驱动成百上千个线程。

特性 CPU数据并行 GPU数据并行
并行粒度 4-8个数据(AVX-512可达16个) 32-1024个数据(warp/wavefront)
适用场景 小批量、计算密集 大批量、吞吐优先
编程方式 编译器自动向量化或手写intrinsic CUDA/OpenCL编写kernel

举个例子,向量加法。用CPU的AVX指令,一次能加4个float。用GPU的CUDA,一个block里256个线程同时算。我做过对比,同样1000万长度的向量加法,GPU比CPU快了20倍以上。

// CUDA数据并行:向量加法
__global__ void vec_add(float* a, float* b, float* c, int n) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < n) {
        c[idx] = a[idx] + b[idx];
    }
}
// 调用:vec_add<<<(n+255)/256, 256>>>(a, b, c, n);

核心思想:数据并行要求「计算逻辑相同,数据不同」。如果你的算法对每个数据点的处理方式不一样,那数据并行就不太适合。

模型并行:大模型的救星

模型并行,是把模型本身拆开,放到不同设备上。为什么需要这个?因为模型太大了,一张显卡装不下。比如GPT-3有1750亿参数,就算用FP16也得350GB显存,哪个显卡能装下?

模型并行有两种常见方式:

  • 层间并行(Pipeline Parallelism):把模型的不同层放到不同设备上。比如前10层在GPU0,中间10层在GPU1,后10层在GPU2。数据像流水线一样流过各设备。
  • 层内并行(Tensor Parallelism):把同一层的参数拆开。比如一个4096x4096的矩阵,切成4个2048x2048的块,分别放在4个GPU上。

我参与过一个百亿参数模型的推理优化,用的就是模型并行。当时遇到一个坑:层间并行的通信开销太大。每个batch的数据都要在GPU之间传输,带宽成了瓶颈。

避坑指南:我曾经把模型切成8份放在8张卡上,结果通信时间比计算时间还长。后来改成4张卡,每张卡负责两层,反而更快。为什么?因为减少了跨卡通信次数。模型并行不是分得越细越好,要平衡计算和通信。

四种并行方式的关系

这四种并行方式不是互斥的,而是可以叠加使用的。我画了一张图,帮你理清它们的关系:

四种并行方式的关系 并行计算 线程级并行 多核CPU,任务拆分 指令级并行 流水线、超标量、SIMD 数据并行 GPU、SIMT、向量化 模型并行 层间/层内拆分 实际应用中,这四种方式可以组合使用,比如:数据并行 + 模型并行 = 混合并行

实际项目中,这四种方式经常组合使用。比如训练大模型时:

  • 用模型并行把模型拆到多张GPU上
  • 每张GPU内部用数据并行处理多个batch
  • 每个计算核心用指令级并行优化
  • 整个集群用线程级并行调度

这就是所谓的「混合并行」。我参与过的项目里,混合并行比单一并行方式快了3-5倍。但代价是编程复杂度指数级上升。嗯,这里要注意:不是所有场景都需要混合并行,先搞清楚瓶颈在哪。

总结一下:

  • 计算密集、任务独立 → 线程级并行
  • 数据量大、计算逻辑统一 → 数据并行
  • 模型太大、单卡装不下 → 模型并行
  • 代码优化、减少依赖 → 指令级并行

最后说一句,并行计算不是银弹。我见过太多人一上来就搞并行,结果代码复杂了、性能没提升。先做profiling,找到热点,再决定用哪种并行方式。这才是正经路子。

可可精品资料库