硬件基础 在写 SIMD 或 GPU 代码之前,你必须先理解自己要编程的硬件。本节会讲清楚:为什么并行取代了时钟主频、现代 CPU 如何执行指令、SIMD 是什么、用来推理性能的屋顶线模型,以及各种芯片架构的全景。 几十年来,软件变快都是「免费的」:买一颗主频更高的新 CPU,不用改一行代码,程序就更快了。这个时代在 2005 年前后终结。理解它为什么终结、又是什么取代了它,对任何想写出快代码的人来说都是必修课。 「免费性能」的终结 摩尔定律(Moore's Law,1965)观察到:芯片上的晶体管数量大约每两年翻一番。这条规律持续了 60 年。晶体管越多意味着晶体管越小,晶体管越小意味着时钟频率越高,时钟频率越高意味着程序跑得越快。
在写 SIMD 或 GPU 代码之前,你必须先理解自己要编程的硬件。本节会讲清楚:为什么并行取代了时钟主频、现代 CPU 如何执行指令、SIMD 是什么、用来推理性能的屋顶线模型,以及各种芯片架构的全景。
摩尔定律(Moore's Law,1965)观察到:芯片上的晶体管数量大约每两年翻一番。这条规律持续了 60 年。晶体管越多意味着晶体管越小,晶体管越小意味着时钟频率越高,时钟频率越高意味着程序跑得越快。
但在 2005 年前后,时钟频率撞上了一堵墙,停在了约 4 GHz。问题出在功耗。一颗芯片的功耗大约是:
其中 C 是电容(与晶体管数量成正比),V 是电压,f 是时钟频率。想提高频率,就必须提高电压(让晶体管开关更快)。但功耗与 V^2 \cdot f 成正比,所以频率上的小幅提升会带来功耗(与发热)的大幅上升。在 4 GHz 时,芯片的功耗已经突破 100 瓦;要冲到 8 GHz,散热将变得不切实际。
解决办法:不再把单核做得更快,而是在同一颗芯片上塞进多个核心。一颗 4 核、3 GHz 的芯片,功耗与一颗单核 4.5 GHz 的芯片相近,但能并行做 4 倍的工作。这就是为什么每颗现代 CPU 都是多核的,也是为什么并行(SIMD、多线程、GPU 计算)成了获取更高性能的唯一路径。
对 ML 的启示:一个在单核上要跑 10 分钟的训练步骤,靠「买更快的 CPU」是没法加速的。要加速,只能用更多核心(数据并行,第 6 章)、更宽的 SIMD 单元(本章),或者 GPU(数千个核心)。
一颗现代 CPU 核心远比第 13 章里讲的「取指-译码-执行」模型复杂。它用了几招来在每个周期内执行更多指令:
超标量执行(superscalar execution):CPU 内部有多个执行单元(ALU、FPU、load/store 单元),可以同时执行多条相互独立的指令。如果指令之间没有依赖,一个现代核心每个周期能执行 4-6 条指令。
乱序执行(out-of-order execution,OoO):CPU 不按程序顺序执行指令。它向前看指令流,找到输入数据已就绪的指令,立即执行它们,不管它们原本的位置。这能隐藏延迟:当一条指令在等内存数据(100 多个周期)时,CPU 去执行其他就绪的指令。
分支预测(branch prediction):条件分支(if 语句、循环条件)带来不确定性:在条件算出来之前,CPU 不知道该走哪条路。与其停顿,CPU 会预测结果,并沿着预测的路径投机地往下执行。如果预测正确(用现代预测器可达 95% 以上),不损失时间;如果错了,就把投机做的工作丢掉,改走正确路径(约 15 个周期的惩罚)。
投机执行(speculative execution):分支预测的延伸。CPU 会执行一些可能并不需要的指令,赌它们最终会被用到。这能把流水线填满,让执行单元保持忙碌。
这些都是自动的——CPU 不需要程序员干预就会这么做。但它们只对指令级并行(instruction-level parallelism,ILP)有帮助:单条指令流里相互独立的指令。对于数据级并行(对很多数据元素做同一个操作),我们需要 SIMD。
SIMD(Single Instruction, Multiple Data,单指令多数据)的核心思想是:用一条指令同时处理多个数据元素。不再是把两个数相加,而是一次把两个各含 4 个(或 8 个、16 个)数的向量相加,只用一条指令。
不用 SIMD(标量):
// 逐元素相加两个数组:4 条加法指令 for (int i = 0; i < 4; i++) { c[i] = a[i] + b[i]; // 每次迭代一次加法 }
// 相加两个数组:1 条 SIMD 指令完成全部 4 次加法 #include <immintrin.h> // x86 SIMD 内联函数 __m128 va = _mm_load_ps(a); // 把 4 个 float 装进一个 128 位寄存器 __m128 vb = _mm_load_ps(b); // 把 4 个 float 装进另一个寄存器 __m128 vc = _mm_add_ps(va, vb); // 同时把这 4 对加起来 _mm_store_ps(c, vc); // 存回 4 个结果
| 寄存器宽度 | float(32 位) | double(64 位) | 名称 |
|---|---|---|---|
| 128 位 | 4 | 2 | SSE(x86)、NEON(ARM) |
| 256 位 | 8 | 4 | AVX/AVX2(x86) |
| 512 位 | 16 | 8 | AVX-512(x86) |
| 可变(128-2048) | 可变 | 可变 | SVE/SVE2(ARM) |
寄存器更宽 = 并行度更高。一条 512 位的 AVX-512 指令一次处理 16 个 float,理论上比标量代码快 16 倍。实际上由于内存带宽的限制(你的算力快过了把数据喂给 CPU 的速度),加速会低一些。
对 ML 而言:float32 矩阵乘法从 SIMD 中获益极大。内层循环(两个向量的点积)正好对应 SIMD 的乘加指令。这也是为什么 BLAS 库(NumPy 和 PyTorch 背后调用的库)会如此重度地用 SIMD 优化。
峰值算力(peak compute,FLOPS):每秒最多能做多少次浮点运算。对一颗 4 GHz、带 256 位 AVX(每条指令 8 个 float)、2 个 FMA 单元的 CPU:4 \times 10^9 \times 8 \times 2 = 64 GFLOPS。
峰值内存带宽(peak memory bandwidth,字节/秒):数据从内存搬到 CPU 的速度有多快。一颗现代 CPU 的内存带宽可能在 50 GB/s。
如果算术强度低(每搬一个字节做的运算少),你的代码就是**访存受限(memory-bound)**的:它大部分时间在等数据。让算得更快(更宽的 SIMD、更高的主频)没用。
如果算术强度高(每搬一个字节做的运算多),你的代码就是**算力受限(compute-bound)**的:它大部分时间在算。让内存变快没用。
屋顶线公式:
矩阵乘法的算术强度很高:O(n^3) 次运算对应 O(n^2) 的数据,所以强度约 \approx O(n)。对大矩阵,它是算力受限的。这也是为什么 GPU(算力高)在矩阵密集的 ML 工作负载上能碾压 CPU。
逐元素操作(ReLU、加法、乘法)的算术强度很低:每取一个元素只做 1 次运算。这些是访存受限的。让 GPU 算得更快没用;你要么让内存更快(要么把这些操作和计算密集的操作融合,避免单独的内存往返)。
屋顶线模型解释了为什么**内核融合(kernel fusion)**如此重要:把一次 matmul 加上 bias 加法再加 ReLU 合成一个内核,能避免把中间结果写回内存再读出来,把三个访存受限的操作变成一个算力受限的操作。
**延迟(latency)**是完成一次操作所需的时间。**吞吐(throughput)**是单位时间内完成的操作数。
一个类比:公交车延迟高(每站都停)但吞吐高(一次能载 50 人)。出租车延迟低(直达目的地)但吞吐低(一次载 1-4 人)。
GPU 是公交车:单次操作延迟高(每条指令要很多周期才能完成)但吞吐巨大(数千个核心同时处理)。CPU 是出租车:延迟低(乱序执行、分支预测、深缓存把延迟压到最小)但吞吐有限(4-64 个核心)。
这就是为什么 GPU 更适合 ML 训练(吞吐重要:处理百万级样本),CPU 更适合操作系统任务(延迟重要:立刻响应一次按键)。
**流水线(pipelining)**把延迟转成吞吐。如果一条指令要 5 个周期,但流水线每个周期都启动一条新指令,那么吞吐就是每周期 1 条指令(即便每条都要 5 个周期才能完成)。这与第 13 章讲的 CPU 流水线是同一个原理,但它适用于每一个层面:SIMD 单元、内存控制器、GPU 核心都是流水化的。
统治桌面、笔记本和数据中心 CPU。SIMD:SSE(128 位)、AVX/AVX2(256 位)、AVX-512(512 位)。Intel AMX 为 AI 工作负载提供了专用的矩阵乘法单元。
优势:单核性能最高、SIMD 最宽、软件生态成熟(MKL、oneDNN)。
劣势:功耗高、指令集复杂、价格贵。
统治移动端(每一部智能手机),在服务器(AWS Graviton、Ampere Altra)和笔记本(Apple M 系列)中份额不断扩大。SIMD:NEON(128 位)、SVE/SVE2(可扩展,128-2048 位)。
优势:极佳的能效(每瓦性能)、自研核心(Apple M4 在单核性能上能以远低于 Intel 的功耗匹敌 Intel)。
劣势:SIMD 较窄(NEON 只有 128 位,尽管 SVE 可以更宽),HPC 软件生态较小。
基于 ARM 并带有自定义扩展。包含 AMX(Apple Matrix eXtensions,苹果矩阵扩展)——未公开文档的矩阵乘法单元,Accelerate 框架用它来做 BLAS。统一内存架构:CPU 和 GPU 共享同一块物理内存,消除了 CPU↔GPU 复制的这一瓶颈。
对 ML:苹果的 Neural Engine(16 核、专用 ML 加速器)加上统一内存,让 M 系列芯片在本地 ML 推理和小规模训练上出人意料地能干。但没有 CUDA:你得用 Metal(苹果的 GPU API)或 MLX(苹果的 ML 框架)。
开源指令集架构(ISA)。没有授权费(不像 ARM)。在嵌入式系统、IoT 和研究中不断增长。SIMD:「V」(向量)扩展提供类似 ARM SVE 的可扩展向量处理。
对 ML:目前还无法与 x86/ARM 在 ML 工作负载上竞争,但值得持续关注。好几家 AI 加速器初创公司在用 RISC-V 核心。
性能最终受限于功耗和散热:
TDP(Thermal Design Power,热设计功耗):一颗芯片能持续消耗的最大功率。笔记本 CPU 的 TDP 可能是 15 W;服务器 CPU 是 250 W;数据中心 GPU 是 700 W(NVIDIA B200)。
暗硅(dark silicon):在任何时刻,都必须让相当一部分晶体管断电,才能待在散热预算之内。理论上芯片可以同时启用所有晶体管,但那样它会熔化。
**能效(FLOPS/瓦)**越来越成为比原始 FLOPS 更重要的指标。这也是为什么:
对规模化 ML 而言:训练一个前沿大语言模型要消耗数兆瓦的功率长达数月。电费可能超过硬件成本。能效直接影响 AI 研究的经济性。
#include <iostream> #include <chrono> #include <vector> // 标量加法 void add_scalar(const float* a, const float* b, float* c, int n) { for (int i = 0; i < n; i++) { c[i] = a[i] + b[i]; } } int main() { const int N = 1 << 24; // 约 1600 万个元素 std::vector<float> a(N, 1.0f), b(N, 2.0f), c(N); // 预热(填满缓存、触发频率爬升) add_scalar(a.data(), b.data(), c.data(), N); // 计时 auto start = std::chrono::high_resolution_clock::now(); for (int trial = 0; trial < 100; trial++) { add_scalar(a.data(), b.data(), c.data(), N); } auto end = std::chrono::high_resolution_clock::now(); double elapsed = std::chrono::duration<double>(end - start).count(); double total_bytes = 3.0 * N * sizeof(float) * 100; // 读 a、读 b、写 c double bandwidth = total_bytes / elapsed / 1e9; // GB/s std::cout << "Time: " << elapsed << " s\n"; std::cout << "Bandwidth: " << bandwidth << " GB/s\n"; return 0; }
# 带优化编译 g++ -O3 -march=native -o bench bench.cpp ./bench
这段代码里的关键 C++ 概念:
#include <vector>:动态数组(std::vector<float>)——类似 Python 的 list,但有类型且在内存中连续。a.data():返回指向底层数组的裸指针(float*)——SIMD 内联函数需要它。std::chrono:用于基准测试的高分辨率计时器。-O3:编译器最高优化级别。编译器可能会自动向量化你的循环(自动用 SIMD)。-march=native 启用你的 CPU 支持的所有 SIMD 指令。为什么要预热:第一次运行会填满缓存,并可能触发 CPU 的频率爬升(睿频)。后续运行才更有代表性。
为什么要测带宽:对访存受限的操作(比如逐元素加法),有意义的指标是带宽(GB/s),而不是 FLOPS。如果你测出的带宽接近硬件极限(DDR5 约 50 GB/s),说明你受限于内存,SIMD 帮不了多少忙(瓶颈在内存,不在算力)。
import jax.numpy as jnp def arithmetic_intensity(flops, bytes_transferred): return flops / bytes_transferred # 逐元素 ReLU:每个元素 1 次比较,读 + 写 n = 1024 relu_flops = n # 每个元素 1 次操作 relu_bytes = 2 * n * 4 # 读输入 + 写输出(float32) print(f"ReLU: {arithmetic_intensity(relu_flops, relu_bytes):.2f} FLOPS/byte → memory-bound") # 矩阵乘法:2*n^3 次操作,读 2*n^2 + 写 n^2 个 float matmul_flops = 2 * n**3 matmul_bytes = 3 * n**2 * 4 # 读 A + 读 B + 写 C print(f"Matmul ({n}×{n}): {arithmetic_intensity(matmul_flops, matmul_bytes):.0f} FLOPS/byte → compute-bound") # Layer norm:约 5n 次操作(均值、方差、归一化),读 + 写 ln_flops = 5 * n ln_bytes = 2 * n * 4 print(f"LayerNorm: {arithmetic_intensity(ln_flops, ln_bytes):.2f} FLOPS/byte → memory-bound") # 3x3 卷积:2*9*C_in*C_out*H*W,读 kernel + 特征图 + 写输出 C_in, C_out, H, W = 64, 128, 32, 32 conv_flops = 2 * 9 * C_in * C_out * H * W conv_bytes = (9 * C_in * C_out + C_in * H * W + C_out * H * W) * 4 print(f"Conv3x3: {arithmetic_intensity(conv_flops, conv_bytes):.0f} FLOPS/byte → compute-bound")
import numpy as np import time for n in [1000, 10000, 100000, 1000000, 10000000]: a = np.random.randn(n).astype(np.float32) b = np.random.randn(n).astype(np.float32) # 「串行」(Python 循环) start = time.time() c = [a[i] * b[i] for i in range(min(n, 100000))] # 为稳妥起见封顶 10 万 seq_time = time.time() - start if n > 100000: seq_time *= n / 100000 # 外推 # 「并行」(NumPy,内部用 SIMD + 多线程) start = time.time() c = a * b par_time = time.time() - start print(f"n={n:>10,} sequential={seq_time:.4f}s parallel={par_time:.6f}s " f"speedup={seq_time/par_time:.0f}x")