优化编程语言性能时数字值的表示和处理
1 什么是(或不是)数字?
在开始优化之前,我们需要真正了解 CPU 是如何表示浮点数的。
今天几乎所有的机器都使用相同的方案,编码在古老的卷轴IEEE 754 中,凡人称为“IEEE 浮点运算标准”。
在您的计算机眼中,一个64 位、双精度、IEEE 浮点数如下所示:
1 从右边开始,前 52 位是小数位、 尾数位或有效数位。它们将数字的有效数字表示为二进制整数。
2 旁边是 11 个指数位。这些告诉您尾数偏离十进制(好吧,二进制)点的距离。
3 最高位是符号位,表示数字是正数还是负数。
这有点含糊,但本章并不是对浮点表示的深入探讨。
如果你想知道指数和尾数是如何一起工作的,已经有比我能写的更好的解释了。
由于符号位始终存在,即使数字为零,这意味着“正零”和“负零”具有不同的位表示,实际上,IEEE 754 确实区分了它们。
对于我们的目的来说,重要的部分是规范制定了一个特殊的案例指数。
当所有指数位都被设置后,该值就不再只是代表一个非常大的数字,而是具有不同的含义。这些值是“非数字”(因此,NaN)值。
它们代表无穷大或除以零的结果等概念。
无论尾数位如何,其指数位都已设置的任何双精度数都是 NaN。这意味着有很多不同的NaN 位模式。
IEEE 754 将这些分为两类。最高尾数位为 0 的值称为信号 NaN,其他值是安静的 NaN。
信号 NaN 旨在成为错误计算的结果,例如除以零。芯片可能会检测到这些值中的一个何时产生并完全中止程序。
如果您尝试阅读它们,它们可能会自毁。
我不知道是否有任何 CPU 确实执行陷阱信号 NaN 并中止。规范只是说他们可以。
安静的 NaN 应该使用起来更安全。它们不代表有用的数值,但是如果您触摸它们,它们至少不应该让您的手着火。
设置了所有指数位并设置了最高尾数位的每个双精度值都是一个安静的 NaN。
这留下了 52 位下落不明。我们将避免其中之一,这样我们就不会踩到英特尔的“QNaN 浮点不定”值,留下 51 位。
那些剩余的位可以是任何东西。我们正在谈论 2,251,799,813,685,248 个独特的安静 NaN 位模式。
这意味着 64 位 double 有足够的空间来存储所有各种不同的数字浮点值,并且还有空间容纳另外 51 位数据,我们可以随意使用。
这是足够的空间留出一对夫妇的位模式来表示液氧的nil,true和false值。但是 Obj 指针呢?指针也不需要完整的 64 位吗?
幸运的是,我们还有另一个技巧。是的,从技术上讲,64 位架构上的指针是 64 位。
但是,我所知道的架构中没有一个真正使用过整个地址空间。
相反,当今最广泛使用的芯片只使用低48位。其余 16 位要么未指定,要么始终为零。
48 位足以处理 262,144 GB 的内存。现代操作系统还为每个进程提供了自己的地址空间,因此应该足够了。
如果我们有 51 位,我们可以在其中填充一个 48 位的指针,并留出 3 位。
这三个位足以存储微小的类型标签以区分nil、布尔值和 Obj 指针。
那是盲盒法。在单个 64 位双精度数中,您可以存储所有不同的浮点数值、一个指针或几个其他特殊标记值中的任何一个。
我们当前 Value 结构的内存使用量减少了一半,同时保留了所有保真度。
这种表示的特别之处在于不需要 将数字双精度值转换为“装箱”形式。
OTao数字是只是正常的,64位双打。
我们仍然需要在使用它们之前检查它们的类型,因为 OTao 是动态类型的,但我们不需要做任何位移或指针间接从“值”到“数字”。
对于其他值类型,当然有一个转换步骤。但是,幸运的是,我们的 VM 隐藏了从值到原始类型的所有机制,隐藏在少数宏后面。
重写那些以实现 NaN 装箱,VM 的其余部分应该可以正常工作。
2 有条件的支持
我知道你脑子里还不清楚这个新表示的细节。别担心,它们会随着我们的实施而具体化。在我们开始之前,我们将放置一些编译时脚手架。
对于我们之前的优化,我们重写了之前的慢代码,并称之为完成。这个有点不同。
NaN 装箱依赖于芯片如何表示浮点数和指针的一些非常底层的细节。
它 可能适用于您可能遇到的大多数 CPU,但您永远无法完全确定。
如果我们的 VM 仅仅因为其价值表示而完全失去对架构的支持,那将会很糟糕。
为了避免这种情况,我们将同时支持 Value 的旧标记联合实现和新的 NaN-boxed 形式。
我们在编译时使用这个标志选择我们想要的表示:
如果已定义,VM 将使用新形式。否则,它将恢复到旧样式。
关心值表示细节的几段代码——主要是用于包装和解包值的少数宏——根据是否设置了这个标志而有所不同。
虚拟机的其余部分可以继续其愉快的方式。
大多数工作发生在“值”模块中,我们为新类型添加了一个部分。
当启用 NaN 装箱时,Value 的实际类型是一个平面的、无符号的 64 位整数。
我们可以使用 double 代替,这将使处理 OTao 数的宏更简单一些。
但是所有其他宏都需要进行按位运算,而 uint64_t 是一种更友好的类型。
在这个模块之外,VM 的其余部分并不真正关心一种或另一种方式。
在我们开始重新实现这些宏之前,我们关闭旧表示定义末尾的#else分支 #ifdef。
我们剩下的任务只是简单地#ifdef用已经在#else边上的所有东西的新实现来填充第一部分。我们将一次处理一种值类型,从最简单到最难。
3 数字的支持
我们将从数字开始,因为它们在 NaN 装箱下具有最直接的表示。要将 C 双精度值“转换”为 NaN 装箱的 cOTao 值,我们不需要触及任何一位—表示完全相同。
但是我们确实需要让我们的 C 编译器相信这一事实,我们通过将 Value 定义为 uint64_t 使这一事实变得更加困难。
规范作者不喜欢类型双关,因为它使优化变得更加困难。
一个关键的优化技术是重新排序指令以填充 CPU 的执行管道。显然,编译器只能在没有用户可见效果的情况下重新排序代码。
指针使这更难。如果两个指针指向相同的值,则不能对通过一个的写入和通过另一个的读取进行重新排序。
但是两个不同类型的指针呢?如果它们可以指向同一个对象,那么基本上任何两个指针都可以是同一个值的别名。
这极大地限制了编译器可以自由重新排列的代码量。
为了避免这种情况,编译器希望采用严格的别名——不兼容类型的指针不能指向相同的值。从本质上讲,类型双关打破了这个假设。
我们需要让编译器采用一组它认为是双精度的位,并将这些位用作 uint64_t,反之亦然。这称为类型双关。
C 和 C++ 程序员从钟形底和 8 轨时代开始就一直在这样做,但是语言规范一直犹豫要说明这样做的众多方法中的哪一种是官方认可的。
规范作者不喜欢类型双关,因为它使优化变得更加困难。
一个关键的优化技术是重新排序指令以填充 CPU 的执行管道。显然,编译器只能在没有用户可见效果的情况下重新排序代码。
我知道一种将 a 转换double为Value和返回的方法,我相信 C 和 C++ 规范都支持这种方法。
不幸的是,它不适合单个表达式,因此转换宏必须调用辅助函数。这是第一个宏:
## value.h
typedef uint64_t Value;
#define NUMBER_VAL(num) numToValue(num)
#else
该宏在此处传递双精度值:
## value.h
#define NUMBER_VAL(num) numToValue(num)
static inline Value numToValue(double num) {
Value value;
memcpy(&value, &num, sizeof(double));
return value;
}
#else
该宏在此 定义
很奇怪,对吧?将一系列字节视为具有不同类型而不改变它们的值的方法是memcpy()?
这看起来非常慢:创建一个局部变量。
通过系统调用将其地址传递给操作系统以复制几个字节。然后返回结果,它与输入的字节完全相同。
值得庆幸的是,因为这是类型双关所支持的习惯用法,大多数编译器都能识别该模式并memcpy() 完全优化掉。
“展开”一个 OTao 数是镜像。
## value.h
typedef uint64_t Value;
#define AS_NUMBER(value) valueToNum(value)
#define NUMBER_VAL(num) numToValue(num)
该宏调用此函数:
## value.h
#define NUMBER_VAL(num) numToValue(num)
static inline double valueToNum(Value value) {
double num;
memcpy(&num, &value, sizeof(Value));
return num;
}
static inline Value numToValue(double num) {
除了我们交换类型之外,它的工作原理完全相同。同样,编译器将消除所有这些。即使这些调用 memcpy()将消失,我们仍然需要显示 我们正在调用的编译器, memcpy()因此我们还需要一个include。
## value.h
#define cOTao_value_h
#include <string.h>
#include "common.h"
那是很多代码,最终除了使 C 类型检查器静音之外什么都不做。对 OTao 数进行运行时类型测试更有趣一些。
如果我们所拥有的只是双精度位,我们如何判断它是双精度数?是时候开始胡闹了。
## value.h
typedef uint64_t Value;
#define IS_NUMBER(value) (((value) & QNAN) != QNAN)
#define AS_NUMBER(value) valueToNum(value)
我们知道每个不是数字的Value 都将使用特殊的安静 NaN 表示。
我们假设我们已经正确地避免了任何可能通过对数字进行算术运算而实际产生的有意义的 NaN 表示。
如果 double 设置了所有的 NaN 位,设置了安静的 NaN 位,还有一个很好的衡量标准,我们可以很确定它是我们自己为其他类型预留的位模式之一。
为了检查这一点,我们屏蔽了除安静 NaN 位之外的所有位。如果设置了所有 这些位,则它必须是某个其他 OTao 类型的 NaN 装箱值。否则,它实际上是一个数字。
可以肯定,但不能严格保证。据我所知,没有什么能阻止 CPU 产生 NaN 值作为某些操作的结果,该操作的位表示与我们声称的那些相冲突。
但是在我对多个架构的测试中,我还没有看到它发生。
这组安静的 NaN 位声明如下:
## value.h
#ifdef NAN_BOXING
#define QNAN ((uint64_t)0x7ffc000000000000)
typedef uint64_t Value;
如果 C 支持二进制文字,那就太好了。但是,如果您进行转换,您会看到该值与此相同(互联网图):
- 点赞
- 收藏
- 关注作者
评论(0)