计算相关性与使用 gdb 调试
1. 计算相关性
在给定 n 个样本值的情况下,计算两个变量 x 和 y 之间的相关性是优化的一个重要示例。一种计算相关性的方法需要对数据进行两次遍历,一次计算平均值,另一次完成公式计算。不过,有一个不太直观但更便于计算的公式,该公式在扫描数据时需要计算 5 个和:$X_i$ 的和、$Y_i$ 的和、$X_i^2$ 的和、$Y_i^2$ 的和以及 $X_iY_i$ 的和。计算完这 5 个和之后,再花少量时间实现计算相关性的公式。
1.1 C 语言实现
下面是使用 C 语言实现计算相关性的
corr
函数:
#include <math.h>
double corr ( double x [] , double y [] , long n )
{
double sum_x , sum_y , sum_xx , sum_yy, sum_xy;
long i;
sum_x = sum_y = sum_xx = sum_yy = sum_xy = 0.0;
for ( i = 0; i < n; i++ ) {
sum_x += x [i];
sum_y += y [i];
sum_xx += x [i] * x [i];
sum_yy += y [i] * y [i];
sum_xy += x [i] * y [i];
}
return (n*sum_xy - sum_x*sum_y) /
sqrt ( (n*sum_xx - sum_x*sum_x) * (n*sum_yy - sum_y*sum_y) );
}
GCC 编译器生成的汇编代码使用了所有 16 个 XMM 寄存器,它展开了循环,在主循环中处理 for 循环的 4 次迭代。当数组大小不是 4 的倍数时,编译器也能正确处理额外的数据值。对两个大小为 10000 的数组进行 100 万次相关性计算,C 语言版本需要 13.44 秒,大约是 5.9 GFLOPs,对于编译后的代码来说,这是相当不错的表现。
1.2 使用 SSE 指令实现
下面是使用 SSE 指令实现的核心函数版本,该版本可以在许多现代计算机上执行:
segment .text
global corr
; rdi: x array
; rsi: y array
; rdx: n
; rcx: loop counter
; xmm0: 2 parts of sum_x
; xmm1: 2 parts of sum_y
; xmm2: 2 parts of sum_xx
; xmm3: 2 parts of sum_yy
; xmm4: 2 parts of sum_xy
; xmm5: 2 x values - later squared
; xmm6: 2 y values - later squared
; xmm7: 2 xy values
corr:
xor r8, r8
mov rcx, rdx
subpd xmm0, xmm0
movapd xmm1, xmm0
movapd xmm2, xmm0
movapd xmm3, xmm0
movapd xmm4, xmm0
movapd xmm8, xmm0
movapd xmm9, xmm0
movapd xmm10, xmm0
movapd xmm11, xmm0
movapd xmm12, xmm0
.more:
movapd xmm5, [rdi + r8] ; mov x
movapd xmm6, [rsi + r8] ; mov y
movapd xmm7, xmm5 ; mov x
mulpd xmm7, xmm6 ; xy
addpd xmm0, xmm5 ; sum_x
addpd xmm1, xmm6 ; sum_y
mulpd xmm5, xmm5 ; xx
mulpd xmm6, xmm6 ; yy
addpd xmm2, xmm5 ; sum_xx
addpd xmm3, xmm6 ; sum_yy
addpd xmm4, xmm7 ; sum_xy
movapd xmm13, [rdi + r8 + 16] ; mov x
movapd xmm14, [rsi + r8 + 16] ; mov y
movapd xmm15, xmm13 ; mov x
mulpd xmm15, xmm14 ; xy
addpd xmm8, xmm13 ; sum_x
addpd xmm9, xmm14 ; sum_y
mulpd xmm13, xmm13 ; xx
mulpd xmm14, xmm14 ; yy
addpd xmm10, xmm13 ; sum_xx
addpd xmm11, xmm14 ; sum_yy
addpd xmm12, xmm15 ; sum_xy
add r8, 32
sub rcx, 4
jnz .more
addpd xmm0, xmm8
addpd xmm1, xmm9
addpd xmm2, xmm10
addpd xmm3, xmm11
addpd xmm4, xmm12
haddpd xmm0, xmm0 ; sum_x
haddpd xmm1, xmm1 ; sum_y
haddpd xmm2, xmm2 ; sum_xx
haddpd xmm3, xmm3 ; sum_yy
haddpd xmm4, xmm4 ; sum_xy
movsd xmm6, xmm0 ; sum_x
movsd xmm7, xmm1 ; sum_y
cvtsi2sd xmm8, rdx ; n
mulsd xmm6, xmm6 ; sum_x*sum_x
mulsd xmm7, xmm7 ; sum_y*sum_y
mulsd xmm2, xmm8 ; n*sum_xx
mulsd xmm3, xmm8 ; n*sum_yy
subsd xmm2, xmm6 ; n*sum_xx - sum_x*sum_x
subsd xmm3, xmm7 ; n*sum_yy - sum_y*sum_y
mulsd xmm2, xmm3 ; denom*denom
sqrtsd xmm2, xmm2 ; denom
mulsd xmm4, xmm8 ; n*sum_xy
mulsd xmm0, xmm1 ; sum_x*sum_y
subsd xmm4, xmm0 ; n*sum_xy - sum_x*sum_y
divsd xmm4, xmm2 ; correlation
movsd xmm0, xmm4 ; need in xmm0
ret
在这个函数的主循环中,使用
movapd
指令从 x 数组加载 2 个双精度值,再从 y 数组加载 2 个值。然后在寄存器
xmm0 - xmm4
中进行累加。每个累加寄存器保存 2 个累加值,一个用于偶数索引,一个用于奇数索引。之后,再次使用
movapd
指令加载 x 和 y 的另外 2 个值,并将这些值累加到另外 5 个寄存器
xmm8 - xmm12
中。循环完成后,将每个所需求和的 4 个部分相加。首先使用
addpd
指令将寄存器
xmm8 - xmm12
加到寄存器
xmm0 - xmm4
中,然后使用
haddpd
指令将每个求和寄存器的上下半部分相加得到最终的和,最后实现前面给出的公式。对 10000 个样本进行 100 万次相关性计算,该程序用时 6.74 秒,大约是 11.8 GFLOPs。考虑到 CPU 运行在 3.4 GHz,这个结果相当令人印象深刻,它每个周期大约产生 3.5 个浮点结果,这意味着多个 SSE 指令可以同时完成,CPU 正在进行乱序执行,每个周期可以完成多个 SSE 指令。
1.3 使用 AVX 指令实现
Core i7 CPU 实现了一组名为“高级矢量扩展”(AVX)的新指令。这些指令提供了 XMM 寄存器的扩展,即
ymm0
到
ymm15
,以及一些新指令。每个 YMM 寄存器为 256 位,可以容纳 4 个双精度值,这使得 SSE 函数可以很容易地适应一次处理 4 个值。除了提供更大的寄存器外,AVX 指令还增加了现有指令的版本,允许使用 3 个操作数:2 个源操作数和 1 个不参与源操作的目标操作数(除非两次指定同一个寄存器)。AVX 版本的指令以字母“v”为前缀,3 操作数指令减少了寄存器压力,允许在一条指令中使用两个寄存器作为源,同时保留它们的值。
下面是
corr
函数的 AVX 版本:
segment .text
global corr
; rdi: x array
; rsi: y array
; rdx: n
; rcx: loop counter
; ymm0: 4 parts of sum_x
; ymm1: 4 parts of sum_y
; ymm2: 4 parts of sum_xx
; ymm3: 4 parts of sum_yy
; ymm4: 4 parts of sum_xy
; ymm5: 4 x values - later squared
; ymm6: 4 y values - later squared
; ymm7: 4 xy values
corr:
xor r8, r8
mov rcx, rdx
vzeroall
.more:
vmovupd ymm5, [rdi + r8] ; mov x
vmovupd ymm6, [rsi + r8] ; mov y
vmulpd ymm7, ymm5, ymm6 ; xy
vaddpd ymm0, ymm0, ymm5 ; sum_x
vaddpd ymm1, ymm1, ymm6 ; sum_y
vmulpd ymm5, ymm5, ymm5 ; xx
vmulpd ymm6, ymm6, ymm6 ; yy
vaddpd ymm2, ymm2, ymm5 ; sum_xx
vaddpd ymm3, ymm3, ymm6 ; sum_yy
vaddpd ymm4, ymm4, ymm7 ; sum_xy
vmovupd ymm13, [rdi + r8 + 32] ; mov x
vmovupd ymm14, [rsi + r8 + 32] ; mov y
vmulpd ymm15, ymm13, ymm14 ; xy
vaddpd ymm8, ymm8, ymm13 ; sum_x
vaddpd ymm9, ymm9, ymm14 ; sum_y
vmulpd ymm13, ymm13, ymm13 ; xx
vmulpd ymm14, ymm14, ymm14 ; yy
vaddpd ymm10, ymm10, ymm13 ; sum_xx
vaddpd ymm11, ymm11, ymm14 ; sum_yy
vaddpd ymm12, ymm12, ymm15 ; sum_xy
add r8, 64
sub rcx, 8
jnz .more
vaddpd ymm0, ymm0, ymm8
vaddpd ymm1, ymm1, ymm9
vaddpd ymm2, ymm2, ymm10
vaddpd ymm3, ymm3, ymm11
vaddpd ymm4, ymm4, ymm12
vhaddpd ymm0, ymm0, ymm0
vhaddpd ymm1, ymm1, ymm1
vhaddpd ymm2, ymm2, ymm2
vhaddpd ymm3, ymm3, ymm3
vhaddpd ymm4, ymm4, ymm4
vextractf128 xmm5, ymm0, 1
vaddsd xmm0, xmm0, xmm5
vextractf128 xmm6, ymm1, 1
vaddsd xmm1, xmm1, xmm6
vmulsd xmm6, xmm0, xmm0
vmulsd xmm7, xmm1, xmm1
vextractf128 xmm8, ymm2, 1
vaddsd xmm2, xmm2, xmm8
vextractf128 xmm9, ymm3, 1
vaddsd xmm3, xmm3, xmm9
cvtsi2sd xmm8, rdx
vmulsd xmm2, xmm2, xmm8
vmulsd xmm3, xmm3, xmm8
vsubsd xmm2, xmm2, xmm6
vsubsd xmm3, xmm3, xmm7
vmulsd xmm2, xmm2, xmm3
vsqrtsd xmm2, xmm2, xmm2
vextractf128 xmm6, ymm4, 1
vaddsd xmm4, xmm4, xmm6
vmulsd xmm4, xmm4, xmm8
vmulsd xmm0, xmm0, xmm1
vsubsd xmm4, xmm4, xmm0
vdivsd xmm0, xmm4, xmm2
ret
现在代码为每个所需的和累积 8 个部分和。不幸的是,
vhaddpd
指令并没有对寄存器中的所有 4 个值进行求和,而是对前 2 个值求和并将结果留在寄存器的下半部分,对后 2 个值求和并将结果留在寄存器的上半部分。因此需要使用
vextractf128
指令将这些和的上半部分移动到寄存器的下半部分,以便将两部分相加。对 10000 对值进行 100 万次相关性计算,AVX 版本用时 3.9 秒,相当于 20.5 GFLOPs,平均每个时钟周期实现 6 个浮点结果。代码中有许多可以执行 4 个操作的指令,CPU 在乱序执行方面表现出色。使用 2 组累加寄存器很可能减少了指令间的依赖关系,有助于 CPU 并行执行更多指令。
下面是不同实现方式的性能对比表格:
| 实现方式 | 执行时间(秒) | GFLOPs |
| ---- | ---- | ---- |
| C 语言实现 | 13.44 | 5.9 |
| SSE 指令实现 | 6.74 | 11.8 |
| AVX 指令实现 | 3.9 | 20.5 |
相关性计算的实现流程可以用以下 mermaid 流程图表示:
graph TD
A[开始] --> B[选择实现方式]
B --> C{C 语言实现}
B --> D{SSE 指令实现}
B --> E{AVX 指令实现}
C --> F[初始化变量]
F --> G[循环累加]
G --> H[计算相关性]
H --> I[结束]
D --> J[初始化寄存器]
J --> K[循环加载和累加]
K --> L[合并部分和]
L --> M[计算相关性]
M --> I
E --> N[初始化 YMM 寄存器]
N --> O[循环加载和累加]
O --> P[合并部分和]
P --> Q[计算相关性]
Q --> I
2. 使用 gdb 调试
gdb 调试器是自由软件基金会的产品,其官网为 http://www.gnu.org 。它支持多种语言,包括 C、C++、Fortran 和汇编。不过,该调试器似乎更适合 C 和 C++,调试 yasm 代码的效果不太理想。
2.1 为 gdb 做准备
为了让 gdb 能够识别源代码和变量,代码必须使用特殊选项进行编译,以便在目标代码中添加调试符号信息。使用 GCC 或 g++ 时,使用
-g
选项来启用调试支持。使用 yasm 时,同样使用
-g
选项,但必须指定调试格式,对于 Linux 可以是
dwarf2
或
stabs
,对于 Microsoft Visual Studio 可以是
cv8
,其中
dwarf2
选项提供的兼容性最完整。作者开发了一个名为
yld
的脚本,用于在程序以
_start
开始时进行链接,还开发了
ygcc
脚本用于在使用
main
时进行链接。这些脚本会检查链接行上的每个目标文件,对于有匹配的
.asm
文件的目标文件,会检查
.asm
文件以定位数据定义语句。对于汇编代码中定义的每个变量,脚本会生成一个宏,并将其放在一个隐藏文件(文件名以“.”开头)中,该文件在调试时使用。gdb 初始化文件的命名基于链接命令
-o
选项指定的可执行文件名称。例如,如果可执行文件名为
array
,则初始化文件名为
.array.gdb
。以下是一个初始化宏文件的示例:
break main
macro define a ( (unsigned char *) &a)
macro define b ( (int *)&b)
macro define c ( (long *)&c)
macro define s ( (unsigned char *)&s)
macro define next ( (short *)&next)
macro define val ( (unsigned char *)&val)
macro define f ( (float *)&f)
macro define d ( (double * ) &d)
初始化文件的第一行在
main
处设置断点,这样进入调试器后即可立即开始调试。其余行创建与汇编代码中变量同名的宏,每个宏使用类型转换将变量的地址转换为正确类型的指针,这样就可以使用变量名来获取指针。例如,
next
是一个指向短整型的指针,使用
*next
可以获取
next
指向的值,也可以使用
next[0]
、
next[1]
、
next[2]
等访问数组元素。如果不使用初始化文件,gdb 会认为所有变量都是双字整数。
2.2 启动 gdb
启动 gdb 的典型方式是:
gdb program
其中
program
是链接程序时
-o
选项指定的名称。作者还准备了一个名为
ygdb
的脚本,其调用方式类似:
ygdb program
该脚本使用
-x .program.gdb
选项运行 gdb,让 gdb 读取并执行初始化文件中的命令。
2.3 退出 gdb
退出 gdb 的命令是
quit
,可以缩写为
q
。如果已经启动程序并且程序仍在运行,gdb 会提示程序仍在运行,并询问是否要终止该进程,输入
y
即可终止进程并退出。
2.4 设置断点
可以使用
breakpoint
命令设置断点,该命令可以缩写为
b
。可以使用源代码中的标签或文件的行号来设置断点,示例如下:
b main
b 17
2.5 运行程序
在 gdb 中使用
run
命令(可缩写为
r
)来启动程序执行。如果正在运行程序,gdb 会在终止进程并重新启动之前提示确认。如果设置了断点,调试器会执行到断点处,然后将控制权返回给调试器,此时可以检查寄存器、检查内存、逐行执行代码或执行任何 gdb 命令。如果没有设置断点,程序将运行到完成或遇到故障,这有时是了解段错误等问题的便捷方式。在调试过程中,有几种继续执行的选项:
-
继续执行
:使用
continue
命令(可缩写为
c
)继续执行,直到程序完成或到达下一个断点。
-
单步执行
:有 4 种单步执行的选项。首先可以选择执行一条源代码语句或一条机器指令,在 C/C++ 中可能不希望一次执行一条机器指令。还可以选择只在同一函数内调试,或者在调用其他函数时进入其他函数。在同一函数内单步执行可以使用
next
或
next instruction
,在汇编代码中这两个指令效果相同,可以缩写为
n
或
ni
。使用
next
时,调试器会执行所有函数调用,直到从函数返回才会返回调试器。另一种选择是使用
step
或
stepinstruction
命令,这些命令可以执行一条源代码语句或一条机器指令,并允许在被调用的函数内进行调试,可以缩写为
s
或
si
,在汇编代码中这两个命令效果相同。如果编写自己的函数,可能更愿意使用
step
来调试被调用的函数,但可能希望使用
next
来“跳过”像
printf
这样的函数调用。
2.6 打印栈帧跟踪信息
程序在执行过程中崩溃是比较常见的情况,例如出现段错误。段错误通常是编码错误,程序试图访问未映射到程序中的内存,可能是因为越界访问数组。以下是使用 gdb 调试出现段错误程序的示例:
seyfarth@tux : -/teaching/asm$ ./testcopy
Segmentation fault
使用 gdb 调试该程序:
Reading symbols from /home/seyfarth/teaching/asm/testcopy...
(gdb) run
Starting program: /home/seyfarth/teaching/asm/testcopy
Program received signal SIGSEGV, Segmentation fault.
copy_repb () at copy.asm:12
12 rep movsb
(gdb) bt
#0 copy_repb () at copy.asm:12
#1 0x000000000040097e in test (argc=<value optimized out>, argv=<value optimized out>) at testcopy.c:27
#2 main (argc=<value optimized out>, argv=<value optimized out>) at testcopy.c:45
再次出现段错误,但可以立即看到程序在
copy.asm
文件的第 12 行
copy_repb
函数中崩溃,当时正在执行
rep movsb
指令。
bt
命令(回溯)会反向遍历函数调用的栈帧,报告
copy_repb
是由
test
函数调用的,而
test
函数是从
main
函数调用的。由于优化级别较高,回溯命令可能无法跟踪某些变量,重新以
-O1
而不是
-O3
级别编译可以得到更详细的结果。
gdb 调试的操作流程可以用以下 mermaid 流程图表示:
graph TD
A[准备代码和调试信息] --> B[启动 gdb]
B --> C[设置断点]
C --> D[运行程序]
D --> E{是否到达断点}
E -- 是 --> F[检查和调试]
F --> G{是否继续执行}
G -- 是 --> H[选择继续方式]
H --> D
G -- 否 --> I[退出 gdb]
E -- 否 --> J{是否出现故障}
J -- 是 --> K[分析故障原因]
K --> I
J -- 否 --> L[程序完成]
L --> I
通过以上对计算相关性的不同实现方式以及 gdb 调试的介绍,我们可以看到在不同场景下如何优化计算性能以及如何有效地调试程序。在实际应用中,可以根据具体需求选择最合适的实现方式和调试方法。
计算相关性与使用 gdb 调试
3. 相关性计算实现方式总结与分析
在前面我们介绍了计算两个变量相关性的不同实现方式,包括 C 语言实现、SSE 指令实现和 AVX 指令实现。下面我们对这些实现方式进行更深入的总结与分析。
3.1 性能对比分析
从前面给出的性能对比表格可以看出,不同实现方式在执行时间和 GFLOPs 上有明显差异:
| 实现方式 | 执行时间(秒) | GFLOPs |
| ---- | ---- | ---- |
| C 语言实现 | 13.44 | 5.9 |
| SSE 指令实现 | 6.74 | 11.8 |
| AVX 指令实现 | 3.9 | 20.5 |
- C 语言实现 :C 语言实现是最基础的方式,它的代码逻辑清晰,易于理解和维护。但由于其是通用的高级语言实现,没有针对特定硬件进行优化,所以在性能上相对较弱。在处理大规模数据时,执行时间较长,GFLOPs 较低。
- SSE 指令实现 :SSE 指令实现利用了 CPU 的 SIMD(单指令多数据)特性,通过一次处理多个数据来提高计算效率。相比于 C 语言实现,SSE 指令实现的执行时间大幅减少,GFLOPs 提高了近一倍。这是因为 SSE 指令可以并行处理多个数据,减少了循环次数,从而提高了计算速度。
- AVX 指令实现 :AVX 指令是 SSE 指令的扩展,它提供了更大的寄存器和更多的操作数,进一步提高了 SIMD 处理能力。AVX 指令实现的执行时间最短,GFLOPs 最高,平均每个时钟周期能实现 6 个浮点结果。这得益于 AVX 指令的并行处理能力和对寄存器的高效利用,以及 CPU 的乱序执行机制。
3.2 适用场景分析
- C 语言实现 :适用于对代码可读性和可维护性要求较高,数据规模较小,对性能要求不是特别苛刻的场景。例如在开发初期进行功能验证,或者处理小规模的测试数据时,可以使用 C 语言实现。
- SSE 指令实现 :适用于需要一定性能提升,且 CPU 支持 SSE 指令集的场景。当数据规模较大,对计算速度有一定要求时,SSE 指令实现可以在不增加太多开发难度的情况下,显著提高计算性能。
- AVX 指令实现 :适用于对性能要求极高,且 CPU 支持 AVX 指令集的场景。例如在科学计算、机器学习等领域,需要处理大规模的数据和复杂的计算任务,AVX 指令实现可以充分发挥 CPU 的性能优势,提高计算效率。
4. gdb 调试技巧与注意事项
在使用 gdb 调试程序时,除了前面介绍的基本操作外,还有一些技巧和注意事项可以帮助我们更高效地进行调试。
4.1 调试技巧
- 使用宏进行类型转换 :如前面所述,使用 gdb 的宏功能可以方便地进行类型转换,避免手动进行复杂的类型转换操作。通过在初始化文件中定义宏,可以在调试过程中直接使用变量名来获取正确类型的指针,提高调试效率。
- 设置条件断点 :除了使用普通断点外,还可以设置条件断点。条件断点只有在满足特定条件时才会触发,这在调试复杂程序时非常有用。例如,可以设置在某个变量的值满足特定条件时触发断点,这样可以快速定位问题所在。设置条件断点的命令格式为:
b <位置> if <条件>
例如,在
main
函数的第 20 行设置一个条件断点,当变量
x
的值等于 10 时触发:
b main:20 if x == 10
-
查看内存内容
:在调试过程中,有时需要查看内存中的内容。可以使用
x命令来查看内存内容,其基本格式为:
x/<n/f/u> <地址>
其中,
<n>
表示要显示的内存单元数量,
<f>
表示显示格式(如
x
表示十六进制,
d
表示十进制等),
<u>
表示每个内存单元的大小(如
b
表示字节,
h
表示半字,
w
表示字,
g
表示双字),
<地址>
表示要查看的内存地址。例如,查看地址
0x12345678
开始的 4 个字节的十六进制内容:
x/4xb 0x12345678
4.2 注意事项
-
调试信息的完整性
:为了确保 gdb 能够正确识别源代码和变量,必须在编译时添加调试符号信息。使用不同的编译器和汇编器时,要注意调试格式的选择,如使用 yasm 时,选择
dwarf2格式可以提供更完整的兼容性。 -
优化级别对调试的影响
:较高的优化级别可能会导致调试信息丢失或不准确,影响调试的效果。在调试过程中,如果发现无法正确跟踪变量或程序执行流程异常,可以尝试降低优化级别,如使用
-O1而不是-O3进行编译。 -
多线程调试
:如果程序是多线程的,gdb 提供了一些专门的命令来进行多线程调试。例如,使用
info threads命令可以查看当前所有线程的信息,使用thread <线程号>命令可以切换到指定线程进行调试。在进行多线程调试时,要注意线程之间的同步和竞争问题,避免出现调试结果不准确的情况。
5. 总结与展望
通过以上对计算相关性的不同实现方式和 gdb 调试的介绍,我们了解到在不同的场景下如何选择合适的计算方式来提高性能,以及如何使用 gdb 进行有效的程序调试。
在计算相关性方面,随着硬件技术的不断发展,CPU 的指令集也在不断更新和扩展,如 AVX-512 等更高级的指令集已经出现。未来,我们可以进一步探索如何利用这些新的指令集来实现更高性能的相关性计算。同时,对于不同的应用场景,如实时数据处理、分布式计算等,也需要研究更适合的计算方法和优化策略。
在调试方面,虽然 gdb 是一个强大的调试工具,但在面对复杂的程序和多线程环境时,仍然存在一些挑战。未来可能会出现更智能、更高效的调试工具,能够自动分析程序的运行状态,快速定位问题所在。此外,结合人工智能和机器学习技术,调试工具可能会具备预测和预防程序故障的能力,进一步提高软件开发的效率和质量。
总之,无论是计算性能的优化还是程序调试的技术,都在不断发展和进步。我们需要不断学习和掌握新的知识和技能,以适应不断变化的技术环境,提高自己的编程和调试能力。
超级会员免费看
613

被折叠的 条评论
为什么被折叠?



