C# 使用SIMD向量类型加速浮点数组求和运算(5):如何查看Release程序运行时汇编代码

发布时间 2023-08-19 19:03:21作者: zyl910

作者: zyl910

一、引言

前面的几篇文章里,介绍了 C# 编写向量算法的各种办法。
虽然也做了一些基准测试,初步验证了向量算法的效率高。但是由于 CPU睿频、其他进程抢占CPU资源 等原因,基准测试的结果不太稳定,有时难以评价哪种向量算法的效率更高。
这时便需要检查一下程序运行时的汇编代码,从而能进行更精准的分析。

例如汇编代码里的这些情况, 会影响程序的性能:

  • 以函数调用的方式来使用内在函数。内在函数的运行时间,一般仅是个位数的时钟周期;而函数调用因存在参数压栈、在栈中保存寄存器 等操作,会花费数十个时钟周期或更长的时间。而以Release方式运行程序时,正常情况下会以内联(inline)的方式使用内在函数,即直接使用CPU指令,而不是函数调用。
  • 对于变量访问存在多余的读写内存操作,未充分利用寄存器(Register)。当以内联的方式使用内在函数时,是使用CPU内部的寄存器来传递参数的。寄存器与CPU同频工作,速度比内存快好几个数量级。正常情况下,仅当寄存器数量不够用时,才需要使用栈内存来暂存中间变量,这会拖慢速度。有时向量版代码编写的不够好,就容易遇到“超过了寄存器数量”问题。检查汇编代码,可以发现这种问题。
  • 存在多余的越界检查等安全检查。
    ……

C# 程序编译时,只是编译为IL(Intermediate Language,中间语言)的代码。
随后在真机上运行时,通过JIT(just in time,即时编译)技术,将IL再编译为本机代码(Native Code),从而使程序运行。
使用 ildasm、ILSpy 等工具进行反编译时,只能看到IL代码,而不是运行时的本机汇编代码。
若想查看运行时的本机汇编代码,得用其他办法。

二、办法说明

2.1 基本办法

若想查看运行时汇编代码,最简单的办法是使用Visual Studio的“Disassembly”功能。
具体办法是:在Visual Studio里打开程序的解决方案,并设置断点。按“F5”运行程序直至遇到断点,然后点击菜单栏里的“DEBUG”(调试)->“Windows”(窗口)->“Disassembly”(反汇编)。
Asm01.png

在“Disassembly”菜单项的旁边,还有一个“Registers”(寄存器)菜单项,可以用来查看CPU的各个寄存器。这对调试汇编代码很有用。
Asm01_reg.png

对于 C# 程序,目前Visual Studio还不支持“查看AVX寄存器信息”,会发现它的数据为灰色字体。仅在运行 C/C++ 程序时,可以用该窗口查看AVX的寄存器。
C# 仅是看不了AVX寄存器而已,“Registers”窗口仍然能查看 SSE寄存器、通用寄存器 等内容。

2.2 Release程序如何设置断点

在Visual Studio里,可通过“在某行代码的左侧点击鼠标左键”的办法,设置断点。
在Debug模式下运行时,该办法一般是有效的。而在Release模式下运行时,大多数时候会发现断点没生效。
这可能是因为Release模式下进行了很多编译优化,导致断点失效了。
初步发现仅Visual Studio 2022在调试 .NET 7.0程序时,断点在Release模式运行时有效。而低版本的,一般没成功。
所以对于Release模式下运行的程序,我们需要用另一种办法来设置断点。

办法就是使用 Debugger.Break 来主动触发断点. 它的名称空间是 System.Diagnostics.

using System.Diagnostics;

Debugger.Break();
// ...

2.3 如何避免“分层编译”的误导

设置好断点后,按“F5”运行Release模式的程序。随后会发现一个情况——怎么内在函数全部是函数调用方式运行的?这种方式的效率很低啊。
别慌,这是 .NET的“分层编译”技术所导致的。
为了提高程序的启动速度,.NET推出了“分层编译”技术。即在程序启动时,几乎不进行编译优化,而是以最简单、最快的办法进行即时编译(JIT),使程序能在很短的时间内启动。随后JIT会监控程序的热点代码, 对热点代码进行 慢速的、复杂的二次编译,此时才会使用多种编译优化手段。例如改为内联使用内在函数,而不是函数调用。
为了查看编译优化后的汇编代码,需要在热点代码运行后才触发断点。
此时有一个技巧——先按原来的办法对函数进行基准测试,随后在基准测试代码的后面,插入“Debugger.Break”,并再调用一次测试函数。
因为在对函数进行基准测试时,会使该函数变为热点代码,触发编译优化。基准测试完毕遇到“Debugger.Break”时会暂停执行,便能打开“Disassembly”窗口。然后单步运行,从而查看汇编代码。
为了不影响程序发布, 可以建立useBreakPoint变量。随后根据该变量写分支语句,写好断点时相关代码。
修改后的代码如下。

bool useBreakPoint = true;

// SumVectorAvxRef.
tickBegin = Environment.TickCount;
rt = SumVectorAvxRef(src, count, loops);
msUsed = Environment.TickCount - tickBegin;
mFlops = countMFlops * 1000 / msUsed;
scale = mFlops / mFlopsBase;
tw.WriteLine(indent + string.Format("SumVectorAvxRef:\t{0}\t# msUsed={1}, MFLOPS/s={2}, scale={3}", rt, msUsed, mFlops, scale));
if (useBreakPoint) {
    Debugger.Break();
    rt = SumVectorAvxRef(src, count, 1);
}

2.4 实际演练(汇编调试)

2.4.1 进入断点

按照上面办法修改好程序,并将程序切换为Release方式,然后按“F5”(或工具栏的运行按钮)运行程序。
程序运行一段时间后,断点触发了,Visual Studio的C#源码窗口,会停在“Debugger.Break”的下一行 C# 代码上。如下图。
Asm01.png

此时点击点击菜单栏里的“DEBUG”(调试)->“Windows”(窗口)->“Disassembly”(反汇编)。
随即便打开了“Disassembly”窗口,能查看本机的汇编代码。例如这台电脑是X86架构,于是汇编代码是X86的。如下图。
Asm02.png

“Disassembly”窗口里不仅展示了汇编代码,且还将对应的 C# 代码也一起展示。注意有些时候因为编译优化等原因,对应的 C# 代码没能一起展示。此次阅读汇编代码会稍微吃力一些,需人工翻阅 C# 代码, 思考对照关系.
格式说明——

  • C# 代码。格式为前导空格 + C#行号 + 冒号(:) + 多个空格 + C#代码.
  • 汇编代码。格式为十六进制地址 + 多个空格 + 汇编指令.
   173:                         rt = SumVectorAvxRef(src, count, 1);
00007FF95A68985B  mov         rcx,rdi  
00007FF95A68985E  mov         edx,1000h  
00007FF95A689863  mov         r8d,1  
00007FF95A689869  call        Method stub for: BenchmarkVector.BenchmarkVectorDemo.SumVectorAvxRef(Single[], Int32, Int32) (07FF95A680FB0h)  

这里的3个“mov”指令,是使用寄存器传递参数。最后用“call”指令,调用测试函数(SumVectorAvxRef).
可参考C#代码中函数的参数列表, 弄清楚这3个“mov”指令所对应的参数。

  1. mov rcx,rdi。它对应函数的 float[] src 参数。即 float[] src = new float[count];
  2. mov edx,1000h。它对应函数的 int count 参数。即 const int count = 1024*4;1024*4 的结果是 4096, 用十六进制表示就是 1000h .
  3. mov r8d,1。它对应函数的 int loops 参数。即 1

2.4.2 单步调试

“Disassembly”窗口也支持“F11”单步调试等快捷键。注意此时不再以 “一条C#语句” 为单位, 而是以“一条汇编指令” 为单位。

按3次“F11”,跳过了3个“mov”指令,当前语句是“call”指令。此时再按一次“F11”,便能进入该函数。如下图。
Asm03.png

上面的截图里,展示了“Disassembly”窗口的快捷菜单,其中有如下的查看选项。

  • Show Address:显示汇编代码的地址。
  • Show Source Code:显示 C# 源码。
  • Show Code Bytes:显示代码的字节。即显示机器码。
  • Show Symbol Names:显示符号名。
  • Show Line Numbers:显示 C# 源码的行号。
  • Show Toolbar:显示工具栏。即此窗口顶部的“Viewing Options”工具栏。

上述截图里,还展示了“cntBlock”字段是如何计算的,结果会保存到“eax”寄存器。汇编代码摘录如下。

   734:             int cntBlock = count / nBlockWidth; // Block count.
00007FF95A68CBDC  mov         eax,edx  ; eax = edx; 注意edx是函数的输入参数 `int count`。
00007FF95A68CBDE  sar         eax,1Fh  ; eax >>= 1Fh; “1Fh”是十进制的“31”,这个带符号移位是为了获得edx的符号. 0或正数会得到“0”,负数会得到“-1”(FFFFFFFFh).
00007FF95A68CBE1  and         eax,7    ; eax &= 7
00007FF95A68CBE4  add         eax,edx  ; eax += edx
00007FF95A68CBE6  sar         eax,3    ; eax >>= 3; 因为此时 nBlockWidth 为8,可优化为右移3位.

为了便于读者阅读,我在上述汇编代码的后面加了注释,用 ;(分号)隔开。
这一段代码使用了一种常见的编译优化手段——“将除法优化为移位”。且由于是带符号数,有可能为负数,于是在右移前对数据进行了一些转换。

上述截图里,还展示了“vrt”字段是如何清零的,它使用了“ymm1”寄存器。汇编代码摘录如下。

   736:             Vector256<float> vrt = Vector256<float>.Zero; // Vector result.
00007FF95A68CBFE  vxorps      ymm1,ymm1,ymm1  ; ymm1 = ymm1^ymm1 = 0; 将同一个数进行“xor”运算, 结果为0, 这便完成了“赋值为0”的工作.

2.4.3 观察主循环的汇编代码

由于程序已经运行到该函数内,于是可以不用进行单步调试了。可直接拖动滚动条,查看主循环的汇编代码。如下图。
Asm04.png

由于有 C# 语句做对照,所以能很快定位关键代码。例如下面这几条汇编语句,是计算“p0”的初始值(数组中首个元素的引用)

00007FF95A68CC18  add         rcx,10h  ; rcx += 10h
   740:                 ref Vector256<float> p0 = ref Unsafe.As<float, Vector256<float>> (ref src[0]); // Pointer for src data.
00007FF95A68CC1C  mov         r10,rcx  ; r10 = rcx

注意rcx是函数的输入参数 float[] src。加上 10h 后,使rcx改为指向“数组中首个元素的地址”。10h.NET内部的数组内存布局有关, 不同的处理器架构, 该值不同。
虽然 Unsafe.As 写起来比较冗长, 但这些冗长内容,只是为了满足 C# 的语法检查,它的本质是“赋值”。于是并不需要调用Unsafe.As 等函数, 仅用1条“mov”指令就行。

接下来可以观察向量处理的关键循环(Vector processs)。汇编代码摘录如下。

   741:                 // Vector processs.
   742:                 for (i = 0; i < cntBlock; ++i) {
00007FF95A68CC1F  xor         r11d,r11d  ; r11d = r11d^r11d = 0; 这是for的“i = 0”部分.
   742:                 for (i = 0; i < cntBlock; ++i) {
00007FF95A68CC22  test        eax,eax  ; 检查eax(cntBlock)是否小于等于(le)零。若为真,会用下一条语句跳转到“00007FF95A68CC37h”,即循环外.
00007FF95A68CC24  jle         BenchmarkVector.BenchmarkVectorDemo.SumVectorAvxRef(Single[], Int32, Int32)+067h (07FF95A68CC37h)   ; 若为假, 则往下执行.
   743:                     vrt = Avx.Add(vrt, p0);    // Add. vrt += vsrc[i];
00007FF95A68CC26  vaddps      ymm1,ymm1,ymmword ptr [r10]  ; ymm1 = ymm1 + [r10]; 该指令的最后一个参数可以是内存地址,便从 r10(p0)加载了数据。注意ymm1是累加结果变量“vrt”.
00007FF95A68CC2B  add         r10,20h  ; r10 + 20h; 对应 `p0 = ref Unsafe.Add(ref p0, 1);`. 使r10寄存器指向下一笔数据. 向量类型的长度是256位,为32字节,既十六进制的“20h”.
   742:                 for (i = 0; i < cntBlock; ++i) {
00007FF95A68CC2F  inc         r11d  ; ++r11d; 这是for的“++i”部分.
00007FF95A68CC32  cmp         r11d,eax  ; 这是for的“i < cntBlock”部分. 若为真,会用下一条语句跳转到“07FF95A68CC26h”,即循环内的第一条语句.
00007FF95A68CC35  jl          BenchmarkVector.BenchmarkVectorDemo.SumVectorAvxRef(Single[], Int32, Int32)+056h (07FF95A68CC26h)  ; 若为假, 则往下执行, 离开循环.
   748:                 for (i = 0; i < cntRem; ++i) {
00007FF95A68CC37  xor         r11d,r11d  

可以看出,上面的汇编代码的质量很高——

  • 不仅对内在函数做了内联优化, 且将引用的相关操作(Unsafe.AsUnsafe.Add)也做了内联, 转成了高效率的地址计算指令。
  • 充分利用了寄存器,避免了慢速的栈内存。

三、结语

还可以对照一下SumVectorAvxPtr运行时的汇编代码, 会发现它与SumVectorAvxRef的几乎是一样的。这就是“引用版函数与指针版函数的性能几乎一致”的原因,现在有汇编代码为证。
这也是 第2篇文章 发现“C# 向量算法与C++版向量算法的性能几乎一致”的原因。因为它们都已经被编译为高效率的汇编代码。

除了“Disassembly”窗口外,还可以使用 WinDbg、BenchmarkDotNet、CoreCLR命令行 等办法来查看汇编代码。有兴趣的读者,可以查看 Nemanja Mijailovic 的文章.

参考文献