跳转至

硬件基础

一句话总结:写 SIMD 或 GPU 代码前,先搞懂硬件为什么从"堆时钟"转向"堆并行",以及 Roofline 模型如何判断代码是被计算还是被内存卡住。

写 SIMD 或 GPU 代码之前,你得先理解自己在给什么样的硬件写程序。 本节讲清楚:为什么并行取代了时钟频率、现代 CPU 是如何执行指令的、SIMD 是什么、如何用 Roofline 模型思考性能,以及芯片架构的整体格局.

  • 有几十年时间,软件是"白嫖式"变快的:买一颗时钟频率更高的 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 需要不切实际的散热方案。

  • 解法是:与其把一个核心做得更快,不如在同一颗芯片上放多个核心. 一颗 3 GHz 的 4 核芯片,功耗和一颗 4.5 GHz 的单核差不多,却能干 4 倍的并行活。 这就是为什么每颗现代 CPU 都有多个核心,也是为什么并行 (SIMD、多线程、GPU 计算) 是通向更高性能的唯一出路。

  • 对 ML 的启示:在一个核心上跑 10 分钟的训练步,你没办法靠买一颗更快的 CPU 让它变快。 只能靠更多核心 (数据并行,见第 6 章)、更宽的 SIMD 单元 (本章),或者 GPU (成千上万个核心).

现代 CPU 是如何执行指令的

  • 一颗现代 CPU 核心,比第 13 章那个简单的"取指-译码-执行"模型要复杂得多。 它靠好几招来让每个周期多干几条指令:

  • 超标量执行 (superscalar execution): CPU 有多个执行单元 (算术逻辑单元 ALU、浮点单元 FPU、加载/存储单元),可以同时执行多条相互独立的指令。 一颗现代核心如果指令之间没有依赖,一个周期可以跑 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):也就是单条指令流里彼此独立的指令。 想要数据级并行 (data-level parallelism) (对许多数据元素做同一个操作),就得靠 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 模型

  • 怎么判断自己的代码算不算快?Roofline 模型 (roofline model) 提供了一套框架,从两个硬件极限来刻画性能:

  • 峰值计算力 (peak compute, FLOPS):每秒最多能干多少次浮点运算。 以一颗 4 GHz、支持 256 位 AVX (每条指令 8 个 float) 且有 2 个融合乘加 (Fused Multiply-Add, FMA) 单元的 CPU 为例:\(4 \times 10^9 \times 8 \times 2 = 64\) GFLOPS.

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

  • 代码的算术强度 (arithmetic intensity) 是"计算量与内存访问量之比":

\[\text{Arithmetic Intensity} = \frac{\text{FLOPS}}{\text{Bytes transferred}}\]
  • 如果算术强度低 (每加载一字节只做几次运算),你的代码就是内存受限 (memory-bound) 的:大部分时间都在等数据。 让计算更快 (更宽的 SIMD、更高的频率) 帮不上忙。

  • 如果算术强度高 (每字节能做很多次运算),代码就是计算受限 (compute-bound) 的:大部分时间都在算。 让内存更快也帮不上忙。

  • Roofline 就是这条上限:

\[\text{Achievable FLOPS} = \min\left(\text{Peak FLOPS}, \; \text{Bandwidth} \times \text{Arithmetic Intensity}\right)\]
  • 矩阵乘法算术强度高:\(O(n^3)\) 次运算作用在 \(O(n^2)\) 的数据上,所以强度约为 \(O(n)\). 对大矩阵而言,它是计算受限的。 这就是 GPU (计算力高) 会在矩阵密集的 ML 工作负载里占统治地位的原因。

  • 逐元素运算 (ReLU、加法、乘法) 算术强度低:每加载一个元素只做 1 次运算。 这些是内存受限的。 让 GPU 更快也没用;你得让内存更快 (或者把它们和计算密集的算子融合起来,免掉一次单独往返内存).

  • Roofline 模型也解释了算子融合 (kernel fusion) 为什么这么重要:把矩阵乘法、偏置加法和 ReLU 合并成一个 kernel,就不用把中间结果写回内存再读回来,于是三个内存受限的操作被合并成一个计算受限的操作。

延迟与吞吐

  • 延迟 (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:流式 SIMD 扩展 (Streaming SIMD Extensions, SSE, 128 位)、高级向量扩展 (Advanced Vector Extensions, AVX)/AVX2 (256 位)、AVX-512 (512 位). Intel AMX 提供了专门给 AI 负载用的矩阵乘单元。

  • 优点:单核性能最强、SIMD 最宽、软件生态成熟 (MKL、oneDNN).

  • 缺点:功耗高、指令集复杂、价格贵。

ARM

  • 统治移动端 (每一部智能手机),在服务器 (AWS Graviton、Ampere Altra) 和笔记本 (Apple M 系列) 上快速崛起。 SIMD: NEON (128 位),可扩展向量扩展 (Scalable Vector Extension, SVE) 及 SVE2 (可变宽度,128-2048 位).

  • 优点:能效比顶尖 (每瓦性能),自研核心极强 (Apple M4 单核性能已经能和 Intel 掰手腕,功耗却只是零头).

  • 缺点: SIMD 更窄 (NEON 只有 128 位,不过 SVE 可以更宽),高性能计算 (High-Performance Computing, HPC) 的软件生态较小。

Apple Silicon (M1/M2/M3/M4)

  • 基于 ARM,加了自研料。 里面藏着 AMX (Apple Matrix eXtensions,苹果矩阵扩展) — 未公开文档的矩阵乘单元,Accelerate 框架会用它做 BLAS 运算。 采用统一内存架构 (unified memory): CPU 和 GPU 共享同一块物理内存,消掉了 CPU↔GPU 拷贝这个瓶颈。

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

RISC-V

  • 开源指令集架构 (Instruction Set Architecture, ISA). 没有授权费 (跟 ARM 不同). 在嵌入式、物联网 (Internet of Things, IoT) 和科研领域快速增长。 SIMD: "V" (向量) 扩展提供了可扩展的向量处理能力,类似 ARM SVE.

  • 对 ML 来说:在 ML 负载上还打不过 x86/ARM,但值得关注。 好几家 AI 加速器创业公司在用 RISC-V 做核心。

GPU (NVIDIA、AMD、Intel)

  • 04-05 节会详细讲。 数以千计的简单核心,为吞吐量优化。 NVIDIA 靠 CUDA 主宰 ML; AMD 用 ROCm 竞争;Intel 靠 Arc GPU 和 Gaudi 加速器入场。

TPU (Google)

  • 张量处理单元 (Tensor Processing Unit, TPU):专门为 ML 定制的专用集成电路 (Application-Specific Integrated Circuit, ASIC). 采用脉动阵列 (systolic array),专为矩阵乘法优化。 详见 05 节。

热和功耗约束

  • 性能最终会被功耗和散热卡死:

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

  • 暗硅 (dark silicon):任何时刻,都必须关掉相当一部分晶体管来守住热预算。 一颗芯片理论上可以让所有晶体管同时工作,但会烧化。

  • 能效 (FLOPS/瓦) 越来越是那个真正重要的指标,而不是原始 FLOPS. 这就是为什么:

    • ARM 正在攻占数据中心 (每瓦 FLOPS 比 x86 更优).
    • TPU 在跟 GPU 竞争,虽然峰值 FLOPS 低,但对 ML 负载的每瓦 FLOPS 高得多。
    • 量化 (INT8、FP8) 的意义不只是省内存:每次运算的功耗也降下来了。
  • 对大规模 ML:训练一个前沿大语言模型 (Large Language Model, LLM),要消耗几兆瓦的电力持续好几个月。 电费可能比硬件本身还贵。 能效直接影响 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 内建函数 (intrinsic) 需要这种指针。
    • std::chrono:高精度计时器,用来做基准测试。
    • -O3:编译器最大优化级别。 编译器可能会自动把你的循环向量化 (自动用 SIMD). -march=native 打开 CPU 支持的所有 SIMD 指令。
  • 为什么要预热:第一次运行会把缓存填起来,还可能触发 CPU 的频率缩放 (turbo boost). 后面的运行才更具代表性。

  • 为什么要量带宽:对内存受限的运算 (比如逐元素加法),有意义的指标是带宽 (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")
    

  2. 展示并行为什么重要。 比较随数据规模增长,顺序执行 vs 并行执行 (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))]  # 上限 100K 保稳
        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")
    


📌 本节要点

  • 免费性能时代结束: 2005 年前后,时钟频率因 \(P \propto C V^2 f\) 撞上功耗墙,加频等于烧钱。
  • 并行是唯一出路:从"更快一个核心"转向"更多核心 / 更宽 SIMD / GPU",是硬件层面不可逆的方向。
  • 超标量与乱序:现代 CPU 靠多执行单元、乱序执行、分支预测、推测执行,自动挖掘指令级并行 (ILP).
  • SIMD 定义:一条指令同时作用于多个数据元素,是数据级并行的核心手段。
  • 向量寄存器: SSE/NEON 是 128 位、AVX/AVX2 是 256 位、AVX-512 是 512 位、ARM SVE 可变宽。
  • Roofline 模型:用峰值 FLOPS 与内存带宽两条上限,把代码分为内存受限或计算受限。
  • 算术强度:计算量除以内存传输量;矩阵乘高、逐元素低,决定优化方向。
  • 算子融合的意义:把内存受限算子合到计算受限算子里,免去多轮内存往返。
  • CPU vs GPU 定位: CPU 是低延迟的出租车,GPU 是高吞吐的公交车;前者适合响应式任务,后者适合训练。
  • 能效为王: FLOPS/瓦成为核心指标,驱动 ARM 上数据中心、TPU 与量化的兴起。