尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

深入解析 Vector 的 VRL LLVM 后端 RFC:从解释执行到原生机器码的性能探索

深入解析 Vector 的 VRL LLVM 后端 RFC:从解释执行到原生机器码的性能探索 深入解析 Vector 的 VRL LLVM 后端 RFC从解释执行到原生机器码的性能探索【免费下载链接】vectorA high-performance observability data pipeline.项目地址: https://gitcode.com/GitHub_Trending/vect/vector导读本篇技术文章以 Vector 仓库中的设计文档 RFC 10517 - LLVM Backend for VRL 为核心深入剖析 VRLVector Remap Language执行模型演进的完整方案通过 LLVM 将 VRL 程序编译为机器码消除解释执行带来的运行时开销。你将理解 VRL 当前编译为解释表示的真实执行模型、CPU 分支预测与流水线对解释器性能的超比例影响、LLVM IR 的发射与优化思路以及该方案在 Vector 中的落地路径与测试策略。读完本文你可以从源码层面读懂 Vector 的 VRL 执行架构并掌握 LLVM 代码生成前端设计的核心方法论。背景为什么 VRL 需要新的执行后端VRL 是 Vector 内置的日志转换语言用于在拓扑中按事件流执行字段修改、条件判断、函数调用等逻辑。性能是 VRL 的关键设计目标官方特性列表承诺其极快且高效并在 features.cue 中强调要让写出缓慢或有 bug 的 VRL 程序变得困难。但在本 RFC 撰写时2021-12-20VRL 的程序执行仍依赖解释器其单核执行性能在多条 Vector 拓扑的性能调查中被识别为瓶颈——这就是 LLVM 后端提案的出发点。在深入技术细节前RFC 首先澄清了一系列围绕 VRL 执行模型的常见误解这些澄清是理解整个方案的前提。VRL 程序被编译为原生 Rust 代码运行——并不准确Vector 代码库全部由 Rust 编写因此容易产生VRL 程序以原生 Rust 运行的直觉。从可计算性computability角度看这没错VRL 处理事件的方式与手写 Rust 变换在语义上不可区分。但实现细节对执行时间至关重要而不仅仅是计算的定义。RFC 明确纠正准确描述应该是——VRL 程序被编译为一种在原生 Rust 代码中被解释的表示。从我们实现了一个能够执行足够通用计算的机制到我们实现了一个执行程序逻辑的程序这一层隐藏的间接性indirection会带来出人意料巨大的性能差异即使前者本身是用高性能语言写成的。VRL 性能特性非常接近 Rust 本身——顶层控制流除外VRL 程序执行中的许多代码路径确实由 Rust 编译器编译并高度优化例如 VRL 值的路径插入/删除、JSON 解析、正则匹配等——这些路径在 Rust 编译器产物之上不产生额外运行时开销。然而VRL 程序顶层的控制流是在运行时编排的。编译 Rust 程序时CPU 能静态地知道表达式之间将走哪些分支排除条件与错误处理后而解释执行时CPU 无法在 VRL子表达式边界上预测任何控制流。这一差异对性能有超比例super-proportional的影响。VRL 没有运行时——它明确存在Runtime无法把 CPU 指令计数器直接指向 VRL 的Program来执行它。VRL 依赖一个运行时为每个表达式实现resolve方法通过递归求值来解释程序。这完全符合任何不能直接归因于程序本身的行为的运行时定义甚至在代码中就有名为Runtime的结构。在 Vector 主仓库中remap 变换通过vrl::compiler::runtime::Runtime驱动程序执行见 src/transforms/remap.rs。解释器/VM 自身消耗的时间很小移除它难有显著提升——分支预测才是关键对 VRL 程序执行火焰图的分析显示粗略估计不超过 25% 的时间花在未推进程序状态的解释调用本身。那么移除这些开销为何可能带来超过 33% 的提升答案与冯·诺依曼架构的内存瓶颈及现代 CPU 缓解它的机制密切相关指令流水线instruction pipelining只要后续指令不分散在不可预测的路径之间CPU 就能加速其执行分支预测与推测执行speculative execution当条件分支严重偏向某一侧时CPU 可推测执行指令并从主内存预读写以隐藏比寄存器/缓存访问高数个数量级的内存访问延迟。在当前的解释执行模型中CPU 无法预测任何 VRL子表达式边界上的控制流导致 CPU 频繁停顿stall利用率大幅受限。因此移除解释开销的效果会通过分支预测与缓存的改善被放大而非简单的线性加成。单核性能不如直接上并行——Amdahl 定律的约束事件处理看似令人尴尬地并行embarrassingly parallel似乎加线程的收益远大于压榨单核。但根据Amdahl 定律即使程序中只有 5% 无法并行化在无限线程数下的性能提升上限也被封顶在 20 倍。同步点哪怕很少也会对最优性能造成破坏性影响。因此单核性能仍然是值得深入优化的方向。LLVM 是一个虚拟机——它只是名字带 VMLLVM最初确实是 Low Level Virtual Machine 的缩写但该缩写早已被官方移除LLVM 已演变为一个与进程虚拟机关系甚少的伞形项目。虽然 LLVM 提供虚拟指令集架构LLVM IR但它只是编译过程中介于高级语言与机器码之间的中间表示运行时没有任何解释过程。LLVM 的核心价值之一是其作用于 LLVM IR 的优化 pass例如函数内联、代码分支合并、内存访问提升为寄存器访问、常量折叠constant-folding、分配合并batching allocations等。Rust 正是使用 LLVM 发射机器码——而 VRL 后端打算采用完全相同的技术。提案范围与现状背景In scope引入一种新的 VRL 执行模型直接执行机器码无运行时解释开销且可由用户选择开启opt-in。Out of scope总体而言这是一个实验用于衡量使用 LLVM 能赢得多大的性能提升。当时计划不将其投入生产使用并需在后续调查用户的具体安全与性能需求。任何适用于 VRL 所有执行模型traversal、VM、LLVM的通用优化例如改进 VRLValue的访问路径都不在本方案关注范围内。交叉关注点同期进行的 VRL VM 工作仓库中同时存在为 VRL 实现 VM 的持续工作PR #10011。VM 虽能降低相对当前表达式遍历expression traversal的解释开销但不能完全消除它更关键的是它无法从根本上改善推测执行/分支预测行为因为 CPU 依然无法预测解释器循环中的下一条指令。LLVM 后端与 VM 因此构成了执行速度、内存安全与实现复杂度的不同取舍点。用户体验语义完全不变LLVM 后端的第一原则是VRL 的语义保持不变。任何在 LLVM 执行模型下比 traversal 或 VM 模型没有严格更快的情况都被视为明确的 bug。对用户而言这是无条件的体验提升——同一份 VRL 程序只是跑得更快。LLVM 入门资源与快速上手RFC 为不熟悉 LLVM 的读者提供了学习路径官方教程Kaleidoscope: Code generation to LLVM IRLLVM 官方文档第三节Rust 生态中的inkwellcrate——对 LLVM C API 的安全封装其仓库中提供了Kaleidoscope 教程的 Rust 改编版Mukul Rathi 的A Complete Guide to LLVM for Programming Language CreatorsGodbolt 的Compiler Explorer可用于直观理解编译器如何发射 LLVM。RFC 给出一个可在本地复现的最小实验将如下 Rust 函数#[no_mangle] pub extern C fn foo(n: i32) - i32 { n * 42 }用rustc ./program.rs --crate-typelib --emitllvm-ir -O编译会得到define i32 foo(i32 %n) unnamed_addr #0 !dbg !6 { %0 mul i32 %n, 42, !dbg !10 ret i32 %0, !dbg !11 }这个例子直观展示了一条乘法算术指令如何直接出现在 LLVM IR 中以及优化后的 IR 可以极其精简——这正是目标把 VRL 程序的顶层逻辑也变成这样可被 LLVM 优化的中间表示。实现方案为表达式增加emit_llvm发射能力高层执行流程在高层面上目标是为 VRL 程序产出可执行的机器码Vector 启动时解析 VRL 程序将其翻译为 LLVM IR通过 LLVM 编译为机器码动态加载进当前运行的进程从生成的二进制中解析出vrl_execute函数符号对每个待转换的事件调用该函数。于是解释器循环中对Expression::resolve的递归调用被彻底移除——CPU 直接执行的是为该程序量身编译的机器码。核心 APIemit_llvm方法与Context取代递归调用resolveRFC 提出在表达式 trait 上新增一个方法/// Emit LLVM IR that computes the Value for this expression. fn emit_llvmctx( self, state: crate::state::Compiler, context: mut crate::llvm::Contextctx, ) - Result(), String;其中Context封装了 LLVM 代码生成所需的所有句柄pub struct Contextctx { context: ctx inkwell::context::Context, execution_engine: inkwell::execution_engine::ExecutionEnginectx, module: inkwell::module::Modulectx, builder: inkwell::builder::Builderctx, function: inkwell::values::FunctionValuectx, context_ref: inkwell::values::PointerValuectx, result_ref: inkwell::values::PointerValuectx, ... }设计上的几个关键约定保留现有resolve它既是当前 VRL 语义的权威参考又可作为自动化正确性测试的对照目标context.result_ref()每个表达式调用它取得一个 LLVMPointerValue指向Resolved值应被写入的位置context.set_result_ref()可临时改变结果指针使父表达式在调用子表达式的emit_llvm时能控制子表达式机器码把结果存到哪里——例如发射二元运算时两个操作数都需要先被计算context.context_ref()返回对 VRLContext由 Vector 内部任何使用 VRL 的组件提供的引用。只用四种 LLVM 指令拼装 VRL 功能RFC 提出了一个极简且对 Rust 程序员友好的实现哲学除了发射分支和调用函数这类琐碎逻辑外尽量借助 Rust 编译器。这既避免了自行处理内存布局、也无需为仅使用基本整数类型或指针/引用的情况定义 FFI同时还把 Rust 的内存安全保证带入大部分发射出的 LLVM IR。具体而言VRL 的功能完全由以下 LLVM 指令组合而成alloca栈分配br条件与无条件分支call调用 Rust stubglobal常量这意味着即使只对 LLVM 有肤浅了解也能维护这套实现而 LLVM 的优化 pass 足以把这种碎片化 IR 优化到位。通过 Rust stub 缝合代码片段大多数依赖预编译 Rust 的表达式会发射出类似如下的 IR。先在模块中查找预编译函数再对其发起调用let fn_ident vrl_resolved_initialize; let fn_impl ctx .module() .get_function(fn_ident) .ok_or(format!(r#failed to get {} function#, fn_ident))?; ctx.builder() .build_call(fn_impl, [result_ref.into()], fn_ident);生成的 LLVM 指令call void vrl_resolved_initialize(%std::result::Resultvrl_compiler::Value, vrl_compiler::ExpressionError* %result)注意该调用指向同一个 LLVM 模块内、由 Rust 编译出的函数。由于整个源码已知LLVM 完全可以内联并优化掉这个调用——这种发射方式只是拼接代码片段的便利手段并非必须真正执行该调用。临时值分配与生命周期安全对于二元运算等需要临时值的场景可分配未初始化的栈值let result_temp_ref ctx.build_alloca_resolved(temp)?;对应的 LLVM 指令%temp alloca %std::result::Resultvrl_compiler::Value, vrl_compiler::ExpressionError, align 8此时初始化与销毁drop的责任在发射代码一方需调用vrl_resolved_initialize与vrl_resolved_drop的实现。为了安全使用RFC 提出暴露一个模块构建器 API分配临时值时立即插入vrl_resolved_initialize调用并在构建器值离开作用域时自动插入vrl_resolved_drop调用。结合 LLVM 的llvm.lifetime.start与llvm.lifetime.endintrinsics可以防止使用未初始化值或在值被 drop 之后继续使用。常量的搬移与常量折叠VRL 常量可以在 Rust 侧被消费consume后通过transmute 为[i8]写入 LLVM 全局变量。由于 Rust 语义允许所有类型在内存中搬移除非被Pin这种转换是安全的。把常量写进 LLVM 模块的好处是LLVM 能在编译期对其应用常量折叠。为保证金字塔上 Rust 侧创建常量时可能分配的资源被正确清理卸载 LLVM 模块时会再 transmute 回T并妥善 drop。预编译 stub 函数清单下面展示 RFC 中初步、仍在开发中的预编译函数示例LLVM 模块将以结果 bitcode 初始化因此这些符号在运行时可能因被 LLVM 优化掉而不存在#[no_mangle] pub extern C fn vrl_resolved_initialize(result: *mut Resolved) { unsafe { result.write(Ok(Value::Null)) }; } #[no_mangle] pub extern C fn vrl_resolved_drop(result: *mut Resolved) { drop(unsafe { result.read() }); } #[no_mangle] pub extern C fn vrl_resolved_is_err(result: mut Resolved) - bool { result.is_err() } #[no_mangle] pub extern C fn vrl_resolved_boolean_is_true(result: Resolved) - bool { result.as_ref().unwrap().as_boolean().unwrap() } #[no_mangle] pub extern C fn vrl_expression_assignment_target_insert_external_impl( ctx: mut Context, path: LookupBuf, resolved: Resolved, ) { let value resolved.as_ref().unwrap().clone(); let _ ctx.target_mut().insert(path, value); } #[no_mangle] pub extern C fn vrl_expression_literal_impl(value: Value, result: mut Resolved) { *result Ok(value.clone()); } #[no_mangle] pub extern C fn vrl_expression_op_eq_impl(rhs: mut Resolved, result: mut Resolved) { let rhs std::mem::replace(rhs, Ok(Value::Null)); *result match (result.clone(), rhs) { (Ok(lhs), Ok(rhs)) Ok(Value::Boolean(rhs lhs)), _ unimplemented!(), }; } #[no_mangle] pub extern C fn vrl_expression_query_target_external_impl( context: mut Context, path: LookupBuf, result: mut Resolved, ) { *result Ok(context .target() .get(path) .ok() .flatten() .unwrap_or(Value::Null)); }这些 stub 覆盖了结果初始化/销毁、错误判断、布尔判定、路径写入、字面量与相等比较、目标查询等基础能力构成发射任何 VRL 程序 IR 的最小原语集。示例if语句生成的 LLVM IR以如下 VRL 程序为例if .status 123 { .foo bar }RFC 展示了它初步生成的 LLVM IR节选vrl_execute函数体; Function Attrs: mustprogress nofree norecurse nosync nounwind readnone uwtable willreturn define void vrl_execute(%vrl_compiler::Context* noalias nocapture align 8 dereferenceable(32) %context, %std::result::Resultvrl_compiler::Value, vrl_compiler::ExpressionError* noalias nocapture align 8 dereferenceable(88) %result) unnamed_addr #55 { start: br label %if_statement_begin if_statement_begin: ; preds %start br label %op__begin op__begin: ; preds %if_statement_begin call void vrl_expression_query_target_external_impl(%vrl_compiler::Context* %context, %lookup_buf::LookupBuf* bitcast ([32 x i8]* status to %lookup_buf::LookupBuf*), %std::result::Resultvrl_compiler::Value, vrl_compiler::ExpressionError* %result) %rhs alloca %std::result::Resultvrl_compiler::Value, vrl_compiler::ExpressionError, align 8 call void vrl_resolved_initialize(%std::result::Resultvrl_compiler::Value, vrl_compiler::ExpressionError* %rhs) br label %literal_begin literal_begin: ; preds %op__begin call void vrl_expression_literal_impl(%memmem::SearcherKind* bitcast ([40 x i8]* 123 to %memmem::SearcherKind*), %std::result::Resultvrl_compiler::Value, vrl_compiler::ExpressionError* %rhs) call void vrl_expression_op_eq_impl(%std::result::Resultvrl_compiler::Value, vrl_compiler::ExpressionError* %rhs, %std::result::Resultvrl_compiler::Value, vrl_compiler::ExpressionError* %result) call void vrl_resolved_drop(%std::result::Resultvrl_compiler::Value, vrl_compiler::ExpressionError* %rhs) %vrl_resolved_boolean_is_true call i1 vrl_resolved_boolean_is_true(%std::result::Resultvrl_compiler::Value, vrl_compiler::ExpressionError* %result) br i1 %vrl_resolved_boolean_is_true, label %if_statement_if_branch, label %if_statement_else_branch ...这段 IR 清晰展示了设计意图条件表达式先查询.status经status常量、字面量123通过123常量位转换传入、相等比较调用vrl_expression_op_eq_impl、临时%rhs的初始化与 drop 配对出现、最后用br i1 按布尔结果分支出 if/else 分支。优化后的 IR内联、分配合并与控制流整合对上述 IR 运行若干 LLVM 优化 pass 后节选RFC 特别指出三种显著的优化效果define void vrl_execute(%142* noalias nocapture align 8 dereferenceable(32) %0, %752* noalias nocapture align 8 dereferenceable(88) %1) unnamed_addr #87 personality i32 (i32, i32, i64, %462*, %9*)* rust_eh_personality { %3 alloca %529*, align 8 %4 alloca %752, align 8 %5 alloca [5 x i64], align 8 %6 alloca %135, align 8 %7 alloca %116, align 8 %8 alloca %135, align 8 tail call void vrl_expression_query_target_external_impl(%142* nonnull %0, %74* bitcast ([32 x i8]* 16146 to %74*), %752* nonnull %1) #104 ...批量的栈分配batched stack allocations多个alloca集中在函数头部便于栈帧优化函数调用内联inlining of function callsvrl_resolved_initialize、vrl_expression_literal_impl等调用被展开为直接的内存操作如store i64 0、llvm.memcpy控制流整合consolidation of control flow原来的线性标签跳转被优化为更紧凑的分支结构配合landingpad/resume处理 Rust 的 panic 展开路径。panic 行为的控制当 Rust stub 内部发生panic时其行为可通过链接panic_unwind*.bc或panic_abort*.bc文件来控制这与在Cargo.toml中设置panic键unwind 或 abort等价。Vector 主二进制采用默认的unwind策略LLVM 后端沿用相同策略。测试策略正确性与性能的双重验证RFC 为这套高风险涉及unsafe与手写代码生成的实现设计了分层测试策略单元测试为每个表达式单独添加代码生成测试确保发射出的 LLVM IR 能通过静态分析、生成代码产生预期结果覆盖各表达式特有的边界情况行为测试Behavior tests保证现有的测试语料在 LLVM 执行引擎下全部通过。该语料位于lib/vrl/tests/tests目录与 Vector 的 tests/behavior/transforms/remap.yaml 行为测试文件中——后者在仓库中真实存在包含 remap_source、remap_file、remap_abort、remap_arithmetic、remap_coercion、remap_function_arguments 等大量 VRL 场景用例基准测试Benchmark tests对复杂度各异的 VRL 脚本横向对比各执行模式traversal、VM、LLVM的运行时。LLVM 方案在概念上应始终最快若发现反例则深入检查其他执行引擎应用了哪些我们遗漏的优化浸泡测试Soak tests端到端运行以观察管道整体性能影响。整体加速比很大程度上取决于 remap 脚本的权重以及管道中是否存在其他瓶颈组件模糊测试Fuzz tests自动生成任意表达式组合的 VRL 程序找出只在特定表达式交互时出现的极端边界缺陷并用现有执行模式交叉验证正确性人工审查Manual reviewunsafe代码中的逻辑错误可能违反不变量导致内存破坏。对策之一是把文本形式的 LLVM IR 保存进lib/vrl/tests/tests/expressions使代码生成结果可纳入 PR 审查流程同时建议审查规范例如要求 PR 添加unsafe标签并让审查者显式确认已审阅相关 unsafe 块。方案论证Rationale 与 Drawbacks为什么值得做只要 VRL 的单核性能是拓扑瓶颈VRL 的通用性能提升就等价于整个拓扑同等规模的性能提升。RFC 希望兑现 VRL 特性列表中的性能承诺每个 VRL 程序都能从降低的运行时开销中受益而无需针对特定用例做专项优化。一致的执行速度对于建立语言信任至关重要。更进一步以手写 Rust 程序才可企及的速度执行日志转换 DSL将强化 Vector一流性能这一关键价值主张。代价与风险内存安全通过 LLVM 生成机器码后VRL 执行路径在很大程度上不再免疫内存违规。为缩小错误面方案结合模糊测试、LLVM 静态分析并依赖 Rust 编译器处理任何非平凡代码片段。但内存安全保证始终依赖一条无法自动验证的信任链——这次由实现者自己维护一小撮不变量而不是把正确性委托给第三方。对最终用户而言VRL 语言本身仍然是内存安全的nightly 工具链依赖为 Rust 的std库生成 LLVM bitcode 受-Z build-std标志保护且仅在 nightly 编译器可用。为完整链接预编译 bitcode 需要std。可通过设置RUSTC_BOOTSTRAP1环境变量规避 nightly 要求从而得到与 Vector 相同 Rust/LLVM 版本构建的std。该 hack 被隔离在只含std的独立 crate 中并在构建步骤中链接到库 bitcode二进制体积静态链接 LLVM 大约为 Vector 二进制增加 9MB此外还需附带预编译 bitcode。若体积成为问题可考虑发布禁用 LLVM 特性的二进制专业门槛使用 LLVM 需要关于编译器构造的专门知识。不过公开资料充足RFC 已链接且该部分代码应获得格外充分的文档与测试关注。备选方案对比为什么不是编译到 Rust / C / WebAssembly / Bitcode编译到 Rust被否决需要随产品附带一个 Rust 编译器及其库还要附带 Vector 源码及其依赖 crate——体积达数百 MB不可行。编译到 C被否决需要附带 C 编译器同时没有任何更好的安全保证无法内联函数因而错失优化潜力相对直接使用 LLVM 没有显著收益。编译到 WebAssembly被否决需要附带 Wasm 运行时事件数据进出 Wasm 要么需要拷贝要么使用mmap技术后者会约束事件数据必须驻留的内存区域。WebAssembly 的优势是提供更高的抽象级别、可安全执行不受信任的代码但代价是更慢的执行速度。值得提及的关联背景Vector 当时刚移除了对 WebAssembly 变换的支持。编译到 Bitcode / 维持 VM 路线保留为中间地带同期推进的 VRL VM 相比当前执行模型和 LLVM 方案在执行速度、内存安全与实现复杂度之间提供了中间地带。究竟哪种方案胜出取决于两者在真实世界的性能表现——这也是 RFC 将 LLVM 后端定位为实验、先测量再决策的原因。实施计划Plan of AttackRFC 获批后按以下增量步骤执行将转化为 issue提交 spike 级 PR 粗略演示该变更对应 PR #10442从 VRL 中抽取一个核心库以最小依赖暴露其类型用于减小预编译 bitcode 的体积功能对齐到足够运行首次浸泡测试的程度与当前执行模型对比初步窥见端到端性能定义可选、命名与编译函数参数的约定利用类型信息精化代码生成为每个表达式添加隔离的单元测试添加交叉验证三种执行模式结果的模糊测试调查堆分配是否与 Vector 主二进制采用相同策略并纳入常规性能分析工具覆盖。结语一条可验证的性能探索路线RFC 10517 的价值不仅在于给 VRL 加一个 LLVM 后端更在于它示范了一套严谨的编译器工程方法论先以火焰图与分支预测理论论证收益来源再以最小指令集加 Rust stub 的缝合策略控制实现复杂度最后以分层测试单元/行为/基准/浸泡/模糊/人工审查兜底unsafe风险并明确将首次落地定位为实验。无论该方案最终是否全面上线其对 VRL 执行模型的理解解释器开销、CPU 推测执行、内存安全边界都已成为评估 Vector 数据管道性能的重要参考坐标。读者若想深入源码可从 remap 变换 的runtime: VrlRuntime配置与 行为测试用例 出发继续追踪 VRL 执行模型的演进。【免费下载链接】vectorA high-performance observability data pipeline.项目地址: https://gitcode.com/GitHub_Trending/vect/vector创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表