C++ -O3编译微基准测试:防循环消除及asm("")性能影响问题
一、-O3级别下正确阻止测试逻辑被优化的方案
你当前使用的空asm("")不是可靠方案,正确的做法是明确告知编译器:被测试代码的计算结果会被实际使用,不能被消除,推荐两种兼容GCC/Clang的零开销方案:
- 方案1:使用带明确操作数的内联汇编标记,这也是Google Benchmark等主流基准测试库的实现方式,封装通用的防优化函数:
template <typename T> void do_not_optimize(const T& value) { asm volatile("" : : "r,m"(value) : "memory"); }
使用时只需要在测试循环外部,把循环计算得到的最终结果传入这个函数即可,比如你代码里内层j循环跑完后调用do_not_optimize(x)。这个写法不会生成任何额外运行指令,同时明确告诉编译器value对应的内存/寄存器值会被使用,必须真实计算出所有依赖value的逻辑,不会阻碍循环内部的正常优化(循环展开、指令调度、不变量外提等)。
- 方案2:使用跨编译单元的逃逸函数。单独在一个关闭LTO(链接时优化)的编译单元里定义一个空函数
void escape(void* p) {},测试循环结束后调用escape(&x)。由于编译器在编译测试代码时看不到escape的实现,它必须假设escape会读取p指向的内存内容,因此必须保证x的值被正确计算,同样是零运行时开销。
注意:不要把防优化标记放在循环内部,否则会不必要打断编译器的流分析,阻碍正常优化,导致测试结果失真。
二、空asm("")的实际影响
空汇编语句本身不会生成任何处理器指令,直接运行开销为0,但存在严重的语义缺陷,完全不适合基准测试使用:
- 优化行为不可控:你没有声明任何输入、输出操作数,也没有标记内存影响,编译器只能按照最保守的逻辑处理,不同GCC版本的处理逻辑差异极大:部分高版本GCC可以识别出空汇编没有任何副作用,直接绕过它把循环完全优化掉;部分版本则会过度保守,禁止所有跨汇编语句的优化,包括本该执行的循环展开、不变量外提、指令重排,导致测出来的执行时间比真实优化后的代码慢很多。
- 无法保证计算正确性:空汇编没有告知编译器你需要用到x、p等变量的值,编译器完全可以在保留空汇编的前提下,把循环里的计算逻辑删掉,只要最终程序结构看起来和源码一致就行,起不到防优化的作用。
你当前测试时空汇编能拦住优化,只是刚好匹配了你所用编译器版本的保守逻辑,换个编译器版本或者调整下代码结构就可能失效。
三、无法被优化为闭式计算的测试逻辑设计建议
你当前的测试逻辑本身存在设计缺陷:内层j循环的计算long p = (i % 8) * (i % 16)完全和循环变量j无关,属于典型的循环不变量,就算循环不被删除,编译器也会把p的计算提到j循环外面,甚至直接算出j循环对x的总修改量,把load次循环合并成1次加减操作,你测到的时间根本不是你以为的循环执行时间。
设计不可被优化的测试逻辑,遵循三个原则即可:
- 构造跨迭代数据依赖:让每次迭代的计算输入依赖上一次迭代的输出,编译器就无法把多次迭代合并为常数计算,必须逐次执行迭代。比如把你当前的p计算改为依赖j和上一步的x:
long p = ((x + j + i) % 8) * ((x + j + i) % 16),每一步的p都依赖之前的x值,形成串行依赖链,没法提前算出最终结果。 - 避免循环不变量:所有循环内的计算都要和循环变量、迭代状态相关,不要出现和当前迭代无关的固定计算,否则这部分计算会被编译器外提,不会计入循环执行时间。
- 避免编译期可推导的常数输入:如果所有输入都是编译期已知的常量,哪怕存在数据依赖,编译器也可能通过常数折叠直接算出最终结果,删掉整个循环。如果需要测试固定计算逻辑,可以在计时开始前用随机数生成输入数组,注意随机数生成的时间不要计入测试区间。
不要用volatile修饰测试变量来防优化:volatile会强制所有读写都访问内存,禁止寄存器缓存、指令重排、循环展开等正常优化,测出来的结果远慢于真实代码的性能,没有参考价值。
内容的提问来源于stack exchange,提问作者user19401035

