本文由我的大学同学 AlecHe 翻译
原文地址:https://v8.dev/blog/bigint
原文标题:Adding BigInts to V8
在过去的几个月中,我们已经实现了在V8中支持BigInt,就像在这个提议中定义的一样,包含在了下一个版本的ECMAScript中。接下来这篇文章讲述了我们这段旅程中发生的故事。
总结来说
作为一名Javascript的程序员,现在在你的工具箱中有了支持任意精度的整数:
const a = 2172141653n;const b = 15346349309n;a * b;// → 33334444555566667777n // Yay!Number(a) * Number(b);// → 33334444555566670000 // Boo!const such_many = 2n ** 222n;// → 6739986666787659948666753771754907668409286105635143120275902562304n
关于新功能以及如何使用的细节,请参考我们深度解析BigInt的文章。我们期待看见你们用它来创造出美好的东西。
在内存中表示BigInt
通常来说,计算机把整数存储在寄存器中(现在通常是32位或是64位),或者是寄存器大小的内存块中。这也决定了你所熟悉的最小值和最大值。比如,一个32位的有符号整数可以表示的数值是从 -2,147,483,648 到 2,147,483,647。但BigInt并不会有这样的限制。
那么什么东西能够存储一个有成百上千位,甚至百万位的BigInt呢?因为它不能存在寄存器里,所以我们需要在内存中分配一个对象,使它足够大,能够在一系列的内存块中存储所有BigInt的二进制位,我们把它称作“digits” ------ 因为它在概念上类似于我们在表示比9大的数时就需要使用更多的位数,比如“10”;十进制中我们用的digits是从0到9,而我们BigInt的digtis是从 0 到 4,294,967,295 (即2^32 - 1)。这是32位CPU寄存器的范围,不包括符号位,因为符号位是分开存储的。在下面的伪代码中,一个拥有 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如何相乘BigInt的方法:每次一位,把所有的中间结果相加。这个方法适用于0到9的digits,同样也适用于BigInt中更大的digits。
1969年,Donald Knuth曾在他的著作《计算机程序设计艺术》的第二卷中发表了专门给由较小块组成的大数的乘除法实现。V8的实现也遵循了这本书,从而说明这一成果并不受时间推移的影响。
”少一点去糖化“ === 更多糖?
也许令人惊讶的是,我们不得不花很多精力去实现一元运算,比如 -x
。到目前为止,-x
和 x * (-1)
做的事情是一样的。为了简化,V8在处理 JavasScript 的时候会尽早地完成这个替换,这个替换过程完成于语法解析阶段。这个方法被称作“去糖化”,因为它把像 -x
一样的表达式当作 x * (-1)
的“糖衣语法”。系统的其他部分(解释器,编译器,和整个运行时系统)并不知道什么是一元运算,因为它们只看见了乘法,这个它们当然必须支持的运算。
然而有了BigInt,这样的实现突然变得不合理了,因为把BigInt和一个数(如-1
)相乘肯定会抛出一个TypedError
。如果x
是一个BigInt,语法解析器不得不将-x
去糖为x *(-1n)
,但是它并不知道x
会被解析成什么。所以我们必须停止依赖这个尽早去糖化的过程,取而代之的是,我们应该在系统各处都添加对Number和BigInt一元运算的适当支持。
有意思的位运算
如今大部分的计算机系统都采用了一种巧妙的方法“2的补码”来存储有符号整数,它的一个很棒的特质是用第一位来表示符号,并且加上1之后总能保证所表示的数值也增加1,它可以自动地处理符号位的变化,例如对于一个8-bit的整数:
10000000
是 -128,能表示的最小的数,10000001
是 -127,11111111
是 -1,00000000
是 0,00000001
是 1,01111111
是 127,能表示的最大的数。
这套编码系统被广泛使用,许多程序员都期待并依赖使用它。传统BigInt的定义也预示着,如果这里新定义的BigInt遵照的这个定义,那么它必须要使用2的补码。但根据之前的内容,V8的BigInt并没有这样做!
为了根据定义来执行二进制运算,我们的BigInt必须假装在后台使用着2的补码。对于正数来说,这没有什么区别,但对于负数,我们必须做额外的工作去达到这样的效果。这有点像 a&b
一样神奇的效果。如果 a 和 b 都是负的 BigInt,实际上是需要执行以下四个步骤(与之相反两个数都是正数的情况下只需要一个步骤):
- 把 a 转化成模拟的2的补码
- 把 b 转化成模拟的2的补码
- 执行运算
- 把计算结果再做模拟2的补码转化为得到实际的结果
你或许会问为什么要反复做转换?因为这样会使所有非二进制运算简单一些。
两种新的类型化数组
BigInt提议中包括了两种类型化数组:BigInt64Array
和 BigUint64Array
。因为BigInt提供了读和写的方式,我们现在可以定义64位的类型化数组,然而如果有人试图用Number来表示其中的元素,那么就会导致有些位的丢失。这就是为什么这两种新的类型化数组与已经存在的 8/16/32 位整数的类型化数组有所不同:前者使用BigInt来访问元素,而使用Number访问元素会抛出异常。
> const big_array = new BigInt64Array(1);> big_array[0] = 123n; // OK> big_array[0]123n> big_array[0] = 456;TypeError: Cannot convert 456 to a BigInt> big_array[0] = BigInt(456); // OK
就像JavaScirpt代码,在表示和处理这两种新的类型化数组与传统的类型化数组代码时有所不同,我们必须泛化类型化数组的实现,让它能够对这两个新的类型化数组有不用的处理方式。
优化的考虑
现在,我们正在提交BigInt的基本功能。它已经具备完整的功能并有稳定的性能(比目前的userland库稍微快一些),但它还没有经过特别的优化过。这么做的理由是,我们的目标是优先完成实际应用的功能支持,其次才是那些人为创建的性能测试。我们首先想看看大家是怎么使用BigInt的,这样我们可以根据这些用例去准确地优化大家真正关心的性能!
比如,如果我们看见相对比较小的BigInt(最多64位)才是重要的实际用例,我们可以采用一个特殊的表示方式来提高它的空间效率:
{ type: 'BigInt-Int64', value: 0x12…,}
其中的一个细节是,我们仍需要考虑是否应该使用"int64"的取值范围,还是"uint64",亦或是两者都支持 ------ 但请注意,如果支持更少这样的"捷径"我就可以更早地发布,而且讽刺的是,每增加一条"捷径"就会使其他的执行路径慢一点,因为涉及的操作总是需要去检查当前的情况是否符合某条”捷径“的要求。
另一个故事是关于对于BigInt在编译器中的优化支持。对于在64位硬件上运行操作着64位数据的计算密集型应用来说,把这些数值存储在寄存器中会比分配给它们一个对象存储在堆空间上(我们现在的方式)快得多。我们已经有了该怎么去支持这一优化的方案,但我们首先需要弄清这个是否是大家最关心的点,我们是否应该把时间花在解决其他问题上。