CHAPTER 50 / Compiler 与 runtime

优化首先要合法:别名、溢出与浮点重排

少一次读取,为什么反而算错了?

阅读与推演约 55 分钟练习时间另计

这一章要弄清楚

  • 用反例检查优化等价性
  • 区分signed/unsigned及IR承诺
  • 解释alias分析与vectorization条件

先备知识:指针:地址、空值与有效访问 / 原子操作与 happens-before / CPU cache、缓存一致性与 SIMD / AST、IR 与 SSA:同一个表达式的三种表示

C++20 本机逻辑模型;不是设备仿真或性能测量。厂商语法片段未在设备或 SDK 执行,实际 API 以匹配版本的公开文档为准。

优化不是把代码写得看起来更短

编译器优化必须保留语言合同要求的行为。把两次相同地址读取合并成一次,看似省事;但如果中间有写入,第二次值可能已经变化。优化前先问“哪些输入和执行是合法的”,再证明在这些前提下新旧程序等价,最后讨论收益。

例一函数先读p得到old,再通过q写入5,最后返回old加p当前值。若p和q指向同一个初始为2的对象,正确答案是2加5等于7;若错误地用第一次读取代替第二次,答案成为4。两条指针的类型和值关系,不应在没有依据时假定互不重叠。

别名信息为什么影响性能

Alias表示不同访问路径可能指向同一存储。编译器不能排除这种情况时,某些load消除、循环重排或vectorization需要更保守,或者生成运行期检查,选择安全的快速路径与通用路径。解决方法不是随便添加“不别名”承诺,而是让接口、数据组织和实际调用满足其要求。

int observe(int* p, int* q) {
    const int old = *p;
    *q = 5;
    return old + *p;
}
// 若 p==q,最后一次读取不能用old替换。

该例取值很小,不涉及算术溢出;它单独暴露内存依赖。好的优化测试会像这样一次隔离一个原因,而不是把别名、线程和浮点误差混在一个大程序里。

数学恒等式不自动适用所有类型

在无界整数数学中,x加一总大于x。但无符号整数按固定宽度模运算,最大值加一会回到零。例二使用uint32_t,普通值时比较为真,最大值时为假。Signed overflow在C++中是未定义行为,不能为了“验证溢出”直接运行有符号越界再把输出当可靠结果。

LLVM IR中的普通整数运算和带nsw、nuw标记的运算具有不同合同。额外标记是对允许执行的承诺,不是无条件加速开关。前端和优化pass只有在证明成立时才能引入;错误承诺可能让后续推导建立在错误前提上。

浮点重排也需要前提

浮点加法受有限精度与舍入影响,先把大正数与大负数相消,再加小数,可能与先把小数加到大数上不同。NaN、无穷、有符号零以及异常语义也可能影响变换是否合法。Fast-math一类选项允许更宽的假设,但使用者必须明确数值需求,不能只因为benchmark变快就默认接受。

对reduction尤其如此:树形合并改变结合顺序。应预先定义误差指标与非有限值处理,保留严格baseline,不把所有差异都叫bug,也不把任意差异都归为正常舍入。正确的讨论是某个变换在什么合同下被允许。

怎样验证一个优化假设

先列反例类别:指针重叠、不整除长度、零和边界值、非有限浮点、不同优化级别。接着检查优化remarks或IR变化,确认编译器究竟做了什么,再测目标负载。看到-O3比-O2慢也不矛盾,因为代码体积、寄存器压力、访存和分支行为都可能改变。

本章例子都在定义良好的小范围或无符号算术中运行,不靠触发UB产生戏剧化输出。面试时能说出一个具体合法性反例,并给出修正前提,比只列constant folding、CSE、loop unrolling的名字更能体现理解。

观察 · 推演

同一地址上的两次读取

object2old201 / 03 · MEMORYobject2old201 / 03 · MEMORY
第一次读取

p与q是同一对象的两个访问路径。

1 / 3
阅读完整推演文字
  1. 第一次读取

    object:2;old:2

    p与q是同一对象的两个访问路径。

  2. 写入

    object:5;old:2

    通过q写5;先前复制到old的2不随对象改变。

  3. 第二次读取

    correct:2+5=7;cached:2+2=4

    必须读取新值,而非复用old。

跟着例子,走完一遍

例题 01C++20 · 本机可运行

别名使第二次读取改变

p与q指向同一个初始值2。

  1. 先读old=2
  2. 通过q写5
  3. 正确返回2+5,错误缓存返回2+2
46-a.cpp
下载
// Original CPU teaching model; not a device simulator or benchmark.
#include <iostream>
#include <stdexcept>

void require(bool ok) { if (!ok) throw std::runtime_error("model check failed"); }

int original(int* p,int* q){const int old=*p;*q=5;return old+*p;}
int cached(int* p,int* q){const int old=*p;*q=5;return old+old;}
int main() {
 int a=2,b=2;const int good=original(&a,&a),bad=cached(&b,&b);
 require(good==7 && bad==4);
 std::cout<<"original="<<good<<" cached="<<bad<<'\n';
}

如何编译和运行下载的 .cpp 文件 →

结果与解释

original=7 cached=4。

演示错误变换;代码本身没有越界或溢出。

在本机运行这个例子

下载后,在文件所在目录执行。需要支持 C++20 的编译器;POSIX 示例还需要章节说明中的系统条件。

clang++ -std=c++20 -Wall -Wextra -Wpedantic -Werror -pthread 46-a.cpp -o example && ./example

预期标准输出:

original=7 cached=4
例题 02C++20 · 本机可运行

无符号边界反例

比较uint32_t的x+1>x,x=7及最大值。

  1. 固定32位无符号类型
  2. 定义良好的模加法
  3. 检查最大值回绕
46-b.cpp
下载
// Original CPU teaching model; not a device simulator or benchmark.
#include <iostream>
#include <cstdint>
#include <limits>
#include <stdexcept>

void require(bool ok) { if (!ok) throw std::runtime_error("model check failed"); }

bool increases(std::uint32_t x) {
 const std::uint32_t next=x+std::uint32_t{1};return next>x;
}
int main() {
 const bool ordinary=increases(7),edge=increases(std::numeric_limits<std::uint32_t>::max());
 require(ordinary && !edge);
 std::cout<<"ordinary="<<ordinary<<" at_max="<<edge<<'\n';
}
结果与解释

ordinary=1 at_max=0。

不能把无界数学恒等式无条件搬入固定宽度整数。

在本机运行这个例子

下载后,在文件所在目录执行。需要支持 C++20 的编译器;POSIX 示例还需要章节说明中的系统条件。

clang++ -std=c++20 -Wall -Wextra -Wpedantic -Werror -pthread 46-b.cpp -o example && ./example

预期标准输出:

ordinary=1 at_max=0
常见错误

用优化标记代替证明

无依据声明不别名或不溢出以获得更短代码。

修正思路:先明确真实调用与类型合同,证明或检查前提,再应用变换。

轮到你动手

先写预测或代码,再按需打开提示。完整答案用于对照自己的推理。

练习 1

把例一改成p和q指向不同对象,两个实现结果是多少?

给我一点提示
  1. p初值2不变
  2. q写5不影响p
查看答案与推理

两者都返回4,因为第二次*p仍为2。说明缓存变换可能在明确不别名条件下合法,但不能从一个不重叠测试推广到所有调用。

练习 2

为循环优化列四种测试类别,并解释作用。

给我一点提示
  1. 覆盖别名和尾部
  2. 不要运行有符号UB
查看答案与推理

指针重叠检查内存依赖;长度0、1及非向量宽度整数倍检查边界;合法算术边界检查类型合同;浮点NaN/Inf及大小差异悬殊输入检查数值前提。每类应有预先定义的期望行为。

动手看真实 Clang / LLVM IR / 汇编与链接 →

把理解说出来

先用中文讲清因果,再用英文回答。问题依据技能主题编写,并非公司内部题库。

解释

两次*p之间有*q=5,为什么不能总合并load?

参考回答 / English answer

q可能与p别名,第二次读取看到新值;需证明不重叠或分析写入。

The store through q may change the value read through p. Load elimination needs a valid alias and dependency argument.
预测

p==q且初值2,例一为什么得7?

参考回答 / English answer

第一次读2,写入5后第二次读5,因此返回7。

The first load observes two. The second observes five after the aliasing store, so the result is seven.
预测

uint32最大值加一大于自己吗?

参考回答 / English answer

不大于,按无符号模算术回到零;与C++有符号溢出不同。

Unsigned addition wraps modulo the type's range. The maximum value therefore becomes zero.
追问

nsw能随便加来帮助优化吗?

参考回答 / English answer

不能,它声明特定不溢出语义,错误标记会使优化推导失效。

No-signed-wrap is a semantic promise. It must be justified rather than added as a performance hint.
解释

为什么树形浮点sum可能与顺序sum不同?

参考回答 / English answer

加法结合顺序与舍入改变;需数值合同及非有限值处理。

Changing the addition tree changes rounding. I define the error contract and handle non-finite values explicitly.
调试

代码没有vectorize,下一步如何查?

参考回答 / English answer

检查优化remarks、依赖、别名、循环边界和目标支持,而非直接宣告编译器差。

I inspect optimization remarks and loop dependencies. Aliasing, bounds, and target support may legitimately block vectorization.

继续查证

公开资料用于查证;本章图解和例题是独立教学内容。CPU 逻辑模型不能证明设备性能。