A high-performance execution engine based on LLVM JIT.
Environment
- llvm 19.1.7
- googletest
- xsimd-13.2.0 (optional)
First, you need to have LLVM 19.1.7; other versions will not work. However, if you can only use other versions, you can modify the exec_engine.cc file to adapt it, and it shouldn't require too many changes.
You can use the following command to check your version.
llvm-config --versioncmake -B build
cmake --build build -jThe build defaults to Release (-O3 -DNDEBUG) when CMAKE_BUILD_TYPE is
not specified. Override with -DCMAKE_BUILD_TYPE=Debug / RelWithDebInfo /
MinSizeRel as needed.
xsimd acceleration is optional. The HAS_XSIMD option accepts three values:
AUTO(default) — detect xsimd during configure; use it if found, silently fall back to the scalar code path otherwise. This lets a fresh clone build on machines without xsimd installed.ON— require xsimd; configure fails with a clear error if the headers are not found. Recommended for CI and release builds.OFF— disable xsimd entirely and always use the scalar code path.
Example:
cmake -B build -DHAS_XSIMD=ON # require xsimd
cmake -B build -DHAS_XSIMD=OFF # disable xsimd
cmake -B build # AUTO (default)After configuring and building, you can install the libraries, public
headers and a CMake package config so that downstream projects can consume
jitfusion via find_package.
cmake -B build
cmake --build build -j
cmake --install build --prefix /path/to/installLayout of the install tree:
<prefix>/
├── include/
│ ├── jitfusion/ # jitfusion public headers
│ └── athena/ # athena public headers
├── lib/
│ ├── libjitfusion.{a,so}
│ ├── libathena.{a,so}
│ └── cmake/jitfusion/
│ ├── jitfusionConfig.cmake
│ ├── jitfusionConfigVersion.cmake
│ └── jitfusionTargets.cmake
└── share/doc/jitfusion/ # LICENSE, README.md
Downstream CMakeLists.txt:
find_package(jitfusion 1.2 REQUIRED)
add_executable(my_app main.cc)
target_link_libraries(my_app PRIVATE jitfusion::jitfusion jitfusion::athena)
# Downstream source uses: #include <jitfusion/exec_engine.h> etc.Notes:
- The installed package records the LLVM libraries reported by
llvm-config --libsat build time as its link interface. Consumers must therefore have the matching LLVM installation discoverable by the linker (e.g. via-L<llvm-prefix>/lib). - Pass
-DJITFUSION_INSTALL=OFFif you embed jitfusion viaadd_subdirectory()from a larger project and do not want install rules.
If you are using Bazel 8 or above, you will need to use the --enable_workspace=true option.
You can build the library with the following command.
bazel build //:jitfusionYou can build the unit test binary with the following command.
bazel build //:jitfusion_testYou can build athena with the following command.
bazel build //:athenaYou can build the athena unit test binary with the following command.
bazel build //:athena_testI considered how many types of nodes are needed to represent a function in the execution engine, and I ultimately abstracted it into 13 types of nodes:
EntryArgumentNode: A node used to obtain the entry argument variables of a function.
ExecContextNode: A node used to obtain the context variables of the execution engine.
ConstValueNode: Scalar constant node.
ConstListValueNode: List constant node.
UnaryOPNode: Unary operation node.
BinaryOPNode: Binary operation node.
FunctionNode: Function node.
IfNode: If condition node.
SwitchNode: Switch condition node.
NoOPNode: No operation node.
IfBlockNode: Block-level if node. Unlike IfNode (which is an expression returning a value), IfBlockNode is a statement-level control flow node that can modify outer-scope variables within its branches. It supports when/elif/else semantics and is used in pipeline mode.
RefNode: Reference node. Used in pipeline mode to reference a named variable defined in a previous statement within the same NoOPNode, avoiding redundant subtree cloning and reducing IR bloat.
OutputNode: Used for optimizing complex scenarios. Data is not returned via a return value but is instead written using a custom store function.
In jitfusion, there are the following data types: u8, u16, u32, u64, i8, i16, i32, i64, float, double, string, u8list, u16list, u32list, u64list, i8list, i16list, i32list, i64list, floatlist, doublelist, stringlist. The type u8 corresponds to uint8_t in C, and so on. The C structures corresponding to all types can be found in the include/type.h file.
For example, generally speaking, the process of an execution flow graph might look like this.
Typically, the initial node is a data loading node. After a series of operations in the middle to obtain the desired value, the process is completed with a data storing node at the end.
Data can be read by combining an EntryArgumentNode with a custom FunctionNode. Writing back is typically done via an OutputNode paired with a store function registered through RegisterStoreCFunc (see the Optimize section below). You can refer to test/entry_argument_node_test.cc for more details. For example:
using namespace jitfusion;
int32_t LoadValue(void* data, uint32_t i) { return reinterpret_cast<int32_t*>(data)[i]; }
int main() {
std::unique_ptr<FunctionRegistry> func_registry;
FunctionRegistryFactory::CreateFunctionRegistry(&func_registry);
FunctionSignature sign("load", {ValueType::kPtr, ValueType::kI32}, ValueType::kI32);
func_registry->RegisterReadOnlyCFunc(sign, reinterpret_cast<void*>(LoadValue));
auto args_node = std::unique_ptr<ExecNode>(new EntryArgumentNode);
auto index_node = std::unique_ptr<ExecNode>(new ConstantValueNode(1));
std::vector<std::unique_ptr<ExecNode>> load_func_args;
load_func_args.emplace_back(std::move(args_node));
load_func_args.emplace_back(std::move(index_node));
auto load_func_node = std::unique_ptr<ExecNode>(new FunctionNode("load", std::move(load_func_args)));
ExecEngine exec_engine;
auto st = exec_engine.Compile(load_func_node, func_registry);
if (!st.ok()) {
std::cerr << st.ToString() << std::endl;
return 1;
}
RetType result;
std::vector<int32_t> data_list = {100, 200, 300, 400};
st = exec_engine.Execute(data_list.data(), &result);
if (!st.ok()) {
std::cerr << st.ToString() << std::endl;
return 1;
}
}The EntryArgumentNode always returns a pointer value, which is the input parameter passed to the execution engine's Execute function. By forwarding this pointer to a custom function, you can perform arbitrary operations on the input. If you want to achieve maximum performance, you can implement the codegen function. You can refer to the implementations in the src/function directory for guidance.
The intermediate processes can all be converted into corresponding op nodes, function nodes, condition nodes, etc. Additionally, there are usually multiple execution flows within a single task, and these flows may use the same variables. To achieve maximum optimization, you can have all the store nodes ultimately point to a NoOP node, allowing LLVM to perform the optimization for you.
If you have many small expressions that all operate on the same inputs, you can compile them together into a single LLVM module with BatchCompile. All functions share the same JIT engine, so they use fewer memory pages and are slightly faster to initialize overall than compiling each expression in its own ExecEngine.
std::vector<std::unique_ptr<ExecNode>> nodes;
nodes.emplace_back(MakeExpr1());
nodes.emplace_back(MakeExpr2());
nodes.emplace_back(MakeExpr3());
ExecEngine exec_engine;
auto st = exec_engine.BatchCompile(nodes, func_registry);
// Execute a single one by index:
RetType r0, r1, r2;
exec_engine.ExecuteAt(0, entry_args, &r0);
exec_engine.ExecuteAt(1, entry_args, &r1);
exec_engine.ExecuteAt(2, entry_args, &r2);
// Or execute all of them in one call (results vector is resized automatically):
std::vector<RetType> results;
exec_engine.ExecuteAll(entry_args, &results);
// For batch-compiled functions whose root is a NoOPNode (void return type),
// use the overloads that take a `void*` result pointer instead.Each expression may have its own return type; use GetBatchFunctionReturnType(index) to introspect.
It is more recommended to use this interface.
// Applicable to more complex scenarios, users need to use an output node and a custom store function to write data,
// and it will not return data from the root node. and root node must be the NoOpNode.
Status Execute(void* entry_arguments, void* result);This function does not return data through the root node; instead, the user provides a pointer for writing, which can be obtained through the OutputNode. This function requires that the root node be the NoOpNode.
It is recommended to divide custom functions into two categories: read-only functions and store functions. Read-only functions can either be data loading functions or computation functions that generate an intermediate variable. It is not recommended for computation functions to directly modify parameter variables; instead, they should return the computation result by generating a new variable. In this case, you can set a read-only attribute for your function, as shown in the code below.
uint32_t LoadU32(void* entry_arguments, int32_t index) {
auto* args = reinterpret_cast<uint32_t*>(entry_arguments);
return args[index];
}
FunctionSignature sign("load", {ValueType::kPtr, ValueType::kI32}, ValueType::kU32);
EXPECT_TRUE(func_registry->RegisterReadOnlyCFunc(sign, reinterpret_cast<void*>(LoadU32)).ok());When using store functions, it is recommended to set the OutputNode as a corresponding attribute, similar to the code below. The third parameter of RegisterStoreCFunc is used to tell jitfusion which argument is the OutputNode.
int32_t StoreF32(void* output, int32_t index, float value) {
reinterpret_cast<float*>(output)[index] = value;
return 0;
}
FunctionSignature sign("store", {ValueType::kPtr, ValueType::kI32, ValueType::kF32}, ValueType::kI32);
EXPECT_TRUE(func_registry->RegisterStoreCFunc(sign, reinterpret_cast<void*>(StoreF32), 1).ok());If you find that you have significant overhead in memory allocation, you can perform pooling operations on ExecContext and pass it in using the function below during execution. This way, you can avoid repeatedly allocating memory.
Status Execute(ExecContext& exec_ctx, void* entry_arguments, RetType* result);
Status Execute(ExecContext& exec_ctx, void* entry_arguments, void* result);Between two calls that reuse the same ExecContext, remember to invoke exec_ctx.Clear() — this resets the arena and clears any errors carried over from the previous call.
ExecEngine accepts an ExecEngineOption struct at construction time. All fields have sensible defaults; tweak them if the defaults don't fit your workload.
| Field | Default | Purpose |
|---|---|---|
const_value_arena_alloc_min_chunk_size |
4096 |
Minimum chunk size (in bytes) for the engine-owned arena that backs constant values embedded in the IR. Raise it if you embed large constant lists / strings and want to avoid chunk churn during compilation. |
exec_ctx_arena_alloc_min_chunk_size |
4096 |
Minimum chunk size for the per-execution arena created when you call an Execute* overload that does not take an explicit ExecContext. Ignored for the ExecContext& overloads (the caller owns the arena). |
dump_ir |
false |
When true, the fully optimized LLVM IR text is captured during Compile / BatchCompile and made available via GetIRCode(). Useful for debugging; avoid enabling in production because serializing a large Module to text is expensive. |
fp_math_mode |
FPMathMode::kFast |
Floating-point semantics requested from the JIT backend. kFast enables FMA fusion and -ffast-math-style algebraic rewrites (1.3x - 2x faster on FP-heavy list kernels). Switch to kStrict if you need bit-for-bit IEEE-754 reproducibility (finance / risk / regression tests). |
Example:
ExecEngineOption opt;
opt.fp_math_mode = FPMathMode::kStrict;
opt.dump_ir = true;
ExecEngine exec_engine(opt);The library is designed around a "compile once, execute from many threads" pattern.
Compile()/BatchCompile()are not thread-safe. Call them from exactly one thread and finish them before anyExecute*call.- After a successful compile, the
Execute*/ExecuteAt*/ExecuteAll*overloads are safe to invoke concurrently from multiple threads on the sameExecEngine— as long as each thread supplies its ownExecContext. - A single
ExecContextmust never be shared across threads. - The
Execute*overloads that do not take an explicitExecContextinternally construct a fresh one on every call. They remain thread-safe but pay per-call allocation cost; prefer theExecContext&overloads on hot paths.
Recommended pattern for parallel execution: one shared ExecEngine plus one ExecContext per worker thread.
- If you need to allocate memory that you cannot manage yourself and require the execution engine to manage it for you, you need to use the ExecContextNode. The ExecContext structure corresponding to ExecContextNode contains an arena. By using it to allocate memory, the memory will be automatically released when the execution is complete.
You can refer to test/exec_context_node_test.cc for more details. For example:
using namespace jitfusion;
U32ListStruct CreateU32List(void* ctx) {
auto* exec_ctx = reinterpret_cast<ExecContext*>(ctx);
U32ListStruct u32_list;
u32_list.data = reinterpret_cast<uint32_t*>(exec_ctx->arena.Allocate(sizeof(uint32_t) * 4));
u32_list.data[0] = 1;
u32_list.data[1] = 2;
u32_list.data[2] = 3;
u32_list.data[3] = 4;
u32_list.len = 4;
return u32_list;
}
int main() {
std::unique_ptr<FunctionRegistry> func_registry;
FunctionRegistryFactory::CreateFunctionRegistry(&func_registry);
FunctionSignature sign("create_u32_list", {ValueType::kPtr}, ValueType::kU32List);
func_registry->RegisterReadOnlyCFunc(sign, reinterpret_cast<void*>(CreateU32List));
auto args_node = std::unique_ptr<ExecNode>(new ExecContextNode);
std::vector<std::unique_ptr<ExecNode>> create_func_args;
create_func_args.emplace_back(std::move(args_node));
auto create_func_node = std::unique_ptr<ExecNode>(new FunctionNode("create_u32_list", std::move(create_func_args)));
ExecEngine exec_engine;
auto st = exec_engine.Compile(create_func_node, func_registry);
if (!st.ok()) {
std::cerr << st.ToString() << std::endl;
return 1;
}
RetType result;
st = exec_engine.Execute(nullptr, &result);
if (!st.ok()) {
std::cerr << st.ToString() << std::endl;
return 1;
}
}Currently supported function documentation: function.md
- athena: An execution engine utilizing DSL in combination with jitfusion. You can find more details in the athena directory.
