Vector VRL 字节码虚拟机VenuM设计剖析从 AST 解释执行到基于 OpCode 的指令集 VM【免费下载链接】vectorA high-performance observability data pipeline.项目地址: https://gitcode.com/GitHub_Trending/vect/vector本篇技术指南以 Vector 仓库中的设计文档 RFC 9811VRL enum VM / VenuM 为核心深入剖析 Vector Remap LanguageVRL从编译为 AST 后遍历解释执行转向编译为紧凑字节码、由栈式虚拟机执行的完整设计思路。文中将展开 OpCode 指令集、Vm 结构体各字段职责、函数调用与参数栈机制、调试与测试策略并结合 src/transforms/remap.rs 等当前仓库源码说明该设计在 Vector 中的落地形态。读完本文你将理解 VRL 程序执行的底层原理以及字节码 VM 相比 AST 遍历在缓存局部性、指令紧凑度上的根本差异。RFC 背景与目标本 RFC 起草于 2021-11-12是其前身性能专题 RFC2021-10-14 的 VRL 性能讨论的延续。它的目标非常明确VRL 将被编译为一条指令列表并由一个 VM 执行其目的在于显著提升 VRL 程序的执行性能。也就是说VRL 源码不再在运行时被现场解释而是先被编译成一份紧凑、线性的字节码再由专用 VM 逐条执行。这样一份字节码可以被反复运行在 Vector 中即为每个事件反复执行同一份程序编译成本被摊薄运行路径得到极大优化。适用范围该 RFC 只讨论 VRL 的 VM 本身包括其实现风险与缓解措施其余与 VRL 性能相关的话题属于前序性能 RFC 的范畴不在本文讨论之列。痛点AST 遍历解释执行带来的缓存局部性问题在设计 VM 之前VRL 的执行方式是将 VRL 源码编译为一棵 AST抽象语法树在每次求值时遍历这棵树walk the tree逐个节点调用其求值逻辑。RFC 明确指出这种方式的性能症结VRL 编译为 AST 后在求值过程中被遍历。树中的每个节点都被装箱boxed并散布在内存中互不相关的区域。结果就是遍历这棵树意味着 CPU 缓存必须被不断地换入换出。用一句话概括指针追逐 缓存失效。每访问一个 AST 节点就要做一次堆指针跳转节点之间在内存中不连续CPU 的 L1/L2 缓存命中率极低数据需要频繁从主存加载从而拖慢整体执行。核心方案把树压平成指令数组RFC 提出的方案是构建一个enum VMVenuM指令集用一个大枚举enum表达所有指令存放在一段连续内存中。这样程序执行时指令流是线性的、紧凑的能够充分利用 CPU 缓存。OpCode 指令集指令的核心是一个OpCode枚举覆盖了求值所需的各类操作#[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum OpCode { Return, Constant, Negate, Add, Subtract, Multiply, Divide, Print, Not, Greater, GreaterEqual, Less, LessEqual, NotEqual, Equal, Pop, JumpIfFalse, Jump, SetPath, GetPath, Call, ... }可以看到指令集同时包含算术运算Add、Subtract、Multiply、Divide、Negate比较运算Greater、GreaterEqual、Less、LessEqual、NotEqual、Equal控制流Jump、JumpIfFalse条件跳转对应 VRL 中的if等分支结构数据访问SetPath、GetPath读写事件字段路径函数调用Call生命周期与栈操作Return、Pop、Constant加载常量、Print调试输出等。...表明这是一份可扩展的指令清单——VM 面向 VRL 定制可以随时增加 VRL 特有的 OpCode。指令与常量索引指令本身被定义为一个二选一的枚举——要么是纯操作码要么是操作码的操作数enum Instruction { Opcode(Opcode), Literal(LiteralIndex), } pub struct LiteralIndex(usize);关键设计在于操作数数据与操作码分离存放。一个带数据的指令在字节码数组中占两条槽位。例如要加载常量字节码形态为[.., OpCode(Constant), Literal(12), ..]求值时VM 遇到OpCode(Constant)后读取下一条Literal(12)将constants列表中下标为12的常量压入栈顶。Vm 结构体七个字段支撑整个执行状态VM 本身是一个结构体字段如下#[derive(Clone, Debug, Default)] pub struct Vm { instructions: VecInstruction, constants: VecLiteral, targets: VecVariable, stack: VecValue, parameter_stack: VecOptionValue, error: OptionError, instruction_pointer: usize, }逐字段解析instructions指令序列一个Instruction的Vec承载整个编译后的程序。指令要么是OpCode要么是该 OpCode 的数据。上文[.., OpCode(Constant), Literal(12), ..]就是其典型用法求值时把constants列表第 12 个位置存储的常量加载到栈上。constants常量池程序中出现过的常量值列表。因为字节码中只包含整数索引真正的值必须存放在这个池中。将所有字面量集中存放还有一个额外好处字面量可以去重——同一字符串在程序中多次出现只需在池中存一份字节码里反复引用同一索引即可。targets路径池程序中用到的路径event 字段路径如.foo.bar列表其作用与constants类似路径本身存放在池中指令中只引用下标。这与 Vector 中lookup路径的复用之需是一致的。stack求值栈VM 是基于栈的 VM每一个被求值的表达式都会把结果压入栈顶每一个操作则从栈顶弹出它所需要的数据。例如Add指令从栈上弹出两个值相加再把结果压回栈顶。所有中间结果都经由这个栈流转。parameter_stack参数栈用于函数调用。函数参数需要先被求值、再传给函数。参数栈是参数名 值的Vec。编译器需要一个专门的 OpCode把值从主栈stack拷贝到参数栈并打上参数名标签FunctionArgs编译器会在求值某参数的代码之后紧跟着 dump 出这个 OpCode。关于参数栈的细节见下文函数调用机制一节。error错误槽位如果某个操作出错就填充该字段。类似result, error thing()这种错误赋值操作会检查此字段是否已被填充并据此决定后续行为。这为 VRL 的错误传播提供了一条显式的、VM 层面的通道。instruction_pointer指令指针指向下一条待执行指令的游标是整个执行循环的核心状态。每执行一条指令指针前移遇到Jump/JumpIfFalse时按目标地址调整。函数调用机制参数栈与 None 占位符调用 stdlib 函数的流程是逐个求值每个参数把结果压入参数栈。但 VRL 函数调用有两个特点使得这一过程需要精心设计1. 命名参数必须按Function实现中的声明顺序压栈。因为 VRL 支持命名参数为了让 VM 与 stdlib 函数之间建立确定的契约参数必须按照函数实现里声明的顺序依次压入参数栈而不是按调用者书写的顺序。2. 未提供的可选参数必须压入None占位。参数是可选的可能未被调用者指定。为了避免参数错位传给错误的函数即使某个参数未提供仍然要发射一条 OpCode把占位值None压入参数栈。RFC 用了一个假设性的例子说明为什么必须如此thing(surname: nong, name: thang(name: nork))假设函数thang也接受一个可选参数surname。当thang被调用时外层调用留下的surname参数还在参数栈上。如果内层调用不压入占位符thang会误以为surname是传给自己的而只要内层调用把占位符压进去thang就会消费这个None从而不会误收外层参数。stdlib 函数与 VM 的隔离RFC 明确了边界不希望 stdlib 函数直接访问 VM因为那意味着某个不守规矩的函数可能破坏 VM 的内部状态。因此现有 stdlib 函数由compile与resolve两部分组成在 VM 方案下需要合并为一个统一的call函数call收到的ArgumentList参数可以访问参数栈以及Function暴露的参数列表据此为required、optional等参数返回合适的值由于部分 stdlib 函数原本靠compile完成参数校验因此可能还需要一个validate_parameters函数供这些函数对参数做额外校验。调试与正确性工具字节码需要可观测性VM 的一大副作用是代码不再一目了然对比遍历 AST 的直白因此 RFC 规划了三类调试工具工具作用反汇编器Disassembler把编译后的字节码以可读格式输出到控制台单步调试器Step debugger每执行一步 VM 都向控制台输出该步状态打印栈及 VM 中的其他状态变量允许用户按键进入下一步Linter逐条检查指令每个 Jump 操作码跳转到字节码中的合法位置、每个常量引用常量池中的有效索引、每个路径索引引用有效路径Linter 有一个重要的性能要求它应当能在每次编译后运行即 Vector 启动时所以必须足够快。这说明调试工具不仅是开发期的辅助还计划在运行时承担字节码正确性的防线角色。测试策略字节码必须是编译器不可能生成的VM 方案引入了一类全新的错误来源无效字节码。例如OpCode(CONSTANT) Primitive(1) OpCode(ADD)ADD指令期望栈上有两个值可供相加而这串字节码只压入了一个值——这是非法的。RFC 的态度是绝不能让 VRL 编译器有机会生成这类字节码因此测试策略分为三层属性测试property testing生成海量合法 VRL AST 的组合编译为字节码后运行 VM确保不出现缓冲区下溢buffer underflow、非法常量位置或其他无效字节码情形双轨对照由于实现 VM 需要给Expressiontrait 增加方法因此可以在同一份代码里同时运行旧 AST 与新的 VM断言两种方式得到完全相同的结果单元测试与属性测试互补覆盖具体指令与边界场景。双轨对照策略既是对正确性的兜底也给出了平滑迁移的路径——这也是 RFC 实施计划中Test阶段的直接依据。性能原理与权衡为什么值得做Rationale设计理由随着 AST 的每个节点被编译成仅几个字节并且所有指令都保存在连续内存中程序的求值可以充分利用 CPU 缓存从而获得更快的执行速度。这是本方案的核心理论依据指令紧凑每节点仅数字节 内存连续全部指令在同一个Vec→ 缓存友好 → 执行更快。Drawbacks代价RFC 坦诚列出了三个主要缺点代码复杂度上升。遍历 AST 时代码在任意位置做什么是相当显而易见的而面对 VM 的指令序列很难一眼对应回 VRL 源码的哪个片段。缓解之道是编写上文所述的扩展调试工具。不过 VRL 本身是一个非常简单的语言这也意味着 VM 会保持简单。失去部分 Rust 编译器提供的安全保证。需要大量模糊测试fuzz testing来确保代码在各种情况下正确运行。但需要特别说明的是整个 VM 不需要任何unsafeRust 代码——安全收益仍然可以保留。参数求值时机改变。目前每个 stdlib 函数自己负责求值参数这使得参数可以被惰性求值。而在 VM 方案下参数必须提前求值、以栈形式传入函数这可能对性能产生负面影响。备选方案WASM 与 OpCode 编码的取舍WASMWebAssembly另一个思路是把 VRL 转译transpile为 WASM借助成熟且高性能的 WASM VM 获得接近原生的速度。RFC 否定了这一方向理由有二数据序列化开销数据需要在 Vector 与 WASM 之间来回移动必须进行序列化这会产生显著性能损耗过度设计WASM 提供的功能与复杂度远超 VRL 所需。自研 VM 可以纯粹为 VRL 定制实现 VRL 特有的 OpCode让字节码保持简单。在 OpCode 中内嵌数据RFC 当前的方案是每个带数据的 OpCode 在指令列表中占两条操作码 数据索引。加载一个常量需要三步加Constant操作码、把常量值加入常量列表、把常量在列表中的下标加入指令流。一个替代设计是把值直接放进 OpCodeenum OpCode { ... Constant(BoxValue) ... }优点无需单独维护常量池运行时加载常量少一次查表。代价size_of::OpCode从 8 字节涨到 16 字节指令列表内存可能翻倍且常量无法去重——同一字符串在代码中出现两次就要在字节码里存两份。用位掩码压缩 OpCode 与数据第三个思路是位压缩由于实际不会出现usize量级的 OpCode也几乎不会有usize量级的常量索引可以把操作码 数据合并进一个usize用位掩码切分OpCode FromPrimitive::from_usize(instruction (1_usize (usize::BITS / 2)) - 1 (usize::BITS / 2))) data instruction (1_usize (usize::BITS / 2)) - 1这样size_of::OpCode()能保持在绝对最小。RFC 特别指出OpCode 的编码表示是一种可以在后期再做、且不会对核心功能产生重大影响的优化——即先保证功能正确再谈字节级压缩。在 Vector 中的落地现状仓库证据RFC 是设计方案那么它在当前仓库中的实际形态如何从源码看VRL 的执行运行时已经抽象出了一个枚举VrlRuntime位于 lib/vector-config/src/external/vrl.rs对配置系统暴露、可序列化为字符串值目前暴露的变体为Ast。VRL 编译器与运行时的完整实现通过 git 依赖引入根 Cargo.tomlvrl { git https://github.com/vectordotdev/vrl.git, ... }并开启arbitrary、cli、test、test_framework、stdlib-base等特性——其中arbitrary正是为属性测试RFC 的 property testing 策略服务的。remap transformAST 运行时的集成形态VRL 在 Vector 中的主战场是remaptransformsrc/transforms/remap.rs。其配置中有一个隐藏的runtime: VrlRuntime字段build时按运行时分发let (transform, warnings) match self.runtime { VrlRuntime::Ast { let (remap, warnings) Remap::new_ast(self.clone(), context)?; (Transform::synchronous(remap), warnings) } };代码通过VrlRunnertrait 抽象出执行器pub trait VrlRunner { fn new() - Self; fn run( mut self, target: mut VrlTarget, program: Program, timezone: TimeZone, ) - std::result::ResultValue, Terminate; }当前实现AstRunner使用Runtime的resolve执行程序并在每次事件处理后调用runtime.clear()清理状态——这与测试check_remap_doesnt_share_state_between_eventssrc/transforms/remap.rs验证的事件之间不共享状态约束对应。从源码结构可以推断VrlRunner这一抽象正是为将来接入 VM 运行时预留的接口位当 VM 就绪后可在此处新增对应的 Runner 实现。编译阶段则在compile_vrl_program中完成通过compile_vrl(source, vector_vrl_functions::all(), state, config)得到Program并对编译结果含 warning 诊断、MeaningList等做缓存避免重复编译。这与 RFC 中程序编译一次、反复执行的思路一致。条件组件与测试框架src/conditions/vrl.rs 将 VRL 用于condition表达式例如基于 VRL 布尔表达式的路由条件同样按VrlRuntime::Ast构建说明该抽象覆盖了 VRL 的所有消费方。lib/vector-vrl/tests/src/main.rs 是 VRL 测试框架入口它通过--runtime命令行参数选择用 AST 还是 VM 执行测试集VrlRuntime实现Defaultdefault_value_t取默认值——这正是 RFC 双轨对照测试 思想在测试工具链中的体现。lib/vector-vrl/cli/src/main.rs 提供了 VRL CLI 的包装入口便于在 Vector 仓库内直接运行 VRL 脚本、调试表达式可配合 RFC 中规划的反汇编与单步调试能力使用。benches/remap.rs 是remap相关的性能基准criterion 基准套件为 RFC 所关注的执行性能提供量化观测手段。VRL 仓库中的配套资源VRL 语言自身的生态资源在本仓库中也有完整保留lib/vector-vrl 下包含web-playgroundVRL 在线实验场、doc-builder文档生成、functions/metrics/enrichmentVector 专用 VRL 函数扩展、tests含 lib/vector-vrl/tests/tests/example.vrl 等用例等模块VRL 内置 stdlib 函数则在引入的vrlcrate 的stdlib-base特性中。这些构成了与 RFC 所述编译器、stdlib、测试体系配套的完整工程。实施计划分阶段落地避免巨型 PRRFC 明确指出实现必须分阶段进行防止出现一个永远合不进去的巨型 PR提交一个 spike 级别的 PR粗略演示 VM 的改动方向对应 RFC 中标注的 PR 链接融入单元测试与属性测试编写 VM 及发射 OpCode 相关函数的文档与注释围绕 VM尤其是函数调用开发安全的 API为表达式实现 VM 与VRL 到 VM的编译器stdlib 函数后移默认调用先做成 noop测试——同时运行 VM 与表达式遍历器确保结果一致在 stdlib 函数上实现call函数。从当前仓库的状态可以推断主仓库侧的运行时抽象VrlRuntime、VrlRunner与测试框架的--runtime参数已经就位为 VM 的接入留下了明确插槽。未来改进字节码级优化将程序表示为单维字节码数组之后还打开了一扇新的大门——静态优化由于代码是单维的字节码数组可以扫描代码查找模式并重新组织字节码使其能以更优的方式运行。例如常量折叠、死代码消除、跳转压缩等经典字节码优化都建立在这一形态之上。RFC 对此保持审慎在考虑实施这些改动之前还需要大量的思考与研究。结语RFC 9811 为 VRL 描绘了一条从解释 AST到执行字节码的清晰演进路径用OpCode枚举定义指令集用Vm结构体承载栈、常量池、路径池与错误通道用参数栈 None占位解决命名参数与可选参数的传递难题并以反汇编器、单步调试器、Linter 与属性测试构建正确性防线。它坦诚地列出了复杂度、安全保证与惰性求值三方面的代价也在 WASM 与多种 OpCode 编码之间做了有理有据的取舍。对于希望深入理解 Vector 数据处理内核或自研脚本语言执行引擎的读者这份 RFC 连同 src/transforms/remap.rs 的运行时抽象是一份不可多得的实战参考。【免费下载链接】vectorA high-performance observability data pipeline.项目地址: https://gitcode.com/GitHub_Trending/vect/vector创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考