硬件基础


文档摘要

硬件基础 在写 SIMD 或 GPU 代码之前,你必须先理解自己要编程的硬件。本节会讲清楚:为什么并行取代了时钟主频、现代 CPU 如何执行指令、SIMD 是什么、用来推理性能的屋顶线模型,以及各种芯片架构的全景。 几十年来,软件变快都是「免费的」:买一颗主频更高的新 CPU,不用改一行代码,程序就更快了。这个时代在 2005 年前后终结。理解它为什么终结、又是什么取代了它,对任何想写出快代码的人来说都是必修课。 「免费性能」的终结 摩尔定律(Moore's Law,1965)观察到:芯片上的晶体管数量大约每两年翻一番。这条规律持续了 60 年。晶体管越多意味着晶体管越小,晶体管越小意味着时钟频率越高,时钟频率越高意味着程序跑得越快。

硬件基础

在写 SIMD 或 GPU 代码之前,你必须先理解自己要编程的硬件。本节会讲清楚:为什么并行取代了时钟主频、现代 CPU 如何执行指令、SIMD 是什么、用来推理性能的屋顶线模型,以及各种芯片架构的全景。

  • 几十年来,软件变快都是「免费的」:买一颗主频更高的新 CPU,不用改一行代码,程序就更快了。这个时代在 2005 年前后终结。理解它为什么终结、又是什么取代了它,对任何想写出快代码的人来说都是必修课。

「免费性能」的终结

  • 摩尔定律(Moore's Law,1965)观察到:芯片上的晶体管数量大约每两年翻一番。这条规律持续了 60 年。晶体管越多意味着晶体管越小,晶体管越小意味着时钟频率越高,时钟频率越高意味着程序跑得越快。

  • 但在 2005 年前后,时钟频率撞上了一堵墙,停在了约 4 GHz。问题出在功耗。一颗芯片的功耗大约是:

P \propto C \cdot V^2 \cdot f
  • 其中 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 如何执行指令

  • 一颗现代 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:单指令多数据

  • SIMD(Single Instruction, Multiple Data,单指令多数据)的核心思想是:用一条指令同时处理多个数据元素。不再是把两个数相加,而是一次把两个各含 4 个(或 8 个、16 个)数的向量相加,只用一条指令。

  • 不用 SIMD(标量):

// 逐元素相加两个数组:4 条加法指令 for (int i = 0; i < 4; i++) { c[i] = a[i] + b[i]; // 每次迭代一次加法 }
  • 用 SIMD(向量化):
// 相加两个数组: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 个结果
  • SIMD 版本只用了 1/4 的指令就完成了同样的工作。理论上这就是 4 倍加速,靠的是每条指令处理 4 个 float 而不是 1 个。

向量寄存器

  • SIMD 指令操作的是向量寄存器(vector register):能容纳多个数据元素的宽寄存器。
寄存器宽度 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 优化。

屋顶线模型

  • 怎么知道你的代码够快?**屋顶线模型(roofline model)**提供了一个框架,它用两个硬件极限来刻画性能:
  1. 峰值算力(peak compute,FLOPS):每秒最多能做多少次浮点运算。对一颗 4 GHz、带 256 位 AVX(每条指令 8 个 float)、2 个 FMA 单元的 CPU:4 \times 10^9 \times 8 \times 2 = 64 GFLOPS。

  2. 峰值内存带宽(peak memory bandwidth,字节/秒):数据从内存搬到 CPU 的速度有多快。一颗现代 CPU 的内存带宽可能在 50 GB/s。

  • 你代码的**算术强度(arithmetic intensity)**是计算量与访存量的比值:
\text{Arithmetic Intensity} = \frac{\text{FLOPS}}{\text{Bytes transferred}}
  • 如果算术强度低(每搬一个字节做的运算少),你的代码就是**访存受限(memory-bound)**的:它大部分时间在等数据。让算得更快(更宽的 SIMD、更高的主频)没用。

  • 如果算术强度高(每搬一个字节做的运算多),你的代码就是**算力受限(compute-bound)**的:它大部分时间在算。让内存变快没用。

  • 屋顶线公式:

\text{Achievable FLOPS} = \min\left(\text{Peak FLOPS}, \; \text{Bandwidth} \times \text{Arithmetic Intensity}\right)
  • 矩阵乘法的算术强度很高:O(n^3) 次运算对应 O(n^2) 的数据,所以强度约 \approx O(n)。对大矩阵,它是算力受限的。这也是为什么 GPU(算力高)在矩阵密集的 ML 工作负载上能碾压 CPU。

  • 逐元素操作(ReLU、加法、乘法)的算术强度很低:每取一个元素只做 1 次运算。这些是访存受限的。让 GPU 算得更快没用;你要么让内存更快(要么把这些操作和计算密集的操作融合,避免单独的内存往返)。

  • 屋顶线模型解释了为什么**内核融合(kernel fusion)**如此重要:把一次 matmul 加上 bias 加法再加 ReLU 合成一个内核,能避免把中间结果写回内存再读出来,把三个访存受限的操作变成一个算力受限的操作。

延迟 vs 吞吐

  • **延迟(latency)**是完成一次操作所需的时间。**吞吐(throughput)**是单位时间内完成的操作数。

  • 一个类比:公交车延迟高(每站都停)但吞吐高(一次能载 50 人)。出租车延迟低(直达目的地)但吞吐低(一次载 1-4 人)。

  • GPU 是公交车:单次操作延迟高(每条指令要很多周期才能完成)但吞吐巨大(数千个核心同时处理)。CPU 是出租车:延迟低(乱序执行、分支预测、深缓存把延迟压到最小)但吞吐有限(4-64 个核心)。

  • 这就是为什么 GPU 更适合 ML 训练(吞吐重要:处理百万级样本),CPU 更适合操作系统任务(延迟重要:立刻响应一次按键)。

  • **流水线(pipelining)**把延迟转成吞吐。如果一条指令要 5 个周期,但流水线每个周期都启动一条新指令,那么吞吐就是每周期 1 条指令(即便每条都要 5 个周期才能完成)。这与第 13 章讲的 CPU 流水线是同一个原理,但它适用于每一个层面:SIMD 单元、内存控制器、GPU 核心都是流水化的。

芯片架构全景

  • 你为哪种硬件写代码,决定了能用哪些 SIMD 指令:

x86(Intel、AMD)

  • 统治桌面、笔记本和数据中心 CPU。SIMD:SSE(128 位)、AVX/AVX2(256 位)、AVX-512(512 位)。Intel AMX 为 AI 工作负载提供了专用的矩阵乘法单元。

  • 优势:单核性能最高、SIMD 最宽、软件生态成熟(MKL、oneDNN)。

  • 劣势:功耗高、指令集复杂、价格贵。

ARM

  • 统治移动端(每一部智能手机),在服务器(AWS Graviton、Ampere Altra)和笔记本(Apple M 系列)中份额不断扩大。SIMD:NEON(128 位)、SVE/SVE2(可扩展,128-2048 位)。

  • 优势:极佳的能效(每瓦性能)、自研核心(Apple M4 在单核性能上能以远低于 Intel 的功耗匹敌 Intel)。

  • 劣势:SIMD 较窄(NEON 只有 128 位,尽管 SVE 可以更宽),HPC 软件生态较小。

Apple Silicon(M1/M2/M3/M4)

  • 基于 ARM 并带有自定义扩展。包含 AMX(Apple Matrix eXtensions,苹果矩阵扩展)——未公开文档的矩阵乘法单元,Accelerate 框架用它来做 BLAS。统一内存架构:CPU 和 GPU 共享同一块物理内存,消除了 CPU↔GPU 复制的这一瓶颈。

  • 对 ML:苹果的 Neural Engine(16 核、专用 ML 加速器)加上统一内存,让 M 系列芯片在本地 ML 推理和小规模训练上出人意料地能干。但没有 CUDA:你得用 Metal(苹果的 GPU API)或 MLX(苹果的 ML 框架)。

RISC-V

  • 开源指令集架构(ISA)。没有授权费(不像 ARM)。在嵌入式系统、IoT 和研究中不断增长。SIMD:「V」(向量)扩展提供类似 ARM SVE 的可扩展向量处理。

  • 对 ML:目前还无法与 x86/ARM 在 ML 工作负载上竞争,但值得持续关注。好几家 AI 加速器初创公司在用 RISC-V 核心。

GPU(NVIDIA、AMD、Intel)

  • 在第 04-05 节深入讲解。数千个为吞吐优化的简单核心。NVIDIA 用 CUDA 统治 ML;AMD 用 ROCm 竞争;Intel 用 Arc GPU 和 Gaudi 加速器入场。

TPU(Google)

  • 专为 ML 设计的定制 ASIC。为矩阵乘法优化的脉动阵列(systolic array)。在第 05 节讲解。

散热与功耗约束

  • 性能最终受限于功耗和散热:

  • TDP(Thermal Design Power,热设计功耗):一颗芯片能持续消耗的最大功率。笔记本 CPU 的 TDP 可能是 15 W;服务器 CPU 是 250 W;数据中心 GPU 是 700 W(NVIDIA B200)。

  • 暗硅(dark silicon):在任何时刻,都必须让相当一部分晶体管断电,才能待在散热预算之内。理论上芯片可以同时启用所有晶体管,但那样它会熔化。

  • **能效(FLOPS/瓦)**越来越成为比原始 FLOPS 更重要的指标。这也是为什么:

    • ARM 正在接管数据中心(每瓦 FLOPS 比 x86 更好)。
    • TPU 尽管峰值 FLOPS 较低,却能和 GPU 竞争(在 ML 工作负载上每瓦 FLOPS 好得多)。
    • 量化(INT8、FP8)不仅仅是为了省内存:它还降低了每次操作的功耗。
  • 对规模化 ML 而言:训练一个前沿大语言模型要消耗数兆瓦的功率长达数月。电费可能超过硬件成本。能效直接影响 AI 研究的经济性。

实践:用 C++ 测量性能

  • 要谈性能,先得会测。下面是一个最小化的 C++ 基准测试框架:
#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 帮不了多少忙(瓶颈在内存,不在算力)。

编程练习(使用 CoLab 或 notebook)

  1. 计算常见 ML 操作的算术强度,并判断它们是访存受限还是算力受限。
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")
  1. 演示为什么并行很重要。随着数据规模增长,比较串行与并行(NumPy)执行。
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")

发布者: 作者: HenryNdubuaku 转发
评论区 (0)
U