.. _embedding_vm: ======================== Daslang 虚拟机 ======================== ----------------------- 基于树的解释 ----------------------- Daslang 的虚拟机由一个小型执行上下文组成,该上下文管理堆栈和堆分配。 编译后的程序本身是一个 “节点” 树(称为 SimNode),它可以评估虚拟函数。这些 Node 不检查参数类型,假设所有此类检查都是由编译器完成的。 为什么是基于树的?基于树的解释器很慢! 是的,与字节码解释器相比,基于 Tree 的解释器因其性能低而臭名昭著。 但是,这种观点通常基于动态类型语言的 AST(抽象语法树)解释器与优化的基于寄存器或堆栈的优化解释器之间的比较。 由于基于 AST 的解释器实现简单,在许多“自制”的幼稚解释器中也可以看到,这给基于树的解释器带来了额外的坏名声。 AST 通常包含大量对解释无用或不必要的数据,以及大树的深度和复杂性。 甚至很难制作静态类型语言的 AST 解释器,它将以某种方式从静态类型数据中受益 - 基本上,每个树节点访问者仍然会以通用形式返回值和类型信息。 相比之下,典型的动态类型语言的一个好的字节码 VM 解释器将具有所有或最频繁指令(可能带有计算的 goto)的紧密核心循环,此外还可以静态(或在执行期间)推断类型并为其优化代码。 **寄存器**和**基于堆栈**的 VM 都有自己的权衡,特别是通常生成的指令/融合指令较少,基于寄存器的 VM 的内存移动/间接内存访问较少,基于堆栈的 VM 的指令集更小,更容易实现。 VM 的 “core types” 越多,指令集中可能需要的指令就越多,并且/或者指令成本会增加。 尽管动态类型语言通常没有很多核心类型,并且有些语言可以将其所有主要类型的值和类型信息嵌入到 64 位中(例如,使用 NAN 标记),但通常仍然会留下这些核心类型之一(表/类/对象)使用关联容器查找 (unordered_map/hashmap) 来实现。 这对于缓存位置/性能来说不是最佳的,并且还会使与主机 (C++) 类型的互作变得缓慢且效率低下。 因此,与 host/C++ 函数的互作通常很慢,因为它需要复杂且缓慢的参数/返回值类型转换和/或关联表查找。 因此,通常情况下,主机函数调用非常“繁重”,程序员通常无法通过将部分功能提取到 C++ 函数中来优化脚本 - 他们必须重写大块/循环。 增加核心内部类型的数量可能会有所帮助(例如,使游戏开发中的典型类型“float3”成为“核心”类型之一),但这会使指令集更大/更慢,增加类型转换的复杂性,并且通常会引入强制间接寻址(由于值类型的 bitsize 有限),这也不利于缓存局部性。 但是,**Daslang** *不解释 AST,也不是一种动态类型语言。 相反,对于运行时程序执行,它会发出一个不同的树(模拟树),它不需要为参数/返回类型提供类型信息,因为它是静态类型的,并且所有类型都是已知的。 对于 Daslang ABI,使用 128 位字,这对于大多数现代硬件来说是很自然的。 所选的表示形式有助于分支预测,增加缓存局部性,并提供基于堆栈和寄存器的代码混合 - 每个 'Node' 都使用本机机器寄存器。 同样重要的是要注意,“类型”和“指令”的数量并不重要 - 重要的是特定程序/函数中使用的不同指令的数量。 VM 和 C++ ABI 之间的类型转换非常简单(对于大多数类型来说,就像移动指令一样简单),因此它非常快速且对缓存友好。 它还使程序员可以通过将特定功能提取为 C++/主机函数来优化特定功能(在解释中)- 基本上是将指令“融合”为一个。 添加新的用户类型相当简单,而且在性能或工程方面不会带来痛苦。 “值”类型必须适合 128 位,并且必须是可重定位和零初始化的(即应该很容易被破坏,并允许 memcpy 和 memsetting 带零);所有其他类型都是 “RefTypes” 或 “Boxed Types”,这意味着它们只能在脚本中作为引用/指针进行作。 用户类型的数量没有限制,使用此类类型也不会对性能造成影响(除了 Boxed/Ref Types 的明显间接成本)。 使用通用节点还可以在运行时无缝混合解释和提前编译代码 - 即,如果脚本中的某些函数发生更改,未更改的部分仍将运行优化的 AoT 代码。 这些是 Daslang 解释器选择基于树的解释(不要与基于 AST 的解释混淆)的主要原因,以及为什么它的解释器比大多数(如果不是全部)基于字节码的脚本解释器更快。 ----------------- 执行上下文 ----------------- Daslang 执行上下文是轻量级的。它基本上由堆栈分配和两个堆分配器(用于字符串和其他所有内容)组成。 一个 Context 可用于执行不同的程序;但是,如果程序在堆中具有任何全局状态,则对程序的所有调用都必须在同一个 Context 中完成。 可以在 Context 上调用 stop-the-world 垃圾回收(此调用最好在程序执行之外完成,否则不安全)。 但是,重置上下文(即释放所有内存)的成本非常低,并且(取决于内存使用情况)可以低至几条指令,这允许对所有无状态脚本进行最简单、最快速的内存管理形式 - 只需在每帧或每次调用中重置上下文。 这基本上将 Context 堆管理变成了 “bump/stack allocator” 的形式,大大简化和优化了内存管理。 有一些方法(包括策略代码)可以确保所有脚本都不使用全局变量,或者至少使用需要堆内存的全局变量。 例如,可以将所有上下文拆分为多个类别:一个上下文用于所有无状态脚本程序,一个上下文用于每个 GC 的(或者另外,``unsafe``)的脚本。 然后根据需要经常重置无状态上下文(例如,来自 C++ 的每个 'top' 调用或每个帧/tick),在 GC-ed 上下文中,可以在需要时立即调用垃圾回收(使用一些内存使用/性能的启发式)。 每个 context 只能同时在一个线程中使用,即对于多线程,每个同时运行的线程都需要一个 Context。 要在不同上下文之间交换数据/通信,请使用 'channels' 或其他一些定制的 C++ 托管代码。