浏览器和浮点数



图片-www.freepik.com



几年前,我思考并写了很多有关浮点数学的知识。这非常有趣,在研究过程中,我学到了很多东西,但有时我很长时间没有在实践中使用所有这些技能,因此付出了沉重的劳动。因此,每次必须处理需要各种专业知识的错误时,我都会感到非常高兴。在本文中,我将讲三个我在Chromium中学习到的有关浮点错误的故事。



第1部分:不切实际的期望



该错误称为“ JSON无法正确解析64位整数”。起初它看起来不像是浮点或浏览器问题,但已发布到crbug.com,因此要求我看看。重新创建它的最简单方法是打开Chrome开发者工具(F12或Ctrl + Shift + I),然后将以下代码粘贴到开发者控制台中:



json = JSON.parse(‘{“x”: 2940078943461317278}’); alert(json[‘x’]);


在控制台窗口中插入未知代码是一种很好的方法,但是代码是如此简单,以至于我认为它不是恶意的。在错误报告中,作者友好地指出了他的期望和实际结果:



预期的行为是什么?应该返回2940078943461317278的整数值,

这是什么错误?而是返回整数2940078943461317000。


“错误”是在Linux上发现的,而我正在使用Windows的Chrome浏览器,但是这种行为是跨平台的,并且我了解浮点数,因此我对其进行了研究。



整数的这种行为可能是浮点错误,因为JavaScript中实际上没有整数类型。出于同样的原因,这实际上不是错误。



输入的数字非常大,大约等于2.9e18。这就是问题所在。由于JavaScript没有整数类型,因此它对数字使用IEEE-754浮点双精度。这种二进制浮点格式具有符号位,11位指数和53位尾数(是的,即65位,其中一位被魔术隐藏了))。这种double类型非常善于存储整数,以至于许多JavaScript程序员从未注意到没有整数类型。但是,非常多的人破坏了这种幻想。



JavaScript数字可以精确地存储最大2 ^ 53的任何整数值。之后,它可以存储所有偶数,最大为2 ^ 54。之后,它可以存储四个数字的所有倍数,最高可达2 ^ 55,依此类推。



问题编号以2为底的指数表示法,大约为1.275 * 2 ^ 61。在此间隔中只能表示非常少的整数-数字之间的距离为512。这是三个对应的数字:



  • 错误报告的作者希望保留的编号为2 940 078 943 461 317 278
  • 2 940 078 943 461 317 120-最接近此数字的两倍(小于它)
  • 2 940 078 943 461 317 632-第二个最接近数字double的数字(大于它的整数)


我们需要的数字在这两个双精度数之间的间隔中,并且JSON模块(例如,JavaScript本身或任何其他将文本转换为双精度数的正确实现的函数)会尽力而为,并返回最接近的双精度数。简而言之,报表作者想要存储的数字不能以内置的JavaScript数字类型存储



到目前为止,一切都很清楚:如果您达到了语言的极限,那么您需要更多地了解它的工作方式。但是仍然还有一个谜。在错误报告中写道,实际上返回了以下数字:



2940078943943461317 000


这种情况很奇怪,因为这不是输入的数字,也不是最接近的双精度数,实际上甚至不是可以表示为双精度数的数字!



JavaScript规范也解释了这个难题。规范说,在打印数字时,实现必须输出足够的数字以唯一地标识它,而不能更多。这对于打印不能精确表示为双精度的数字(如0.1)很有用。例如,如果JavaScript要求将0.1作为存储值输出,那么它将输出:



0.1000000000000000055511151231257827021181583404541015625


这将是一个准确的结果,但这只会通过不添加任何有用内容而使人们感到困惑。可以在此处找到特定规则(查找“应用于数字类型的ToString”行)。我认为规范不需要尾随零,但确实如此。



因此,程序运行时,JavaScript输出2,940,078,943,461,317,000,因为:



  • 保存为JavaScript编号时,原始编号值丢失
  • 显示的数字与存储值足够接近,可以唯一地标识它
  • 显示的数字是唯一标识存储值的最简单数字


一切正常,这不是错误,该问题已通过WontFix(“ unrecoverable”)关闭。原始错误可以在这里找到



第2部分:坏epsilon



这次我实际上是先在Chromium中修复了该错误,然后在googletest中修复了该错误,以避免对后代开发人员造成混淆。





此错误是不确定的测试失败,突然开始发生。我们讨厌这些模糊测试失败。当它们开始进行多年未改变的测试时,它们尤其令人困惑。几周后,他们将我带入调查。错误消息(针对行长稍作修改)如下所示:



Expected_microseconds和converted_microseconds之间的差是512,超过1.0 [expected_microseconds和converted_microseconds之间的差是512,超过1.0]


是的,听起来很糟糕。这是一条googletest错误消息,其中说两个不应该相差不超过1.0的浮点值实际上是相隔512.第



一个证据是浮点数之间的差。两个数字之间恰好被2 ^ 9隔开似乎非常可疑。巧合?我不这么认为。该帖子的其余部分表明了这两个价值观的比较,使我更加相信了原因:



Expected_microseconds评估为4.2934311416234112e + 18,

convert_microseconds评估为4.2934311416234107e + 18


如果您与IEEE 754的战斗时间足够长,您将立即了解发生了什么。



您已经阅读了第一部分,因此由于数字相同,您会感觉到似曾相识。但是,这纯粹是巧合-我只是使用遇到的数字。这次,它们以指数格式显示,这使文章有点多样化。


主要问题是问题的第一部分:计算机中的浮点数与数学家使用的实数不同。随着精度的提高,精度会降低,并且所有双精度数必须是失败数字范围内512的倍数;双精度数具有53位精度,并且这些数字远大于2 ^ 53,因此精度的显着降低是不可避免的。现在我们可以理解问题了。



该测试以两种不同方式计算了相同的值。然后,他检查结果是否接近,“接近”表示相差1.0以内。计算方法给出了非常相似的答案,因此在大多数情况下,结果都以双精度四舍五入为相同值。但是,时不时地正确的答案就在拐点附近,一种计算以一种方式四舍五入,另一种以另一种方式四舍五入。



更具体地说,结果是比较了以下数字:



  • 4293431141623410688
  • 4293431141623411200


如果没有指数,则更明显的是它们被精确地分开512.测试函数生成的两个无限精确的结果之差总是小于1.0,也就是说,当它们的值是429 ... 10653.5和429 ... 10654.3时,两者都四舍五入为429 ... 10688。当无限精确结果接近于4293431141623410944之类的值时,就会出现问题。此值恰好位于两个双精度值的中间。如果一个函数生成429 ... 10943.9,而另一个函数生成429 ... 10944.1,则将这些结果(除以仅0.2的值)沿不同方向取整,并以512的距离结束!



这就是拐点或阶跃函数的性质。您可以得到两个结果,它们彼此靠近,但位于拐点的相反两侧-恰好位于两者之间的中间位置-因此沿不同方向取整。通常建议更改舍入模式,但这无济于事-它只会移动拐点。



这就像在午夜时分生孩子一样-微小的偏差会永久更改事件的日期(可能是一年,世纪或千年)。



也许我的承诺书太过戏剧化了,但没有错误。我觉得自己像一个独特的专家,能够解决这种情况:



提交6c2427457b0c5ebaefa5c1a6003117ca8126e7bc

作者:Bruce Dawson

日期:Fri Dec 08 08:58:50 2017



修正epsilon计算以进行大型双精度比较



我的毕生都致力于解决此错误。[我的一生导致我修复了该错误。]


确实,我很少通过提交注释将Chromium更改为合理的链接到我的两(2!)个帖子



在这种情况下,解决方法是使用计算值的大小来计算两个相邻双打之间的差。这是通过很少使用的nextafter函数完成的或多或少像这样:



epsilon = nextafter(expected, INFINITY)  –  expected;
if (epsilon < 1.0)
      epsilon = 1.0;


nextafter 函数查找下一个双精度(在这种情况下,沿无穷大方向),然后进行减法(精确地进行,这非常方便),然后在双精度值之间找到其差值。经过测试的算法的误差为1.0,因此epsilon不应大于该值。 ε的这种计算使检查值相隔小于1.0或相邻的双精度值非常容易。



我尚未调查测试突然开始失败的原因,但我怀疑是计时器频率或计时器起点的变化导致数字变大。



. QueryPerformanceCounter (QPC), <int64>::max(), 2^63-1. , . , , QPC 2 148 . , QPC, , , , , 3 . QPC 2^63-1 , .



, , QueryPerformanceCounter.


googletest





令我感到恼火的是,要理解该问题需要对浮点数有深刻的了解,因此我想修复googletest。我的第一次尝试以失败告终。



我最初试图通过在传输微不足道的epsilon时使EXPECT_NEAR失败来修复googletest,但是,似乎Google内部的许多测试以及Google外部的许多测试都错误地对双精度值使用EXPECT_NEAR。它们传递的epsilon值太小而无用,但是它们比较的数字相同,因此测试成功。我没有解决问题就解决了使用EXPECT_NEAR的十几个问题,因此我放弃了。



直到撰写这篇文章时(错误出现后将近三年!),我才意识到修复googletest是多么安全和容易。如果代码使用epsilon太少的EXPECT_NEAR并且测试成功(即值实际上相等),那么这不是问题。仅当测试失败时这才成为问题,因此仅在失败的情况下搜索太小的epsilon值并同时显示提示信息就足够了。



进行了更改,现在此2017年崩溃的错误消息如下所示:



expected_microseconds converted_microseconds 512,

expected_microseconds 4.2934311416234112e+18,

converted_microseconds evaluates to 4.2934311416234107e+18.

abs_error 1.0, double , 512; EXPECT_NEAR EXPECT_EQUAL. EXPECT_DOUBLE_EQ.


请注意,EXPECT_DOUBLE_EQ实际上不检查是否相等,而是检查双精度数是否等于最后一位的四个单位(最后一位,ULP)。您可以在我的比较浮点数中阅读有关此概念的更多信息



我希望大多数软件开发人员都能看到此新错误消息并采取正确的方法,并且我认为修复googletest最终比修复Chromium测试更重要。



第3部分:当x + y = x(y!= 0)时



这是接近极限时精度问题的另一种变化:也许我只是一次又一次地发现同一个浮点错误?



在这一部分中,我还将描述如果要调查Chromium源代码或调查崩溃原因,可以应用的调试技术。





当我遇到此问题时,我发布了一个错误报告,标题为“ chrome中的OOM崩溃(内存不足)错误://放大时跟踪”;这不像浮点错误。



和往常一样,我不是自己寻找问题,而是研究chrome://跟踪,试图了解一些事件;突然出现一个悲伤的标签-失败了。



您可以通过chrome查看和下载Chrome的最新崩溃://崩溃,但是我想将崩溃转储加载到调试器中,因此我查看了它们在本地存储的位置:



%localappdata%\ Google \ Chrome \用户数据\ Crashpad \报告


我将最新的故障转储上传到windbg(Visual Studio也会这样做),然后继续进行调查。由于配置了Chrome和Microsoft符号服务器并启用了源服务器,因此调试器会自动下载PDB(调试信息)和所需的源文件。请注意,该方案适用于所有人-您无需成为Google员工或Chromium开发人员,此魔术就可以发挥作用。有关设置Chrome / Chromium调试的说明,请参见此处。自动下载源代码需要安装Python。



崩溃分析表明内存不足错误是由于v8(JavaScript引擎)函数NewFixedDoubleArray尝试分配一个包含75,209,227个元素的数组,并且在这种情况下允许的最大大小为67,108,863(十六进制为0x3FFFFFF)。



关于我自己造成的故障的好处是,您可以尝试通过更仔细的监视来重新创建它们。实验表明,缩放后内存一直保持稳定,直到达到临界点为止,此后内存使用量猛增,即使我什么都不做,标签页也崩溃了。



这里的问题是,我可以轻松查看此失败的调用堆栈,但只能在Chrome代码的C ++部分中查看。但是,显然,该错误本身出现在镶边中://跟踪JavaScript代码。我尝试在调试器下使用Canary版本的Chrome(每天)对其进行测试,并收到以下奇怪消息:



==== JS堆栈跟踪=====================================


不幸的是,在这条有趣的线后面没有堆栈跟踪。git的野外徘徊了一会儿之后,我发现在OOM上输出JS调用栈的能力在2015年被添加,然后在2019年12月被删除



我在2020年1月开始研究了此错误(还记得那些美好的旧时光吗,那件事一切都是天真的并且更容易?),这意味着堆栈跟踪代码OOM已从日常构建中删除,但仍保持稳定的组装状态...



因此,我的下一步是尝试在稳定的Chrome版本中重新创建该错误。这给了我以下结果(为清晰起见,我对其进行了一些编辑):



0:ExitFrame [pc:00007FFDCD887FBD]

1:drawGrid_ [000016011D504859] [chrome://tracing/tracing.js:〜4750]

2:绘制[000016011D504821] [chrome://tracing/tracing.js:4750]




简而言之,OOM崩溃是在x_axis_track.html中(使用Chromium代码查找页面)发现的drawGrid_引起的。稍微调整了此文件后,我将其范围缩小到调用updateMajorMarkData此函数包含一个调用majorMarkWorldPositions_.push函数的循环,这是问题根源



这里值得一提的是,尽管我开发了浏览器,但我仍然是世界上最糟糕的JavaScript程序员。C ++系统编程技能不会给我“前端”的魔力。入侵JavaScript以了解此错误对我来说是一个痛苦的过程。


循环(可以在此处查看)看起来像这样:



for (let curX = firstMajorMark;
curX < viewRWorld;
         curX += majorMarkDistanceWorld) {
    this.majorMarkWorldPositions_.push(
        Math.floor(MAJOR_MARK_ROUNDING_FACTOR * curX) /
        MAJOR_MARK_ROUNDING_FACTOR);
}


我在循环之前添加了调试输出语句并获得了如下所示的数据。当我放大图像时,非常重要的数字(不足以导致崩溃)看起来像这样:



firstMajorMark:885.0999999642371

majorMarkDistanceWorld:1e-13


然后我放大以导致崩溃,并且我得到了这样的数字:



firstMajorMark: 885.0999999642371

majorMarkDistanceWorld: 5e-14


885除以5e-14是1.8e16,双精度浮点数的精度是2 ^ 53,即9.0e15。因此,当majorMarkDistanceWorld(网格点之间的距离)相对于firstMajorMark(第一个主要网格标记的位置)如此之小以至于在循环中添加时,便会发生错误。也就是说,如果我们将一个小数加到一个大数上,那么当小数“太小”时,该大数(在标准模式下/四舍五入到最接近的模式)可以保持相同的值。



因此,循环将无限期运行,并且将执行push命令,直到将数组限制为其大小为止。如果没有大小限制,则push命令将继续运行,直到整个计算机的内存用完为止。如此,问题解决了吗?



该修复程序非常简单-如果不能,则不显示网格标签:



if (firstMajorMark / majorMarkDistanceWorld > 1e15) return;




就像我所做的更改一样,我的错误修正包括一行代码和六行注释。我仅感到惊讶的是,没有五十行的Iambic五角表提交注释,符号表示法和博客文章。等待一分钟...



不幸的是,JavaScript堆栈框架在OOM崩溃时仍未显示,因为它需要内存来编写调用堆栈,这意味着在此阶段尚不安全。当OOM堆栈框架被完全移除时,我今天不太了解如何调查此错误,但是我敢肯定我会找到一种方法。



因此,如果您是一名JavaScript开发人员,试图使用极大的数字,一位测试编写者试图使用最大的整数值,或者实现无限制缩放的UI,那么请务必记住,当您接近浮点数学的界限时,这些界限可能会被突破。






广告



开发服务器史诗从Vdsina。

我们使用Intel的极速NVMe驱动器,并且不节省硬件-仅使用品牌设备和市场上最现代的解决方案!






All Articles