Google Benchmark 汇编级测试(Assembly Tests)完全指南:用 LLVM FileCheck 校验 DoNotOptimize 等函数的机器码输出
2026/9/15 18:28:41 网站建设 项目流程

Google Benchmark 汇编级测试(Assembly Tests)完全指南:用 LLVM FileCheck 校验 DoNotOptimize 等函数的机器码输出

【免费下载链接】benchmarkA microbenchmark support library项目地址: https://gitcode.com/GitHub_Trending/benchmark3/benchmark

Google Benchmark(本仓库 benchmark)作为一款微基准(microbenchmark)支持库,其核心价值在于测量"经过编译器优化后真正执行的代码"。库中DoNotOptimizeClobberMemory等函数的作用是影响汇编生成,而KeepRunning等函数则要求编译器生成高质量的循环代码——因此必须有一套直接针对编译器输出(汇编)的测试来验证这些实现的正确性与质量。本指南以仓库 docs/AssemblyTests.md 为骨架,结合test/目录下的真实测试源码与cmake构建逻辑,系统讲解 Benchmark 库如何测试编译器输出、如何编写新的汇编测试,以及其当前的平台限制。

为什么要测试汇编

普通的单元测试只能验证函数的返回值是否正确,无法回答以下问题:

  • DoNotOptimize(x)是否真的阻止了优化器把x当作死代码消除?
  • ClobberMemory()是否真的阻止了编译器跨过该点对内存访问进行重排或合并?
  • KeepRunning()的循环是否生成了足够紧凑的计数器递减与条件跳转,从而把循环开销压到最低?

这些问题的答案只存在于编译器生成的汇编代码中。因此 Benchmark 库引入了Assembly Tests(汇编测试):将测试源码编译成汇编、清洗后与测试源文件中的// CHECK指令逐一比对,用机器码级别的证据锁定这些"防优化"语义。

一个测试的解剖(Anatomy of a Test)

编写一个汇编测试分两步:

  1. 写出你想为其生成汇编的代码;
  2. 在源码中添加// CHECK行,用于匹配(验证过的)汇编输出。

原文档给出的最小示例:

// CHECK-LABEL: test_add: extern "C" int test_add() { extern int ExternInt; return ExternInt + 1; // CHECK: movl ExternInt(%rip), %eax // CHECK: addl %eax // CHECK: ret }

该示例展示了三个基础指令的用法:

  • CHECK-LABEL:锚定一个函数标签,为后续匹配提供明确的起点;
  • CHECK:在文件中顺序查找一条匹配指令,不要求紧邻上一条匹配;
  • extern "C":禁用 C++ 名称修饰(name mangling),使函数在汇编中的符号名与源码一致,便于在CHECK行中直接书写函数名。

LLVM Filecheck:测试的核心工具

汇编比对工作由 LLVM FileCheck 完成:它以测试源文件(.cc)为"检查模板",以生成的汇编文件为"输入",把源码注释中的CHECK指令当作断言逐条执行。FileCheck 支持丰富的指令集:CHECK-NEXT(必须紧接上一匹配)、CHECK-NOT(断言某模式不出现)、CHECK-DAG(允许非顺序匹配)、CHECK-LABEL(定位标签)以及正则表达式、变量捕获等,这些都会在本指南后续小节展开。

真实的测试管线:从源码到 FileCheck

在仓库中,汇编测试的完整管线由三处 CMake 逻辑驱动:

  • CMakeLists.txt:should_enable_assembly_tests()自动探测环境。函数依次排除 MSVC、非 x86_64 架构、非 64 位构建、32 位构建,然后通过find_program(LLVM_FILECHECK_EXE FileCheck)查找 FileCheck。只有全部条件满足时才把BENCHMARK_ENABLE_ASSEMBLY_TESTS默认置为ON,否则测试被禁用;
  • test/CMakeLists.txt:当BENCHMARK_ENABLE_ASSEMBLY_TESTS开启时,要求必须存在LLVM_FILECHECK_EXE,并注册三个汇编测试目标:donotoptimize_assembly_teststate_assembly_testclobber_memory_assembly_test
  • test/AssemblyTests.cmake:add_filecheck_test()宏是管线的核心实现。

add_filecheck_test()宏的关键步骤(对应 test/AssemblyTests.cmake):

  1. 把测试源码编译为对象库,并注入-S编译标志(-S使编译器只输出汇编而非目标文件);
  2. 通过set_target_properties(... COMPILE_FLAGS "-S ${ASM_TEST_FLAGS}")附加汇编测试专用标志(见下文"编译标志"小节);
  3. 调用仓库自带的 tools/strip_asm.py 把生成的汇编清洗到<build-directory>/test/<test-name>.s(这也是add_custom_target(copy_${name} ALL ...)的作用,产物由BYPRODUCTS声明);
  4. 为每个CHECK前缀注册一个 ctest 用例,实际执行:
${LLVM_FILECHECK_EXE} ${name}.cc \ --input-file=${ASM_OUTPUT_FILE} \ --check-prefixes=CHECK,CHECK-${ASM_TEST_COMPILER}

即:FileCheck 以源码.cc为模板、以清洗后的.s为输入,默认同时启用CHECKCHECK-GNU/CHECK-CLANG前缀(ASM_TEST_COMPILERCMAKE_CXX_COMPILER_ID的大写形式)。

编译标志:只测优化后的输出

test/AssemblyTests.cmake 通过check_cxx_compiler_flag逐个探测并追加以下标志:

标志含义
-O3最高优化级别,确保测试的是真实优化后的代码;
-g0不生成调试信息,避免.loc等调试伪指令干扰匹配;
-fno-stack-protector关闭栈保护,避免编译器自动插入__stack_chk_fail检查代码。

正如原文档强调的:"The tests are compiled with-O3 -g0. So we're only testing the optimized output."——这些测试只验证优化后的输出,因为DoNotOptimize等函数在未优化构建下毫无意义。

另外,test/AssemblyTests.cmake 对编译器版本做了基线校验:Clang 期望版本为5.0.0,GCC 期望版本为5.5.0。若实际版本不匹配,仅打印警告"Assembly tests may be broken",并不会终止构建——这体现了代码生成测试对编译器版本的高度敏感性。

汇编清洗:strip_asm.py 做了什么

原文档指出汇编输出会先经 tools/strip_asm.py 清洗,再交给 FileCheck。对照脚本源码,清洗规则包括:

  • 丢弃指令类行\s+\..*$匹配所有以点号开头的汇编伪指令(directive);
  • 丢弃注释#注释行以及内联汇编的#APP/#NO_APP标记(见discard_regexes,tools/strip_asm.py);
  • 丢弃全局声明.globl/.global
  • 丢弃数据定义.string.byte.long.quad.zero等(防止数据段内容混入匹配);
  • 去除 Mach-O 特性:删除@GOTPCREL后缀,使同一测试可同时兼容 ELF(Linux)与 Mach-O(macOS)两种目标文件格式;
  • 标签归一化normalize_labels()统一.L前缀差异,transform_labels()删除未被分支指令引用的无用标签,只保留函数标签与真正被跳转的目标;
  • 符号名归一化process_identifiers()去掉 Mach-O 在符号前插入的下划线(例如_test_addtest_add),保证CHECK行在两种 ABI 下都能命中。

清洗后,每个函数之间插入空行,最终产物输出到<build-directory>/test/<test-name>.s,供 FileCheck 读取。

真实仓库中的测试:三个既有用例

仓库现有三个汇编测试文件,分别覆盖三类核心函数:

  1. test/donotoptimize_assembly_test.cc:覆盖benchmark::DoNotOptimize面对右值、左值、const左值、超大对象(2049 元素数组)、非平凡拷贝类型、指针等 16 种输入时的汇编形态;
  2. test/state_assembly_test.cc:覆盖for (auto _ : state)while (state.KeepRunning())两种基准循环的汇编质量,验证StartKeepRunning/FinishKeepRunning调用与循环计数器递减、跳转的排布;
  3. test/clobber_memory_assembly_test.cc:覆盖benchmark::ClobberMemory()对冗余存储(redundant store)与冗余读取(redundant read)的消除/保留行为。

编写可移植测试的难题与对策

针对编译器输出的测试天然不可移植(inherently non-portable):不同编译器、甚至同一编译器的不同版本,可能生成完全不同的代码。Benchmark 的汇编测试必须容忍这种差异。FileCheck 为此提供了正则匹配、命名变量捕获与CHECK-DAG非顺序匹配等机制。

技巧一:变量捕获(Capturing Variables)

原文档的经典场景:GCC 把变量存进寄存器,而 Clang 存在内存中。此时先"捕获"存储目标,再用捕获到的表达式书写后续断言:

// CHECK-LABEL: test_div_no_op_into_shr: extern "C" void test_div_no_op_into_shr(int value) { int divisor = 2; benchmark::DoNotOptimize(divisor); // hide the value from the optimizer return value / divisor; // CHECK: movl $2, [[DEST:.*]] // CHECK: idivl [[DEST]] // CHECK: ret }

[[DEST:.*]]是 FileCheck 的命名变量语法:DEST为变量名,.*是捕获模式。第一行把movl $2的目的操作数捕获进DEST,随后idivl [[DEST]]复用该值。无论该目的是寄存器还是栈槽,后续断言都能对齐。

该技巧在仓库测试中被大量复用。例如 test/donotoptimize_assembly_test.cc 的test_div_by_two与文档示例几乎一致;test/clobber_memory_assembly_test.cc 则用[[DEST:[^,]+]](否定字符类,排除逗号)捕获leaq的地址操作数:

// CHECK-LABEL: test_basic: extern "C" void test_basic() { int x; benchmark::DoNotOptimize(&x); x = 101; benchmark::ClobberMemory(); // CHECK: leaq [[DEST:[^,]+]], %rax // CHECK: movl $101, [[DEST]] // CHECK: ret }
同一变量多次捕获

当编译器对同一基址寄存器使用不同子寄存器时,还可以让捕获表达式只定义一次、多次引用。看 test/donotoptimize_assembly_test.cc:

// CHECK-LABEL: test_with_large_rvalue: extern "C" void test_with_large_rvalue() { benchmark::DoNotOptimize(Large{ExternInt, {ExternInt, ExternInt}}); // CHECK: ExternInt(%rip) // CHECK: movl %eax, -{{[0-9]+}}(%[[REG:[a-z]+]] // CHECK: movl %eax, -{{[0-9]+}}(%[[REG]]) // CHECK: movl %eax, -{{[0-9]+}}(%[[REG]]) // CHECK: ret }

第一次出现[[REG:[a-z]+]]时定义并捕获基址寄存器名(如rbp),后两次[[REG]]只是引用——FileCheck 保证引用处的值必须与定义处一致,从而把三处栈偏移写入约束在同一个栈帧基址上。

技巧二:用正则表达式匹配差异输出

不同编译器在栈帧布局上常有细微差异(例如栈偏移量不同)。FileCheck 的正则语法用{{...}}包裹,其中{{[0-9]+}}表示匹配任意数字序列。原文档的示例(test_store_point):

int ExternInt; struct Point { int x, y, z; }; // CHECK-LABEL: test_store_point: extern "C" void test_store_point() { Point p{ExternInt, ExternInt, ExternInt}; benchmark::DoNotOptimize(p); // CHECK: movl ExternInt(%rip), %eax // CHECK: movl %eax, -{{[0-9]+}}(%rsp) // CHECK: movl %eax, -{{[0-9]+}}(%rsp) // CHECK: movl %eax, -{{[0-9]+}}(%rsp) // CHECK: ret }

这里栈偏移-{{[0-9]+}}允许是任意数值,但三处"写回栈"的动作必须出现且顺序正确,从而证明Point的三个成员都被写入了栈内存。test_with_large_rvalue-{{[0-9]+}}(%[[REG:[a-z]+]]则是正则与变量捕获的组合用法。

技巧三:CHECK-DAG 容忍非顺序匹配

ClobberMemory场景下,编译器可能重排某些无关指令。仓库测试用CHECK-DAG放宽顺序约束。看 test/clobber_memory_assembly_test.cc 的test_redundant_store

// CHECK-LABEL: test_redundant_store: extern "C" void test_redundant_store() { ExternInt = 3; benchmark::ClobberMemory(); ExternInt = 51; // CHECK-DAG: ExternInt // CHECK-DAG: movl $3 // CHECK: movl $51 }

CHECK-DAG声明"ExternInt符号引用"与"movl $3"两条断言可以在扫描窗口内以任意顺序出现;随后的CHECK: movl $51是顺序匹配,验证最终的写值必须保留。这个测试的核心语义是:ClobberMemory()之后的movl $51绝对不能被优化器当作死存储删除——即ExternInt = 51这一写必须真实落入内存。

test_redundant_read(test/clobber_memory_assembly_test.cc)则同时展示了CHECK-NOT的用法:

// CHECK-LABEL: test_redundant_read: extern "C" void test_redundant_read() { int x; benchmark::DoNotOptimize(&x); x = ExternInt; benchmark::ClobberMemory(); x = ExternInt2; // CHECK: leaq [[DEST:[^,]+]], %rax // CHECK: ExternInt(%rip) // CHECK: movl %eax, [[DEST]] // CHECK-NOT: ExternInt2 // CHECK: ret }

x = ExternInt; ClobberMemory(); x = ExternInt2;中,CHECK-NOT: ExternInt2断言"对ExternInt2的引用不得出现"——因为x的两次赋值之间没有任何观测点,第一次赋值x = ExternInt才是真正被保留的读,而第二次赋值会被优化器合法地消除(其结果在函数末尾无人使用)。反之,若两次赋值之间插入第二个ClobberMemory()(见test_redundant_read2),两个读都必须保留,此时测试改用两条顺序CHECK分别验证ExternInt(%rip)ExternInt2(%rip)的加载。

技巧四:CHECK 前缀区分编译器

FileCheck 的--check-prefixes允许定义带前缀的断言,让同一测试文件容纳多套编译器专属的预期。原文档说明:Benchmark 测试使用CHECK-CLANGCHECK-GNU分别匹配 Clang 与 GCC 的专属输出,普通CHECK行对所有编译器生效(注意CHECK-NOTCHECK-LABEL不是前缀,而是非前缀CHECK指令的变体)。

仓库中的真实例子,test/donotoptimize_assembly_test.cc:

// CHECK-LABEL: test_with_lvalue: extern "C" void test_with_lvalue() { int x = 101; benchmark::DoNotOptimize(x); // CHECK-GNU: movl $101, %eax // CHECK-CLANG: movl $101, -{{[0-9]+}}(%[[REG:[a-z]+]]) // CHECK: ret }

GCC 会把x直接物化到寄存器%eax,而 Clang 选择写入栈槽——两者都是合法的DoNotOptimize语义实现。测试用CHECK-GNU/CHECK-CLANG分别锁定各自的行为,用公共CHECK: ret验证函数正确结束。类似地,test/state_assembly_test.cc 用CHECK-GNU-NEXT: subq $1, %rbxCHECK-CLANG-NEXT: {{(addq \$1, %rax|incq %rax|addq \$-1, %rbx)}}匹配两套编译器不同的循环计数器递减方式({{...}}内用|提供多种合法模式)。

在 test/AssemblyTests.cmake 中,前缀由--check-prefixes=CHECK,CHECK-${ASM_TEST_COMPILER}注入,因此只要 CMake 检测到编译器是 GCC 或 Clang,CHECK-GNU/CHECK-CLANG就会自动生效。

技巧五:extern "C" 与 CHECK-LABEL 搭配

原文档强调:使用extern "C"禁用名称修饰,让函数名在CHECK行中直接可写。若不用extern "C",C++ 名称修饰会生成_ZN...之类的符号名,虽然也可匹配(例如 test/state_assembly_test.cc 就通过CHECK: call(q)* _ZN9benchmark5State16StartKeepRunningEv匹配库内部符号),但可读性远不如裸函数名。因此所有仓库汇编测试的测试函数都包在extern "C" { ... }块内。

汇编质量测试:不只"正确",还要"高效"

DoNotOptimize/ClobberMemory之外的第三类被测对象是基准循环本身。对for (auto _ : state)while (state.KeepRunning())而言,循环的每次迭代开销会直接计入测量结果,所以循环体必须尽量精简。

test/state_assembly_test.cc 的test_for_auto_loop展示了对 range-for 循环的严格约束:

// CHECK-LABEL: test_for_auto_loop: extern "C" int test_for_auto_loop() { State& S = GetState(); int x = 42; // CHECK: [[CALL:call(q)*]] _ZN9benchmark5State16StartKeepRunningEv // CHECK-NEXT: testq %rbx, %rbx // CHECK-NEXT: je [[LOOP_END:.*]] for (auto _ : S) { // CHECK: .L[[LOOP_HEAD:[a-zA-Z0-9_]+]]: // CHECK-GNU-NEXT: subq $1, %rbx // CHECK-CLANG-NEXT: {{(addq \$1, %rax|incq %rax|addq \$-1, %rbx)}} // CHECK-NEXT: jne .L[[LOOP_HEAD]] benchmark::DoNotOptimize(x); } // CHECK: [[LOOP_END]]: // CHECK: [[CALL]] _ZN9benchmark5State17FinishKeepRunningEv // CHECK: movl $101, %eax // CHECK: ret return 101; }

该测试的断言揭示了 range-for 展开后的理想循环形态:

  1. 进入循环前调用State::StartKeepRunning()
  2. 循环头检查迭代计数,为零则直接跳到LOOP_END
  3. 循环体内部只有一条计数器递减指令(GNU 用subq $1,Clang 用addq $1/incq/addq $-1之一)和一条条件跳转jne回到循环头——不允许出现多余的内存访问或函数调用;
  4. 循环结束后调用State::FinishKeepRunning(),最后返回固定值101

while版本(test_while_loop,test/state_assembly_test.cc)的约束稍复杂:它要求循环头/循环体标签用[[LOOP_HEADER]]/[[LOOP_BODY]]捕获并在CHECK-DAG段中交叉引用,同时验证迭代计数在寄存器与DoNotOptimize的存储目标之间同步。这些测试把"基准循环必须高效"这一工程要求,转译成了可自动执行的机器码级断言。

当前需求与限制

环境前提:FileCheck 必须在 PATH 中

原文档明确指出:测试要求构建机上PATH中存在 FileCheck,否则测试会被禁用。对照 CMakeLists.txt 的实现,find_program(LLVM_FILECHECK_EXE FileCheck)找不到时会打印 "Failed to find LLVM FileCheck" 并返回,BENCHMARK_ENABLE_ASSEMBLY_TESTS默认值即为OFF。FileCheck 随 LLVM 分发,通常可通过包管理器安装(例如llvm包),或由完整安装的 Clang 工具链附带。

平台限制:x86_64 + GCC/Clang

由于代码生成测试本质上的不可移植性,当前测试被限制在:

  • x86_64 目标(CMakeLists.txt 中CMAKE_SYSTEM_PROCESSOR必须匹配x86_64,且CMAKE_SIZEOF_VOID_P等于 8,排除 32 位构建);
  • GCC 或 Clang 编译器(MSVC 在 CMakeLists.txt 被直接排除;其他编译器在 test/AssemblyTests.cmake 会收到 "Unsupported compiler" 警告)。

原文档补充:借助CHECK前缀机制,未来可以在有限范围内把测试扩展到其他架构与编译器(例如 ARM 上使用CHECK-ARM前缀)。

标志冲突:覆盖类标志会让测试失败

原文档强调:若构建额外指定了会修改代码生成的标志——包括--coverage-fsanitize=——这些测试会失败。原因很直观:插桩(coverage 探针)与消毒器检查(sanitizer check)都会在函数体内插入额外的指令与调用,破坏CHECK行对"最小汇编形态"的精确约束。同理,栈保护标志也会注入检查代码,因此 test/AssemblyTests.cmake 特意追加-fno-stack-protector来消除这一干扰源。

从零编写一个新汇编测试:完整流程

综合文档与仓库实现,新增一个汇编测试的完整步骤是:

  1. 选择测试文件:若测试对象是DoNotOptimize,可加入 test/donotoptimize_assembly_test.cc;涉及State循环则加入 test/state_assembly_test.cc;涉及ClobberMemory则加入 test/clobber_memory_assembly_test.cc;
  2. 书写被测代码:用extern "C"包裹测试函数,函数体内调用待验证的 Benchmark API;
  3. 编写 CHECK 断言:在函数体注释中写下// CHECK-LABEL: <函数名>:与逐条// CHECK行,遵循"最小匹配"原则——只匹配足以确立正确性的指令,省略无关汇编细节;需要强顺序时用CHECK-NEXT,需要跨编译器兼容时用CHECK-GNU/CHECK-CLANG,需要容忍重排时用CHECK-DAG,需要排除指令时用CHECK-NOT
  4. 注册测试:在 test/CMakeLists.txt 的if (BENCHMARK_ENABLE_ASSEMBLY_TESTS)块内追加add_filecheck_test(<name>)(若需要额外前缀,可传CHECK_PREFIXES参数,见 test/AssemblyTests.cmake 的宏签名);
  5. 运行验证:确保PATH中有 FileCheck、构建机为 x86_64 且使用 GCC/Clang,然后重新配置构建(-DBENCHMARK_ENABLE_ASSEMBLY_TESTS=ON)并执行测试;生成的清洗后汇编位于<build-directory>/test/<name>.s,可人工复查匹配失败的指令。

编写 CHECK 指令的黄金法则

原文档"Tips and Tricks"小节浓缩为以下要点:

  • 只匹配最小必要输出CHECK不要求紧接上一条匹配,因此省略不重要的汇编片段;只有需要验证"紧随其后"时才用CHECK-NEXT
  • 测试的是-O3 -g0优化输出:不要用未优化构建的直觉来写断言;
  • 汇编先经 tools/strip_asm.py 清洗:注释、伪指令、未使用标签与 Mach-O 下划线均已被移除,CHECK行只需面向"干净"的指令流;
  • 清洗后的汇编文件位于<build-directory>/test/<test-name>.s:调试失败时优先打开它核对实际输出;
  • CHECK前缀区分编译器CHECK-CLANGCHECK-GNU只匹配对应编译器的输出,普通CHECK匹配所有编译器(CHECK-NOTCHECK-LABEL是变体而非前缀);
  • extern "C"关闭名称修饰:让函数名在CHECK行中直接可写。

结语

汇编测试是 Google Benchmark 质量保障体系中独特的一环:它把DoNotOptimizeClobberMemoryKeepRunning这些"以影响代码生成为己任"的 API,从"行为正确"验证推进到"机器码正确且高效"验证。其技术栈——-S编译、tools/strip_asm.py 清洗、LLVM FileCheck 断言、CHECK前缀与变量捕获——构成了一个完整、可复用的"编译器输出测试"范式,不仅适用于 Benchmark 库本身,也为任何需要锁定汇编形态的底层库提供了可直接借鉴的方法论。理解这一套机制,你既能读懂仓库中三个汇编测试的每一行断言,也能为自己的高性能代码编写同样严谨的机器码级回归测试。

【免费下载链接】benchmarkA microbenchmark support library项目地址: https://gitcode.com/GitHub_Trending/benchmark3/benchmark

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询