跳到主要内容

向V8添加BigInts

· 阅读需 8 分钟
Jakob Kummerow,精度仲裁者

过去几个月里,我们在V8中实现了对BigInts的支持,该功能目前由此提案规范,计划在未来版本的ECMAScript中加入。以下是我们的冒险故事。

简而言之

作为JavaScript程序员,现在1你的工具箱中有了具有任意2精度的整数:

const a = 2172141653n;
const b = 15346349309n;
a * b;
// → 33334444555566667777n // 太棒了!
Number(a) * Number(b);
// → 33334444555566670000 // 不好!
const such_many = 2n ** 222n;
// → 6739986666787659948666753771754907668409286105635143120275902562304n

关于新功能的详细信息以及如何使用,请参阅我们关于BigInt的深入文章。我们期待看到你用它们构建的精彩内容!

BigInt在内存中的表示

通常,计算机将整数存储在CPU寄存器中(现代寄存器通常是32位或64位宽),或以寄存器大小的块存储在内存中。这就导致了你可能熟悉的最小和最大值。例如,32位有符号整数可以存储的值范围是-2,147,483,648到2,147,483,647。然而,BigInts的理念是不受这些限制约束。

那么如何存储一个具有一百位、一千位或者一百万位的BigInt呢?它无法装入寄存器,因此我们在内存中分配一个对象。我们使其足够大以容纳所有BigInt的位,通过一系列块,这些块我们称为“数字”——因为这在概念上非常类似于使用更多数字表示大于“9”的数字,比如“10”;但不同的是,十进制系统使用从0到9的数字,而我们的BigInts使用从0到4294967295(即2**32-1)的数字。这是没有符号位时32位CPU寄存器的值范围3;我们将符号位单独存储。用伪代码表示,一个具有3*32 = 96位的BigInt对象如下所示:

{
type: 'BigInt',
sign: 0,
num_digits: 3,
digits: [0x12, 0x34, 0x56],
}

重回课堂,重回Knuth

在CPU寄存器中存储整数非常容易:比如说要乘积两个寄存器中的整数,有一个机器指令,软件可以用它告诉CPU“将这两个寄存器的内容相乘!”,CPU会完成它。对于BigInt算术,我们必须想出自己的解决方案。幸运的是,这项任务完全是每个孩子在某个时候都会学会如何解决的问题:还记得在学校时,当你不得使用计算器计算345 * 678时是如何操作的吗?

345 * 678
---------
30 // 5 * 6
+ 24 // 4 * 6
+ 18 // 3 * 6
+ 35 // 5 * 7
+ 28 // 4 * 7
+ 21 // 3 * 7
+ 40 // 5 * 8
+ 32 // 4 * 8
+ 24 // 3 * 8
=========
233910

这正是V8如何计算BigInts乘积的方式:一位一位地计算,加上中间结果。该算法对于09以及BigInt的更大数字都一样有效。

Donald Knuth在他的经典著作《计算机程序设计艺术》第2卷中,就如何对由更小块组成的大数字进行乘法和除法进行了具体说明,这本书出版于1969年。V8的实现遵循了这本书,这表明这是一个非常持久的计算机科学研究。

“减少语法糖化” = 更多甜蜜?

出乎意料地,我们不得不花费大量精力来使看似简单的一元操作,比如-x,能够正常工作。到目前为止,-x的工作方式与x * (-1)完全一样,因此为了简化操作,V8尽早在处理JavaScript时应用了这种替换,主要是在解析器中。这种方法被称为“语法糖化”,因为它将像-x这样的表达式视为x * (-1)的“语法糖”。其他组件(解释器、编译器、整个运行时系统)甚至不需要知道什么是一元操作,因为它们只看到乘法,当然它们必须支持乘法。

然而,对于 BigInts,这种实现突然变得无效了,因为将一个 BigInt 与一个 Number(例如 -1)相乘必须抛出一个 TypeError 错误4。如果 x 是一个 BigInt,解析器需要将 -x 转换为 x * (-1n) ——但解析器无法知道 x 的具体值。所以我们不得不停止依赖这种早期的转换,而是为 Numbers 和 BigInts 的一元运算提供适当的支持。

有趣的按位操作

如今大多数计算机系统使用一种称为“两补码”的技巧来存储有符号整数,这种方式的优点是第一个位指示符号,并且将 1 加到二进制位模式总是相当于数字加 1,并自动处理符号位。例如,对于 8 位整数:

  • 10000000 表示 -128,是最低的可表示数,
  • 10000001 表示 -127,
  • 11111111 表示 -1,
  • 00000000 表示 0,
  • 00000001 表示 1,
  • 01111111 表示 127,是最高的可表示数。

这种编码方式非常普遍,许多程序员都期望并依赖它。BigInt 规范也反映了这一事实,规定 BigInts 必须表现得像使用两补码表示一样。然而如上所述,V8 的 BigInts 并没有这样做!

因此,为了根据规范执行按位操作,我们的 BigInts 必须假装在底层使用了两补码表示。对于正值,这没有区别,但负数必须执行额外的操作才能实现这一点。这产生了一个有些令人惊讶的效果:如果 ab 都是负的 BigInts,那么 a & b 实际上执行了四个步骤(而正数只需要一步):两个输入都被转换为伪两补码格式,然后执行实际操作,最后把结果转换回我们的真实表示方式。你可能会问为什么要来回转换?因为所有非按位操作这样做会更简单。

两种新的 TypedArrays 类型

BigInt 提案包括两种新的 TypedArray 类型:BigInt64ArrayBigUint64Array。现在我们可以拥有元素宽度为 64 位的整数 TypedArrays,因为 BigInts 提供了一种自然方式来读取和写入这些元素中的所有位,而如果尝试使用 Numbers,某些位可能会丢失。这就是为什么新数组与现有的 8/16/32 位整数 TypedArrays 不太一样:访问它们的元素始终使用 BigInts,尝试使用 Numbers 会抛出异常。

> const big_array = new BigInt64Array(1);
> big_array[0] = 123n; // OK
> big_array[0]
123n
> big_array[0] = 456;
TypeError: 无法将 456 转换为 BigInt
> big_array[0] = BigInt(456); // OK

就像处理这些类型的数组的 JavaScript 代码看起来与传统 TypedArray 代码有点不同一样,我们不得不泛化我们的 TypedArray 实现以适应这两个新类型的不同行为。

优化考虑

目前,我们提供了 BigInts 的一个基础实现。它在功能上是完整的并且应该提供稳健的性能(比现有的用户层库稍快一点),但并没有特别针对性能优化。原因是,与我们优先考虑实际应用而非人工基准测试的目标一致,我们首先想观察你们如何使用 BigInts,然后再精确优化你们关心的用例!

例如,如果我们发现相对较小的 BigInts(最多 64 位)是一个重要的用例,我们可以通过为它们使用一种特殊表示方式来让这些数值更内存高效:

{
type: 'BigInt-Int64',
value: 0x12,
}

是否应该针对“int64”值范围、“uint64”值范围或两者进行此种优化仍然有待验证——请记住,支持更少的快速路径意味着我们可以更快地发布它们,但每增加一种快速路径反而会使其他所有操作稍微慢一点,因为受影响的操作总是必须检查它是否适用。

另一个故事是优化编译器对 BigInts 的支持。对于在 64 位硬件上运行并操作 64 位值的计算密集型应用程序,将这些值保存在寄存器中将比当前在堆上分配为对象更高效。我们已经制定了支持此功能的实施计划,但这又是一个我们想先了解用户是否真的最关心这个需求的场景;或者我们是否应该把时间花在其他事情上。

请向我们反馈您正在如何使用 BigInts 以及您遇到的任何问题!您可以通过我们的错误追踪器联系crbug.com/v8/new,通过邮件发送到[email protected],或通过 Twitter 联系@v8js

Footnotes

  1. 如果你运行Chrome Beta、Dev或Canary,或Node.js预览版本,你就可以使用;否则很快就可以使用(Chrome 67、Node.js顶端版本大约同时推出)。

  2. 任意精度受实现限制。抱歉,我们还没能找到一种方法将无限数据压缩到你的计算机有限的内存中。

  3. 在64位机器上,我们使用64位数字,即从0到18446744073709551615(即2n**64n-1n)。

  4. 混合使用 BigIntNumber 操作数类型通常是不允许的。这对 JavaScript 来说有些不寻常,但这种决定有一个解释