Skip to content

硬件基础

在编写 SIMD 或 GPU 代码之前,你需要理解你正在编程的硬件。本文涵盖为何并行性取代了时钟速度、现代 CPU 如何执行指令、SIMD 是什么、用于推理性能的 roofline 模型,以及芯片架构全景。

  • 数十年来,软件免费提速:买一块时钟速度更高的新 CPU,无需改一行代码程序就跑得更快。那个时代在 2005 年前后结束了。理解它为何结束,以及取代它的是什么,对于任何想写快代码的人都至关重要。

免费性能的终结

  • 摩尔定律(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.5 GHz 的单核芯片相近,但可以完成 4 倍的并行工作。这就是为什么每款现代 CPU 都有多个核心,以及为什么并行性(SIMD、多线程、GPU 计算)是获得更多性能的唯一途径。

  • 对 ML 的影响:一个在单核上需要 10 分钟的训练步骤,无法通过购买更快的 CPU 来加速。只能通过使用更多核心(数据并行,第 6 章)、更宽的 SIMD 单元(本章)或 GPU(数千个核心)来加速。

现代 CPU 如何执行指令

  • 一个现代 CPU 核心远比第 13 章中简单的取指-译码-执行模型复杂。它使用多种技巧在每个周期内执行更多指令:

  • 超标量执行:CPU 拥有多个执行单元(ALU、FPU、加载/存储单元),可以同时执行多条相互独立的指令。如果指令之间没有依赖关系,现代核心每个周期可能执行 4-6 条指令。

  • 乱序执行(OoO):CPU 不按程序顺序执行指令。它向前预读指令流,找到输入数据已就绪的指令,并立即执行,无论其位置如何。这隐藏了 latency:当一条指令等待内存数据(100+ 个周期)时,CPU 执行其他已就绪的指令。

  • 分支预测:条件分支(if 语句、循环条件)带来不确定性:CPU 在条件求值之前不知道走哪条路。CPU 并不停顿,而是预测结果,并沿预测路径投机执行。如果预测正确(现代预测器超过 95% 的情况正确),不浪费时间。如果错误,投机执行的工作被丢弃,然后执行正确路径(约 15 个周期的惩罚)。

  • 投机执行:分支预测的延伸。CPU 执行可能不需要的指令,赌它们会被需要。这填充了 pipeline 并保持执行单元忙碌。

  • 所有这些都是自动的——CPU 无需任何程序员干预就会执行。但它们只有在指令级并行(ILP)方面才有帮助:单个指令流中的独立指令。对于数据级并行(对许多数据元素执行相同操作),我们需要 SIMD。

SIMD:单指令多数据

  • SIMD 的思想是将一条指令同时应用于多个数据元素。不是对两个数求和,而是在一条指令中对两个含 4 个(或 8 个,或 16 个)数的向量求和。

  • 不使用 SIMD(标量):

// 逐元素加两个数组:4 条 add 指令
for (int i = 0; i < 4; i++) {
    c[i] = a[i] + b[i];  // 每次迭代一次 add
}
  • 使用 SIMD(向量化):
// 加两个数组:1 条 SIMD 指令完成全部 4 次加法
#include <immintrin.h>  // x86 SIMD intrinsic

__m128 va = _mm_load_ps(a);    // 将 4 个 float 加载到 128 位 register
__m128 vb = _mm_load_ps(b);    // 将 4 个 float 加载到另一个 register
__m128 vc = _mm_add_ps(va, vb); // 同时加 4 对数据
_mm_store_ps(c, vc);            // 存储 4 个结果
  • SIMD 版本用 1/4 的指令完成相同的工作。通过每条指令处理 4 个 float 而不是 1 个,理论上实现了 4 倍加速。

向量 Register

  • SIMD 指令操作向量 register:持有多个数据元素的宽 register。
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)
  • Register 越宽 = 并行度越高。一条 512 位 AVX-512 指令同时处理 16 个 float,理论上比标量代码快 16 倍。实际加速更低,因为受内存 bandwidth 限制(计算速度可以超过给 CPU 喂数据的速度)。

  • 对于 ML:float32 值的矩阵乘法极大地受益于 SIMD。内层循环(两个向量的点积)直接映射到 SIMD 乘加指令。这就是为什么 BLAS 库(NumPy 和 PyTorch 调用的)对 SIMD 进行了如此重度的优化。

Roofline 模型

  • 如何知道你的代码是否够快?Roofline 模型通过将性能表征为两个硬件限制来提供框架:

  • 峰值计算能力(FLOPS):每秒最大浮点运算次数。对于一块 4 GHz CPU,256 位 AVX(每条指令 8 个 float),有 2 个 FMA 单元:\(4 \times 10^9 \times 8 \times 2 = 64\) GFLOPS。

  • 峰值内存 bandwidth(字节/秒):数据从内存移动到 CPU 的速度。现代 CPU 可能有 50 GB/s 的内存 bandwidth。

  • 代码的算术强度是计算与内存访问的比值:

\[\text{算术强度} = \frac{\text{FLOPS}}{\text{传输字节数}}\]
  • 如果算术强度低(每字节加载操作少),你的代码是内存受限的:大部分时间在等待数据。加快计算速度(更宽的 SIMD、更高的时钟)没有帮助。

  • 如果算术强度高(每字节操作多),你的代码是计算受限的:大部分时间在计算。更快的内存没有帮助。

  • Roofline:

\[\text{可达 FLOPS} = \min\left(\text{峰值 FLOPS}, \; \text{Bandwidth} \times \text{算术强度}\right)\]
  • 矩阵乘法具有高算术强度:\(O(n^3)\) 次操作处理 \(O(n^2)\) 数据,因此强度约为 \(O(n)\)。对于大矩阵,它是计算受限的。这就是为什么 GPU(高计算能力)在矩阵密集的 ML 工作负载中占主导地位。

  • 逐元素操作(ReLU、add、multiply)算术强度低:每个加载元素 1 次操作。这些是内存受限的。使 GPU 更快没有帮助;你需要更快的内存(或将这些操作与计算密集型操作融合,以避免单独的内存往返)。

  • Roofline 模型解释了为什么 kernel 融合如此重要:将一个 matmul 与 bias add 和 ReLU 合并到单个 kernel 中,避免将中间结果写入内存再读回,将三个内存受限操作变为一个计算受限操作。

Latency 与 Throughput

  • Latency 是完成一个操作所需的时间。Throughput 是单位时间内完成的操作数量。

  • 类比:公共汽车 latency 高(每站都要停)但 throughput 高(一次可载 50 人)。出租车 latency 低(直接去你的目的地)但 throughput 低(一次载 1-4 人)。

  • GPU 是公共汽车:每个操作的 latency 高(每条指令需要多个周期完成),但 throughput 巨大(数千个核心同时处理)。CPU 是出租车:latency 低(乱序执行、分支预测、深层 cache 最大限度地减少延迟),但 throughput 有限(4-64 个核心)。

  • 这就是为什么 GPU 更适合 ML 训练(throughput 重要:处理数百万示例)而 CPU 更适合 OS 任务(latency 重要:立即响应按键操作)。

  • Pipeline 将 latency 转化为 throughput。如果一条指令需要 5 个周期,但 pipeline 每个周期启动一条新指令,则 throughput 为每个周期 1 条指令(即使每条指令需要 5 个周期才能完成)。这与第 13 章的 CPU pipeline 原理相同,但它适用于每个层面:SIMD 单元、内存控制器和 GPU 核心都是流水线化的。

芯片架构全景

  • 你编写代码的硬件决定了哪些 SIMD 指令可用:

x86(Intel、AMD)

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

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

  • 劣势:高功耗、复杂的 instruction set、价格昂贵。

ARM

  • 主导移动端(每款智能手机),在服务器(AWS Graviton、Ampere Altra)和笔记本电脑(Apple M 系列)中日益扩大。SIMD:NEON(128 位)、SVE/SVE2(可扩展,128-2048 位)。

  • 优势:出色的能效(性能/瓦),自定义核心(Apple M4 以极低功耗在单核性能上媲美 Intel)。

  • 劣势:较窄的 SIMD(NEON 仅 128 位,但 SVE 可以更宽),HPC 软件生态系统较小。

Apple Silicon(M1/M2/M3/M4)

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

  • 对于 ML:Apple 的 Neural Engine(16 核,专用 ML 加速器)和统一内存使 M 系列芯片在本地 ML 推理和小规模训练方面出奇地强大。不过没有 CUDA:你必须使用 Metal(Apple 的 GPU API)或 MLX(Apple 的 ML 框架)。

RISC-V

  • 开源 ISA。无需授权费(与 ARM 不同)。在嵌入式系统、IoT 和研究中日益增长。SIMD:"V"(向量)扩展提供类似 ARM SVE 的可扩展向量处理。

  • 对于 ML:在 ML 工作负载上还无法与 x86/ARM 竞争,但值得关注。一些 AI 加速器初创公司使用 RISC-V 核心。

GPU(NVIDIA、AMD、Intel)

  • 在第 04-05 文件中深入介绍。数千个为 throughput 优化的简单核心。NVIDIA 凭借 CUDA 主导 ML;AMD 以 ROCm 竞争;Intel 以 Arc GPU 和 Gaudi 加速器进入市场。

TPU(Google)

  • 专门为 ML 设计的自定义 ASIC。为矩阵乘法优化的脉动阵列。在第 05 文件中介绍。

散热和功耗约束

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

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

  • 暗硅(Dark silicon):在任何给定时刻,相当一部分晶体管必须关闭以保持在热预算内。芯片理论上可以同时使用所有晶体管,但那会融化。

  • 能效(FLOPS/瓦)越来越成为重要指标,而非原始 FLOPS。这就是为什么:

    • ARM 正在接管数据中心(比 x86 更好的 FLOPS/瓦)。
    • TPU 尽管峰值 FLOPS 较低,仍与 GPU 竞争(ML 工作负载的 FLOPS/瓦好得多)。
    • 量化(INT8、FP8)不只是关于内存:它还减少了每次操作的功耗。
  • 对于大规模 ML:训练一个前沿 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);

    // 预热(填充 cache,触发频率扩展)
    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 指令。
  • 为什么需要预热:第一次运行会填充 cache 并可能触发 CPU 频率扩展(turbo boost)。后续运行更具代表性。

  • 为什么测量 bandwidth:对于内存受限的操作(如逐元素加法),有意义的指标是 bandwidth(GB/s),而不是 FLOPS。如果你测量到的 bandwidth 接近硬件限制(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 → 内存受限")
    
    # 矩阵乘法: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 → 计算受限")
    
    # Layer norm:约 5n 次操作(均值、方差、归一化),读 + 写
    ln_flops = 5 * n
    ln_bytes = 2 * n * 4
    print(f"LayerNorm: {arithmetic_intensity(ln_flops, ln_bytes):.2f} FLOPS/byte → 内存受限")
    
    # 卷积 3x3:2*9*C_in*C_out*H*W,读 kernel + feature map + 写输出
    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 → 计算受限")
    

  2. 演示为什么并行性很重要。随数据规模增长,比较顺序执行与并行(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")