jitfusion,一个完全基于 LLVM JIT 实现的执行引擎

LLVM 是一个用于构建编译器和相关工具的开源项目。它最初是由克里斯·拉特纳(Chris Lattner)在2000年作为他的硕士学位项目在伊利诺伊大学厄巴纳-香槟分校(UIUC)开始的。LLVM 的名字最初是 “Low Level Virtual Machine” 的缩写,但现在它已经不再是一个缩写,而是一个独立的名称。它提供了强大的代码分析和优化,已经是许多现代编译器的基础。JIT 指的是即时编译,其主要思想是在运行时生成机器指令,让程序可以直接运行这些指令而无需额外操作。本文主要是介绍一下 LLVM JIT 的优势和使用 LLVM JIT 实现的一个执行引擎 jitfusion 的实现。

什么是JIT

首先我们来回想一下程序是怎么运行的。

有一串很简单的代码。

int sum(int a, int b) { return a + b; }

它的汇编如下。

	.section	__TEXT,__text,regular,pure_instructions
	.build_version macos, 14, 0	sdk_version 15, 0
	.globl	__Z3sumii                       ; -- Begin function _Z3sumii
	.p2align	2
__Z3sumii:                              ; @_Z3sumii
	.cfi_startproc
; %bb.0:
	sub	sp, sp, #16
	.cfi_def_cfa_offset 16
	str	w0, [sp, #12]
	str	w1, [sp, #8]
	ldr	w8, [sp, #12]
	ldr	w9, [sp, #8]
	add	w0, w8, w9
	add	sp, sp, #16
	ret
	.cfi_endproc
                                        ; -- End function
.subsections_via_symbols


进程的本质实际上是将一系列机器指令(这些指令实际上是一串01数据,汇编语言是其更高级的抽象)加载到进程空间的 .text 段中,然后依次执行这些指令的结果。

而 JIT(即时编译)则是在运行时将这些机器指令编译好,并放置在进程空间的某个位置,然后获取到这些指令的入口地址,通常是一个函数指针。以一个 sum 函数为例,假设通过 JIT 编译后得到了一个函数指针 s。在执行时,直接调用 s(1, 2) 就可以得到结果,这与静态编译的使用方式并无区别。

LLVM JIT的优势

一个显而易见的优势是可以利用 LLVM 的优化能力。众所周知,Clang 的优化非常强大,而 Clang 正是由 LLVM 提供的一个编译器前端。具体来说,LLVM 可以进行以下优化:

* 常量折叠:将编译时已知的常量表达式计算出来。
* 死代码消除:移除不会被执行的代码。
* 循环优化:包括循环展开、循环分割等。
* 内联:将小函数的代码直接插入到调用点,减少函数调用的开销。
* CPU 指令集优化:可以用 CPU 特有的指令集做优化,比如向量化操作。

不过,这些优化仅限于 JIT 编译时内部可见的函数。如果编译单元依赖了外部的函数,LLVM 只能看到其声明,此时无法进行更深入的优化。

相比解释执行(例如常见的 DAG 实现),JIT 编译的一个显著优势是编译完成后可以重复利用,而 DAG 每次运行都需要重新遍历甚至重新构建图。

除此之外,使用 JIT 相比 DAG 实现还有其他优化优势。

Type Inference

通常情况下,自行实现的 DAG(有向无环图)会在上层封装一个通用的数据结构,但底层必须兼容不同的数据类型。因此,在算子内部必然会存在类似于以下的操作。以标准库的 std::variant 为例:

struct ExampleStruct {
  ValueType operator()(int8_t /*v*/) {}
  ValueType operator()(int16_t /*v*/) {}
  ValueType operator()(int32_t /*v*/) {}
  ValueType operator()(int64_t /*v*/) {}
  ValueType operator()(uint8_t /*v*/) {}
  ValueType operator()(uint16_t /*v*/) {}
  ValueType operator()(uint32_t /*v*/) {}
  ValueType operator()(uint64_t /*v*/) {}
  ValueType operator()(float /*v*/) {}
  ValueType operator()(double /*v*/) {}
  ValueType operator()(const std::string& /*v*/) {}
};

using ExampleType =
    std::variant<int8_t, int16_t, int32_t, int64_t, uint8_t, uint16_t, uint32_t, uint64_t, float, double, std::string>;

void Example(ExampleType &x) {
	std::visit(ExampleStruct(), x);
}

需要根据实际的类型转换数据结构并进入相应的分支。这样不仅会增加额外的跳转指令,还会妨碍编译器进行更深入的优化。而使用 LLVM JIT 时,类型可以在编译期确定,不需要再进行分支跳转,LLVM 也能够为你进行最大程度的优化。同时因为这个特性,在编译期还能帮你校验类型是否正确。

Short-Circuit Evaluation

通常情况下,DAG(有向无环图)是基于拓扑排序来实现的。在拓扑排序中,只有入度为0的节点才可以执行,这意味着所有依赖节点都需要完成后,当前节点才能执行。请思考以下这种情况:

请添加图片描述

假设一个 if 节点依赖于三个节点,其中 condition 是一个条件操作,返回 true 或 false。op1 是在 condition 为 true 时执行的节点,而 op2 则是在 condition 为 false 时执行的节点。然而,由于无法提前知道 condition 的结果,因此需要同时计算 op1 和 op2 的结果,然后再根据 condition 的结果选择其中一个。这会导致额外计算一个分支。当然,这并不是无法避免的。可以通过将拓扑排序的实现修改为深度优先搜索(DFS),根据节点的类型执行不同的遍历方式来解决这个问题。使用 JIT 时,可以先判断 condition,再执行相应的路径,从而实现短路求值。

Inlining & Register Allocation Optimization

在执行 DAG 图时,由于图是动态的,编译器无法预先知道节点之间的运行关系,因此无法进行优化。因此,每个节点在执行前需要重新加载其依赖节点的数据,并且在执行完后需要将结果写入内存的某个位置,以供下一个节点使用。如果节点都是单值运算,这通常会引入 CPU 和内存之间的 I/O 瓶颈。例如,假设需要计算 (a + b) * b,其中加法和乘法是两个不同的操作符。那么,对应需要执行的机器指令可能如下所示:

load a
load b
add a  b
store a
load a
load b
mul a b
store a

在这种情况下,你需要执行 4 次内存读取操作和 2 次内存写入操作,而实际的计算操作只有 2 次。然而,CPU 的速度远远快于内存,这样就很可能会触发 I/O 瓶颈。当然,如果操作符不是单值计算,情况可能会有所改善。

使用 LLVM JIT 时,当你通过 LLVM 的 API 实现相应的函数时,LLVM 可以利用其强大的分析能力对程序进行充分优化。通过函数内联,LLVM 可以充分利用 CPU 寄存器,使得你实现的操作符无需反复读写内存。优化后的机器指令可能如下所示:

load a
load b
add a b
mul a b
store a

这样一来,你只需要进行 2 次内存读取操作和 1 次内存写入操作即可完成任务。

尽管上述优化带来了显著的性能提升,但实际上要享受这些优化也存在一些缺点。最显著的缺点之一是调试的难度大大增加。此外,为了实现最大的优化,函数实现需要完全通过 API 来完成。如果函数实现是外部链接到 C 函数,优化器将无法获取到函数的具体实现,只能基于函数声明进行优化。

jitfusion实现

jitfusion 是完全基于 LLVM JIT 实现的一个执行引擎。在 jitfusion 里,最重要的三个组件是 ExecNode,FunctionRegistry,ExecEngine。

ExecNode

ExecNode 用于表示执行流中的每一个执行节点。

首先,让我们思考一下,需要多少种类型的节点才能完整地描述一个函数。请看下面这个函数,它基本上可以涵盖所有可能的情况。

class Test {
public:
int test1(int a) {
	a = a + 1;
	a = test2(a, b);
	if (a > 0) {
		a = !a
	}
}

private:
  int b;
}

为了能够表达这样的一个函数,我设计了以下几种类型的节点。

* EntryArgumentNode:入参节点,用于获取由使用者传递的入口参数。对应上面用于获取 a 变量。
* ExecContextNode:执行引擎的上下文节点,用于获取引擎执行时固定传递的一个上下文变量。对应上面用于获取 b 变量
* ConstValueNode:常量节点,用于表示常量。对应上面 a + 1 中的 1。
* ConstListValueNode:常量节点,用于表示列表类型的常量。
* UnaryOPNode:一元运算节点。对应上面 !a 的操作。
* BinaryOPNode :二元运算节点。对应上面 a + 1 的操作。
* FunctionNode:函数节点。对应上面 test2(a, b) 的调用。
* IfNode:if 节点。对应上面的 if 语句。
* SwitchNode:switch 节点,用于表达更复杂的 if-else 逻辑。
* NoOPNode:无操作节点,用于表示无操作。

FunctionRegistry

FunctionRegistry 的作用是管理函数节点的实现,用户可以通过 FunctionRegistry 注册自己实现的函数。在设计上,函数通过一个 FunctionSignature 结构来描述其声明。其结构的大概如下:

struct FunctionSignature {
  std::string func_name_;
  std::vector<ValueType> param_types_;
  ValueType ret_type_;
};

该结构大致包括:函数名、参数类型和返回类型。同时,jitfusion 对函数重载的处理方式与 C++ 相同,采用函数名加参数类型来识别一个函数。这意味着函数名相同但参数类型不同是可以接受的,但函数名相同且参数类型相同而返回类型不同则是不被接受的。

函数内容则使用 FunctionStructure 来描述,其结构如下:

struct FunctionStructure {
  FunctionType func_type;
  void *c_func_ptr;
  std::function<llvm::Value *(const FunctionSignature &, const std::vector<llvm::Type *> &,
                              const std::vector<llvm::Value *> &, IRCodeGenContext &)>
      codegen_func;
};

func_type 仅支持两种类型:LLVMIntrinsicFunc 和 CFunc。对于 LLVMIntrinsicFunc 类型,需要实现 codegen_func,利用 LLVM 的 API 实现整个函数,以最大程度地利用 LLVM 的优化。而 CFunc 则直接传递一个 C 函数指针,jitfusion 会帮助你将其映射到编译好的模块的符号表中。

ExecEngine

ExecEngine 是 jitfusion 的执行引擎类,对外提供两个接口:Compile 和 Execute。

  Status Compile(const std::unique_ptr<ExecNode>& exec_node, const std::unique_ptr<FunctionRegistry>& func_registry);
  Status Execute(void* entry_arguments, RetType* result);

Compile 接口通过传入编排好的 ExecNode 根节点以及一个 FunctionRegistry 对象,实时编译一个模块并将其存储在内存中。用户随后可以通过 Execute 接口传入自己的参数并执行该模块以获得结果。

例子

通过 ExecNode、FunctionRegistry 和 ExecEngine 这三者的抽象,基本上可以适配大部分的场景。举个简单的例子,假设你有两个 std::vector<uint32_t> 类型的变量 a 和 b,你希望将 a 中的每个数值加 1 后写入到 b 中。其执行流程图大致如下:

请添加图片描述

实际构建执行引擎的代码如下:


#include <memory>
#include "exec_engine.h"
#include "exec_node.h"
#include "function_registry.h"
#include "type.h"

struct TestStruct {
  std::vector<uint32_t> a;
  std::vector<uint32_t> b;
};

jitfusion::LLVMComplexStruct load_u32s(int64_t entry_args) {
  auto *text_struct = reinterpret_cast<TestStruct *>(entry_args);
  jitfusion::LLVMComplexStruct ret{reinterpret_cast<int64_t>(text_struct->a.data()),
                                   static_cast<uint32_t>(text_struct->a.size())};
  return ret;
}

jitfusion::LLVMComplexStruct add(jitfusion::LLVMComplexStruct a, int64_t exec_context) {
  auto *data = reinterpret_cast<uint32_t *>(a.data);
  jitfusion::LLVMComplexStruct b;
  auto *new_data = reinterpret_cast<uint32_t *>(
      reinterpret_cast<jitfusion::ExecContext *>(exec_context)->arena.Allocate(a.len * sizeof(uint32_t)));
  b.len = a.len;
  for (uint32_t i = 0; i < a.len; i++) {
    new_data[i] = data[i] + 1;
  }
  b.data = reinterpret_cast<int64_t>(new_data);
  return b;
}

uint8_t store_u32s(int64_t entry_args, jitfusion::LLVMComplexStruct a) {
  auto *text_struct = reinterpret_cast<TestStruct *>(entry_args);
  auto *write_data = reinterpret_cast<uint32_t *>(a.data);
  text_struct->b.insert(text_struct->b.end(), write_data, write_data + a.len);
  return 0;
}

int main() {
  std::unique_ptr<jitfusion::FunctionRegistry> func_registry;
  jitfusion::FunctionRegistryFactory::CreateFunctionRegistry(&func_registry);
  jitfusion::FunctionSignature load_sign("load_u32s", {jitfusion::ValueType::kI64}, jitfusion::ValueType::kU32List);
  jitfusion::FunctionStructure load_func_struct = {jitfusion::FunctionType::kCFunc, reinterpret_cast<void *>(load_u32s),
                                                   nullptr};
  func_registry->RegisterFunc(load_sign, load_func_struct);
  jitfusion::FunctionSignature store_sign("store_u32s", {jitfusion::ValueType::kI64, jitfusion::ValueType::kU32List},
                                          jitfusion::ValueType::kU8);
  jitfusion::FunctionStructure store_func_struct = {jitfusion::FunctionType::kCFunc,
                                                    reinterpret_cast<void *>(store_u32s), nullptr};
  func_registry->RegisterFunc(store_sign, store_func_struct);

  jitfusion::FunctionSignature add_sign("add", {jitfusion::ValueType::kU32List, jitfusion::ValueType::kI64},
                                        jitfusion::ValueType::kU32List);
  jitfusion::FunctionStructure add_func_struct = {jitfusion::FunctionType::kCFunc, reinterpret_cast<void *>(add),
                                                  nullptr};
  func_registry->RegisterFunc(add_sign, add_func_struct);

  auto entry_node1 = std::unique_ptr<jitfusion::ExecNode>(new jitfusion::EntryArgumentNode);
  std::vector<std::unique_ptr<jitfusion::ExecNode>> load_func_args;
  load_func_args.emplace_back(std::move(entry_node1));
  auto load_node =
      std::unique_ptr<jitfusion::ExecNode>(new jitfusion::FunctionNode("load_u32s", std::move(load_func_args)));

  auto exec_ctx_node = std::unique_ptr<jitfusion::ExecNode>(new jitfusion::ExecContextNode);
  std::vector<std::unique_ptr<jitfusion::ExecNode>> add_func_args;
  add_func_args.emplace_back(std::move(load_node));
  add_func_args.emplace_back(std::move(exec_ctx_node));
  auto add_node = std::unique_ptr<jitfusion::ExecNode>(new jitfusion::FunctionNode("add", std::move(add_func_args)));

  auto entry_node2 = std::unique_ptr<jitfusion::ExecNode>(new jitfusion::EntryArgumentNode);
  std::vector<std::unique_ptr<jitfusion::ExecNode>> store_func_args;
  store_func_args.emplace_back(std::move(entry_node2));
  store_func_args.emplace_back(std::move(add_node));
  auto store_node =
      std::unique_ptr<jitfusion::ExecNode>(new jitfusion::FunctionNode("store_u32s", std::move(store_func_args)));

  jitfusion::ExecEngine exec_engine;
  exec_engine.Compile(store_node, func_registry);

  TestStruct text_st;
  text_st.a = {1, 2, 3, 4};
  jitfusion::RetType result;
  exec_engine.Execute(&text_st, &result);
  for (const auto &data : text_st.b) {
    std::cout << data << std::endl;
  }
}

像这样,你只需使用 ExecNode 来表达整个过程,然后通过 ExecEngine 进行编译和执行即可。

结语

目前库已经开源:https://github.com/viktorika/jitfusion。更多细节请看源码,欢迎大家使用。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值