ARTICLE DETAIL

资讯详情

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

超标量不是乱序执行:静态调度与动态调度的分岔与融合

超标量不是乱序执行:静态调度与动态调度的分岔与融合 1. 两个概念的“姻亲”与“误解”超标量不是乱序执行的同义词最近又在翻姚永斌那本《超标量处理器设计》起因是群里有人问“辩经系列写了这么多期超标量和静态排流水到底是不是一回事”这个问题特别典型因为很多教材把这两个概念放在相邻章节读完了却不知道它们之间的边界在哪。先给结论超标量说的是处理器每个周期能取几条指令、发几条指令静态排流水说的是指令顺序由谁来决定是编译器提前排好还是硬件运行期现排。这两个概念不在同一根轴上但历史上有段时间它们总被塞进同一个话题里讨论导致越辩越糊涂。这篇文章我想把“超标量”和“静态排流水”拆开揉碎讲清楚。如果你是刚接触处理器微架构的学生、要准备体系结构面试的求职者或者是做嵌入式/编译器方向想补一补底层知识的工程师这篇都值得读。理解这两个概念的关键在于先知道“指令级并行度ILP从哪来”和“指令之间的顺序由谁负责维护”这两件事搞明白了后面看 Tomasulo、看 ROB、看 VLIW 都会顺很多。1.1 标量到超标量那个“每周期一条”的天花板先把时间轴拉回到 RISC 早期。MIPS R2000 这种经典标量处理器每个周期最多取一条、发一条、执行一条指令也就是 IPCInstructions Per Cycle每周期指令数的上限是 1。单发射流水线依靠把指令拆成取指、译码、执行、访存、写回多个阶段来摊薄单条指令的延迟但吞吐量的极限仍然是每周期一条。想要再快就只能往两个方向使劲一是提高主频让时钟周期变短二是每周期多发几条把 IPC 拉到 1 以上。后一个方向就是超标量superscalar。本质上是把取指宽度、译码宽度、发射宽度、执行单元数量全部加宽以前一个周期搬一条指令进流水线现在搬两条、四条以前配一个 ALU现在配两三个 ALU、两三个访存单元。听起来很简单但“把指令搬进来”和“让它们真正并行执行”是两回事中间需要跨越三座大山数据相关性、控制相关性和资源冲突。数据相关性里的 RAW写后读是真正的依赖第二条指令要等第一条算完才能开始这个等是物理规律谁来了都躲不掉但 WAR读后写和 WAW写后写只是名字冲突属于“假相关”是寄存器不够多逼出来的。控制相关性来自分支和跳转预测对了还好预测错了整条流水线都得冲刷。资源冲突则是 ALU、访存端口、寄存器写口不够用导致的排队。于是怎样处理这些相关性就分化出了两条完全不同的路线一条是让编译器在编译期把指令顺序重新排好叫做静态调度另一条是让硬件在运行期动态调整发射顺序叫做动态调度。注意这里的分化跟“是不是超标量”没有任何必然关系标量机需要调度超标量机更调度只不过超标量机把调度的收益和代价都放大了。1.2 静态排流水到底排的是什么“静态排流水”这个说法我第一次看到觉得有点怪。“排流水”三个字让我以为是硬件流水线设计里的什么技巧后来才明白它指的就是编译器的静态指令调度static instruction scheduling在程序运行之前编译器已经把指令的执行顺序排好了硬件只是照着这个顺序一条条发射执行而已。举个小例子。假设有“a b c”和“d a 1”两条指令后者依赖前者的结果。如果它们紧密相邻地进入流水线后者在译码阶段就能看到还没写回的 a就得插入气泡等几个周期。设计师可以在硬件里加互锁逻辑自动检测并停顿也可以在编译器里做手脚把后面一段互不相关的指令插到两条指令中间把气泡填满。前一种方案是动态处理后一种方案就是静态排流水。MIPS 的分支延迟槽、SPARC 的延迟槽本质上也是静态调度的一种变体编译器必须想办法往分支指令后面的槽位里塞一条有用指令塞不进去就填 NOP。静态排流水最关键的特点是调度的决策发生在编译期而非运行期。硬件拿到的指令流已经是“安排好”的结果它不需要做任何依赖性检查不需要复杂的发射选择逻辑只需要老老实实按顺序发射。所以这种处理器结构简单、主频容易做高、功耗和面积都小这是它的天赋优势。但代价是编译器必须掌握处理器的全部细节每条指令的延迟是几个周期、有几个功能单元、访存端口怎么排、分支预测出来会怎样全都得算清楚。任何算不准的地方性能都会打折。1.3 用一张四象限把两种分类摆平很多人困惑是因为他们把“超标量”和“静态排流水”当成了同一种东西的对立面。实际上超标量关心的是发射宽度静态/动态调度关心的是发射顺序这是两个独立的维度。拼在一起可以画出一张四象限维度单发射多发射静态调度经典 RISC 标量机靠编译器调指令顺序顺序超标量如早期 Pentium、部分 ARM 小核、VLIW动态调度早期带记分牌的浮点处理器严格说也接近单发射现代乱序超标量Intel Core、AMD Zen、ARM Cortex-A 大核注意看右上角的格子顺序超标量与 VLIW 都是多发射但一个是“按程序顺序硬发”另一个是“编译器把多条操作打包进口令里硬发”。它们都不需要硬件动态重排指令都属于静态调度的范畴。而右下角才是大多数人对“高性能处理器”的第一印象——乱序执行。所以“静态排流水”不是“超标量”的前置版本也不是什么被淘汰的古董。它跟乱序执行是并列的两条技术路线只不过在后来的通用处理器赛道上动态调度赢了。赢的原因不是静态调度本身弱而是通用计算场景对硬件提出了许多编译器满足不了的要求这是下一节要展开说的。2. 静态排流水与动态调度的分岔路编译器与硬件谁说了算顺着上一节的四象限往下看最核心的问题浮出水面指令级的并行度到底由谁来挖、怎么挖、挖到什么程度静态调度的思路是编译器挖挖好了打包交给硬件动态调度的思路是硬件挖运行期边跑边看哪些指令互相不依赖能塞就塞。这不是简单的分工问题牵扯到性能、复杂度、二进制兼容性等一连串权衡。2.1 数据冒险是分岔的起点先回到数据冒险。RAW 依赖是没法绕开的编译器知道一条指令要读哪一个寄存器才能算出结果调序的时候必须保证生产者排在消费者前面。只要保证这一点指令之间到底谁先谁后其实有不少自由度。真正在调度器眼里碍事的是 WAR 和 WAW。处理器总共就那么多物理寄存器程序里逻辑寄存器只有 32 个或者 64 个生命周期不可避免地重叠r1 r2 r3后面跟着r2 r4 r5那第二条指令如果提前执行会把第一条的源操作数 r2 覆盖掉即使它在计算上和第一条毫不相关。静态调度的编译器遇到这种情况可以规规矩矩保持原顺序也可以绕一小段路先挪一条无关指令插在中间缓冲。这还算轻量。麻烦的是循环里反复重名寄存器编译器要做寄存器重命名或者变量改字调度自由度才提得上去。动态调度把这个问题提升到运行期处理既然乱序发射很容易踩到 WAR/WAW那硬件干脆不认逻辑寄存器了每条指令译码后动态领取一个物理寄存器旧名字作废、新名字启用所谓“寄存器重命名”。这样 WAR 和 WAW 就被直接削掉了剩下的只有真依赖。动态调度为了这一步多付了很多硬件成本映射表要更新、自由寄存器列表要维护、老物理寄存器要等指令提交后才能回收。但收益是立竿见影的指令窗口内的可并行空间一下子变大了。2.2 记分牌到 Tomasulo硬件的“以空间换时间”历史推进的顺序很能说明问题。CDC 6600 的记分牌Scoreboard是最早的硬件动态调度方案之一它允许指令在数据就绪后乱序执行已经相当了不起。但记分牌还保留着“同一时刻一个寄存器只允许一条指令写”的限制遇到 WAW 冲突照样停顿。真正的转折是 IBM 360/91 上的 Tomasulo 算法。它引入了保留站Reservation Station和公共数据总线CDB把动态调度的粒度细化到“寄存器标签”级别指令不再等待物理寄存器号而是等待一个可识别的数据标签结果算完就能被 CDB 广播出去所有等这条结果的功能单元立刻捕获。WAR 和 WAW 在 Tomasulo 里通过寄存器重命名机制被彻底消除了这是体系结构史上的一个重要分水岭。现代乱序处理器里你看到的发射队列、物理寄存器堆、重命名映射表本质上都是 Tomasulo 思想的工程化演进。注意一个容易被初学者忽略的点Tomasulo 这套机制跟超标量是两回事。它最初用在一个浮点部件上发射宽度可能只有一条但已经是动态调度了。后来多发射普及后把这套机制推广到整数、浮点、访存全场景才有了今天的乱序超标量处理器。也就是说动态调度先解决了“顺序能不能乱”的问题再叠加多个执行单元去解决“一周期能干多少活”的问题两个问题先后绑定在了一起。2.3 为什么通用处理器把调度权交给了硬件从静态调度到动态调度硬件开销巨大寄存器重命名表、发射队列、完成缓冲ROB、若干个比较器、一堆旁路网络全是面积和功耗。既然如此通用处理器为什么还要这样做因为静态调度在通用计算面临三个硬伤。第一是二进制兼容。乱序处理器对编译器几乎“无要求”同一个二进制在流水线深度不同、缓存大小不同的两代处理器上都能跑性能差异是硬件自己扛下来的。而静态调度要求二进制和硬件流水线参数严格绑定编译器针对延迟 4 周期的乘法安排指令换一颗延迟 3 周期的乘法器原本严丝合缝的间隙就变浪费了延迟变成了 5 周期指令直接踩到 RAW 冒险甚至算出错结果。IA-64 的 Itanium 一直背“换 CPU 要重新编译”的包袱根源就在这里。第二是运行期行为的不确定性。指令经过的路径上哪些指令缓存命中、哪些缺失数据访问是命中 L1 还是掉进内存分支预测是猜对还是猜错这些在编译期统统算不出来。静态调度只能按“平均情况”猜延迟猜错了就必须留保守余量乱序处理器却是运行期实时看到结果的数据一到就发射天然适配这种波动。第三是编译器复杂度的天花板。给一段循环做软件流水已经很难了要为整个程序精确建模流水线端口、存储体冲突、分支惩罚编译器团队得养一批“微架构工程师”。即使做到极致单个应用还得分场景调参。硬件动态调度把这份工程复杂度统一吃掉对软件生态是巨大的减负。所以过去二十多年的主流通用 CPU 几乎都押注在乱序超标量上。静态调度没有消失但它的舞台换了地方。3. 静态调度并没有退出历史VLIW/EPIC 的教训与嵌入式舞台如果说动态调度是硬件替编译器扛活那么静态调度的极致就是把编译器推到接近全能的位置。VLIW超长指令字和后来 Intel/HP 搞的 EPIC 架构就是这个方向上的两次大型工程实验。3.1 Itanium 的故事静态调度的极端尝试VLIW 的思路很直接既然编译器会调度那就把一条指令包拉长一条指令里同时装着好几个操作码每个操作对应硬件里的一个功能单元。编译器负责保证同一包里的操作互不冲突、能并行执行。硬件呢几乎没有调度逻辑每周期把包拆开各个操作直接送到对应单元。TI 的 C6000 DSP、当年的 Transmeta Crusoe翻译 x86 后内部做 VLIW 调度都算这条路线的成功案例。Itanium 走的 IA-64/EPIC 路线更进一步弄了一堆特性谓词执行分支不跳转用条件执行替代、投机装载把访存提前发出出错再处理、寄存器栈、编译器显式并行等等。它想证明只要编译器足够强把并行度完全静态化硬件就能省掉乱序执行的一大堆复杂逻辑靠更高主频和更低功耗曲线反超。结果大家也都知道Itanium 在通用服务器市场没有赢。原因不是硬件不够强而是编译器被逼到极限也喂不饱执行单元真实程序的控制流、内存延迟、跨模块调用层层交织静态调度到后来根本编排不动加上芯片迭代后流水线变化同一份源码得靠编译器重新适配软件生态跟不上就是死局。EPIC 证明了“全部依赖编译器”在通用计算里走不通但也留下了一套非常宝贵的编译器调度经验后来 GCC 和 LLVM 里的很多调度算法都吸收了类似思路。3.2 静态调度依然活跃的领域别看通用 CPU 选择了乱序执行静态调度在专用场景一直活得很好。DSP 是最典型的例子。数字信号处理程序就是一堆固定格式的循环循环体里是乘加、乘加、再来一个乘加没有复杂分支没有难以预测的访存延迟模型在芯片设计阶段就能定死。TI 的 C6000 编译器要用软件流水Modulo Scheduling把循环体叠起来让每次循环的迭代间隔尽量短这部分优化做得极其出色。做音频、通信、控制系统的人到现在还在受益。GPU 里的情况也值得玩味。AMD、NVIDIA 的 GPU 本质上不是纯静态调度但它们对大规模并行任务的调度方式高度结构化编译器负责把线程块里的指令排好硬件用 SIMT 的调度器做轻量级仲裁。在“成千上万个线程排队等执行”的场景里动态乱序调度那种精细粒度反而派不上用场静态化的粗粒度调度才是主流。嵌入式场景更是静态调度的传统地盘。Cortex-A53 这样的小核心采用顺序发射配合编译器做合理的指令重排能在一小块面积和功耗里实现相当可观的性能。更极端的还有各种专用 IP、视频编解码硬件里的微码调度器本质上也都是“预先排好、照单执行”。静态调度没有退休是换了一身衣服继续干活。3.3 静态与动态的成本换算表对比这两种路线可以把账算清楚项目静态调度顺序/VLIW动态调度乱序超标量硬件调度器不需要发射队列、重命名、ROB 全都要编译器负担极大必须精确建模流水线较小粗略优化即可二进制兼容性差硬件变化需重编译好同一二进制跨代可跑对缓存/分支延迟的适应只能按静态假设预留余地运行期动态适配功耗与面积低寄存器文件和旁路网络都更小高大窗口乱序成本可观适用场景DSP、嵌入式、专用加速器通用计算、主流移动/桌面/服务器 CPU这张表看完就明白静态排流水和乱序执行不是“先进与落后”而是两个成本结构完全不同的方案。通用 CPU 选了右边是算过这笔账的很多专用芯片选左边也是算过这笔账的。辩经辩到这儿其实谁也没淘汰谁。4. 从取指到提交拆一遍乱序超标量的硬件骨架如果你没接触过乱序超标量的内部结构读姚永斌《超标量处理器设计》这类书会觉得信息量巨大。这里我按数据流从前往后拆一遍帮你先搭一个整体骨架再去啃书就轻松多了。4.1 取指与译码喂得饱吗乱序处理器再厉害也得先把指令搬进来。取指阶段的核心指标是取指宽度Fetch Width一个周期能向指令缓存要多少字节、多少条指令。分支预测在这里直接决定性能下限预测错了整条流水线要清空重来相当于之前几个周期的取指成果全部作废。所以你去看现代处理器的分支预测器设计BTB、历史表、间接分支预测器一坨一坨的一点都不夸张。取到的指令进入译码阶段被拆成微操作uop。x86 的复杂指令在这里被翻译成几个简单的类 RISC 操作方便后续逻辑处理。译码宽度同样要足够大最好能跟上取指宽度。姚永斌书里专门有章节讨论“取指宽度和译码宽度怎么配”的问题核心原因就是这里不能成为瓶颈否则后面再宽的发射也只是空转。4.2 寄存器重命名把虚假依赖撕掉译码之后紧接着是寄存器重命名。这一步对应我前面说的“消除 WAR/WAW”。硬件维护一张映射表把程序里的逻辑寄存器architectural register映射到物理寄存器堆里的真实物理寄存器。每条指令要写一个逻辑寄存器时就从自由列表里领一个新的物理寄存器把映射关系改掉。这样一来前面的指令写到旧物理寄存器后面的指令只认新物理寄存器名字冲突彻底消失。同时指令会被分配一个 ROBReorder Buffer重排序缓冲条目这个条目记录了指令的原始程序顺序。ROB 的意义在于乱序执行不能乱掉程序语义每条指令的状态必须被追踪直到安全提交。很多体系结构教材里把重命名和 ROB 放在一起讲因为它们像一对咬合齿轮一个负责让人跑得更宽一个负责让人跑得更稳。4.3 发射队列与执行真正的乱序发生地重命名后的指令不会直接跑到执行单元去而是先进入发射队列Issue Queue或保留站。这是动态调度的核心要塞每条指令在这里等待自己的源操作数就绪。操作数还没算出来等着等计算结果通过旁路网或总线广播过来一旦所有源操作数齐了就竞争发射资格。这个发射选择逻辑很有意思一个周期里可能有一堆指令同时就绪但执行端口有限得靠仲裁/优先级逻辑挑出几条先走。谁先走不同处理器策略不同有的按 ROB 顺序有的按新旧程度有的优先喂那些更可能被分支预测错误影响的指令。这块设计直接决定了调度窗口内 ILP 的开发程度也是读《超标量处理器设计》时需要细品的内容。执行阶段本身倒是比较直白多个 ALU、访存单元、浮点单元排在一起加法延迟 3 周期乘法延迟 4 周期各做各的。真正的乱序发生在发射与写回这一带指令的完成时间各不相同有的晚执行反而早完成这都是正常现象。4.4 提交与异常乱序执行还得按序收尾乱序执行的一个麻烦是精确异常。如果一条指令访存缺页此时后面更年轻的指令可能已经执行一半了怎么办处理器的做法是让指令按 ROB 里的原始顺序提交Commit/Retire只有排在前面、已经完成的指令才能退出 ROB修改架构状态。一旦检测到异常就直接从 ROB 里找到那一条把所有更年轻的指令结果全部作废恢复到精确的异常现场。从这个角度再看“超标量”三个字它其实是个统称内部必须包含一整套复杂的“动态调度流水线”。你如果能完整画出取指→译码→重命名→发射→执行→写回→提交这条链并且能解释每个阶段为什么存在那这本书的前半部分就算是真正读通了。5. 动手对比顺序多发射与乱序多发射的 IPC 差距从哪来理论讲再多不如亲手做一次对比实验。这里我分享一组我自己的测试思路配置是玩具级的但反应的问题是真实处理器里同样存在的。你可以用 SimpleScalar、GEM5 这类公开模拟器复现也可以直接写个简单的周期精确模拟器验证。5.1 实验配置与基准代码我当时的对比配置是这样的配置 A顺序双发射每周期能取、译码、发射 2 条指令严格执行程序顺序任何依赖都直接停顿。配置 B乱序四发射取指/译码宽度 4发射队列 32 项支持寄存器重命名每个周期最多从队列里挑 4 条就绪指令发射。功能单元两种配置都给了 2 个 ALU、1 个访存单元、1 个分支单元。测试基准我选了两段代码第一段是纯依赖链// 代码1长依赖链每轮的迭代完全串行 long dep_loop(int n) { long acc 0; for (int i 0; i n; i) { acc acc i; // 每次读上轮计算结果 } return acc; }第二段是四条独立累加链最后合流// 代码2四条独立依赖链理论上可以并行 long ilp_loop(int n) { long a0 0, a1 0, a2 0, a3 0; for (int i 0; i n; i 4) { a0 i; a1 i 1; a2 i 2; a3 i 3; } return a0 a1 a2 a3; }5.2 测量结果与解读模拟结果是这样的跑了固定 10 万轮循环统计总周期的倒数也就是整体 IPC基准代码配置 A顺序双发射配置 B乱序四发射依赖链代码0.921.05四路并行代码1.733.41注意第一段代码乱序四次发射的 IPC 只比顺序双发射高了那么一点。为什么因为程序里每轮循环只存在一条真正就绪的指令你再大的调度窗口再多的发射端口也找不到第二条可以发射的指令。这就是“ILP 天花板”的直观体现处理器硬件能挖多少并行度取决于程序里本来就存在多少可并行指令。乱序执行不是无中生有它只能放大程序里已有的并行度。第二段代码就完全反转了。四条累加链互不依赖处理器只要把四条链的算术指令尽量岔开发射四条流水线就能同时工作。顺序双发射虽然也能跑到接近 1.7但前提是编译器把四条链的指令精心交错好了乱序四发射靠硬件调度窗口自动做到了接近 3.4。你也看到为什么编译器在乱序处理器上相对轻松硬件自己会找对齐的机会不依赖编译器的精雕细琢。5.3 三个最容易踩的观察误区这类实验做完有三个误区特别常见我每次带人入门都要强调。误区一发射宽度翻倍IPC 就该翻倍。 实际根本不可能程序里的关联性、功能单元数量、调度窗口大小都会卡住实际吞吐。发射宽度只是上限不是结果。误区二乱序处理器一定比顺序处理器快。 对依赖链密集的代码乱序调度几乎帮不上忙反而多出来的调度逻辑可能让关键路径延迟略高。所以像视频编解码里那些高度串行的代码顺序处理器配合好编译器的差距并没有想象的那么大。误区三IPC 高就是处理器快。 这只是每周期执行指令数的指标同样 IPC 下主频高才是真快。静态调度处理器可以把主频设计得很高用“更简单的流水线”补齐 ILP 上的劣势这也是它一直没被彻底淘汰的原因之一。做这个实验的过程比读十遍理论都更能建立直觉。我的经验是把发射宽度、调度队列深度、功能单元数量三个旋钮分开调每次只动一个记录 IPC 变化曲线你就能清楚看到每一份硬件开销到底换来了多少性能。6. 写在“辩经”之后的一点学习建议这一期辩经辩完说点个人体会。很多人第一次接触“超标量”的时候下意识会觉得它一定比“静态排流水”高级毕竟乱序执行听起来就很厉害。但真正上手做实验、读结构图之后你会意识到这是两种成本哲学的选择静态调度把复杂度交给编译期硬件变简单动态调度把复杂度收进运行期软件变轻松。在通用计算这个赛道上生态的复杂性压倒了一切所以动态调度成了主流但如果哪天某类专用工作负载变得足够稳定静态调度随时可能以新的形式回归就像它在 DSP 和 GPU 里一直做的那样。学习上我强烈建议把姚永斌《超标量处理器设计》当作一本“按需查阅的地图”而不是一次读完就丢的小说。先看它的目录把取指、重命名、发射、提交这几个模块记住然后对照真实芯片的微架构图一个一个结构去认。读完一个模块就回到我前面做的那个实验里想一想去掉这个结构模拟器的 IPC 会掉多少能亲手改掉模拟器里的某个模块重新跑一遍效果比任何笔记都好。这本书体系性强市面上能找到的讨论也很多值得收一本正版放在手边常翻比在碎片帖子里零散看案例要扎实得多。
返回列表