完全指南:用 LLVM FileCheck 校验 DoNotOptimize 等函数的机器码输出)
Google Benchmark 汇编级测试Assembly Tests完全指南用 LLVM FileCheck 校验 DoNotOptimize 等函数的机器码输出【免费下载链接】benchmarkA microbenchmark support library项目地址: https://gitcode.com/GitHub_Trending/benchmark3/benchmarkGoogle Benchmark本仓库 benchmark作为一款微基准microbenchmark支持库其核心价值在于测量经过编译器优化后真正执行的代码。库中DoNotOptimize、ClobberMemory等函数的作用是影响汇编生成而KeepRunning等函数则要求编译器生成高质量的循环代码——因此必须有一套直接针对编译器输出汇编的测试来验证这些实现的正确性与质量。本指南以仓库 docs/AssemblyTests.md 为骨架结合test/目录下的真实测试源码与cmake构建逻辑系统讲解 Benchmark 库如何测试编译器输出、如何编写新的汇编测试以及其当前的平台限制。为什么要测试汇编普通的单元测试只能验证函数的返回值是否正确无法回答以下问题DoNotOptimize(x)是否真的阻止了优化器把x当作死代码消除ClobberMemory()是否真的阻止了编译器跨过该点对内存访问进行重排或合并KeepRunning()的循环是否生成了足够紧凑的计数器递减与条件跳转从而把循环开销压到最低这些问题的答案只存在于编译器生成的汇编代码中。因此 Benchmark 库引入了Assembly Tests汇编测试将测试源码编译成汇编、清洗后与测试源文件中的// CHECK指令逐一比对用机器码级别的证据锁定这些防优化语义。一个测试的解剖Anatomy of a Test编写一个汇编测试分两步写出你想为其生成汇编的代码在源码中添加// 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.txtshould_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_test、state_assembly_test、clobber_memory_assembly_testtest/AssemblyTests.cmakeadd_filecheck_test()宏是管线的核心实现。add_filecheck_test()宏的关键步骤对应 test/AssemblyTests.cmake把测试源码编译为对象库并注入-S编译标志-S使编译器只输出汇编而非目标文件通过set_target_properties(... COMPILE_FLAGS -S ${ASM_TEST_FLAGS})附加汇编测试专用标志见下文编译标志小节调用仓库自带的 tools/strip_asm.py 把生成的汇编清洗到build-directory/test/test-name.s这也是add_custom_target(copy_${name} ALL ...)的作用产物由BYPRODUCTS声明为每个CHECK前缀注册一个 ctest 用例实际执行${LLVM_FILECHECK_EXE} ${name}.cc \ --input-file${ASM_OUTPUT_FILE} \ --check-prefixesCHECK,CHECK-${ASM_TEST_COMPILER}即FileCheck 以源码.cc为模板、以清洗后的.s为输入默认同时启用CHECK与CHECK-GNU/CHECK-CLANG前缀ASM_TEST_COMPILER是CMAKE_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 were only testing the optimized output.——这些测试只验证优化后的输出因为DoNotOptimize等函数在未优化构建下毫无意义。另外test/AssemblyTests.cmake 对编译器版本做了基线校验Clang 期望版本为5.0.0GCC 期望版本为5.5.0。若实际版本不匹配仅打印警告Assembly tests may be broken并不会终止构建——这体现了代码生成测试对编译器版本的高度敏感性。汇编清洗strip_asm.py 做了什么原文档指出汇编输出会先经 tools/strip_asm.py 清洗再交给 FileCheck。对照脚本源码清洗规则包括丢弃指令类行\s\..*$匹配所有以点号开头的汇编伪指令directive丢弃注释#注释行以及内联汇编的#APP/#NO_APP标记见discard_regexestools/strip_asm.py丢弃全局声明.globl/.global丢弃数据定义.string、.byte、.long、.quad、.zero等防止数据段内容混入匹配去除 Mach-O 特性删除GOTPCREL后缀使同一测试可同时兼容 ELFLinux与 Mach-OmacOS两种目标文件格式标签归一化normalize_labels()统一.L前缀差异transform_labels()删除未被分支指令引用的无用标签只保留函数标签与真正被跳转的目标符号名归一化process_identifiers()去掉 Mach-O 在符号前插入的下划线例如_test_add→test_add保证CHECK行在两种 ABI 下都能命中。清洗后每个函数之间插入空行最终产物输出到build-directory/test/test-name.s供 FileCheck 读取。真实仓库中的测试三个既有用例仓库现有三个汇编测试文件分别覆盖三类核心函数test/donotoptimize_assembly_test.cc覆盖benchmark::DoNotOptimize面对右值、左值、const左值、超大对象2049 元素数组、非平凡拷贝类型、指针等 16 种输入时的汇编形态test/state_assembly_test.cc覆盖for (auto _ : state)与while (state.KeepRunning())两种基准循环的汇编质量验证StartKeepRunning/FinishKeepRunning调用与循环计数器递减、跳转的排布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_pointint 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_readtest/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-CLANG与CHECK-GNU分别匹配 Clang 与 GCC 的专属输出普通CHECK行对所有编译器生效注意CHECK-NOT与CHECK-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, %rbx与CHECK-CLANG-NEXT: {{(addq \$1, %rax|incq %rax|addq \$-1, %rbx)}}匹配两套编译器不同的循环计数器递减方式{{...}}内用|提供多种合法模式。在 test/AssemblyTests.cmake 中前缀由--check-prefixesCHECK,CHECK-${ASM_TEST_COMPILER}注入因此只要 CMake 检测到编译器是 GCC 或 ClangCHECK-GNU/CHECK-CLANG就会自动生效。技巧五extern C 与 CHECK-LABEL 搭配原文档强调使用extern C禁用名称修饰让函数名在CHECK行中直接可写。若不用extern CC 名称修饰会生成_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 展开后的理想循环形态进入循环前调用State::StartKeepRunning()循环头检查迭代计数为零则直接跳到LOOP_END循环体内部只有一条计数器递减指令GNU 用subq $1Clang 用addq $1/incq/addq $-1之一和一条条件跳转jne回到循环头——不允许出现多余的内存访问或函数调用循环结束后调用State::FinishKeepRunning()最后返回固定值101。while版本test_while_looptest/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来消除这一干扰源。从零编写一个新汇编测试完整流程综合文档与仓库实现新增一个汇编测试的完整步骤是选择测试文件若测试对象是DoNotOptimize可加入 test/donotoptimize_assembly_test.cc涉及State循环则加入 test/state_assembly_test.cc涉及ClobberMemory则加入 test/clobber_memory_assembly_test.cc书写被测代码用extern C包裹测试函数函数体内调用待验证的 Benchmark API编写 CHECK 断言在函数体注释中写下// CHECK-LABEL: 函数名:与逐条// CHECK行遵循最小匹配原则——只匹配足以确立正确性的指令省略无关汇编细节需要强顺序时用CHECK-NEXT需要跨编译器兼容时用CHECK-GNU/CHECK-CLANG需要容忍重排时用CHECK-DAG需要排除指令时用CHECK-NOT注册测试在 test/CMakeLists.txt 的if (BENCHMARK_ENABLE_ASSEMBLY_TESTS)块内追加add_filecheck_test(name)若需要额外前缀可传CHECK_PREFIXES参数见 test/AssemblyTests.cmake 的宏签名运行验证确保PATH中有 FileCheck、构建机为 x86_64 且使用 GCC/Clang然后重新配置构建-DBENCHMARK_ENABLE_ASSEMBLY_TESTSON并执行测试生成的清洗后汇编位于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-CLANG、CHECK-GNU只匹配对应编译器的输出普通CHECK匹配所有编译器CHECK-NOT与CHECK-LABEL是变体而非前缀用extern C关闭名称修饰让函数名在CHECK行中直接可写。结语汇编测试是 Google Benchmark 质量保障体系中独特的一环它把DoNotOptimize、ClobberMemory、KeepRunning这些以影响代码生成为己任的 API从行为正确验证推进到机器码正确且高效验证。其技术栈——-S编译、tools/strip_asm.py 清洗、LLVM FileCheck 断言、CHECK前缀与变量捕获——构成了一个完整、可复用的编译器输出测试范式不仅适用于 Benchmark 库本身也为任何需要锁定汇编形态的底层库提供了可直接借鉴的方法论。理解这一套机制你既能读懂仓库中三个汇编测试的每一行断言也能为自己的高性能代码编写同样严谨的机器码级回归测试。【免费下载链接】benchmarkA microbenchmark support library项目地址: https://gitcode.com/GitHub_Trending/benchmark3/benchmark创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考