我们大多数人使用高级语言(Go、C++)编写代码,但是如果您想了解对您的处理器很重要的代码,您需要查看代码的“汇编”版本。组装只是一系列指令。
起初,汇编代码看起来令人生畏,我不鼓励您在汇编中编写大型程序。但是,只需很少的培训,您就可以学会计算指令和发现分支。它可以帮助您更深入地了解您的程序是如何工作的。让我来说明你可以通过查看汇编来学到什么。让我们考虑以下 C++ 代码:
长f ( int x ) { 长数组[ ] = { 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 , 999 , 10 } ; 返回数组[ x ] ; } 长f2 ( int x ) { 长数组[ ] = { 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 , 999 , 10 } ; 返回数组[ x + 1 ] ; }
此代码包含两个 80 字节数组,但它们是相同的。这是担心的根源吗?如果您查看大多数编译器生成的汇编代码,您会发现完全相同的常量通常被“压缩”(仅存储一个版本)。如果我使用 gcc 或 clang 编译器使用 -S 标志编译这两个函数,我可以清楚地看到压缩,因为数组只出现一次:
。文本 .文件“f.cpp” .globl _Z1fi // -- 开始函数 _Z1fi .p2对齐 2 .type _Z1fi, @function _Z1fi: // @_Z1fi .cfi_startproc // %bb.0: adrp x8, .L__const._Z2f2i.array 添加 x8, x8, :lo12:.L__const._Z2f2i.array ldr x0, [x8, w0, sxtw #3] ret .Lfunc_end0: .size _Z1fi, .Lfunc_end0-_Z1fi .cfi_endproc // -- 结束函数 .globl _Z2f2i // -- 开始函数 _Z2f2i .p2对齐 2 .type _Z2f2i, @function _Z2f2i: // @_Z2f2i .cfi_startproc // %bb.0: adrp x8, .L__const._Z2f2i.array 添加 x8, x8, :lo12:.L__const._Z2f2i.array 添加 x8、x8、w0、sxtw #3 ldr x0, [x8, #8] ret .Lfunc_end1: .size _Z2f2i, .Lfunc_end1-_Z2f2i .cfi_endproc // -- 结束函数 .type .L__const._Z2f2i.array, @object // @__const._Z2f2i.array .section .rodata,"a", @progbits .p2对齐 3 .L__const._Z2f2i.array: .xword 1 // 0x1 .xword 2 // 0x2 .xword 3 // 0x3 .xword 4 // 0x4 .xword 5 // 0x5 .xword 6 // 0x6 .xword 7 // 0x7 .xword 8 // 0x8 .xword 999 // 0x3e7 .xword 10 // 0xa .size .L__const._Z2f2i.array, 80 .ident "Ubuntu clang 版本 14.0.0-1ubuntu1" .section ".note.GNU-stack","", @progbits .addrsig
但是,如果您稍微修改常量,则通常不会发生这种压缩(例如,如果您尝试将一个整数值附加到其中一个数组,代码将完全复制数组)。
为了评估代码例程的性能,我的第一道攻击总是计算指令。保持一切不变,如果您可以重写代码以生成更少的指令,它应该会更快。我也喜欢发现条件跳转,因为如果分支难以预测,那通常是你的代码可能受到影响的地方。
将一整套函数转换为汇编很容易,但随着项目变得越来越大,它变得不切实际。在 Linux 下,标准的“调试器”( gdb ) 是一个很好的工具,可以选择性地查看编译生成的汇编代码。让我们考虑一下我之前的博客文章, 在 Amazon Graviton 3 处理器上使用 SVE 快速过滤数字。在该博客文章中,我介绍了我在一个简短的 C++ 文件中实现的几个函数。为了检查结果,我只需将编译后的二进制文件加载到gdb中:
$ gdb ./过滤器
然后我可以检查函数……例如remove_negatives函数:
(gdb) 设置打印 asm-demangle (gdb) disas remove_negatives 函数 remove_negatives(int const*, long, int*) 的汇编代码转储: 0x00000000000022e4 <+0>: mov x4, #0x0 // #0 0x00000000000022e8 <+4>: mov x3, #0x0 // #0 0x00000000000022ec <+8>: cntw x6 0x00000000000022f0 <+12>: 同时 p0.s, xzr, x1 0x00000000000022f4 <+16>: 无 0x00000000000022f8 <+20>: ld1w {z0.s}, p0/z, [x0, x3, lsl #2] 0x00000000000022fc <+24>: cmpge p1.s, p0/z, z0.s, #0 0x0000000000002300 <+28>: 紧凑 z0.s, p1, z0.s 0x0000000000002304 <+32>: st1w {z0.s}, p0, [x2, x4, lsl #2] 0x0000000000002308 <+36>: cntp x5, p0, p1.s 0x000000000000230c <+40>: 添加 x3, x3, x6 0x0000000000002310 <+44>: 添加 x4, x4, x5 0x0000000000002314 <+48>: 同时 p0.s, x3, x1 0x0000000000002318 <+52>: b.ne 0x22f8 <remove_negatives(int const*, long, int*)+20> // b.any 0x000000000000231c <+56>: ret 汇编程序转储结束。
在地址 52,我们有条件地回到地址 20。所以我们的主循环中总共有 9 条指令。根据我的基准测试(参见之前的博客文章),我使用每个 32 位字的 1.125 条指令,这与每个循环处理 8 个 32 位字是一致的。
另一种评估性能的方法是查看分支。让我们反汇编remove_negatives_scalar ,一个分支函数:
(gdb) disas remove_negatives_scalar 函数 remove_negatives_scalar(int const*, long, int*) 的汇编代码转储: 0x0000000000002320 <+0>: cmp x1, #0x0 0x0000000000002324 <+4>: b.le 0x234c <remove_negatives_scalar(int const*, long, int*)+44> 0x0000000000002328 <+8>: 添加 x4, x0, x1, lsl #2 0x000000000000232c <+12>: mov x3, #0x0 // #0 0x0000000000002330 <+16>: ldr w1, [x0] 0x0000000000002334 <+20>: 添加 x0, x0, #0x4 0x0000000000002338 <+24>: tbnz w1, #31, 0x2344 <remove_negatives_scalar(int const*, long, int*)+36> 0x000000000000233c <+28>: str w1, [x2, x3, lsl #2] 0x0000000000002340 <+32>: 添加 x3, x3, #0x1 0x0000000000002344 <+36>: cmp x4, x0 0x0000000000002348 <+40>: b.ne 0x2330 <remove_negatives_scalar(int const*, long, int*)+16> // b.any 0x000000000000234c <+44>: ret 汇编程序转储结束。
我们在地址 24 处看到分支(指令tbnz ),它有条件地跳过接下来的两条指令。我们有一个等效的“无分支”函数,称为remove_negatives_scalar_branchless 。让我们看看它是否确实是无分支的:
(gdb) disas remove_negatives_scalar_branchless 函数 remove_negatives_scalar_branchless(int const*, long, int*) 的汇编代码转储: 0x0000000000002350 <+0>: cmp x1, #0x0 0x0000000000002354 <+4>: b.le 0x237c <remove_negatives_scalar_branchless(int const*, long, int*)+44> 0x0000000000002358 <+8>: 添加 x4, x0, x1, lsl #2 0x000000000000235c <+12>: mov x3, #0x0 // #0 0x0000000000002360 <+16>: ldr w1, [x0], #4 0x0000000000002364 <+20>: str w1, [x2, x3, lsl #2] 0x0000000000002368 <+24>: eor x1, x1, #0x80000000 0x000000000000236c <+28>: lsr w1, w1, #31 0x0000000000002370 <+32>: 添加 x3, x3, x1 0x0000000000002374 <+36>: cmp x0, x4 0x0000000000002378 <+40>: b.ne 0x2360 <remove_negatives_scalar_branchless(int const*, long, int*)+16> // b.any 0x000000000000237c <+44>: ret 汇编程序转储结束。 (gdb)
除了循环产生的条件跳转(地址 40)之外,代码确实是无分支的。
在这个特殊的例子中,使用一个小的二进制文件,很容易找到我需要的函数。如果我加载一个包含许多编译函数的大型二进制文件怎么办?
让我检查一下来自 simdutf 库的基准二进制文件。它有很多功能,但让我们假设我正在寻找一个可以验证 UTF-8 输入的功能。我可以使用info 函数来查找与给定模式匹配的所有函数。
(gdb) 信息函数 validate_utf8 匹配正则表达式“validate_utf8”的所有函数: 非调试符号: 0x0000000000012710 event_aggregate simdutf::benchmarks::BenchmarkBase::count_events<simdutf::benchmarks::Benchmark::run_validate_utf8(simdutf::implementation const&, unsigned long)::{lambda()#1}>(simdutf::benchmarks:: Benchmark::run_validate_utf8(simdutf::implementation const&, unsigned long)::{lambda()#1}, unsigned long) [clone .constprop.0] 0x0000000000012b54 simdutf::benchmarks::Benchmark::run_validate_utf8(simdutf::implementation const&, unsigned long) 0x0000000000018c90 simdutf::fallback::implementation::validate_utf8(char const*, unsigned long) const 0x000000000001b540 simdutf::arm64::implementation::validate_utf8(char const*, unsigned long) const 0x000000000001cd84 simdutf::validate_utf8(char const*, unsigned long) 0x000000000001d7c0 simdutf::internal::unsupported_implementation::validate_utf8(char const*, unsigned long) const 0x000000000001e090 simdutf::internal::detect_best_supported_implementation_on_first_use::validate_utf8(char const*, unsigned long) 常量
您会看到info 函数给了我函数名称和函数地址。我对simdutf::arm64::implementation::validate_utf8感兴趣。此时,通过地址引用函数变得更容易:
(gdb)disas 0x000000000001b540 函数 simdutf::arm64::implementation::validate_utf8(char const*, unsigned long) const 的汇编代码转储: 0x000000000001b540 <+0>: stp x29, x30, [sp, #-144]! 0x000000000001b544 <+4>: adrp x0, 0xa0000 0x000000000001b548 <+8>: cmp x2, #0x40 0x000000000001b54c <+12>: mov x29, sp 0x000000000001b550 <+16>: ldr x0, [x0, #3880] 0x000000000001b554 <+20>: mov x5, #0x40 // #64 0x000000000001b558 <+24>: movi v22.4s, #0x0 0x000000000001b55c <+28>: csel x5, x2, x5, cs // cs = hs, nlast 0x000000000001b560 <+32>: ldr x3, [x0] 0x000000000001b564 <+36>: str x3, [sp, #136] 0x000000000001b568 <+40>: mov x3, #0x0 // #0 0x000000000001b56c <+44>: 潜艇 x5, x5, #0x40 0x000000000001b570 <+48>: b.eq 0x1b7b8 <simdutf::arm64::implementation::validate_utf8(char const*, unsigned long) const+632> // b.none 0x000000000001b574 <+52>: adrp x0, 0x86000 0x000000000001b578 <+56>: adrp x4, 0x86000 0x000000000001b57c <+60>: 添加 x6, x0, #0x2f0 0x000000000001b580 <+64>: adrp x0, 0x86000 ...
我已经缩短了输出,因为它太长了。当单个函数变大时,我发现将输出重定向到可以在其他地方处理的文件更方便。
gdb -q ./benchmark -ex "设置分页关闭" -ex "设置打印 asm-demangle" -ex "disas 0x000000000001b540" -ex quit > gdbasm.txt
有时我只是对做一些基本的统计感兴趣,比如弄清楚函数使用了哪些指令:
$ gdb -q ./benchmark -ex "设置分页关闭" -ex "设置打印 asm-demangle" -ex "disas 0x000000000001b540" -ex 退出 | awk '{打印 $3}' |排序 |uniq -c |排序-r |头 32 和 24 汤匙 24分机 18厘米 17 或 16岁 16岁 14 路德 13 移动 10 电影
我们看到这段代码中最常见的指令是and 。它让我确信代码已正确编译。我可以对所有生成的指令进行一些研究,考虑到我生成的代码,它们似乎都是足够的选择。
一般的教训是,查看生成的程序集并不难,只需很少的培训,它就可以让你成为一个更好的程序员。
原文: https://lemire.me/blog/2022/06/28/looking-at-assembly-code-with-gdb/