2. 理解CPU架构:核心、缓存、流水线、超线程等

做性能调优,不了解CPU怎么工作,等于闭着眼睛开车。你写一行C代码,CPU内部要经历十几步甚至几十步。今天我们就拆开这个"黑盒",看看核心、缓存、流水线、超线程这些部件到底在干嘛。

CPU核心架构概览 Core 0 逻辑核0 (HT) 逻辑核1 (HT) 取指 → 译码 → 执行 → 写回 L1 Cache L1 Cache Core 1 逻辑核0 (HT) 逻辑核1 (HT) 取指 → 译码 → 执行 → 写回 L1 Cache L1 Cache L2 L2 L3 Cache(共享) 内存控制器(IMC) 每个核心内部有独立流水线 超线程通过复用执行单元实现

CPU核心:真的不是越多越好

核心,就是物理上独立的计算单元。每个核心有自己的寄存器、ALU、FPU。你打开任务管理器看到的"物理核心"就是它。

但我见过有人盲目堆核心数,以为16核一定比8核快一倍。结果跑一个串行程序,16核只有1个在工作,其他都在睡觉。你想想看,调度开销反而更大了。优化前先问自己:我的任务能不能并行?

关键认知:不是核心数决定一切,而是单核性能 + 并行度共同决定吞吐量。

缓存层级:距离决定速度

CPU访问内存太慢了,所以设计了多级缓存。最贴近核心的是L1,最快但也最小;接着是L2,然后是所有核心共享的L3。再往外就是内存,差了两个数量级。

层级典型大小延迟带宽位置
L1 Cache32KB~64KB(每核)~1ns~500GB/s核心内部
L2 Cache256KB~1MB(每核)~4ns~200GB/s核间私有
L3 Cache8MB~64MB(共享)~15ns~100GB/s芯片内共享
主存(DDR5)16GB~128GB~80ns~50GB/s片外

我曾经优化一个数据库哈希表,每次查询都要跳内存,时延高得离谱。后来把热点数据align到缓存行大小,并且做了prefetch,查询性能直接翻了3倍。这就是缓存友好的力量。

提示:写代码时尽量让数据按访问顺序连续存放(空间局部性),访问过的数据很可能马上再用(时间局部性)。缓存命中率上去了,程序自然快。
避坑:我曾经遇到过“伪共享”(False Sharing)问题——两个核心各自修改同一缓存行不同变量,导致缓存行来回颠簸。加了padding让变量不在一个缓存行里,性能就回来了。

流水线:让指令“流水”起来

一条指令要经过取指、译码、执行、访存、写回多个阶段。如果按部就班做完一条再做下一条,吞吐量太低了。流水线把不同阶段重叠起来:核心在执行当前指令时,同时译码下一条,再取再下一条。理想情况下,每个时钟周期都能完成一条指令。

但流水线怕分支预测失败。如果预测错了,已经预取的指令全得扔掉,重新填充流水线——这就是所谓的“流水线冲刷”。

我在调优一个网络转发程序时发现,switch-case里一个分支命中率只有80%,预测错误率很高。改成无条件跳转或查表后,吞吐提升了12%。

  • 流水线深度:现代CPU有14~20级流水线,深度越深,每个阶段做的活越细,频率能更高;但分支预测错误代价更大。
  • 乱序执行:CPU不是死板按顺序执行,它会分析指令依赖,让不依赖的指令提前执行,填满流水线气泡。
  • 超标量:每个时钟周期发射多条指令(例如4~8条),需要足够多的执行单元。
调优启示:尽量减少分支(尤其是难以预测的分支),如果必须用分支,让大概率走的那条路径对齐到默认分支(likely/unlikely宏有用)。

超线程(SMT):一核双线程的魔法

超线程就是一个物理核心模拟成两个逻辑核心。它利用执行单元的空闲时隙——当一个逻辑核在等缓存命中或等待资源时,另一个逻辑核可以趁机用执行单元。说白了,就是榨干硬件的每一滴性能。

我测试过,超线程通常能提升20%~30%的吞吐量,但如果两个线程都在争抢同样紧缺的资源(比如L1缓存或浮点单元),可能反而变慢。超线程不是银弹。

场景超线程效果原因
内存延迟敏感型(如数据库)明显提升一个线程等内存时,另一个运行
CPU密集计算型(如矩阵乘法)提升有限甚至下降执行单元全部被占,竞争加剧
多线程并行任务中等提升资源复用效率中等
做法:在性能测试中,我习惯先关闭超线程跑一次基准,再打开超线程跑一次,对比结果决定是否启用。有些虚拟机环境我甚至会绑定物理核心,避免超线程干扰。

实战建议:架构感知编码

理解这些不是纸上谈兵。我整理了几条能立刻用上的经验:

  1. 数据对齐:结构体按缓存行对齐(64字节),避免伪共享。
  2. 分支优化:把高分文概率分支写在if里,低概率用unlikely宏。
  3. 循环展开:减少分支,提升指令级并行。
  4. 选择合适的线程数:物理核心数 + 是否启用超线程。IO密集型可以再多一些。
  5. 使用profiler工具:perf stat能告诉你缓存缺失率、分支预测错误率。
// 伪共享示例 - 糟糕做法
struct Data {
    int a;   // 核心0频繁写
    int b;   // 核心1频繁写
};           // 两个变量挤在同一缓存行

// 修正:加padding
struct Data {
    int a;
    char padding[60];  // 填充到64字节
    int b;
};