前端工程化 · 关于浏览器计算精度进位近似的一些事情

这个众所周知,SVG 作为解释描述性语言 xml 的一个分支,是不支持直接的逻辑运算的。

而偏偏有一个叫做 stroke 的属性又十分依赖于路径长度,而 xml 的 dom 又不会把长度直接双手奉上,所以需要依靠一个叫做 getTotalLength() 的函数来计算求得。

在前端开发中,我们通常会使用这个函数来直接获取某一段路径的长度。如果是在 javascript 中获取,直接调用就好。当面对复杂多段路径的时候,最多也不过写一个循环就可以快速搞定。

但当开发过程离开浏览器环境或者 Node.js 后,在复杂需求的情况下,求长度的需求发生在 Python 环境下,那么有趣的事情就发生了。

这也就是我最近遇到的一个事情,或者称之为现象,也可以勉强叫做"bug"吧。

① 小数值:Python vs JavaScript

事情是这样的,Python 环境下对 SVG 支持的第三方库甚少,而且试用过一些被 pip 支持的,单论求长度这个需求来说,第三方库的精度十分粗糙,几乎到了不可接受的程度。

所以我自己动手造轮子,实现了一个针对 path-data——也就是 "d" 属性——的高精度长度计算函数(当然这个十分困难,翻了无数论坛、github,甚至把浏览器的部分内核代码转写成了 py,至于具体怎么实现的以后详细论述,这里先略过)。  

精度对标浏览器控制台的 getTotalLength() 函数,误差也精确到了千万分之一的程度。甚至在大多数曲线下,精度还高于浏览器结果。  

当然,高精度结果的代价,就是数学运算函数的高时耗。不过时间消耗上也没太离谱,总之这套通过 py 实现的长度算法达到了能稳定使用的程度,而且最重要的是,可以提供非常优质的计算结果。

所以目前,我们可以将这套轮子当做参照新基准。

按照流程,接下来需要从开发环境来到生产环境了,那么它能否经受住生产环境的测试呢?

在翻 github 的时候,找到了某 JS 代码的 SVG path-data 测试集【big.txt】:

raw.githubusercontent.com/fontello/sv…

有兴趣的读者可以自行查看。

简单介绍一下,这个测试集是一个总计包含 2036 行 d 字符串的文本文档,每一行字符串内又有多种子路径。这个测试集里剔除了“A/a”指令,不过这个无关痛痒,有没有对于后文影响不大。

大致内容截图如下:

image (100).png

用这个文档进行长度计算测试再合适不过了。只需要分别在 Python 里跑一遍,再用 JS 内置函数遍历获取一遍,两次的结果做对比,就可以知道 py 版的实现效果到底如何了。

同时为了计算平均误差,两边分别设置了累加变量 TotalLength 计算当整个文档为一条路径时的长度。  

提示:这里 TotalLength 的计算算法为分段长度累加。

从算法正确度上来看,这种求法是毋庸置疑是正确的。

在经历过用两种语言,各一趟的 2036 次执行后:

Python 版 TotalLength:11407602.3134924
JavaScript 版 TotalLength:11407603.5324707
平均误差为:0.000000107(千万分之1.07)  

由于浏览器内核(这里为:Chromium)和我自己写的长度计算算法均使用了直线近似拟合曲线的思想(也有称为:牛顿法,本例这里是离散实现),所以在正确性均有保证的情况下,数值越大越精准,也就是越逼近上确界。

这里的上确界为数学上的理论精确值,现实离散状况下只能无限逼近,而不能准确达到,整数除外。  

举个例子,连接两个端点的直线和曲线,显然曲线长度大于直线,那么当用直线拟合曲线的时候,直线段越细分越密集,长度也就越逼近曲线弧长。这就是基本思想,而计算的重点其实也就是【精度】的控制。

image - 2022-04-07T193952.275.png

计算结果如上,截止到目前,还一切如愿。

然后,有趣的事情来了。

② 大数值:Split vs Whole

关于“d”的介绍可以翻看以前写的文章 前端 · 深入理解 SVG - d 属性的作用原理 ,这里我们直接将整个文档当做一份“d”的字符串作为输入。

从理论上来看,这份超巨大的“d”应当与通过累加算法得到的 TotalLength 一模一样,考虑到大数值的精度问题,至少也应当相差无几。

Python 版,在计算了将近一分钟后,输出了一个总长误差在 0.00000055 的值。

也就是说,
累加计算:11407602.313492425
直接计算:11407602.313491877

考虑到整数位也达到了千万位,这种误差完全可以忽略不计。

但是,JavaScript 秒给出了一个意想不到的结果:11407201

没有小数位,总长误差大到 401.3,平均到 2036 个片段上,误差为 0.00003518(千万分之352)。

而分段累加的平均误差为千万分之1.07。

在所有都正确的情况下,无端少了 401 单位(这里是 px)的长度。

有没有感到一丝惊悚?  

当所有的操作过程、计算算法、理论支持都是正确的情况下,一个错误的答案,出现了。

这是一个现象,也可以说是一种十分高级的 “bug”。

③ 求距离:勾股定理 or 牛顿法 ?

所谓前端工程化,最终落点,还是在前端。纵使 Python 计算地再准确,浏览器这个大环境的“错误土壤”才是最后要落地的地方。

对于开发者来说,这种现象是令人不安的,意味着评价技术稳定性的大前提,从绝对的数学理论,转为了实现技术的环境,即便它是一个错误的环境。

那么接下来要考虑的,就是如何让已经被数学理论证明正确的 Python 程序,计算出和浏览器同样的错误答案,以实现适配。

俗称:怎么摆烂?

在测试阶段,敏锐的开发者会观察到一个现象,那就是浏览器求解运算的速度是非常迅速的。

即便是多达 2036 的单独“d”字符串,也几乎是秒出。与之相对应的,Python 需要的时间则非常长。

这种时耗是可以部分被理解的,在源码中可以看到,浏览器处理“d”的时候,大部分是以字符串处理的思路来解决的,说白了就是数组处理。学过数据结构可以很轻易理解,数组是一种随机存取的结构,优点就是读取快。

而在 Python 处理时,由于有更高级的需求,所以使用了 OOP 面向对象的方法,为每一段子路径都构建了对象。这就涉及到复杂的内存申请、释放等操作,而且浏览器内核是通过 C++ 这种更底层的语言实现的,本身执行效率也优于 Python。

但抛开上述这些,让我在意的一点是精度问题。毕竟400的误差,第一反应也会是精度。

在牛顿法思想下,高精度需要更多次的迭代和切分,如果精度适当放低,切分段落也就更少,执行次数降低,速度自然也就上去了。

为此做了一个小实验,通过 JS 获取一段直线长度。

结果十分意外,直线为(100,100)到(200,200),学过小学数学都知道,两点之间距离可以用勾股定律求得。理论值为 sqrt(100^2+100^2) ,也就是根号下20000,或者100倍的根号二。

众所周知根号二为1.414,这没什么问题。

但如果把后面的小数点展开,就会发现,
理论值:141.4213562373095
函数值:141.42135620117188

d738cbe3bb13d8b86e8afa75846f664.png

为此还专门直接调用 sqrt() 函数计算了一遍,结果同上。

由此至少可以证明一件事,就是 getTotalLength() 在计算两点距离的时候,不是使用勾股定理计算的。

为此去翻了源码,但截止目前,尚未找到原因和具体算法。

结合上文上确界的定义,盲猜这里也是用了拟合算法。可能是为了优化计算速度,舍弃了高复杂度的平方和根号计算,转为了更多使用加法的拟合算法。这只是一种猜测,如果有大佬知道具体原因,真心求赐教。

拟合一定会涉及到精度,浏览器内核使用的拟合算法多少会有点影响,但从前序的实验来看,这种影响更多的是偏底层的微调。

毕竟分段累加是准确的,可见当数据量偏小的时候,不存在巨大的误差。

影响精度的原因主要有两个,一个是近似拟合算法,还有一个,就是进位问题。

④ 浮点数的陷阱

如果你基础知识扎实的话,应该对浮点数及其构造不陌生。

在前端领域存在一个非常著名的现象,就是 0.1 + 0.2 不等于 0.3

你能说它是错的吗,可以,也不完全可以。

如果是错误的,那么这个问题就不会一直存在到现在,但又是什么原因导致的。结果就在浮点数表示上。

提到浮点数,不可避免会涉及到 IEEE 754 标准,这里严格规定了浮点数的构造和实现方法。现行浏览器标准执行的是 IEEE 754 - 2019 标准,具体细节可以自行查阅。

image - 2022-04-07T194312.341.png

重点就是,0.3 的计算机底层表示不是 0.3,而是 0.30000000000000004。

这俩明显是不一样的,计算机也不会认。

image - 2022-04-07T194403.490.png

当查阅标准的时候,看到一个静态变量的存在:Number.EPSILON

image - 2022-04-07T194407.733.png

它的作用其实就是精度的界限,也有人称之为宽容度。

当两个不同的数值的差小于这个值后,就可以认为这两个值是一样的。

而两个值需要判等时,尾部的数值就可以被近似化处理。

小学我们都学过四舍五入,在计算机当中,数值近似主要有三种主流算法。

分别是:

  1. ceil():向上取整,ceil(0.3) = 1
  2. round():四舍五入,round(0.3) = 0,round(0.8) = 1
  3. floor():向下取整,floor(0.8) = 0

考虑到输出结果为不含小数的整数值,且数值较大,那么极有可能在计算过程中,内核自行执行进位近似操作。

所以便以 JS 版本下的分段数据为样本,分别进行进位近似操作。

ceil() 为向上取整,求出来的结果远大于基准值,故抛弃。

  以四舍五入为核心的 round() 算法在大数值情况下的期望值其实为 0,而且实验结果也验证了与基准值相差无几,故最终需要验证的是 floor() 近似。

image - 2022-04-07T194541.993.png

结果验证了一些猜测。

⑤ 一个可能的真相

我们姑且将浏览器计算的误差400的完整数据结果称之为:目标值。

在准确的分段样本数据下,分别针对个位和小数点后一位做向下取整,先后得到了小于和大于目标值的结果。

这种结果是受到欢迎的,目标值被顺利夹逼,表示目前的思路成功将复现值落入目标区间。

这里不是骂人,如果你学过高数,应该知道夹逼定理,这里引用其意义,反其道行之,以描述上下区间。

换言之,可能存在一个潜在的近似于小数点后0.000001~0.99999位的值,可以使结果准确为目标值。

当然,这里也应当优先考虑 floor(),出于程序员的本能,应当下意识注意到数值精度变化时,是不是类型转换的问题。毕竟绝大多数入门编程的人都知道 int 的工作原理,本质和 floor() 是一样的。

针对 floor() 的实验里,做的是对每一个样本值都做了近似处理,那么只要不对全部样本做处理,就可以找到一个路径,得到目标值。

然后我们把思路反过来,浏览器内核在实现目标值的时候,也极有可能加着加着发现精度不够了,然后边拟合,边舍去精度。也就是说,这里的进位近似,实际是动态发生的。

这种方法是合理、合适且正确的,因为超高精度会影响到数据格式、存储占用和计算时间,作为现实工具,浏览器的实现需要在准确度和可用性中做权衡取舍。

而每一步进位近似过程,都是当下最合适的执行。

这种用准确度换效率的哲学,一直存在在各种工程开发中。而如何最大程度利用所有资源,实现取舍与平衡,才是工程上最终的魅力、艺术、哲学与智慧了。这也是最终考验开发者能力和经验的地方。

歪个楼,虽然程序员都是追求完美的,但“完美”本身,就是有限定区间的。绝对的完美主义不应当存在。

当然,原理部分是个人推测,由于尚未在源码中找到具体实现(至于源码文档里到底有没有开源这部分都是个谜),所以这是目前可以解释通的一种推测。

但不知道具体真相并不影响我们解决问题。基于这种推测,给开发者的提示和经验,就是尽量避免超大规模数据的一次性引入。在实际开发生产中,根据具体数据规模和需求来决定是否要进行数据切分,或者做规模压缩。

既然错误值在正确的流程中诞生了,那么解决错误的方法就是避开它。

站在开发的角度:“逃避”不但不可耻,而且很有用。

合适的条件、合适的区间、合适的过程、合适的结果,所谓工程化,不过也就是这些了。

当然,局部最优并不会直接导致全局最优,关于这部分的哲学,那就是更高层面的循环了。

END

猜你喜欢

转载自juejin.im/post/7084070639195127838