第二章 硬件基础与计算模型:CPU、GPU、NPU架构差异,SIMD/SIMT模型,内存层次结构

做算子优化,说白了就是跟硬件打交道。你写出来的代码跑得快不快,很大程度上取决于你懂不懂底层的硬件是怎么干活的。我见过不少同学,算法功底扎实,但一上板子就懵,性能怎么调都上不去。原因很简单——不了解硬件的脾气。

这一章,我们就把CPU、GPU、NPU这三兄弟的底裤扒开看看。顺便聊聊SIMD和SIMT这两个核心计算模型,以及那个让人又爱又恨的内存层次结构。

2.1 CPU、GPU、NPU:三兄弟的恩怨情仇

先问个问题:为什么同样的矩阵乘法,在GPU上跑得飞快,在CPU上就慢吞吞?

答案其实就四个字——设计目标不同。

2.1.1 CPU:全能选手

CPU的设计哲学是「低延迟」。它要处理各种乱七八糟的任务,从操作系统调度到浏览器渲染,什么活都得接。所以CPU有强大的控制单元、大容量的缓存、复杂的分支预测器。

我个人习惯把CPU比作一个「特种兵」——单兵作战能力极强,但数量有限。一个现代CPU核心,能同时执行的指令也就那么几条。你让它跑一个简单的加法,它可能花几十个周期;但让它处理一个if-else分支,它比谁都溜。

核心特点:

  • 控制单元占比大(约40%面积)
  • 缓存层级多(L1/L2/L3)
  • 擅长串行任务和复杂控制流
  • 并行能力有限(核心数通常4-64个)

2.1.2 GPU:并行怪兽

GPU的设计哲学是「高吞吐」。它不在乎单个任务跑得快不快,它在乎的是——我能不能同时处理一万个任务?

你想想看,渲染一张1920x1080的图片,需要处理超过200万个像素。每个像素的计算逻辑几乎一样,只是数据不同。这种场景,CPU来干就是杀鸡用牛刀。GPU就不一样了,它有成百上千个简单核心,每个核心只管埋头干活。

我在项目中遇到过一个问题:用CPU跑一个图像滤波算子,优化到极致也就30fps。换成GPU,随便写写就跑到120fps。差距就是这么夸张。

核心特点:

  • 控制单元极简(约5%面积)
  • 缓存相对较小,但带宽极高
  • 擅长数据并行任务
  • 核心数可达数千个

2.1.3 NPU:专用加速器

NPU是近几年才火起来的。它比GPU更极端——干脆把控制单元砍到几乎为零,只保留计算单元和本地存储。

说白了,NPU就是为神经网络量身定做的。它内部有大量的乘加阵列(MAC Array),专门做矩阵乘法。我记得第一次用NPU跑卷积时,那个速度让我倒吸一口凉气——同样的算子,比GPU还快3倍,功耗却只有十分之一。

但NPU有个致命弱点:灵活性差。你让它跑个非矩阵运算,它立马歇菜。所以NPU通常作为协处理器,跟CPU配合使用。

我的建议:做算子优化时,先搞清楚目标硬件是哪一类。CPU优化重点在指令级并行和缓存利用;GPU优化重点在线程调度和访存合并;NPU优化重点在数据排布和计算阵列利用率。

2.2 SIMD vs SIMT:两种并行哲学

这两个概念,我当年学的时候也绕了很久。咱们用大白话讲清楚。

2.2.1 SIMD:单指令多数据

SIMD的意思是:一条指令,同时操作多个数据。

CPU上的SIMD指令集,比如x86的AVX、ARM的NEON,就是典型代表。一条AVX-512指令,可以同时处理16个32位浮点数。你想想看,本来要循环16次的加法,现在一条指令搞定。

但SIMD有个坑:数据必须连续对齐。我曾经在优化一个音频处理算子时,数据没对齐,性能直接腰斩。后来排查了半天,才发现是内存地址没按16字节对齐。

// SIMD示例:使用AVX做向量加法
// 注意:数据必须16字节对齐
float a[8] __attribute__((aligned(32))) = {1,2,3,4,5,6,7,8};
float b[8] __attribute__((aligned(32))) = {8,7,6,5,4,3,2,1};
float c[8] __attribute__((aligned(32)));

__m256 va = _mm256_load_ps(a);  // 加载8个float
__m256 vb = _mm256_load_ps(b);
__m256 vc = _mm256_add_ps(va, vb); // 一次完成8个加法
_mm256_store_ps(c, vc);

2.2.2 SIMT:单指令多线程

GPU用的是SIMT模型。它跟SIMD最大的区别是:每个线程有自己的程序计数器。

什么意思呢?在SIMD里,所有数据必须走同一个分支。但在SIMT里,每个线程可以独立跳转。当然,如果同一个warp(线程束)里的线程走了不同分支,性能就会下降——这叫「warp divergence」。

我记得有一次优化一个ReLU算子,在GPU上跑。ReLU有个if判断:大于0保留,小于0置0。这个if导致warp divergence,性能只有理论峰值的一半。后来改成用max函数替代if,问题就解决了。

避坑指南:我曾经在写GPU算子时,没注意warp大小(NVIDIA是32线程一个warp)。循环次数不是32的倍数,导致最后一个warp有空闲线程,性能损失了约15%。后来我学会了padding,把数据补齐到warp大小的整数倍。

2.3 内存层次结构:性能的隐形杀手

做算子优化,最容易被忽视的就是内存。我见过太多人,算法算得飞起,但数据搬运花了90%的时间。

2.3.1 金字塔模型

现代计算机的内存是个金字塔结构:

层级 容量 延迟 带宽
寄存器 ~几百字节 ~1 cycle 极高
L1缓存 ~32KB ~3 cycles ~1TB/s
L2缓存 ~256KB ~10 cycles ~500GB/s
L3缓存 ~几MB ~30 cycles ~200GB/s
主存 ~几GB ~100 cycles ~50GB/s

看到没?从主存取数据比从寄存器取数据慢了100倍。这就是为什么优化要尽量把数据留在缓存里。

2.3.2 缓存友好 vs 缓存不友好

我举个例子。两个矩阵相乘,有几种遍历方式:

// 缓存友好版本:按行访问
for (int i = 0; i < N; i++) {
    for (int j = 0; j < N; j++) {
        float sum = 0;
        for (int k = 0; k < N; k++) {
            sum += A[i][k] * B[k][j];  // B按列访问,缓存不友好
        }
        C[i][j] = sum;
    }
}

// 优化版本:分块 + 转置
// 把B转置后,按行访问
for (int i = 0; i < N; i++) {
    for (int j = 0; j < N; j++) {
        float sum = 0;
        for (int k = 0; k < N; k++) {
            sum += A[i][k] * B_trans[j][k];  // 现在都是按行访问
        }
        C[i][j] = sum;
    }
}

第一个版本,B矩阵按列访问,每次访问都会触发缓存缺失。第二个版本,先把B转置,然后全部按行访问,缓存命中率大幅提升。在我的测试中,N=1024时,性能差距能达到5倍。

小技巧:写算子时,尽量让数据访问模式是「空间局部性」的——也就是访问相邻地址。这样硬件会自动预取,把数据提前搬到缓存里。

2.4 知识体系总览

下面这张图,是我自己总结的算子优化知识体系。每次做新项目前,我都会对照着看一遍,确保没有遗漏。

算子优化知识体系 CPU优化 指令级并行(ILP) SIMD向量化(AVX/NEON) 缓存层次利用 分支预测优化 GPU优化 SIMT线程模型 Warp调度与Divergence 共享内存与全局内存 访存合并(Coalescing) NPU优化 MAC阵列利用率 数据排布(NHWC vs NCHW) 片上存储管理 量化与精度权衡 核心原则:计算与访存平衡,数据局部性优先

嗯,这张图基本覆盖了算子优化的核心要点。你仔细看,三个分支虽然硬件不同,但底层逻辑是相通的——都是想办法让计算单元别闲着,让数据搬运别成为瓶颈。

2.5 本章小结

这一章我们聊了CPU、GPU、NPU的架构差异,SIMD和SIMT的计算模型,以及内存层次结构。说白了,做算子优化就是三件事:

  • 理解硬件特性——知道它擅长什么,不擅长什么
  • 利用并行能力——SIMD、SIMT、多核,能用的都用上
  • 管理数据流动——让数据尽量待在离计算单元近的地方

下一章,我们会深入具体的算子实现,从最简单的向量加法开始,一步步优化到极致。到时候你会发现,今天讲的这些硬件知识,全都会用上。

想要更多资料,联系可可