ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从CPU到内存:一文读懂冯诺依曼体系结构与性能瓶颈

从CPU到内存:一文读懂冯诺依曼体系结构与性能瓶颈 做了这么多年开发带过的实习生和刚入行的同事少说也有几十个我发现一个规律很多人写了好几年代码能把各种框架调得飞起但你要是问他CPU到底是怎么把一行a b c变成结果的十有八九会卡壳。聊到冯诺依曼体系结构大部分人只会背一句存储程序、五大部件再往深了问就没了。这个现象其实挺让人遗憾的因为冯诺依曼体系结构不只是计算机课本第一章的考点它是整个现代计算世界的骨架。无论你是写Java、Python还是C无论你面对的是8块钱一片的单片机还是几十万的服务器CPU底层跑的都是同一套逻辑指令和数据放在同一个存储空间里CPU一条条取出来、翻译、执行。这篇文章我打算把自己这些年对冯诺依曼体系结构的理解、踩过的坑、以及它和现代计算机性能问题之间的联系一次性讲透。适合刚接触计算机组成原理的学生也适合想真正理解程序为什么慢的业务开发者。1. 设计蓝图为什么所有计算机都逃不开这套框架1.1 存储程序:让机器的功能和机器要算的内容彻底分开先讲一段背景。在冯诺依曼提出这套结构之前计算机的程序和数据是混在一起的或者说是硬件写死的。早期的计算机如果要换一个任务往往得重新接线、改插拔开关做个新计算可能耗费几天时间。当时的计算机更像一个专用的计算器而不是通用的机器。冯诺依曼体系结构最关键、也最容易被一笔带过的思想就是存储程序Stored Program。这个思想的本质是把程序当作数据一样预先存放在存储器中计算机运行时再从存储器中逐条取出指令来执行。听上去很简单对吧但恰恰是这个程序也是数据的设计让计算机第一次有了通用的可能。你可以这么理解在存储程序之前计算机是一个只会做固定动作的自动售货机你想买什么取决于机器里装了什么。冯诺依曼体系结构相当于把售货机变成了一个可以读取购物清单的透明盒子清单写什么它就执行什么换一张清单就是换一个任务硬件本身完全不用动。这个设计决策带来的连锁反应极其深远。因为程序可以像数据一样被存储、被修改、被复制我们才可以在一个通用的CPU上运行操作系统、编译器、浏览器才可以用同一台电脑写文档、打游戏、跑深度学习。如果没有存储程序你现在手里的手机和笔记本都不可能存在更别提今天五花八门的软件生态了。1.2 五大部件的分工一条生产线上的五个角色冯诺依曼体系结构把计算机抽象成了五个核心部件运算器ALU、控制器Control Unit、存储器Memory、输入设备Input、输出设备Output。我每次给新同事讲这五个部件都会用一条流水线来类比特别好记。存储器相当于仓库既放原料数据也放加工图纸指令。运算器相当于加工机床负责加减乘除、与或非等运算。控制器相当于车间主任负责读图纸、拆解工序、指挥其他部件协同工作。输入设备相当于收货口把外界的原料送进仓库。输出设备相当于发货口把成品送到客户手中。这五个部件通过总线连接在一起总线的通俗说法就是车间里的一条条传送带负责传递数据数据总线、传递地址地址总线和传递控制信号控制总线。需要说明的是它的核心思路是集中控制、顺序执行即控制器掌握所有指令一条接一条地执行。这个顺序执行的设计是冯诺依曼体系结构的特色也是后面很多性能问题的源头前面先埋个伏笔。1.3 为什么要用二进制物理世界做不了多进制冯诺依曼体系结构的另一个核心特征是二进制。很多人觉得二进制只是一个计算机喜欢用的巧合实际上它是工程实现时最合理、甚至可以说是唯一可行的选择。你要知道早期的计算机确实尝试过十进制。比如ENIAC就是十进制的它的电路复杂得可怕。十进制的0到9意味着需要在物理上区分十种不同的电压状态或机械状态这在工程上是巨大的灾难。而二进制只需要区分两种状态高电平和低电平对应物理上就是导通或截止有电或没电。你家里的开关只有开和关你不会做十个档位的开关去表示数字这是同一个道理。用二进制还有一个好处抗干扰能力强。如果我约定3V以上表示10V附近表示0那么中间就算有一些电磁干扰、电压波动也能很容易地判断到底是1还是0容错能力大大提高。这也是为什么无论CPU制造工艺从微米级做到纳米级底层永远都是二进制的逻辑门电路。2. 存储程序背后的设计思想与细节2.1 指令与数据地位平等是冯诺依曼结构最容易被误解的地方先问一个问题在冯诺依曼体系结构中二进制代码到底表示什么答案取决于它在什么阶段被读取、被谁读取。如果CPU把它当作指令取出来它就是指令如果CPU把它当作被操作的对象它就是数据。指令和数据在存储空间里没有本质区别靠的是谁在使用它来区分。这个设计确实优雅但也带来了一个经典的麻烦程序出Bug时CPU可能把数据当成指令去执行于是屏幕上出现一堆看不懂的乱码或者程序直接崩溃。你在调试Segmentation Fault段错误时如果追过栈帧大概率见过这种情况——跳到了一个非法的地址反汇编出来一堆无意义的指令。这不是系统抽风而是指令和数据共享存储空间这个设计在异常情况下的自然表现。在那种把指令和数据分开放的**哈佛结构Harvard Architecture**里这种情况就会少一些因为指令存储器和数据存储器是物理分离的控制器不可能把数据区的字节当指令取出来。但冯诺依曼结构为了通用性牺牲了这一点换来的是存储器空间利用率的提升和设计上的简洁。现实中很多DSP芯片和部分嵌入式MCU采用哈佛结构而通用的PC、服务器则基本都是冯诺依曼结构或者像现代CPU那样在外观上是冯诺依曼、内部用多级缓存做成了类似哈佛的效果。2.2 一条指令的完整生命周期取指、译码、执行的循环接下来把视角放到微观层面看一条指令到底是怎么活完自己的一生的。在处理器的执行流程中控制器遵循一个永不停歇的循环取指令Fetch→ 译码Decode→ 执行Execute然后继续取下一条指令。这里取指永远优先级最高因为它决定了程序下一步往哪走。具体到一个执行周期里流程大致是这样控制器把程序计数器PCProgram Counter中的地址送给存储器告诉存储把这一地址的字节给我。存储器把该地址处的指令返回同时PC自动加1指向下一条指令的位置。控制器对拿到的指令进行译码判断它是一条加法指令、跳转指令还是一条读取数据的指令。运算器根据译码结果执行计算或控制器根据指令内容决定要不要跳转。如果有计算结果通过总线写回寄存器或者存储器。整个过程周而复始每秒重复几十亿次。正是因为有了PC自动加1这个机制顺序执行才得以实现。而程序里的if、for、while最后都会被编译成一条条改变PC值的跳转指令换句话说程序控制流在CPU眼里只是对PC的不断修改。这个流程在概念上非常清晰但如果你跑过性能剖析会发现实际情况残酷得多取指、译码、执行每一步都涉及访问不同的硬件单元如果严格按照取指→译码→执行→写回一步接一步地做CPU的绝大多数时间都在等待。现代处理器为了优化把取指、译码、执行、写回各安排到独立的硬件阶段让多条指令像流水线一样重叠处理。这就是计算机组成原理课上的CPU流水线基础也是冯诺依曼体系结构下的工程优化的起点。2.3 指令集架构与微架构同一个冯诺依曼两种层次聊到指令流程就绕不开一个常见的混淆点指令集架构ISA和微架构Microarchitecture的区别。冯诺依曼体系结构给出了CPU需要能够取指、译码、执行这种逻辑层面的约定但具体用几条指令、每条指令长什么样子那就是指令集架构的事比如x86、ARM、RISC-V。而微架构是处理器厂商怎么用硬件电路来实现这个指令集比如Intel和AMD都实现x86指令集但内部电路设计的思路完全不同。这就好比冯诺依曼体系结构定义了餐厅要有菜单、厨房、服务员指令集架构是菜单上具体有哪些菜微架构是后厨的灶台怎么摆、厨师怎么分工。菜单和厨师都可以换但菜单厨房服务员这个框架不变。理解这个三层的区别很重要因为在讨论性能问题时很多时候你以为自己在讨论体系结构实际是在讨论微架构的差异。我用一个实际场景来说明为什么苹果的M系列芯片跑同规格的ARM程序比很多ARM手机芯片快那么多根本原因不在指令集都是ARM而在微架构。M系列芯片有更高的指令级并行度、更大的乱序窗口、更好的缓存预取策略。这些都是微架构层面的差异但又不违背冯诺依曼体系统一的原则——指令和数据还是放在同一个存储空间还是按PC取指执行。3. 冯诺依曼瓶颈所有性能问题的根源3.1 瓶颈到底在哪里CPU太快内存太慢任何一个写过高性能程序的人都绕不过这个名词冯诺依曼瓶颈von Neumann bottleneck。这个概念说的是在冯诺依曼体系结构中CPU和存储器之间只有一条数据通道总线无论CPU计算速度多快都必须通过这条通道来取指令和数据。而存储器的访问速度远远跟不上CPU的处理速度结果就是CPU经常在饿肚子——它想干活但数据送不过来。这个速度差夸张到什么程度CPU的L1缓存访问延迟大约在1纳秒左右而主存DRAM的访问延迟大约在60-100纳秒差了两个数量级。如果是访问磁盘那更是差了好几个数量级。CPU从主存中读一个数据的时间足够它执行几百条指令了。这就是为什么现代计算机性能的根本矛盾不是CPU算得不够快而是数据喂不过来。我用最通俗的方式总结就是冯诺依曼结构把CPU比作一个一分钟能做一百道题的学霸但同桌递给他题目的速度是一分钟一道于是学霸九十九分钟都在干等。这个瓶颈在冯诺依曼1964年提出后不久就被认识到之后硬件工程师做了大量工程优化。注意这些优化都没有推翻指令和数据同存储的框架只是把仓库挪得快一点、把传送带加得宽一点、或者在CPU旁边加几个小抽屉。3.2 从业务开发视角感受瓶颈一个真实的慢查询很多做业务开发的同事觉得冯诺依曼瓶颈离自己很远反正用的都是高级语言操作系统和编译器会处理。但实际上你在代码里做的每一个选择都在跟这个瓶颈打交道。举个最简单的例子一段代码遍历一个几百万元素的大数组求和。很多人以为遍历很快实际上如果用for i in range(len(arr)): total arr[i]这种写法在Python里会慢到怀疑人生因为每次循环都在访问一个大对象、做一次解释执行的字节码分发。换成NumPy的向量化求和速度能提升上百倍。这背后当然有解释执行的固定开销但更底层的因素在于CPU访问内存时是按缓存行cache line批量搬运数据的你按顺序访问数组时缓存命中率极高CPU几乎不用等内存反之如果你跳着访问、或者频繁做指针追随后续访问每次都要等主存响应瓶颈立刻显现。我在调优一个数据分析服务时遇到过非常典型的案例同样的数据总量从用哈希表存K-V然后循环查改成排序后二分查找耗时从8秒降到0.9秒。不是因为哈希表本身不好而是哈希表的随机内存访问模式频繁击穿缓存每条记录都触发一次主存访问。二分查找虽然在算法复杂度上增加了logN但顺序的、局部的内存访问模式让缓存效率高了几个级别。这就是冯诺依曼瓶颈对上层开发的影响程序性能好不好很多时候不取决于算法复杂度的高低而取决于它的内存访问模式是否缓存友好。3.3 分层存储体系给CPU旁边一层层加小仓库为了对抗内存太慢的问题现代计算机的存储器采用了分层结构。从CPU寄存器开始依次是L1缓存、L2缓存、L3缓存、主存然后是SSD或HDD。越靠近CPU的层级速度越快、容量越小、成本越高。寄存器CPU内部可以理解为工作台只有几十到几百字节。L1缓存延迟约1纳秒容量几十KBCPU核心独占。L2缓存延迟约3-10纳秒容量几百KB到几MB。L3缓存延迟约10-30纳秒容量从几MB到几十MB多核共享。主存延迟约60-100纳秒容量从几GB到几百GB。磁盘/SSD延迟从几十微秒到几毫秒容量最大。程序运行时会反复访问的数据先被自动加载到缓存中命中缓存就不用访问主存。这个设计对上层程序员是透明的但它的效果极其依赖程序的局部性locality——时间局部性指刚访问过的数据很可能马上再访问空间局部性指访问了某个地址后相邻地址的数据也大概率马上要用。写程序时如果能让内存访问地址尽量连续、循环次数不要太多层、对同一批数据反复利用缓存就能发挥出最大作用。这也解释了一个现象为什么有时候你用了一个很高级的算法跑起来反而比朴素的算法慢。因为高级算法往往伴随更多的间接跳转、更大的内存占用和更差的局部性。算法复杂度是理想化模型真实的冯诺依曼机器还要付出一笔忽视缓存、总线、分支预测的隐性代价。4. 现代CPU如何在不推翻的前提下不断提速4.1 流水线让一条条指令重叠起来前面提过最早的CPU是严格顺序执行指令的取指的时候不能执行执行的阶段不能取指大量硬件资源白白空闲。现代CPU把指令处理过程拆成多个阶段让不同指令在不同阶段并行流动这就是指令流水线。我拿一条五级流水线举例取指、译码、执行、访存、写回。假设每条指令在这五个阶段各占一个时钟周期顺序执行一条指令需要5个周期但流水线化之后就第一管流水线灌满后每个周期都可以完成一条指令的写回。也就是说单条指令的延迟还是5个周期但吞吐率变成了每个周期一条。这就是所谓通过并行提升吞吐、但不降低延迟的典型思路。当然流水线最怕分支跳转。如果流水线正忙着执行一条if后面的指令结果分支判断发现应该走另一条路前面已经处理到一半的指令全部作废流水线要被清空重来。这个分支惩罚在现代CPU中是个大问题于是硬件工程师又设计了分支预测。分支预测的思路是CPU根据程序过去的行为猜一猜这次分支走得通还是走不通。如果猜对了流水线毫无停顿地继续猜错了就需要冲刷流水线回滚。现代CPU的分支预测命中率普遍超过90%某些特定模式甚至超过99%。这也是为什么我们在写代码时尽量让循环条件、判断条件保持固定模式的原因——分支预测器最喜欢有规律的程序。4.2 乱序执行与超标量让指令不再按部就班为了进一步挖掘程序的并行度现代CPU又引入了乱序执行技术。名字很直白CPU在保证最终计算结果与顺序执行结果一致的前提下允许指令不按照源代码顺序乱序执行。举个例子代码里有两条指令第一条结果必须等内存数据返回几百个周期第二条指令本身与第一条无关完全可以通过加法器先算出来。乱序执行会让第二条先运行充分利用第一条空等的时间。这块硬件内部需要一个很大的指令窗口存放已译码的指令、判断依赖关系、跟踪指令状态。指令窗口越大CPU找到可并行指令的机会越多但硬件复杂度也呈指数上升所以处理器设计本质是吞吐量和硬件复杂度的博弈。比乱序执行更进一步的还有**超标量Superscalar**技术也就是一个CPU核内在同一时刻有多条流水线并行发射指令。比如现代Intel/AMD处理器的一个核心每个时钟周期最多可以分发4到6条指令。核心内部实际有多个ALU、多个访存单元、多个浮点执行单元就像一个工厂里有多台并行运转的生产线而控制器可以根据指令之间的依赖关系把它们调度到不同的执行单元上。这些技术加在一起让一个CPU核心的实际计算能力远超主频这个数字本身。主频只是时钟快慢真正决定计算能力的是每个时钟周期处理了多少条指令。这也是为什么同样主频的CPU新架构比老架构性能差好几倍的原因。4.3 多核与缓存一致性另一个维度的并行单核的性能提升越来越难尤其是功耗墙和摩尔定律放缓之后硬件厂商转向多核路线。多核本身也是在冯诺依曼框架内的扩展每个核心都是一个完整的、独立的处理器它们共享主存各自持有私有缓存。但多核带来一个新的问题如果核心A修改了一个变量的值核心B的缓存里还留着旧值怎么办这就引出了缓存一致性协议最著名的是MESI协议Modified, Exclusive, Shared, Invalid。每个缓存行在四个状态之间转换核心之间通过总线消息同步状态。缓存一致性的代价非常大。当你写多线程程序时两个线程如果频繁共享同一个变量每次修改都会引发缓存行状态切换称为缓存行乒乓性能惨不忍睹。这也是为什么无锁编程、线程本地存储、数据分区这么重要的原因——它们从根上避免多个核心竞争同一个缓存行。多说一句很多人以为多线程一定比单线程快实际在多核机器上跑过共享数据密集的任务后你就明白了如果任务的数据共享度太高多线程版本可能比单线程还慢。方向是对的但缓存不一致的同步成本把并行收益吃掉了。这也是为什么每代Java版本都在完善ThreadLocal和Contended注解、LongAdder等工具本质上都是为了绕开冯诺依曼体系结构下的多核缓存竞争。4.4 软硬协同程序员如何利用体系结构知识写出快代码上面几节讲的都是硬件层面的努力但作为软件开发者我们不能坐等硬件优化需要主动去喂饱CPU。我在这里整理几条实战中反复验证过的经验尽量顺序访问内存数组、切片比链表、哈希更友好因为它们天然连续。当你需要频繁查找时很多时候排序后二分反而比哈希表快。避免虚假共享多线程时各线程操作的数据尽量避免落在同一个缓存行中。JAVA里可以用Contended注解C/C里可以用对齐填充来隔离。把浅层循环展开适度循环展开能减少分支判断次数提升指令级并行度。编译器通常也会自动做但当你手写关键循环时手动展开往往还能再压榨一点。选择合适的数据结构如果你频繁在队列头尾操作但数据量不大数组手写的环形队列往往比链式队列快得多。因为链式队列的每次节点访问都可能触发缓存未命中。这些都是吃透了冯诺依曼瓶颈之后的反向利用。知道底层怎么工作写代码时就多了一双眼睛能看出性能瓶颈到底在算法还是在内存访问模式。5. 学习中的常见误区与实战排查技巧5.1 常见误区速查你很可能也是这么想的说到体系结构我发现很多学习者包括当年的我自己都会有一些固化误解。下面这张表我梳理了比较常见的几种后面附上我的纠偏解释。常见误区实际情况冯诺依曼结构已经过时了所有通用CPU至今仍遵循存储程序与数据共用存储的基本设计只能说工程上做了大量修补指令就是指令数据就是数据严格区分在冯诺依曼结构里指令和数据的区别取决于CPU如何解读而不是存储介质本身CPU主频越高性能越好主频只是时钟速度IPC每周期执行指令数、缓存、内存带宽等同样关键流水线只影响硬件跟程序员无关分支预测失败的代价最终会体现在程序运行耗时上分支规律性直接影响流水线效率多核一定快数据共享较多时缓存一致性协议会拖垮并行收益甚至比单线程更慢第二行尤其值得说说。很多人会拿软件和硬件两分法想问题觉得程序代码和程序处理的数据应该是两种完全不同的东西存储空间也该分开。但冯诺依曼恰恰打破了这个直觉它的天才之处也在这里不区分才使得编译器可以把程序自身当作数据来处理才使得写一个能够生成程序的程序比如编译器、解释器变得如此自然才使得操作系统可以轻松地把一个新的程序加载到内存里运行。5.2 从调试现场看体系结构段错误、反汇编与PC跳转我接下来少讲理论多讲几个调试经验。理解了下面这些东西你再遇到系统崩溃时就不至于只会看熟知的是不是空指针——大部分崩溃其实是 CPU 跳到了一条非法指令导致的。第一个经典场景是段错误。段错误的本质是程序访问了无效内存地址可能是一个空指针解引用也可能是一个已经释放的内存。但如果你仔细去看核心转储core dump里的寄存器状态会发现崩溃点往往不是最开始出错的那一行而是被带偏了很久之后才崩。比如说一个结构体里的数据因为越界写被改坏了等到另一个线程去使用这个结构体的时候才爆炸此时崩溃的真正原因已经离得很远。体系结构的指令和数据不分家在这里体现得特别明显数据被覆盖成垃圾CPU把这些垃圾当作指令或指针去执行整个程序的行为就变得诡异了。所以我的排查建议是遇到诡异的崩溃不要只盯着当前的调用栈多往回看看内存中是否有非预期的覆盖写。开个AddressSanitizerASan或者Valgrind通常能直接定位到是哪个地址被非法写入了。ASan生效的原理本质上就是在每个内存访问前后插入检查指令让CPU提前发现越界行为而不是任由垃圾数据自由传播。第二个场景是反汇编。你用objdump -d或者gdb的disassemble命令去看程序的汇编代码时会发现编译器生成的代码顺序跟你写的源代码顺序完全不同。编译器为了优化指令流水线、缓解分支惩罚、提升缓存局部性会尽心尽力地对指令重排。有时候你看到一个mov指令出现在完全意料之外的位置就是这个原因。这种重排绝大多数情况下是安全的但在涉及多线程共享变量、没有显式使用原子操作或内存屏障时重排就会导致可见性问题。这也是Java内存模型、C11内存模型存在的根本原因它们相当于是在一个默认会乱序执行的CPU之上为程序员重新建立了可见性和有序性的规则。5.3 初学者怎么入手建模、做实验、看汇编我经常被问学了冯诺依曼体系结构感觉懂了但过两周就忘了。怎么才能真正内化我分享一个自己用过、也推荐给别人的三步法。第一步画图建模。自己动手画一张包含五大部件的方框图把一条简单汇编指令比如add eax, [ebx]的完整执行过程画出来标记每一步的参与者控制器取指、译码、从内存读数、送到运算器、结果写回。画完这条指令再去练习if-else对应的跳转指令怎么改变PC值最后用纸笔模拟一个只有几条指令的小程序跑完整个过程。这个过程不需要任何开发板或软件但非常有用——它逼着你在抽象逻辑和具体硬件之间建立一条通路。第二步做实验验证。找一个有x86或ARM的Linux环境用perf stat看一下你的程序的分支预测失败率、缓存命中率、IPC。跑一个顺序访问数组的求和程序再跑一个随机访问的程序对比perf stat的输出。这种数据比看十篇理论文章都有说服力。我最初理解局部性这个词就是靠实测一个数组顺序求和和随机求和之间的巨大差距当时真的被震撼到了。第三步读懂一层汇编。不需要精通但至少要会看简单的C程序编译出的汇编。把gcc -S生成的汇编文件打开对照源代码逐行看你会发现编译器是怎么把变量、函数调用、循环翻译成取内存、做算术、跳转这几类基本动作的。这个过程会让你对程序在计算机中到底是什么形成一种非常踏实的理解——程序本质上就是一堆排好序的指令和数据而CPU只是无脑地按PC去跑。我自己在实际带人的时候第一步往往跳得很快因为大多数人急于求成。但其实第一步最不该跳过没有建立起CPU怎么一脚一脚跑指令的直觉后面的流水线、缓存、多核全都学得云里雾里。宁可慢一点把前面这张图画熟后面整个计算机组成原理的课程都会顺很多。最后再分享一个我自己常用的扩展路径学会了冯诺依曼体系结构可以顺手去关注一下RISC-V或者ARM的指令集手册看看一条实际的指令编码长什么样、指令里怎么表示操作码和操作数。你会发现书上说的指令由操作码和地址码组成这句话在真实的指令编码里表现得非常具体。等有一天你能从十六进制机器码反推出一条汇编指令的含义那种计算机在我面前透明了的感觉是这个行业最迷人的体验之一。这个方向不需要很深的高等数学基础只要你愿意把图像画出来、把汇编敲进去、把perf的输出读明白就已经超越了百分之九十只会背概念的开发者。
返回列表