盘点.NET JIT在Release下由循环体优化所产生的不确定性Bug

时间:2021-01-26 18:30:14

盘点在Release下由循环体优化所产生的不确定性Bug

在这篇文章中,我将介绍一些在测试环境(DEBUG)下正常,但在生产环境(Release)下却会出现的一些让人难以捉摸的Bug。

如果你对开源技术感兴趣,欢迎和我一起共同贡献开源项目,请联系QQ群:976304396

Debug和Release的区别

首先,DebugRelease是一种编译常量,其决定了编译器是否对能够对代码开启优化功能。

Release下,代码将被编译器进行优化,这份优化除了我们能够在编译后所了解的IL代码的区别外,还包括JIT(运行时)在正式转化为机器码前所布置的优化内容,而最终都将以汇编的方式呈现出来.

IL代码是一种规范,无论在哪种环境下生成代码,都不会改变逻辑的差异,但最终生成的汇编码却会因为JIT的内部表现而有所不同。

因此,当出现了代码最终执行效果和我们在脑海中所构建的逻辑效果所不同时,我们不应该以IL的角度来去思考,而是以汇编的角度来去查看到底是在哪块有了分歧。

目录

IL代码无论在哪种环境都会始终表现C#代码的原意,因此,下文的示例将不在描述IL的部分,只描述在debug和release下汇编码的真正区别。

循环变量优化

让我们先从一份简单的for循环代码开始看起:

int len = 10;
for (int i = 1; i < len; i++)
{
}

这是一个简单的for循环逻辑,在方法内都始终存在两个局部变量i和len,c#代码逻辑所表述的是,我们通过访问i的地址处的值和len的地址处的值进行比较,然后根据比较中的结果来去进行跳转循环。而汇编码所表述的逻辑也基本相同,但对局部变量i和len的解释有所不同。

Debug下,JIT将始终读取ilen位置处的值去进行比较

L0023: mov dword ptr [ebp-8], 0xa  //assign len
L002a: mov dword ptr [ebp-0xc], 1 //assign i
...
L0036: mov eax, [ebp-0xc]
L0039: inc eax //i++
L003a: mov [ebp-0xc], eax //update i
L003d: mov eax, [ebp-0xc]
L0040: cmp eax, [ebp-8] //compare

而在Release下,JIT将i的变量始终存储在寄存器中,对于len,则以常量代替.

L0003: mov eax, 1
L0008: inc eax
L0009: cmp eax, 0xa

Release较Debug的变化是:JIT知道在当前方法上下文中,len是个局部变量,且始终不会改变,因此可以提升为常量,这样当进行比较时,可以不用每次都进行访问。i也是个局部变量,且每次增加固定的常量1,因此i也不需要在栈中存储,可以直接保留在寄存器中,这样不会有取址的开销。

上述例子说明了,在一定的条件下,编译器会对循环体中进行比较的变量进行特殊的优化,通过避免在地址中取值,以提升循环的效率。

注:由于CPU对指令执行的速度远高于访问内存的速度,因此相比较对内存进行访问是一种开销,在访问性能中,寄存器>cpu缓存行>主存.

性能差异

让我们通过下面一个例子来看一下,使用寄存器和不使用寄存器来保存循环变量所带来的性能差异:

public void Test1()
{
int count = 0;
for (int i = 1; i < 1000; i++)
{
ref _Int32 r = ref Unsafe.As<int, _Int32>(ref i);
count += r.int32;
}
}
public void Test2()
{
int count = 0;
for (int i = 1; i < 1000; i++)
{
_Int32 r = Unsafe.As<int, _Int32>(ref i);
count += r.int32;
}
}

请通过Benchmark来对Test1和Test2进行测试,你会发现,两个方法之间的性能差别非常大,Test2的性能要远超Test1。

BenchmarkDotNet=v0.12.1, OS=Windows 10.0.17763.1518 (1809/October2018Update/Redstone5)
Intel Core i7-7700HQ CPU 2.80GHz (Kaby Lake), 1 CPU, 8 logical and 4 physical cores
.NET Core SDK=3.1.402
[Host] : .NET Core 3.1.8 (CoreCLR 4.700.20.41105, CoreFX 4.700.20.41903), X64 RyuJIT
DefaultJob : .NET Core 3.1.8 (CoreCLR 4.700.20.41105, CoreFX 4.700.20.41903), X64 RyuJIT
Method Mean Error StdDev
Test1 2,261.6 ns 12.62 ns 11.80 ns
Test2 308.3 ns 3.43 ns 3.04 ns

近乎相同的代码,为什么会有如此的差异?

如果我们对其生成的汇编代码进行查看的话,你会发现在Test1中,对变量i的访问,将始终通过寻址来去查找:

L000b: mov dword ptr [ebp-4], 1
L0027: cmp dword ptr [ebp-4], 0x3e8
...
L0020: mov edx, [ebp-4]
L0023: inc edx
...
L0024: mov [ebp-4], edx
L0027: cmp dword ptr [ebp-4], 0x3e8

而在Test2中,则始终通过在寄存器中存储的值直接获取:

L000c: inc edx
L000d: cmp edx, 0x3e8

在Test2方法中,因为变量i没有被造成污染,因此最终代码等价于 count += i , 而在Test1方法中, 因为ref关键字的影响,导致了该代码破坏了jit对循环变量的优化规则,最终无法使用寄存器来直接存储变量i,产生了性能的差异。

因此,在往后对循环体的编程中,若代码主体不会改变循环变量的值的话,那么尽量可以在循环体中创建一个副本来去使用,这样对性能可以有效的提升。

注:ref Unsafe.As<int, _Int32>(ref i) 等价于 (_Int32*)&i

Unsafe.As<int, _Int32>(ref i) 等价于 *(_Int32*)&i

潜在的Bug

介绍完通过将循环变量直接存储在寄存器中的方式所带来的性能提升后,下面我将介绍因为这种jit优化的方式所带来的潜在性Bug。

forwhile是在语法上有所不同,但最终执行表现是相同的,因此,为了后面的例子中所展示的逻辑更直白,对于循环的语法,我将使用do while来描述。

循环变量不变

[Fact]
public void Test1()
{
int i = 1; Task.Run(() => { i = int.MinValue; }); do { }
while (i > 0);
}

这段代码的逻辑是这样的:

  1. 主线程将无限进行循环,直到i<=0才结束.
  2. 第二条线程将改变i的值以让它小于等于0

按照正常逻辑来走,第二条线程一定会执行改变值的代码,因此方法在运行后始终会终止(会因主线程跳出循环的结束而结束).

但这个逻辑实际上只在Debug下是正常的,在Release下,该程序将永远不会结束。不信, 你可以尝试下.

注意,这里只是通过值类型举例说明,平常的编程习惯更多的是引用类型,如下:

object var = new object();
Task.Run(() => { var = null; });
do { }
while (var != null);

为什么会出现这样的情况?

c#中写是易失性写,读是非易失性读,在本文中可以理解为,c#会对对象读取做一定的优化。

在第二段中,我已经举例介绍了这种优化,这取决于JIT是否能跟踪到代码对变量i的更改,若JIT通过中间形式解析后能够跟踪到对循环变量的修改,则对循环变量将不会使用寄存器来进行优化。

下面上述例子在DEBUG下的汇编,可以看到,最终对i的比较和赋值的是同一个地址:

L007e: cmp dword ptr [eax+4], 0
mov dword ptr [eax+4], 0x80000000

下面上述例子在Release下的汇编,可以看到,最终对i的比较和赋值不是同一个地址:

L0037: mov eax, [esi+4]
L003a: test eax, eax
mov dword ptr [ecx+4], 0x80000000

在本例中,因为JIT在没能跟踪到委托中的循环变量,最终取i的地址和在委托的闭包中设置的i的地址不是同一个位置,因此会产生无限轮训。

解决方法也很简单, 可以通过 Volatile.Read(ref i) 的方式来去阅读它,这样,编译器将只是把i变量保留在eax中,且每次访问都将从新取址获取它。

或者像下面这两个例子一样,让JIT能够跟踪到代码对i的修改:

public void Test1()
{
int i = 1; Task.Run(() => { i = int.MinValue; }); do { i++; }
while (i > 0);
}
public void Test1()
{
int i = 1; Task.Run(() => { i = int.MinValue; }); do { Aux(ref i); }
while (i > 0);
}
private void Aux(ref int var)
{
var++;
}

stackalloc不清零

在我编写Bssom.Net(一个结构化的高性能二进制序列化器)时,曾碰见了一个Bug,同样的代码在Debug下进行单元测试时是没问题的,在Release下却会发生错误,最后经过排查并通过官方的帮助已确定是一个JIT的内部Bug,在此把它分享出来。

运行如下示例

public void Test1()
{
for (int i = 1; i >= 0; i--)
{
Console.WriteLine(Test(i));
}
} public byte Test(int i)
{
byte* c = stackalloc byte[8];
c[i] = 42;
return c[1];
}

这个示例在Debug下输出 42,0

但是在Release下却输出 42,42

这意味着在Release下的stackalloc没有对栈内存进行清零,这可能会因为使用到了未清零的数据而导致错误的逻辑产生。

而之所以会出现这样的情况,这是因为JIT会对小的stackalloc分配代码(本例中是8个字节)进行内联,我们可以在Release下看到Test1方法在循环外只进行一次0初始化,而不是每次调用Test方法并在Test方法中进行重新分配。

xor eax, eax
mov[ebp - 0xc], eax
mov[ebp - 8], eax
mov[ebp - 4], eax
...
L001d: lea edx, [ebp-0xc]
L002b: jge short L001d

这种情况源自JIT内部对stackalloc内联的判断逻辑不够具体,这个bug目前已经被修复,将添加在未来.net版本中。

那么,在当下版本(示例是使用net core3.1版本)中,我们该如何避免这种情况的产生?我给出了几个参考:

  • 如果逻辑允许的话,尽可能的将stackalloc提出循环外
  • 使用同等宽度字节进行初始化而不是stackalloc,如 long
  • 使用Span去创建Stackalloc,且通过Span.Clear方法来手动清空.
  • 为方法标记[MethodImpl(MethodImplOptions.NoInlining)]

当然,如果通过stackalloc分配的内存超出32字节,则不必担心会出现本例中的情况,因为目前来说,JIT不会内联stackalloc分配超出32字节的方法。

其它

作者:小曾

出处:https://www.cnblogs.com/1996V/p/13909855.html 欢迎转载,但请保留以上完整文章,在显要地方显示署名以及原文链接。

Net开源技术交流群 976304396 , 抖音账号: 198152455