Profile Guided Optimization (PGO) 初探 [GCC 篇]
前言
编译优化是一个很重要的课题。现代编译器不仅实现了 Link-Time Optimization (LTO),针对跨编译单元优化;也实现了 PGO ,基于实际运行数据来进行优化。
多说无益,直接实验启动!
测试程序与编译
在这之前,有必要提一下我们的环境和编译参数:
- 编译器:GCC 9.4.0 (Ubuntu 9.4.0-1ubuntu1~20.04.2)
- 系统: WSL1 Ubuntu 20.04
为了反编译的简单起见,使用 -Og
进行编译,链接器参数默认。
本次采用的测试程序如下:
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char** argv)
{
int num0 = atoi(argv[1]);
int num1 = atoi(argv[1]);
int branch = atoi(argv[2]);
if (branch < 1) {
for (int i = 2; i < num0; i++) {
if (num0 % i == 0) {
printf("%i",i);
break;
}
}
} else {
for (int i = 2; i < num1; i++) {
if (num1 % i == 0) {
printf("%i",i);
break;
}
}
}
return 0;
}
即存在两个一样内容的块,由命令行参数控制路径。
采用如下命令编译:
gcc -Og -fprofile-generate test.c -o test_pgogen
gcc -Og test.c -o test
这样得到两个不一样的程序:
-rwxrwxrwx 1 wsl wsl 16744 Apr 4 16:58 test
-rwxrwxrwx 1 wsl wsl 552 Apr 4 17:15 test.c
-rwxrwxrwx 1 wsl wsl 28480 Apr 4 17:15 test_pgogen
可以初步看到大小就不一样了。
发生了什么?
要搞清楚发生了什么,还是通过不同的阶段编译器的产物来比较一下。考虑编译的几个步骤(括号内为编译参数):
- 预处理 (-E)
- 编译 (-S)
- 汇编 (-c)
- 链接
依次看下就知道了。
预处理
首先是预处理,加入 -E,除去头文件,是否启用 profile 生成结果均一致,输出如下:
# 3 "test.c" 2
# 4 "test.c"
int main(int argc, char** argv)
{
int num0 = atoi(argv[1]);
int num1 = atoi(argv[1]);
int branch = atoi(argv[2]);
if (branch < 1) {
for (int i = 2; i < num0; i++) {
if (num0 % i == 0) {
printf("%i",i);
break;
}
}
} else {
for (int i = 2; i < num1; i++) {
if (num1 % i == 0) {
printf("%i",i);
break;
}
}
}
return 0;
}
编译
这里的编译特指生成汇编代码。有兴趣的读者可以看一下下面的汇编代码,比较下不同:
详情
.file "test.c"
.text
.section .rodata.str1.1,"aMS",@progbits,1
.LC0:
.string "%i"
.text
.globl main
.type main, @function
main:
.LFB39:
.cfi_startproc
endbr64
pushq %r12
.cfi_def_cfa_offset 16
.cfi_offset 12, -16
pushq %rbp
.cfi_def_cfa_offset 24
.cfi_offset 6, -24
pushq %rbx
.cfi_def_cfa_offset 32
.cfi_offset 3, -32
movq %rsi, %rbx
movq __gcov_indirect_call@gottpoff(%rip), %rax
cmpq $0, %fs:(%rax)
je .L2
leaq main(%rip), %rsi
movl $108032747, %edi
call __gcov_indirect_call_profiler_v3@PLT
.L2:
movq __gcov0.main(%rip), %rax
addq $1, %rax
movq %rax, __gcov0.main(%rip)
cmpq $0, __gcov7.main(%rip)
jne .L3
movq __gcov_time_profiler_counter(%rip), %rax
addq $1, %rax
movq %rax, __gcov7.main(%rip)
movq %rax, __gcov_time_profiler_counter(%rip)
.L3:
movq 8(%rbx), %rdi
movl $10, %edx
movl $0, %esi
call strtol@PLT
movq 8+__gcov0.main(%rip), %rcx
leaq 1(%rcx), %rdx
movq %rdx, 8+__gcov0.main(%rip)
movl %eax, %r12d
movq 8(%rbx), %rdi
movl $10, %edx
movl $0, %esi
call strtol@PLT
movq 16+__gcov0.main(%rip), %rcx
leaq 1(%rcx), %rdx
movq %rdx, 16+__gcov0.main(%rip)
movl %eax, %ebp
movq 16(%rbx), %rdi
movl $10, %edx
movl $0, %esi
call strtol@PLT
testl %eax, %eax
jle .L4
movq 24+__gcov0.main(%rip), %rax
addq $1, %rax
movq %rax, 24+__gcov0.main(%rip)
movl $2, %ebx
.L5:
cmpl %ebp, %ebx
jge .L8
movslq %ebx, %rsi
leaq 24+__gcov3.main(%rip), %rdi
call __gcov_one_value_profiler@PLT
movl %ebp, %eax
cltd
idivl %ebx
testl %edx, %edx
je .L13
movq 72+__gcov0.main(%rip), %rax
addq $1, %rax
movq %rax, 72+__gcov0.main(%rip)
addl $1, %ebx
jmp .L5
.L4:
movq 32+__gcov0.main(%rip), %rax
addq $1, %rax
movq %rax, 32+__gcov0.main(%rip)
movl $2, %ebx
jmp .L6
.L7:
movq 48+__gcov0.main(%rip), %rax
addq $1, %rax
movq %rax, 48+__gcov0.main(%rip)
addl $1, %ebx
.L6:
cmpl %r12d, %ebx
jg .L8
movslq %ebx, %rsi
leaq __gcov3.main(%rip), %rdi
call __gcov_one_value_profiler@PLT
movl %r12d, %eax
cltd
idivl %ebx
testl %edx, %edx
jne .L7
movq 40+__gcov0.main(%rip), %rax
addq $1, %rax
movq %rax, 40+__gcov0.main(%rip)
movl %ebx, %edx
leaq .LC0(%rip), %rsi
movl $1, %edi
movl $0, %eax
call __printf_chk@PLT
movq 56+__gcov0.main(%rip), %rax
addq $1, %rax
movq %rax, 56+__gcov0.main(%rip)
jmp .L8
.L13:
movq 64+__gcov0.main(%rip), %rax
addq $1, %rax
movq %rax, 64+__gcov0.main(%rip)
movl %ebx, %edx
leaq .LC0(%rip), %rsi
movl $1, %edi
movl $0, %eax
call __printf_chk@PLT
movq 80+__gcov0.main(%rip), %rax
addq $1, %rax
movq %rax, 80+__gcov0.main(%rip)
.L8:
movl $0, %eax
popq %rbx
.cfi_def_cfa_offset 24
popq %rbp
.cfi_def_cfa_offset 16
popq %r12
.cfi_def_cfa_offset 8
ret
.cfi_endproc
.LFE39:
.size main, .-main
.type _sub_I_00100_0, @function
_sub_I_00100_0:
.LFB40:
.cfi_startproc
endbr64
subq $8, %rsp
.cfi_def_cfa_offset 16
leaq .LPBX0(%rip), %rdi
call __gcov_init@PLT
addq $8, %rsp
.cfi_def_cfa_offset 8
ret
.cfi_endproc
.LFE40:
.size _sub_I_00100_0, .-_sub_I_00100_0
.section .init_array.00100,"aw"
.align 8
.quad _sub_I_00100_0
.text
.type _sub_D_00100_1, @function
_sub_D_00100_1:
.LFB41:
.cfi_startproc
endbr64
subq $8, %rsp
.cfi_def_cfa_offset 16
call __gcov_exit@PLT
addq $8, %rsp
.cfi_def_cfa_offset 8
ret
.cfi_endproc
.LFE41:
.size _sub_D_00100_1, .-_sub_D_00100_1
.section .fini_array.00100,"aw"
.align 8
.quad _sub_D_00100_1
.section .data.rel.local,"aw"
.align 8
.type .LPBX1, @object
.size .LPBX1, 8
.LPBX1:
.quad __gcov_.main
.section .rodata.str1.1
.LC1:
.string "/mnt/e/C++/PGO/test.gcda"
.section .data.rel,"aw"
.align 32
.type .LPBX0, @object
.size .LPBX0, 120
.LPBX0:
.long 1094267946
.zero 4
.quad 0
.long 714010
.zero 4
.quad .LC1
.quad __gcov_merge_add
.quad 0
.quad 0
.quad __gcov_merge_single
.quad 0
.quad 0
.quad 0
.quad __gcov_merge_time_profile
.quad 0
.long 1
.zero 4
.quad .LPBX1
.section .data.rel.local
.align 32
.type __gcov_.main, @object
.size __gcov_.main, 72
__gcov_.main:
.quad .LPBX0
.long 108032747
.long -345659544
.long 1357976093
.zero 4
.long 11
.zero 4
.quad __gcov0.main
.long 6
.zero 4
.quad __gcov3.main
.long 1
.zero 4
.quad __gcov7.main
.local __gcov7.main
.comm __gcov7.main,8,8
.local __gcov3.main
.comm __gcov3.main,48,32
.local __gcov0.main
.comm __gcov0.main,88,32
.ident "GCC: (Ubuntu 9.4.0-1ubuntu1~20.04.2) 9.4.0"
.section .note.GNU-stack,"",@progbits
.section .note.gnu.property,"a"
.align 8
.long 1f - 0f
.long 4f - 1f
.long 5
0:
.string "GNU"
1:
.align 8
.long 0xc0000002
.long 3f - 2f
2:
.long 0x3
3:
.align 8
4:
.file "test.c"
.text
.section .rodata.str1.1,"aMS",@progbits,1
.LC0:
.string "%i"
.text
.globl main
.type main, @function
main:
.LFB39:
.cfi_startproc
endbr64
pushq %r12
.cfi_def_cfa_offset 16
.cfi_offset 12, -16
pushq %rbp
.cfi_def_cfa_offset 24
.cfi_offset 6, -24
pushq %rbx
.cfi_def_cfa_offset 32
.cfi_offset 3, -32
movq %rsi, %r12
movq 8(%rsi), %rdi
movl $10, %edx
movl $0, %esi
call strtol@PLT
movl %eax, %ebp
movq 8(%r12), %rdi
movl $10, %edx
movl $0, %esi
call strtol@PLT
movl %eax, %ebx
movq 16(%r12), %rdi
movl $10, %edx
movl $0, %esi
call strtol@PLT
testl %eax, %eax
jle .L9
movl $2, %ecx
.L3:
cmpl %ebx, %ecx
jge .L5
movl %ebx, %eax
cltd
idivl %ecx
testl %edx, %edx
je .L11
addl $1, %ecx
jmp .L3
.L4:
addl $1, %ecx
.L2:
cmpl %ebp, %ecx
jge .L5
movl %ebp, %eax
cltd
idivl %ecx
testl %edx, %edx
jne .L4
movl %ecx, %edx
leaq .LC0(%rip), %rsi
movl $1, %edi
movl $0, %eax
call __printf_chk@PLT
jmp .L5
.L9:
movl $2, %ecx
jmp .L2
.L11:
movl %ecx, %edx
leaq .LC0(%rip), %rsi
movl $1, %edi
movl $0, %eax
call __printf_chk@PLT
.L5:
movl $0, %eax
popq %rbx
.cfi_def_cfa_offset 24
popq %rbp
.cfi_def_cfa_offset 16
popq %r12
.cfi_def_cfa_offset 8
ret
.cfi_endproc
.LFE39:
.size main, .-main
.ident "GCC: (Ubuntu 9.4.0-1ubuntu1~20.04.2) 9.4.0"
.section .note.GNU-stack,"",@progbits
.section .note.gnu.property,"a"
.align 8
.long 1f - 0f
.long 4f - 1f
.long 5
0:
.string "GNU"
1:
.align 8
.long 0xc0000002
.long 3f - 2f
2:
.long 0x3
3:
.align 8
4:
不难发现里面多了一堆 __gcov
开头的符号引用,是 libgcov
提供的,想必这就是追踪代码了。
后面的汇编和链接,我们就先不考虑了,可以预见是有差异的。
来反编译吧!
简单起见,我们还是发挥传统艺能,启动 IDA 看一下:
int __cdecl main(int argc, const char **argv, const char **envp)
{
int v3; // eax
int v4; // r12d
int v5; // eax
int v6; // ebp
int j; // ebx
int i; // ebx
if ( __readfsqword(0xFFFFFFF0) )
_gcov_indirect_call_profiler_v3(0x67072EBLL, (__int64)main);
++_gcov0_main;
if ( !_gcov7_main )
_gcov7_main = ++_gcov_time_profiler_counter;
v3 = strtol(argv[1], 0LL, 10);
++qword_61C8;
v4 = v3;
v5 = strtol(argv[1], 0LL, 10);
++qword_61D0;
v6 = v5;
if ( (int)strtol(argv[2], 0LL, 10) <= 0 )
{
++qword_61E0;
for ( i = 2; i < v4; ++i )
{
_gcov_one_value_profiler(&_gcov3_main, i);
if ( !(v4 % i) )
{
++qword_61E8;
__printf_chk(1LL, &unk_4004, (unsigned int)i);
++qword_61F8;
return 0;
}
++qword_61F0;
}
}
else
{
++qword_61D8;
for ( j = 2; j < v6; ++j )
{
_gcov_one_value_profiler(&unk_6198, j);
if ( !(v6 % j) )
{
++qword_6200;
__printf_chk(1LL, &unk_4004, (unsigned int)j);
++qword_6210;
return 0;
}
++qword_6208;
}
}
return 0;
}
int __cdecl main(int argc, const char **argv, const char **envp)
{
int v3; // ebp
int v4; // ebx
int i; // ecx
v3 = strtol(argv[1], 0LL, 10);
v4 = strtol(argv[1], 0LL, 10);
if ( (int)strtol(argv[2], 0LL, 10) <= 0 )
{
for ( i = 2; i <= v3; ++i )
{
if ( !(v3 % i) )
{
LABEL_11:
__printf_chk(1LL, &unk_2004, (unsigned int)i);
return 0;
}
}
}
else
{
for ( i = 2; i < v4; ++i )
{
if ( !(v4 % i) )
goto LABEL_11;
}
}
return 0;
}
稍微翻一下就可以知道,这个库的初始化是由 ctors 调用的,而退出函数是由 dtors 调用的。
对比一下,可以看到编译器主要做了两件事:
- 为基本块和间接调用插桩
- 链接到 libgcov
当程序执行时,会根据程序流经过插桩代码,从而在内存中记录下程序的 profile。当程序正常退出时,将会触发保存逻辑,将内存中的 profile dump 到磁盘上,供 PGO 使用。
来看看数据
我们测试一下,就运行一次,参数为10201 1
,会发现多了一个 test.gcda
文件。
头在这里。hexdump 一下:
00000000 61 64 63 67 2a 34 39 41 ec e2 15 00 00 00 00 a1 |adcg*49A........|
00000010 02 00 00 00 01 00 00 00 63 00 00 00 00 00 00 01 |........c.......|
00000020 03 00 00 00 eb 72 70 06 68 a7 65 eb 1d 12 f1 50 |.....rp.h.e....P|
00000030 00 00 a1 01 16 00 00 00 01 00 00 00 00 00 00 00 |................|
00000040 01 00 00 00 00 00 00 00 01 00 00 00 00 00 00 00 |................|
00000050 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
00000060 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
00000070 00 00 00 00 00 00 00 00 01 00 00 00 00 00 00 00 |................|
00000080 63 00 00 00 00 00 00 00 01 00 00 00 00 00 00 00 |c...............|
00000090 00 00 a7 01 0c 00 00 00 00 00 00 00 00 00 00 00 |................|
000000a0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
000000b0 64 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |d...............|
000000c0 64 00 00 00 00 00 00 00 00 00 af 01 02 00 00 00 |d...............|
000000d0 01 00 00 00 00 00 00 00 00 00 00 00 |............|
包看不懂的。不过,我们有办法可以看,那就是使用 gcov 工具。不过,这个工具设计用于代码覆盖率的检查,而我们知道,程序流可以表示为一个图。
图的组成里面,有节点和边,我们的 .gcda 文件就只包含了边的数据,这对于有源代码的编译器来说已经足够,但是对于 gcov 生成覆盖率报告却不够。
要注意的是,PGO 和 覆盖率测试不是一回事,但是其中的数据可以共用,不过是 GCC 正好用了同一套。对此,我们只需生成一下节点数据即可:
gcc -Og --coverage test.c -S
这将生成 test.s
和 test.gcno
。gcno 文件一样是二进制格式文件,没有读的必要,我们只要提供给 gcov 解读即可。
gcov -c ./test.gcda
显示
./test.gcda:stamp mismatch with notes file
File 'test.c'
Lines executed:0.00% of 9
Creating 'test.c.gcov'
File '/usr/include/stdlib.h'
Lines executed:0.00% of 1
Creating 'stdlib.h.gcov'
File '/usr/include/x86_64-linux-gnu/bits/stdio2.h'
Lines executed:0.00% of 1
Creating 'stdio2.h.gcov'
这是因为我们后生成的 gcno 有不一样的时戳,参照头文件描述的格式,从 gcda 里面修正过来再执行即可,之后得到 test.c.gcov
:
-: 0:Source:test.c
-: 0:Graph:./test.gcno
-: 0:Data:./test.gcda
-: 0:Runs:1
-: 1:#include <stdio.h>
-: 2:#include <stdlib.h>
-: 3:
1: 4:int main(int argc, char** argv)
-: 5:{
1: 6: int num0 = atoi(argv[1]);
1: 7: int num1 = atoi(argv[1]);
1: 8: int branch = atoi(argv[2]);
1: 9: if (branch < 1) {
#####: 10: for (int i = 2; i < num0; i++) {
#####: 11: if (num0 % i == 0) {
-: 12: printf("%i",i);
-: 13: break;
-: 14: }
-: 15: }
-: 16: } else {
100: 17: for (int i = 2; i < num1; i++) {
100: 18: if (num1 % i == 0) {
-: 19: printf("%i",i);
-: 20: break;
-: 21: }
-: 22: }
-: 23: }
-: 24: return 0;
-: 25:}
可以看到,随手测试的数据产生了分支情况的不同。分支 < 1 的情况没有执行过,所以其计数值为0,显示为 #####。
优化一下
执行下面命令,生成使用 PGO 的程序:
gcc -Og -fprofile-use test.c -o test_use
不看汇编了,我们直接启动 IDA:
int __cdecl main(int argc, const char **argv, const char **envp)
{
int v3; // r12d
int v4; // ebx
int v5; // ecx
int v6; // esi
int v7; // r8d
int i; // r9d
v3 = strtol(argv[1], 0LL, 10);
v4 = strtol(argv[1], 0LL, 10);
if ( (int)strtol(argv[2], 0LL, 10) > 0 ) // 这里变成大于了
{
v5 = 2;
v6 = ((_BYTE)v4 - 2) & 7;
if ( (((_BYTE)v4 - 2) & 7) == 0 )
goto LABEL_23;
if ( v4 <= 2 )
return 0;
if ( v4 % 2 )
{
v5 = 3;
if ( v6 == 1 )
goto LABEL_23;
if ( v6 != 2 )
{
if ( v6 != 3 )
{
if ( v6 != 4 )
{
if ( v6 != 5 )
{
if ( v6 != 6 )
{
if ( !(v4 % 3) )
goto LABEL_38;
v5 = 4;
}
if ( !(v4 % v5) )
goto LABEL_38;
++v5;
}
if ( !(v4 % v5) )
goto LABEL_38;
++v5;
}
if ( !(v4 % v5) )
goto LABEL_38;
++v5;
}
if ( !(v4 % v5) )
goto LABEL_38;
++v5;
}
if ( v4 % v5 )
{
++v5;
LABEL_23:
while ( v5 < v4 )
{
if ( !(v4 % v5) )
goto LABEL_38;
v7 = v5 + 1;
v5 = v7;
if ( !(v4 % v7) )
goto LABEL_38;
v5 = v7 + 1;
if ( !(v4 % (v7 + 1)) )
goto LABEL_38;
v5 = v7 + 2;
if ( !(v4 % (v7 + 2)) )
goto LABEL_38;
v5 = v7 + 3;
if ( !(v4 % (v7 + 3)) )
goto LABEL_38;
v5 = v7 + 4;
if ( !(v4 % (v7 + 4)) )
goto LABEL_38;
v5 = v7 + 5;
if ( !(v4 % (v7 + 5)) )
goto LABEL_38;
v5 = v7 + 6;
if ( !(v4 % (v7 + 6)) )
goto LABEL_38;
v5 = v7 + 7;
}
return 0;
}
}
LABEL_38:
__printf_chk(1LL, &unk_2004, (unsigned int)v5);
return 0;
}
for ( i = 2; i < v3; ++i )
{
if ( !(v3 % i) )
{
__printf_chk(1LL, &unk_2004, (unsigned int)i);
return 0;
}
}
return 0;
}
可以发现分支那里从 <
变成了 >
,因为我们的 profile 表明,有极大概率 branch 是 > 0 的,所以将顺序调过来,以便最大化地利用指令 Cache 提速。
此外,针对执行多的分支,还进行了展开,对于最常见情况可以少做循环。
反观没有执行过的分支,编译器倾向于保持不变。
结语
实验到这里就结束了,我们通过反编译的手段,清晰地看到编译器究竟做了什么,生成了什么代码。接下来,我们还将尝试 clang 编译器!