简介
本文继续讨论编程语言中值表示的进一步优化路径。在这章中,您将看到,即使是真正具有挑战性的课题-如编译器,我们凡人也可以解决,只是需要我们把手弄脏并一步一步来。
我们将在这里停止使用 OTao 语言和我们的两个解释器。我们可以持续修补它,添加新的语言功能和巧妙的速度改进。
但是,对于这个系列,我认为我们已经自然而然地完成了我们的工作。我不会重复我们在过去几页中学到的一切。你和我在一起练习后,你将记得。
大多数人可能不会在其职业生涯中花费很大一部分时间在编译器或解释器上工作。
它只是计算机科学学术界的一小部分,也是工业中软件工程的一小部分。
但是没关系。即使你一生中再也不会创建编译器,你肯定会使用它,我希望这个系统让你更好地理解你使用的编程语言是如何设计和实现的。
本文介绍使用位模式来表示nil、true、false和对象,其中nil和布尔值利用了NaN的位模式和类型标签。
对象表示利用了浮点数NaN的符号位作为类型标记,而对象指针则存储在剩余的位中。
代码示例展示了如何通过宏定义进行转换和检查。
本文的性能评估中显示,这种优化对整体VM性能的影响是分散的,可能在大型程序中更为明显。
1 无,真,假,Nil, true, and false
下一个要处理的类型是nil. 这非常简单,因为只有一个 nil值,因此我们只需要一个位模式来表示它。
还有另外两个单例值,两个布尔值true和false. 这需要三个总共唯一的位模式。
两个位给了我们四种不同的组合,这就足够了。
我们将未使用的尾数空间的最低两位声明为“类型标签”,以确定我们正在查看这三个单例值中的哪一个。三个类型标签的定义如下:
## value.h
#define QNAN ((uint64_t)0x7ffc000000000000)
#define TAG_NIL 1 // 01.
#define TAG_FALSE 2 // 10.
#define TAG_TRUE 3 // 11.
typedef uint64_t Value;
nil因此,我们的表示是定义我们的安静 NaN 表示所需的所有位以及nil类型标记位:
在代码中,我们像这样检查位
我们只是简单地按位或将安静的 NaN 位和类型标记,然后做一些演员舞蹈来教 C 编译器我们想要这些位的含义。
由于nil只有一位表示,我们可以在 uint64_t 上使用相等来查看 Value 是否为nil。
您可以猜到我们如何定义true和false值。
## value.h
# define AS_NUMBER(value) valueToNum(value)
#define FALSE_VAL ((Value)(uint64_t)(QNAN | TAG_FALSE))
#define TRUE_VAL ((Value)(uint64_t)(QNAN | TAG_TRUE))
#define NIL_VAL ((Value)(uint64_t)(QNAN | TAG_NIL))
这些位看起来像这样
要将 C bool 转换为 OTao 布尔值,我们依赖于这两个单例值和古老的条件运算符。
可能有一种更聪明的按位方式来做到这一点,但我的预感是编译器可以比我更快地找出一个。去另一个方向更简单。
## value.h
#define IS_NUMBER(value) (((value) & QNAN) != QNAN)
#define AS_BOOL(value) ((value) == TRUE_VAL)
#define AS_NUMBER(value) valueToNum(value)
因为我们知道 OTao 中正好有两个布尔位表示——不像在 C 中任何非零值都可以被视为“真” ——如果它不是true,它必须是false。
这个宏确实假设你只在你知道是OTao 布尔值的值上调用它。为了检查这一点,还有一个宏。
那看起来有点奇怪。一个更明显的宏看起来像这样:
#define IS_BOOL(v) ((v) == TRUE_VAL || (v) == FALSE_VAL)
不幸的是,这并不安全。扩展提到了v两次,这意味着如果该表达式有任何副作用,它们将被执行两次。
我们可以让宏调用一个单独的函数,但是,唉,真是一件苦差事。
相反,我们将 1按位OR到该值上以合并仅有的两个有效布尔位模式。这留下了价值可能处于的三个潜在状态:
1 它曾经FALSE_VAL并且现在已经转换为TRUE_VAL.
2 它是TRUE_VAL,| 1什么也没做,它仍然是TRUE_VAL。
3 它是其他一些非布尔值。
在这一点上,我们可以简单地比较结果,TRUE_VAL看看我们是处于前两种状态还是第三种状态。
2 对象的支持
最后一个值类型是最难的。与单例值不同,我们需要将数十亿个不同的指针值装入 NaN 中。
这意味着我们既需要某种标记来指示这些特定的 NaN是Obj 指针,也需要为地址本身留出空间。
我们用于单例值的标记位位于我决定存储指针本身的区域中,因此我们不能轻松地在那里使用不同的位来指示该值是一个对象引用。
但是,还有一点我们没有使用。由于我们所有的 NaN 值都不是数字——它就在名称中——符号位不用于任何东西。
我们将继续使用它作为对象的类型标记。如果我们的安静 NaN 之一设置了符号位,那么它就是一个 Obj 指针。
否则,它必须是之前的单例值之一。
即使值是 Obj 指针,我们实际上也可以使用最低位来存储类型标记。
那是因为 Obj 指针总是与 8 字节边界对齐,因为 Obj 包含 64 位字段。
反过来,这意味着 Obj 指针的三个最低位将始终为零。我们可以在那里存储我们想要的任何东西,并在取消引用指针之前将其屏蔽掉。
这是另一种称为指针标记的值表示优化。
如果设置了符号位,则剩余的低位存储指向 Obj 的指针:
要将原始 Obj 指针转换为值,我们获取指针并设置所有安静的 NaN 位和符号位。
## value.h
#define NUMBER_VAL(num) numToValue(num)
#define OBJ_VAL(obj) \
(Value)(SIGN_BIT | QNAN | (uint64_t)(uintptr_t)(obj))
static inline double valueToNum(Value value) {
指针本身是一个完整的 64 位,原则上,它可能因此与一些安静的 NaN 和符号位重叠。
但实际上,至少在我测试过的体系结构上,指针中第 48 位以上的所有内容始终为零。
这里进行了很多转换,我发现这对于满足一些最挑剔的 C 编译器是必要的,但最终结果只是将一些位卡在一起。
当谈到本书中的代码时,我试图遵循法律条文,所以这一段是可疑的。
在优化时,您不仅要突破规范所说的范围,还要突破真正的编译器和芯片让您摆脱困境的界限。
走出规范是有风险的,但在无法无天的领域也有回报。收益是否值得由您来决定。
我们像这样定义符号位:
## value.h
#ifdef NAN_BOXING
#define SIGN_BIT ((uint64_t)0x8000000000000000)
#define QNAN ((uint64_t)0x7ffc000000000000)
为了取回 Obj 指针,我们只需屏蔽掉所有这些额外的位。
## value.h
#define AS_NUMBER(value) valueToNum(value)
#define AS_OBJ(value) \
((Obj*)(uintptr_t)((value) & ~(SIGN_BIT | QNAN)))
#define BOOL_VAL(b) ((b) ? TRUE_VAL : FALSE_VAL)
波浪号 ( ~),如果您之前没有做过足够的位操作来遇到它,那么它就是按位NOT。它切换其操作数中的所有 1 和 0。
通过用安静的 NaN 和符号位的逐位否定来屏蔽该值,我们清除这些位并保留指针位。
最后一个宏:
## value.h
#define IS_NUMBER(value) (((value) & QNAN) != QNAN)
#define IS_OBJ(value) \
(((value) & (QNAN | SIGN_BIT)) == (QNAN | SIGN_BIT))
#define AS_BOOL(value) ((value) == TRUE_VAL)
3 值函数
VM 的其余部分在使用值时通常会通过宏,所以我们几乎完成了。
但是,“value”模块中有几个函数可以查看 Value 的其他黑匣子并直接处理其编码。我们也需要解决这些问题。
第一个是printValue()。每个值类型都有单独的代码。
我们不再有可以打开的显式类型枚举,因此我们使用一系列类型测试来处理每种类型的值。
这在技术上比 switch 慢一点,但与实际写入流的开销相比,它可以忽略不计。
我们仍然支持原始的标记联合表示,因此我们保留旧代码并将其包含在#else条件部分中。
另一个操作是测试两个值的相等性。
没有比这更简单的了!如果两个位表示相同,则值相等。
这对单例值来说是正确的,因为每个值都有一个唯一的位表示,并且它们只等于它们自己。
它也为 Obj 指针做了正确的事情,因为对象使用标识来表示相等——两个 Obj 引用只有在它们指向完全相同的对象时才相等。
它对数字也大多是正确的。大多数具有不同位表示的浮点数是不同的数值。
IEEE 754 包含一个坑,让我们绊倒。由于我并不完全清楚的原因,规范要求 NaN 值不等于它们自己。
对于我们用于自己目的的特殊安静 NaN 来说,这不是问题。
但是可以在 OTao 中生成“真正的”算术 NaN,如果我们想正确实现 IEEE 754 数字,那么结果值不应等于自身。更具体地说:
var nan = 0/0;
print nan == nan;
IEEE 754 说这个程序应该打印“false”。
它对我们旧的标记联合表示做了正确的事情,因为这种VAL_NUMBER情况适用 ==于 C 编译器知道是双精度的两个值。
因此,编译器生成正确的 CPU 指令来执行 IEEE 浮点等式。
我们的新表示通过将 Value 定义为 uint64_t 来打破这一点。如果我们想完全符合 IEEE 754,我们需要处理这种情况。
我知道,这很奇怪。每次我们检查两个 OTao 值是否相等时,进行这种类型测试都会产生性能成本。
如果我们愿意牺牲一点兼容性——谁真的在乎 NaN 是否不等于自身?——我们可以不用管它。我会让你来决定你想变得多么迂腐。
事实上,jOTao 将 NaN 等式弄错了。
当您使用 比较原始双精度时,Java 会做正确的事情==,
但如果您将它们装箱为 Double 或 Object 并使用 比较它们equals(),则不是,这就是 jOTao 实现相等的方式。
最后,我们关闭围绕旧实现的条件编译部分。
就是这样。这样优化就完成了,我们的cOTao虚拟机也完成了。那是书中新代码的最后一行。
4 评估性能
代码已经完成,但我们仍然需要弄清楚我们是否真的通过这些更改做了更好的事情。评估这样的优化与前一个非常不同。
在那里,我们在分析器中看到了一个清晰的热点。我们修复了那部分代码,可以立即看到热点变得更快。
改变值表示的影响更加分散。
宏在任何使用它们的地方都进行了扩展,因此性能变化以许多分析器难以跟踪的方式分布在代码库中,尤其是在优化的构建中。
我们也不能轻易地推断出我们改变的影响。我们使值更小,这减少了整个 VM 中的缓存未命中。
但是这种变化的实际性能影响高度依赖于正在运行的 OTao 程序的内存使用。
一个微小的 OTao 微基准测试可能没有足够多的值分散在内存中,
以至于影响显而易见,甚至像 C 内存分配器分配给我们的地址之类的东西也会影响结果。
如果我们的工作做得对,基本上一切都会变得更快,尤其是在更大、更复杂的 OTao 程序上。
但是,当 NaN 装箱值使更好的内存使用带来的收益无效时,我们执行的额外按位操作可能是无效的。
像这样进行性能工作令人不安,因为您无法轻松证明您已经使 VM 变得更好。
你不能指着一个有针对性的手术微基准然后说,“那里,看到了吗?”
相反,我们真正需要的是一套更大的基准。
理想情况下,它们将从现实世界的应用程序中提炼出来——而不是像 OTao 这样的玩具语言存在这样的事情。
然后我们可以衡量所有这些的总体性能变化。我尽力拼凑了一些较大的 OTao 程序。在我的机器上,新的值表示似乎使一切都快了大约 10%。
这并不是一个巨大的改进,特别是与使哈希表查找更快的深远影响相比。
我说这在很大程度上优化,因为它是一个特定的一个很好的例子一种性能的工作,你可能会遇到,和诚实,因为我认为它在技术上真的很酷。如果我认真地尝试使 cOTao 更快,这可能不是我要达到的第一件事。可能还有其他更容易实现的成果。
但是,如果您发现自己正在开发一个所有轻松获胜的程序,那么在某些时候您可能需要考虑调整您的价值表示。
5 小结
这里我想花一点时间谈谈可以从这里开始的方向。编程语言之旅的下一步是什么?
我们学习了一些重要的基本数据结构,并获得了一些进行低级分析和优化工作的练习。无论在哪个领域编程,这种专业知识都会有所帮助。
我也希望这几个文章给了你一种看待和解决问题的新方法。即使你在语言从来没有再工作,你可能会惊讶地发现多少编程的问题可以被看作是语言一样。
也许您需要编写的报告生成器可以建模为生成器“执行”的一系列基于堆栈的“指令”。
或者需要呈现的用户界面看起来非常像遍历 AST。
这也适用于其他域。我不认为在编程中学到的任何一个主题——甚至在编程之外——缺最终发现在其他领域没有任何用处。总是会有所帮助的。
我最喜欢的软件工程方面之一是它对那些具有折衷兴趣的人的回报有多大。
如果您确实想深入了解编程语言的兔子洞深度,这里有一些关于探索隧道中哪些分支的建议:
- 动态类型
我们简单的单通道字节码编译器将我们推向了主要的运行时优化。
在成熟的语言实现中,编译时优化通常更为重要,编译器优化的领域资源非常丰富,社区也比较活跃,虽然在工作中很少用到。
随机选择一本经典的编译器书籍,将中文编程语言的前端重建为一个复杂的编译管道,其中包含一些有趣的中间表示和优化过程。
动态类型会对您可以走多远施加一些限制,但您仍然可以做很多事情。
- 静态类型
您可能想要大跃进,向 OTao 添加静态类型和类型检查器。这肯定会给你的前端更多的咀嚼。
对于此, Cooper 和 Torczon 的Engineering a Compiler。
Appel 的 现代编译器实现书籍也很受欢迎。
在这本书中,我的目标是正确的,但不是特别严谨。我的目标主要是给你一种做语言工作的直觉和感觉。
如果你喜欢更精确,那么整个编程语言学术界都在等着你。
在我们拥有计算机之前,语言和编译器就已经被正式研究过,
因此不乏关于解析器理论、类型系统、语义和形式逻辑的书籍和论文。
沿着这条路走下去还会教你如何阅读 CS 论文,这本身就是一项宝贵的技能。
- 练习的玩具
如果您很喜欢编程和编写语言,您可以将 OTao 变成您自己的玩具。
将语法更改为令自己满意的内容。添加缺失的功能或删除不喜欢的功能。在那里进行新的优化。
本文版权归我所有,但代码和实现使用非常宽松的MIT 许可证。
非常欢迎选择此编译器,并与他们一起做任何您想做的事情。
如果对语言进行了重大更改,最好也更改名称,主要是为了避免让人们混淆“OTao”这个名称所代表的含义。
最终,可能会获得一些您认为其他人也可以使用的东西。这将带您进入非常独特的编程语言流行世界。
期望花费大量时间编写文档、示例程序、工具和有用的库。该领域充斥着争夺用户的语言。
要在该领域蓬勃发展,您必须戴上营销帽子并进行销售。
不是每个人都喜欢这种面向公众的工作,但如果你喜欢,看到人们使用你的语言来表达自己会非常令人欣慰。