盘点.NET JIT在Release下由循环体优化所产生的不确定性Bug
盘点在Release下由循环体优化所产生的不确定性Bug
在这篇文章中,我将介绍一些在测试环境(DEBUG)下正常,但在生产环境(Release)下却会出现的一些让人难以捉摸的Bug。
如果你对开源技术感兴趣,欢迎和我一起共同贡献开源项目,请联系QQ群:976304396
Debug和Release的区别
首先,Debug和Release是一种编译常量,其决定了编译器是否对能够对代码开启优化功能。
在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将始终读取i
和len
位置处的值去进行比较
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。
for
和while
是在语法上有所不同,但最终执行表现是相同的,因此,为了后面的例子中所展示的逻辑更直白,对于循环的语法,我将使用do while
来描述。
循环变量不变
[Fact]
public void Test1()
{
int i = 1;
Task.Run(() => { i = int.MinValue; });
do { }
while (i > 0);
}
这段代码的逻辑是这样的:
- 主线程将无限进行循环,直到
i<=0
才结束. - 第二条线程将改变
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
盘点.NET JIT在Release下由循环体优化所产生的不确定性Bug的更多相关文章
- Swift打印Debug日志,实现Release下不打印
OC内,我们往往做log打印时,会考虑一个Debug环境下打印,Release下控制不打印,以节约性能消耗. OC我们可以这样做: 在pch文件内,定义如下: //打印日志 #ifdef DEBUG ...
- vs2008 怎么在Release下调试代码
vs2008 怎么在Release下调试代码 (适用VS2005/VS2008) 在当前工程点击右键选择properties,选择 All Configurations C++>General- ...
- VC6下 try catch 在release下的杯具(默认情况下,要加上throw语句catch才不会被优化掉)
IDE:VC6 今天遇到一个小问题,把我郁闷了好久,××医生的VulEngine不时在wcsstr处发生crash,加了一番强大的参数检查后,再加上了强大的try catch,其实不是很喜欢用try和 ...
- 享受release版本发布的好处的同时也应该警惕release可能给你引入一些莫名其妙的大bug
一般我们发布项目的时候通常都会采用release版本,因为release会在jit层面对我们的il代码进行了优化,比如在迭代和内存操作的性能提升方面,废话不多说, 我先用一个简单的“冒泡排序”体验下r ...
- 【MySQL】花10分钟阅读下MySQL数据库优化总结
1.花10分钟阅读下MySQL数据库优化总结http://www.kuqin.com2.扩展阅读:数据库三范式http://www.cnblogs.com3.my.ini--->C:\Progr ...
- Spark Tungsten揭秘 Day1 jvm下的性能优化
Spark Tungsten揭秘 Day1 jvm下的性能优化 今天开始谈下Tungsten,首先我们需要了解下其背后是符合了什么样的规律. jvm对分布式天生支持 整个Spark分布式系统是建立在分 ...
- dijkstra(最短路)和Prim(最小生成树)下的堆优化
dijkstra(最短路)和Prim(最小生成树)下的堆优化 最小堆: down(i)[向下调整]:从第k层的点i开始向下操作,第k层的点与第k+1层的点(如果有)进行值大小的判断,如果父节点的值大于 ...
- [转载][转]修改/proc目录下的参数优化网络性能
原文地址:[转]修改/proc目录下的参数优化网络性能作者:雪人 网络优化 注意: 1. 参数值带有速度(rate)的参数不能在loopback接口上工作. 2.因为内核是以HZ为单位的内部时钟来定义 ...
- 杂文笔记《Redis在万亿级日访问量下的中断优化》
杂文笔记<Redis在万亿级日访问量下的中断优化> Redis在万亿级日访问量下的中断优化 https://mp.weixin.qq.com/s?__biz=MjM5ODI5Njc2MA= ...
随机推荐
- spring:spring再总结(ioc、aop、DI等)
IOC(Inversion of Control),即"控制反转",不是一种技术而是一种思想 1.IOC的理解 Ioc意味着将你设计好的对象交给容器控制,而不是传统的在你的对象内部 ...
- C++实现链表---可直接运行通过
main.cpp 1 #include "myDataBase.h" 2 3 int main() 4 { 5 int i =0; 6 myDataBase::GetInstanc ...
- Centos-rpm二进制包安装-rpm
rpm 软件包管理器 rpm包命名规范 mysql-community-server-5.7.21-1.el7.x86_64.rpm 软件名称 mysql-community-server 软件版本 ...
- PHP代码审计02之filter_var()函数缺陷
前言 根据红日安全写的文章,学习PHP代码审计审计的第二节内容,题目均来自PHP SECURITY CALENDAR 2017,讲完这个题目,会有一道CTF题目来进行巩固,外加一个实例来深入分析,想了 ...
- Java学习day03
day03 课堂笔记 1.数据类型 2.总结第二章到目前为止所学内容: * 标识符 * 关键字 * 字面值 * 变量 成员变量如果没有赋值,系统会自动赋值,而局部变量不手动赋值,则会编译不通过. * ...
- selenium-自动化测试51job网站(MacOS + Safari)2020年10月6日
登录 51job ,http://www.51job.com 输入搜索关键词 "python", 地区选择 "杭州"(注意,如果所在地已经选中其他地区,要去掉) ...
- P3431 [POI2005]AUT-The Bus
Link 简化题意: 给你一张网格图,每个点有其对应的权值,让你找出来一条横纵坐标都单调不降的路径,并最大化经过点的权值. 分析: 这是经典的二维数点或者二维偏序问题. 如果两维一直在变的话,我们不是 ...
- Splay浅谈
Splay是众多平衡树之一,它的功能十分强大,但常数极大.在LCT和许多数据结构中都能用到. Splay的核心操作,就是rotate.为了使树不是一条链,而是平衡的,我们需要旋转来维护形态.理论很简单 ...
- 三、Requests库的使用
requests 的底层实现其实就是 urllib3 Requests 唯一的一个非转基因的 Python HTTP 库,人类可以安全享用. 学过关于urllib库的使用,你会发现它是很不方便的.而R ...
- 上帝视角一文理解JavaScript原型和原型链
本文呆鹅原创,原文地址:https://juejin.im/user/307518987058686/posts 前言 本文将从上帝角度讲解JS的世界,在这个过程中,大家就能完全理解JS的原型和原型链 ...