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核芯片使用与4.5 GHz单核相似的功率,但可以做4倍的并行工作。这就是为什么每个现代CPU都有多个核心,以及为什么并行性(SIMD、多线程、GPU计算)是获得更多性能的唯一途径。

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

现代CPU如何执行指令

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

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

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

  • 分支预测:条件分支(if语句、循环条件)产生不确定性:CPU在条件被求值之前不知道走哪条路径。CPU不是停顿,而是预测结果并沿预测路径推测执行。如果预测正确(使用现代预测器正确率>95%),不会浪费任何时间。如果错误,推测工作被丢弃,正确路径被执行(约15周期惩罚)。

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

  • 所有这些都是自动的——CPU在没有任何程序员干预的情况下完成。但它们只帮助指令级并行性(ILP):单个流内的独立指令。对于数据级并行性(对许多数据元素的相同操作),我们需要SIMD。

SIMD:单指令多数据

  • SIMD是将一条指令同时应用于多个数据元素的思想。不是相加两个数字,而是在单条指令中相加两个包含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个浮点数到128位寄存器
__m128 vb = _mm_load_ps(b);    // 加载4个浮点数到另一寄存器
__m128 vc = _mm_add_ps(va, vb); // 同时加所有4对
_mm_store_ps(c, vc);            // 存储4个结果
  • SIMD版本以1/4的指令完成相同工作。这是理论4倍加速,通过每条指令处理4个浮点而非1个实现。

向量寄存器

  • SIMD指令操作向量寄存器:容纳多个数据元素的宽寄存器。
寄存器宽度 浮点数(32位) 双精度(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个浮点数,理论上是标量代码的16倍加速。实际中加速更低,因为内存带宽限制(计算速度可以快于向CPU馈送数据的速度)。

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

Roofline模型

  • 如何判断你的代码是否快?Roofline模型通过用两个硬件极限来刻画性能提供了一个框架:

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

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

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

\[\text{算术强度} = \frac{\text{FLOPS}}{\text{传输字节数}}\]
  • 如果算术强度低(每加载字节的运算少),代码是内存受限的:它花费大部分时间等待数据。提高计算速度(更宽SIMD、更高频率)将无济于事。

  • 如果算术强度高(每字节的运算多),代码是计算受限的:它花费大部分时间计算。更快的内存无济于事。

  • Roofline:

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

  • 逐元素操作(ReLU、加法、乘法)具有低算术强度:每个加载元素只有1次运算。这些是内存受限的。让GPU更快无济于事;你需要更快的内存(或将这些操作与计算密集型操作融合以避免单独的内存往返)。

  • Roofline模型解释了内核融合为何如此重要:将一个矩阵乘法与偏置加法和ReLU结合到单个内核中,避免将中间结果写入内存再读回,将三个内存受限操作变成一个计算受限操作。

延迟 vs 吞吐量

  • 延迟是完成一次操作的时间。吞吐量是每单位时间完成的操作数。

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

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

  • 这就是GPU更适合ML训练(吞吐量重要:处理数百万样本)而CPU更适合OS任务(延迟重要:立即响应按键)的原因。

  • 流水线将延迟转换为吞吐量。如果一条指令需要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)。

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

Apple Silicon(M1/M2/M3/M4)

  • 基于ARM,有自定义添加。包括AMX(Apple矩阵扩展)——未文档化的矩阵乘法单元,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中深入介绍。数千个针对吞吐量优化的简单核心。NVIDIA以CUDA主导ML;AMD以ROCm竞争;Intel以Arc GPU和Gaudi加速器进入。

TPU(Google)

  • 专为ML设计的定制ASIC。针对矩阵乘法优化的脉动阵列。在文件05中介绍。

热和功耗约束

  • 性能最终受功率和冷却限制:

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

  • 暗硅:在任何给定时刻,相当大比例的晶体管必须断电以保持在热预算内。芯片理论上可以同时使用所有晶体管,但会熔化。

  • 能效(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);

    // 预热(填充缓存,触发频率调整)
    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 << "时间: " << elapsed << " s\n";
    std::cout << "带宽: " << 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频率调整(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 op
    relu_bytes = 2 * n * 4  # 读输入 + 写输出(float32)
    print(f"ReLU: {arithmetic_intensity(relu_flops, relu_bytes):.2f} FLOPS/byte → 内存受限")
    
    # 矩阵乘法:2*n^3 ops,读2*n^2 + 写n^2个浮点数
    matmul_flops = 2 * n**3
    matmul_bytes = 3 * n**2 * 4  # 读A + 读B + 写C
    print(f"矩阵乘 ({n}×{n}): {arithmetic_intensity(matmul_flops, matmul_bytes):.0f} FLOPS/byte → 计算受限")
    
    # Layer norm:~5n ops(均值、方差、归一化),读+写
    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 + 写output
    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. 演示并行性为何重要。比较随着数据大小增长的顺序执行 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))]  # 上限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,}  顺序={seq_time:.4f}s  并行={par_time:.6f}s  "
              f"加速={seq_time/par_time:.0f}x")