
① 钩子编译器替你写代码同一份源码-O0时编译器老老实实把你写的每行都搬成指令-O2时它直接给你换了一套算法。你没写过的代码编译器替你写好了——而且往往比你写得更好。本集用 5 个小函数看编译器在不同优化档下的手术。这一集是理解性能从哪来的核心编译器不是翻译机是重写机——它把你的意图重写成更快的等价代码。② 源码 vs 汇编对照// ① 代数化简__attribute__((noinline))intcompute(inta,intb){intxa*2;intybx;returny-a;// b 2a - a a b}// ② 死代码消除__attribute__((noinline))intdead_code(inta){intunuseda*1000;// 死代码returna1;}// ③ 内联intsquare(intx){returnx*x;}__attribute__((noinline))intuse_square(inta){returnsquare(a)1;}__attribute__((noinline))intsquare_nb(intx){returnx*x;}__attribute__((noinline))intuse_square_nb(inta){returnsquare_nb(a)1;}// ④ 循环展开常数界 vs 运行期界__attribute__((noinline))intsum4(int*p){ints0;for(inti0;i4;i)sp[i];returns;}__attribute__((noinline))intsum_n(int*p,intn){ints0;for(inti0;in;i)sp[i];returns;}① compute ——-O0照搬 栈上折腾 vs-O2只剩 1 条指令; -O0 compute(int, int): movl %ecx, 16(%rbp) ; 参数存栈 movl %edx, 24(%rbp) movl 16(%rbp), %eax addl %eax, %eax ; x a*2 movl %eax, -4(%rbp) movl 24(%rbp), %edx movl -4(%rbp), %eax addl %edx, %eax ; y bx movl %eax, -8(%rbp) movl -8(%rbp), %eax subl 16(%rbp), %eax ; return y-a ret ; -O2 —— 代数化简b 2a - a a b中间变量全没了 compute(int, int): leal (%rcx,%rdx), %eax ; a b ret② dead_code —— 死代码a*1000被删除; -O0unused 确实被算了imull $1000并存栈 dead_code(int): movl %ecx, 16(%rbp) movl 16(%rbp), %eax imull $1000, %eax, %eax movl %eax, -4(%rbp) ; ← 死代码没人读 movl 16(%rbp), %eax addl $1, %eax ret ; -O2死代码整体消失只剩 a1 dead_code(int): leal 1(%rcx), %eax ret③ 内联 —— 有noinline和没有的差别; -O0use_square 里真的 call 了 square use_square(int): ... call _Z6squarei addl $1, %eax ; -O2square 被内联call 消失直接算 a*a1 use_square(int): imull %ecx, %ecx leal 1(%rcx), %eax ret ; 对照组 use_square_nbsquare_nb 标了 noinline → 两档都保留 call use_square_nb(int): call _Z9square_nbi ; 编译器遵守 noinline不内联 addl $1, %eax ret④ 循环 —— 常数界直接展开运行期界保持循环; -O0 的 sum4是真的循环cmpl $3 / jle ; -O2 的 sum4完全展开 SSE2 向量化4 个数一趟 128-bit 搞定 sum4(int*): movdqu (%rcx), %xmm0 ; 一次读 4 个 int movdqa %xmm0, %xmm1 psrldq $8, %xmm1 paddd %xmm1, %xmm0 ; 两两相加 movdqa %xmm0, %xmm1 psrldq $4, %xmm1 paddd %xmm1, %xmm0 movd %xmm0, %eax ; 取低 32 位 ret ; -Os 的 sum4完全展开但用标量省空间优先不用向量寄存器 sum4(int*): movl 4(%rcx), %eax addl (%rcx), %eax addl 8(%rcx), %eax addl 12(%rcx), %eax ret ; -O2 的 sum_n运行期界 → 保留循环但做强度削减 ; 不再每次算 i*4base而是直接用指针递增 sum_n(int*, int): testl %edx, %edx jle .L12 xorl %eax, %eax leaq (%rcx,%rdx,4), %rdx ; 终点指针 p n*4 .L11: addl (%rcx), %eax addq $4, %rcx ; 指针 4代替 i*4 的乘法/索引寻址 cmpq %rdx, %rcx jne .L11 ret③ 为什么这么设计-O0的目标是好调试不是快每个局部变量都老实放栈上、每个中间结果都写回内存方便你打断点看值。代价是大量多余的 load/store。-O2的目标是快开启代数化简b2a-a → ab、死代码消除、内联、循环展开、向量化、强度削减等一整套优化。上面的每个手术都来自真实的 GIMPLE/GCC 中间表示优化。优化是层层重写代码编译器内部把你的函数翻译成中间表示做等价变换后再翻译成汇编。compute从 14 条指令变成 1 条是因为它在 IR 层面做代数化简。-O3比-O2多的是更激进的内联/循环变换对本集小函数两者结果基本一致compute/dead_code在-O3下同样只剩leal。-Os优先代码体积sum4完全展开但用标量指令少用向量寄存器、整体更短适合嵌入式/缓存敏感的二进制。noinline是你的关不掉的手术当你需要稳定的调用边界比如做性能剖析、固定栈深度、避免递归膨胀__attribute__((noinline))让编译器必须保留call。④ 深入一编译器重写代码的完整流水线理解优化先看编译器内部结构源码 → 词法/语法分析 → 前端中间表示如 GIMPLE→ 优化 pass一遍遍重写→ RTL → 汇编优化不是一次几十上百个 pass 依次跑死代码消除、常量传播、循环变换、指令选择……每个 pass 都等价重写中间表示让优化更安全在 IR 上做变换编译器能精确判断这是否等价不改变程序可观察行为为什么看似等价的变换会改变性能指令数、内存访问、分支预测、缓存行为——这些都是实现细节等价变换随便改。工程含义别在源码层面替编译器优化比如手动缓存中间变量编译器在 IR 层做得更好。你该做的是写清晰的语义让编译器能分析。⑤ 深入二强度削减strength reduction是什么sum_n里的addq $4, %rcx是强度削减的典型你写的是p[i]——每次迭代算p i*4乘法 索引寻址编译器改成指针每次 4加法——把贵的乘法换成便宜的加法对 int 是 4对别的类型是 sizeof——编译器按类型算好步长。这是编译器替你写代码的又一例你没写指针版本编译器从下标循环推断出来。现代编译器在你手写更快版本之前常常已经替你写了——除非有别名/副作用挡住它。⑥ 深入三向量化SIMD——一次处理 4 个 intsum4的-O2版本用xmm0128 位一次处理 4 个 intmovdqu (%rcx), %xmm0 ; 一次读 4 个 int ... paddd 两两相加 ... movd %xmm0, %eax ; 取结果SIMD单指令多数据一条指令对多个数据做同一操作编译器在循环界已知 无别名冲突时自动向量化-O2起 SSE2-marchnative起 AVX/AVX512-Os不向量化省代码空间——这就是优化档位影响指令选择的直观例证。完整向量化E21 会展开大数据数组的-O2/-marchnative能让一次加一个变成一次加 8/16 个性能差数倍。⑦ 常见误区误区 1“-O2和-O3差很多”对本集小函数几乎一样-O3的激进内联/循环变换对大型代码才明显。别迷信必须 -O3。误区 2“手写优化一定比编译器快”现代编译器在可分析的代码上常优于手工微优化手工技巧在编译器看不到的层面才有效缓存布局、算法选择。误区 3“noinline会让代码更慢”不一定。noinline用于固定调用边界调试、剖析、控制代码膨胀性能上少 call通常更快但内联过多导致指令缓存压力时noinline反而帮助。误区 4“-O0只是慢一点点”实测可能慢 5~10 倍E01 的 40ms vs 7ms。发布版务必开优化。误区 6“优化档只影响速度不影响体积”-Os就是为体积而生-O2内联/展开会增大二进制。嵌入式/下载分发场景要在速度 vs 体积间权衡用-Os或-Oz。误区 5“编译器优化不会改变行为”只要代码没有 UB优化保持可观察行为一旦触碰 UBE20优化可能肆意妄为。所以编译器优化不改变行为的正确表述是在无 UB 的前提下不改变可观察行为。⑧ 实战启示先写可读的代码优化交给-O2/-O3手工微优化比如缓存中间变量在-O2下常常是多余的编译器自己会做。帮助编译器的写法 增加可分析性const、final、尽量少让指针别名同一块内存、把循环界写成常量——这些让编译器敢做更强的假设。noinline 反汇编是理解编译器到底做了什么的显微镜性能出问题时先看.s别猜。发布版务必开优化-O0的产物不只是一点点慢可能慢 5~10 倍本系列 E01 实测过 40ms vs 7ms。用不同档位对比定位同一函数分别-O1/-O2/-O3/-Os出.s对比能一眼看出哪档触发了什么手术——这是性能分析的起点。⑨ 扩展专题一常量传播与折叠编译器会沿数据流追常量intconst_fold(){return402;}// → 编译期就算成 42movl $42, %eax常量传播把已知常量代入后续使用常量折叠402编译期算成42对constexpr变量/函数甚至能在编译期执行复杂计算E11 提过 constexpr lambda。后果constexpr/const越多、越纯无副作用、无 I/O编译器越敢提前算完。让编译器在编译期多做点是 C 性能的免费午餐。⑩ 扩展专题二别名分析与优化的许可证编译器能否激进优化取决于它能否证明无别名voidf(int*a,int*b){*a1;*b2;return*a;}若a/b可能指向同一内存别名return *a必须重新读可能读到 2编译器做别名分析基于类型、restrict 等判断不可能别名才敢缓存*a的值restrict/__restrict告诉编译器这个指针独占换取激进优化。工程启示别在同一函数里让两个指针看起来可能指向同一块内存——编译器会保守丢掉优化。用const/restrict/值语义帮助它分析。⑪ 扩展 FAQQ-O2和-O3到底差什么A-O3增加更激进的内联阈值、循环变换如循环交换、剥离、IPA过程间分析。对本集小函数没差对大型数值代码有差。Q-O0的调试体验为什么好A变量老实放栈、不重排、每步都可断点查看——代价是慢。-OgGCC是保留调试信息 轻度优化的中间档。Qnoinline能用在热函数上吗A能但通常内联到热调用点更快noinline用于固定栈深度/剖析/避免内联爆炸。要压测单函数就用它。Q怎么知道某个优化触发没有A反汇编看指令形态如paddd向量化、leal代数化简、无call内联或用-fopt-info让编译器打印优化决策。Q-marchnative一定更快吗A多数时候是用上 AVX2/AVX512但二进制不能迁移到别的 CPU可能 illegal instruction。发行版用保守-march内网/专属机器用 native。⑫ 扩展实验五档对比对E19_opt.cpp分别-O0/-O1/-O2/-O3/-Os -S对比compute/sum4的指令数。-fopt-infog -O2 -fopt-info-vec -c E19_opt.cpp看编译器报告哪些循环被向量化。常量传播写constexpr表达式-O2 -S确认编译期算完movl $42。别名实验去掉/加上__restrict对比同一函数是否被优化return *a是否直接返回常量。-Osvs-O2对比sum4的两种展开形态向量 vs 标量用objdump -d数指令字节数。⑭ 扩展专题三内联的经济学——为什么不是越多越好内联消除 call 成本但不是免费的代码膨胀每处调用都复制函数体指令缓存I-cache压力增大可能反而变慢编译时间内联更多 → 编译器要做更多分析调试内联后断点/栈回溯变模糊-Og缓解。编译器按启发式决定内联函数小几行→ 基本必内联函数大/调用点多 → 权衡call 成本 vs 指令缓存inline/constexpr是提示__attribute__((always_inline))/noinline是强制。工程含义别给大而冷的函数加inline无用且膨胀也别担心小热函数——编译器自己会内联。inline的正确用法是跨编译单元的小函数定义在头文件而不是求快。⑮ 扩展专题四循环优化全家桶——展开/向量化/交换/剥离-O3的循环变换远不止展开变换作用汇编体现循环展开减少循环开销比较/跳转循环体复制多份向量化一次处理多元素paddd/movdqu循环交换外层/内层互换缓存友好嵌套循环结构变剥离处理边界余数主循环 余数循环强度削减乘法→加法addq $4替代i*4不变量外提循环内不变计算移出循环前算一次启发循环界已知常量/数组大小比未知更能触发优化循环体无别名restrict/值语义更敢变换。写可分析的循环 给编译器发优化许可证。⑯ 扩展 FAQ第二轮Q-O2能向量化吗A默认-O2开 SSE2 向量化本集 sum4 就是更宽的 AVX/AVX512 要-marchnativeE21 会展开。Qinline和constexpr有关系吗Aconstexpr函数隐式 inline可在编译期调用也可能被内联。两者都提示编译器可展开但语义不同。Q为什么我的循环没被优化A常见原因循环界未知、有别名指针可能重叠、有副作用I/O/volatile、函数太大。用-fopt-info看编译器为何放弃。Q-fno-exceptions会改变优化吗A会——函数无需保留异常展开信息代码更小更紧凑E10 讲过某些优化如内存重排更自由。Q-flto链接期优化有什么好处A跨编译单元内联/常量传播E08 讲过 LTO 去虚化。代价编译/链接更慢。发布版常开。⑰ 扩展实验第二轮-fopt-info-vec看哪些循环被向量化、哪些没被以及原因。内联经济学一个大函数多次调用-O2看是否内联加noinline对比指令缓存友好性。-marchnativesum4/数组累加用-marchnative -O3 -S看是否从paddd升级到vpadddAVX256 位。LTO 实验拆两个编译单元-O2 -flto前后对比跨单元内联。restrict效果带/不带__restrict的同一循环反汇编对比是否向量化。⑲ 扩展专题五从本集出发重看编译器信任的边界本集所有手术化简、消除、内联、展开、向量化都有一个共同前提编译器认为你的代码没有 UB因此等价重写是安全的。没有 UB→ 优化只是换一种更快写法行为不变有 UB→ 编译器可以按UB 不存在来推理假设那个分支永不执行、那个指针永远有效——结果可能出乎意料E20 会给你实弹。所以帮编译器优化和别写 UB是同一件事的两面写可读、清晰、无 UB 的代码 → 编译器放心重写 → 快写碰运气/越界/未初始化 → 编译器放飞→ 反直觉行为。这就是为什么本系列一直强调实测汇编汇编暴露的不是代码对不对而是编译器在你的假设下敢做什么。理解了信任边界你就同时理解了优化与UB 的代价。⑳ 扩展专题六性能剖析的起点——先看 .s 再看 profiler当某函数慢正确的排查顺序反汇编看热点objdump -d或编译.s看该函数编译成什么样——是 1 条指令编译器已优化还是 20 条没优化到换档位对比-O1/-O2/-O3各编译一次看指令形态变化——定位是哪档优化没触发查为什么没触发-fopt-info、别名/UB 假设、循环界是否已知再上 profiler只有确认编译器已尽力才值得为算法/缓存/内存布局E21 层动手。为什么先看 .s很多时候慢是编译器在某个假设下不敢优化别名、UB、未知循环界改代码比改算法更有效。先看机器码再决定改哪——这是本系列一贯的方法论。㉑ 扩展 FAQ第三轮Q-O3会引入 bug 吗A不会引入UB但会放大已有 UB 的表现E20。代码本身有竞争/越界/未初始化时越激进越容易暴露。Qvolatile能阻止优化吗A能阻止对该变量的访问被合并/删除/重排E17 讲过但会拖慢访问。它也是编译器信任边界的手动刹车。Q-fno-strict-aliasing是什么A关闭严格别名假设不同类型指针不重叠。旧代码/强制转换多时开着它更稳但会损失部分优化。Q__builtin_expect/likely有用吗A提示分支概率布局优化、预测器辅助现代编译器收益有限但无害。热点分支可用。Q为什么死代码消除需要证明没有副作用Aa*1000若会抛异常/读 volatile/调用外部函数就不能删——编译器只删可证明无副作用的死代码。这解释了为什么看似没用的代码有时不被删。Q-O2下还能用-fno-inline吗A能关闭所有内联。用于对比内联贡献了多少性能、控制代码体积。日常不用调试/测量时有用。Q编译器优化会改浮点结果吗A可能——-ffast-math允许重排浮点运算结果略有差异但快。默认-O2不改浮点语义追求性能且接受微小误差才开 fast-math。Q-Og适合什么A调试档保留优化 完整调试信息。写代码/单步调试用-Og比-O0快又比-O2好调试是开发循环的推荐档。㉒ 扩展实验第三轮strict-aliasing 演示不同类型指针强转访问同一内存-O2下被按类型假设优化出反直觉结果-fno-strict-aliasing后恢复。likely/unlikely热分支加[[likely]]对比汇编布局差异。副作用阻止删除volatile或外部调用插入死代码看它是否被保留证明可证明无副作用门槛。-fopt-info全量对一段数值代码打印全部优化决策读懂为何某处没优化。O0/O2 实测同一算法-O0vs-O2计时可到 10 倍差把本集优化档位变成数字。㉓ 悬念编译器敢做这些激进的重写靠的是一个前提它完全信任你写的每一行都是合法的。只要你的代码触碰了未定义行为UB这种信任就会变成一把刀。