当Double.Epsilon可以等于0时

2020-09-19 14:01:08

大多数情况下,调试并不是很好写,尤其是在C#领域。在VM上执行的语言中,使用托管内存模型,大多数错误都相对较浅且容易修复,除了在执行多线程时偶尔会出现争用-因此,当Double比较突然停止正常工作时,所有的赌注都落空了。

在这一点上,唯一不会导致神志不清的可用选项是:

放弃调查,接受计算机是反复无常的、不可知的机器,不能被你瘦小的肉体思维所控制,

花一周的时间研究一下为什么你正在看的程序显然在算术上完全失败了。

从这个帖子存在的事实来看,你可能已经猜到我选择了第二名。

这一切都始于GitHub的另一个问题,在这个问题中,一名用户报告在OSU!Lazer BeatMap编辑器中点击后崩溃。(我不会深入什么是BeatMap编辑器的具体细节,因为它对本文的更大主题几乎不重要。)。

按照通常的操作程序,我和其他人一起尝试在我的Ubuntu安装上重现这个问题,但失败了;看起来它将成为另一个不可重现的、因此无法操作的崩溃报告。

第一个“Hail Mary”来自记者自己-他们设法确定只有当游戏以单线程模式运行时才会发生崩溃,在共同的努力下,我们还设法确定它也是特定于Windows的。这已经有迹象表明,这将是一个有趣的处理问题--特别是考虑到崩溃发生在…的位置

无需经历太多不必要的细节,OSU!Lazer使用的定制框架就有了可绑定的概念。可绑定是一个值的包装器;顾名思义,可绑定可以绑定到另一个可绑定,因此可以双向收发来自另一个可绑定的值更新。这允许在UI上的多个位置显示一个特定值,并确保如果一个实例发生更改,其他实例也会效仿。

对于由浮点值支持的数值可绑定对象,可绑定对象具有内置的精度概念,以防止1e-10量级的更改在不重要的情况下触发各种回调。下面是Precision属性的实现:

PUBLIC T Precision{GET=&gT;PRECISION;SET{IF(PRECISTION.。Equals(Value))返回;if(value。CompareTo(默认值)<;=0)抛出新的ArgumentOutOfRangeException(nameof(Precision),value,";必须大于0。";);SetPrecision(value,true,this);}}。

由于某种原因,在Windows上,在单线程模式下,将Precision设置为双倍。Epsilon导致抛出ArgumentOutOfRangeException,即使是双倍。Epsilon绝对大于零。不管有没有调试器,您都可以看到手表中的值为5e-324,默认值为0,然后无论如何都会取下抛出的分支,几乎就像现实的结构正从您的脚下滑落一样。在Windows上,在单线程模式下,将Precision设置为双倍。Epsilon导致抛出ArgumentOutOfRangeException,即使是双倍。Epsilon的值肯定大于零。

显然是时候离开我心爱的Rider了,打开生锈(但值得信赖)的Visual Studio,走出反汇编窗口。在项目设置中启用本机调试并进入Double.CompareTo()之后,我看到了以下汇编代码:

-/_/src/System.Private.CoreLib/shared/System/Double.cs-IF(m_Value<;Value)返回-1;00007FFA1F5307E0 sub rsp,18h00007FFA1F5307E4 vzeroupper00007FFA1F5307E7 vmovsd xmm0,qword PTR[RCX]00007FFA1F5307EB vucomisd xmm1,xmm0;比较xmm1与xmm000007FFA1F5307EF 00007F1FA1F53084D;如果(m_value>;value)返回1,则跳过(CF=0,ZF=0);007F1FA1FA5307vucomx0,000mm7FFA1F5307F700F1FA1F7JA 007FA1FA53084D;如果(m_value>;value)返回1;007F1FA1FA5307vucomisx0,000mm7FA1F5307F700F1FA5E_85IF(xmm000007FFA1F5307EF700F1FA5JA00007FA1FA53084D;JUMP OVER IF(CF=0,ZF=0。00007FFA1F5307F7 vucomisd xmm0,xmm100007FFA1F5307FB JP 00007FFA1F5307FF;如果奇偶校验(PF=0)跳转(PF=0)00007FFA1F5307FD JE 00007FFA1F530857;如果相等(ZF=0)跳转。

而且,我可以肯定地看到,这些指令的执行在多线程和单线程模式下是不同的。在“寄存器”窗口中,我转储了这两种情况下的寄存器状态,并得到了以下结果(单击下面的屏幕截图放大):

在这两种情况下,xmm0和xmm1显然都有合理和预期的值,所以它绝对不是误存储。比较本身不知何故是错误的-但为什么呢?

我很快(回想起来,这是愚蠢的)去确认该问题与CPU供应商无关,并得到确认,英特尔和AMD CPU上都存在此问题。唯一有意义的差异似乎是神秘的MXCSR值,因此是时候进行调查了。

在踏上这段旅程之前,我从来没有真正去查过任何关于SSE/AVX注册表的东西。任何有这方面知识的读者都已经在上面的屏幕截图中发现了问题,但对于那些可能从未研究过这类事情的读者来说,这一节的目的是简要回顾一下。

Vucomisd指令是标量双精度浮点值的向量化无序比较,恰好以EFLAGS格式返回其结果。让我们将其进一步分解为几个组成部分:

矢量化部分意味着SIMD(单指令,多数据)。SIMD指令允许数据并行化-在一个具体的例子中,您可以一次对N个不同的值同时执行一条公共指令。谢天谢地,在这种情况下,该部分实际上并不那么相关。

无序部分与NaN有关,在IEEE754浮点数学中,NaN是特殊的(且令人讨厌的)值,每次比较都会失败(因此NaN既不小于、大于也不等于任何其他数字,包括另一个NaN)。

标量双精度浮点值的比较听起来与我们一开始在C#代码中想要的差不多。

结果返回到EFLAGS中,EFLAGS是一种特殊的准寄存器,最好将其视为一组标志。下表描述了vucomisd指令的可能结果:

现在,MXCSR寄存器是一个特殊的控制寄存器,因为它控制其他SSE/AVX指令的执行方式。在这种情况下,我们对两个相关标志感兴趣,其中一个标志将导致疯狂。

寄存器的位15刷新为零(FTZ)。设置该位将导致非正规浮点值的写入被强制为零。

寄存器的位6为非归一化为零(DAZ)。设置该位将导致非正规浮点值的读取被强制为零。

这在这个特定的场景中立即引人注目,因为强制为零肯定会解释不同的相等结果。但是,为了确认,让我们定义什么是非正规化值(因为我也不知道)。

非规格化值是有效位中具有前导零的浮点值(因此其格式为0.00…。1个…)。。只有当值的指数全为零时才会发生这种情况-在这种情况下,通常假定为任何其他指数的隐式前导1被交换为零。因此,最大的非规格化双精度值为。

因为Double.Epsilon本质上是一个(Uint64_T)0x1,所以它绝对是一个非正规数字。而且,果然,正如上面的屏幕截图所示,DAZ设置在单线程的情况下,在这种情况下问题会重现。

顺便说一句,MXCSR(至少在Windows上)是线程上下文的一部分,这解释了多线程模式运行良好的原因-这种更改极有可能也发生在多线程模式中,但不会影响其他线程,包括执行虚假比较的线程,因此有效地“隐藏”了问题。

这回答了一个直接的问题:哪里出了问题,但是现在有一个大问题--任何人都可能在任何时候向寄存器写入值,那么谁是呢?

这就是我开始抓狂的时候。对于一个程序员来说,在抓狂期间的第一步显然是开始疯狂地搜索可以相关的东西,所以我找到了DotNet/Runtime,开始输入模糊相关的术语。

令人惊讶的是,这不是运行时本身的问题,但我确实发现了一些重要线索:

首先,我发现了对_mm_setcsr()x64内在函数的调用,它设置了MXCSR的值:

ResetProcessorStateHolder(){#if Defined(TARGET_AMD64)m_mxcsr=_mm_getcsr();_mm_setcsr(0x1f80);#endif//target_amd64}~ResetProcessorStateHolder(){#if Defined(Target_AMD64)_mm_setcsr(M_Mxcsr);#endif//target_amd64}。

这清楚地表明运行时知道什么是MXCSR,有时它确实会尝试恢复0x1F80的合理值。我没有跟进是在什么时候,因为我认为微软工程师不太可能忽视这种规模的东西,这可能是我们正在做的事情,直接或间接地。

//返回值://如果';x';是2的幂并且不是非正规化的,则为True(在某些平台上,例如如果用户通过P/Invoke修改了浮点环境,则非正规化可能没有很好地定义)。

这立刻敲响了几个警钟。作为一个带有定制框架的跨平台.NET Core游戏,Lazer必须进行大量的P/调用和本机调用才能成为游戏。再加上反规格化/刷新为零通常是由需要浮点性能的程序设置的(因为处理反规格化很慢),我列出的直接怀疑对象包括:Bass-一个音频库(我们使用ManagedBass作为其包装器),FFmpeg.BASS甚至在我检测到在多线程模式下完全用于音频回放的线程也有一个损坏的MXCSR值之后,在我的个人排名中进一步上升。(注:Bass是一个音频库,我们使用ManagedBass作为它的包装器),FFmpeg.BASS甚至在我检测到完全用于音频播放的线程也有一个损坏的MXCSR值后,在我的个人排名中进一步上升。

有了这个理论,我首先想到的最明显的事情就是音频初始化代码。因为音频初始化代码是Lazer在启动时最早做的事情之一,我想我应该从逐步完成初始的P/调用开始,并密切关注MXCSR.中的变化。足够肯定的是,在音频样本加载例程之后,我确实看到了变化;这可能足以将它带给库维护人员,但我想看看完成它的ASM指令,并且我准备取出Windows调试的Tsar Bomba,WinDbg。

(上面的链接指向WinDbg的Windows应用商店版本。尽管我非常讨厌现有的Windows应用商店,但与经典的外壳版本相比,这个版本有两个优点,即UI看起来不像是直接从Windows 95中拉出来的,并且它有一个称为时间旅行调试的新功能,它基本上记录了整个程序的执行状态,一条又一条指令,包括寄存器值。这将在一分钟内派上用场。)

在游戏中实现这一点是非常不可行的,所有的事情都考虑到了这一点。由于有多个线程、多个库和调试符号,甚至附加WinDbg也花了几分钟的时间。我也很想对此使用时间旅行调试,因为当您在WinDbg中单步执行代码时必须非常小心,以免意外地跳过更改值的调用(但您事先不知道它是否会跳过),从而丢失正在进行的调试会话的所有进度,并且不得不重新开始并重新爬行通过调用树。令我懊恼的是,在WinDbg中单步执行代码时必须非常小心,以免意外跳过更改值的调用(但您事先不知道它是否会跳过),从而丢失正在进行的调试会话的所有进度,并且不得不重新开始并再次爬行调用树。令我懊恼的是。尝试将该功能与LAZER一起使用会导致转储输出中的帧速率低于1帧/秒,字面上的输出为千兆字节/秒。

因此,很明显,首先要尝试的是写下下面相当于低音“hello world”的内容,然后祈祷它会按照我想要的方式打破它:

Using System;Using System.Runtime.InteropServices;命名空间BassTestCSharp{public unsafe class Program{public static void main(string[]args){Console.。WriteLine($";epsilon与0相比为:{Double。埃普西隆。Compareto(0)}";);bass_Init();控制台。WriteLine($";epsilon与0相比为:{Double。埃普西隆。CompareTo(0)}";);}[DllImport(";BASS";,Entry Point=";BASS_INIT";)]私有静态外部布尔BASS_INIT(INT DEVICE=-1,INT频率=44100,UINT标志=0x0,INTPTR WIN=DEFAULT,IntPtR CLSID=DEFAULT);}}。

是的,果然,我在可以想象到的最愚蠢的第一次尝试中找到了金子。程序将打印:

现在,这个程序很小很简单,我可以用Time Travel Debugging对它的执行做一个完整的快照。要当场捕捉错误,剩下的唯一部分就是插入本地低音调用的适当部分,并在需要精确定位指令的情况下跳过/进入/后退(由于Time Travel,后退是可能的)。

第一部分很简单,我在谷歌上搜索并找到了合适的魔咒。

在人类语言中,这大致意味着“一旦bas.dll开始加载,就引发一个第一机会的异常(优先于所有其他异常)”。到达产生的断点(接近bass的入口点)后,策略非常简单:

如果跳过的指令是呼叫,并且MXCSR已更改,请后退并进入呼叫。

这就是从0x9FC0到MXCSR的加载,正如ldmxcsr指令所执行的那样。不过,奇怪的是,在堆栈的下面可以看到对LoadLibraryA的调用,它告诉我可以从图片中删除所有C#内容,而且这应该仍然会重现。最后,一个令人厌恶的C程序诞生了:

#include";stdio.h";#include";xmmintrin.h";#include";windows.h";void test_fp_state();int main(){test_fp_state();LoadLibrary(";bas.dll";);test_fp_state();return exit_uccess;}void test_fp_state(){doua=0;int result;long*a_ptr=&;a;*a_ptr=0x1;result=a>;0;fprintf(stdout,";a>;0 is%d\n";,result);fprintf(stdout,";mxcsr is%x\n";,_mm_getcsr());}。

抛开程序的不可移植性(它假设长整型和双精度型都是8字节)和公然违反严格的别名规则来模拟双精度型,抛开C语言中的Epsilon(如果有更合适的方法,我不想费心去搜索),该程序输出以下内容:(=。

生成的程序非常简单,看完所有这些之后,我提交了复制器、堆栈跟踪以及我能想到的与un4see相关的所有细节,然后等待。

就在第二天(这里的周转时间非常大,非常大的道具),我收到了另一个版本来测试。不幸的是,虽然两个最小化的复制器都修复了,但游戏崩溃本身并没有修复。虽然我知道很可能遗漏了一些边缘情况,但发现它需要更多的努力。

在C#中调试这类事情的主要问题是,CLR托管的无限堆栈代码不是本机代码,所以我不能像在Basic时代那样只是偷看和戳入值。如果我决定继续单步执行每个本机调用,我现在可能还在这么做。是时候稍微作弊了。

多亏了xmmintrin.h,知道C具有直接读/写MXCSR的能力,下面这个瘦DLL包装器就诞生了:

#include";pch.h";#include";xmmintrin.h";BOOL APIENTRY DllMain(HMODULE hModule,DWORD ul_Reason_For_Call,LPVOID lpReserve){Switch(Ul_Reason_For_Call){case DLL_PROCESS_ATTACH:case DLL_THREAD_ATTACH:case DLL_THREAD_DETACH:case DLL_PROCESS_DETACH:Break;}return true;}__declspec(Dllexport)unsign ReadMXCSR(){return_mm_getcr();}__declspec(Dllexport)void SetMXCSR(无符号值){return_mm_setcsr(Value);}

(这确实是我一生中编写的第一个本机DLL。是的,我知道这个开关是没有意义的,它是由Visual Studio自动生成的,目的是显示我对这些东西的了解有多少。把它当作灵感吧-如果我可以跌跌撞撞地完成这项工作,那么您可能也可以。)。

现在我有了这个DLL,我可以从C#中P/调用它,使我能够以任何我想要的方式读取和改变MXCSR。这使我可以通过编写以下实用程序类来使检测bass P/调用变得非常容易:

Using System;Using System.Runtime.CompilerServices;Using System.Runtime.InteropServices;Using osu.Framework.Logging;Namespace osu.Framework.Utils{public seal class IntrinsicDebugger:IDisposable{private readonly string caller;private readonly uint initialValue;private readonly uint bitMask=0xFFFF_FFC0;//don&39;t关心标志位(5-0)public IntrinsicDebugger([CasterMemberName]string caller=null){这。Caller=caller;initialValue=IntrinsicWrapper。ReadMXCSR()&;bitMask;}public void Dispose(){uint currentValue=IntrinsicWrapper。ReadMXCSR()&;bitMask;if(currentValue!=initialValue){Logger.。日志($";{CALLER}内部的本机调用已修改MXCSR!(上一个={initialValue:X8},当前={currentValue:X8})。正在还原以前的。";,级别:LogLevel。错误);IntrinsicWrapper。SetMXCSR(InitialValue);}}私有静态类IntrinsicWrapper{[DllImport(";IntrinsicWrapper.dll";)]public static extern uint ReadMXCSR();[DllImport(";IntrinsicWrapper.dll";)]public static extern void SetMXCSR(Uintvalue);}。

MXCSR的每一次更改都会被记录下来,这意味着磁盘上有一个条目和一个漂亮的通知(如下面的屏幕截图所示)。

Celler MemberName,或者仅仅是currentValue!=initialValue分支内的断点给了我调用位置。

因为我现在可以恢复MXCSR,所以我可以将游戏恢复到正常状态,因此可以在一次执行中多次触发更改,而不必重新启动。

装备了上面的东西,很快就变得令人难以置信地清楚地发现,变化是由游戏中的歌曲切换引起的,而造成损害的潜在呼叫是Bass_StreamFree。现在是再次召唤WinDbg Cthulhu,并进入神圣的经文的时候了。

它在进入受影响的特定函数时设置断点,并且令人失望的是,它没有那么迟钝。

不幸的是,由于之前在这篇文章中提到的原因,由于我现在正在调试一个直播的多线程游戏,我不能依靠时间旅行调试,所以策略稍微改变了一下,改为:

如果前面的指令修改了MXCSR并且是一个调用,请在那里设置断点,以便下次重新运行。

因为IntrinsicDebugger恢复了旧的、正常的值,所以现在我只需再次触发bug,继续到点2中设置的断点,然后反复重复该过程,直到确定了调用点的位置。

在送回第二个堆栈跟踪,享受周末,并在下周拿回另一个临时修复的版本后,错误似乎最终被解决了。版本号为2.4.15.25的BASS的错误修复发布似乎肯定解决了这个问题!

在我发表任何笼统的声明之前,我会完全承认,我能够追踪到这件事是一个反常的意外。我甚至仍然不确定它是如何发生的;路上有很多巧合。也就是说,我仍然沉浸在这个过程中大约一个半星期的夜晚,所以我确实投入了相当多的时间。在我的估计中,追踪到这件事大约需要10%的运气,20%的技能,15%的集中意志力,5%的快乐,50%的痛苦,以及100%的理由去写一篇像样的博客。在我的估计中,追踪到这一点大约是10%的运气,20%的技能,15%的集中意志力,5%的快乐,50%的痛苦,以及100%的理由去写一篇像样的博客。

.