Important
Angelsea is alpha quality software.
Angelsea is a JIT compiler for AngelScript written in C++20 which leverages the lightweight MIR JIT runtime and its C11 compiler.
We have a test suite tested in CI and we test the JIT compiler against a test development build of KAG.
Angelsea is still in a bit of a "move fast, break things" phase, but the main
branch should generally be reasonably stable.
Compared to the BlindMindStudios JIT,
we obtain +0~40% runtime performance in a real world application, but YMMV.
If you have results to share, we're interested!
Compared to the BMS JIT, Angelsea may have higher JIT compile times and memory use, however:
- Lazy compilation is enabled by default.1
- Asynchronous compilation can be enabled using
Jit::SetCompileCallback
.2 - Huge functions (~thousands of LoC) are skipped by default.3
OS | Architecture | ABI | Tested compilers | Notes | |
---|---|---|---|---|---|
✅ | Linux | x86-64 | gcc | gcc, clang | Tested on real app |
✅ | Windows | x86-64 | MinGW | MinGW gcc | Tested on real app |
✅ | Windows | x86-64 | MSVC | cl | Not tested on real app |
Linux | aarch64 | gcc | clang | Not tested on real app, considered experimental | |
macOS | aarch64 | Apple | Apple clang | Not tested on real app, considered experimental | |
❌ | macOS | x86-64 | Apple | Apple clang | Fails CI. Voice interest if you would like it fixed. See #3 |
❌ | — | 32-bit x86 | — | Not supported by MIR. Please use BlindMindStudio's JIT | |
❓ | Linux | riscv64 | gcc | Supported by MIR, but not tested. | |
❓ | Linux | ppc64le | gcc | Supported by MIR, but not tested. | |
❌ | Linux | s390x | gcc | Supported by MIR, but big-endian is not supported by angelsea. |
Start by cloning the repository itself:
git clone https://github.com/asumagic/angelsea.git
cd angelsea
Or, if you want to vendor it as a Git submodule to your repository:
cd whatever/vendor/directory/
git submodule add https://github.com/asumagic/angelsea.git
cd angelsea
Example project: example/hello/
Angelsea has hard dependencies on:
- AngelScript v2.37.0+(*)
- fmt,
- MIR v1.0(**)
(*): The version requirement stems from the use of the asIJITCompilerV2
interface.
(**): We require a downstream fork for now,
see rationale further below.
It has optional dependencies on:
Note
In theory, if you want to avoid building via CMake, you could pull Angelsea into your trunk to avoid the build process, and you would just have to ensure the include directories are right. We do not test or support this usecase.
By default, these dependencies are vendored via git submodules.
When you add Angelsea as a CMake subdirectory, you can also use the targets it
provides for those libraries to link against them.
If you use CMake to build your project, you can choose to use the AngelScript
version we vendor for your own project and link against the asea_angelscript
target.
If you want to provide any of dependencies yourself (e.g. you also use them and don't want to rely on Angelsea building it), see the optional step.
Warning
As an user, you should know that upstream MIR has not seen activity in a year. I provide a downstream fork of MIR to work around specific problems, and we hope the defaults to be fully stable.
The contents of the fork is:
- The load/store optimizations of GVN pass broke on various occasions with our
generated code. It just seems to intensely dislike the constant AS stack back
and forth we are doing.
We worked around some issues (see below), but it is frankly more trouble than
it is worth.
Thus we moved it to a "-O3" level; and we default to
config.mir_optimization_level = 2
, and strongly discourage changing this. - Solve Integer sign-extension miscompile
(workaround if using upstream: set
config.mir_optimization_level = 1;
) - Solve Jump optimization can cause use-after-free when using label references
(workaround if using upstream: set
config.mir_optimization_level = 1;
) - Solve high memory usage: implemented a hack; see
config.hack_mir_minimize
(defaults to true)
git submodule update --init --recursive vendor/angelscript
git submodule update --init --recursive vendor/mir
git submodule update --init --recursive vendor/fmt
# if you want to run angelsea tests
git submodule update --init --recursive tests/vendor/*
TODO: the current steps work for static lib builds, but won't for dynamic libs and doesn't work for tests, and it should provide a way to provide the paths to built dependencies in that case.
Pass to CMake:
-DASEA_ANGELSCRIPT_SYSTEM=1
-DASEA_ANGELSCRIPT_ROOT=/path/to/angelscript/sdk/
(defaults tovendor/angelscript/sdk
)
Either:
- Pass
-DASEA_FMT_SYSTEM=1
to CMake to rely onfind_package(fmt)
. - Pass the following to point headers to a known directory:
-DASEA_FMT_SYSTEM=1
-DASEA_FMT_EXTERNAL=1
-DASEA_FMT_ROOT=/path/to/fmt
(defaults tovendor/fmt
)
Pass to CMake:
-DASEA_MIR_SYSTEM=1
-DASEA_MIR_ROOT=/path/to/mir/
(defaults tovendor/mir
)
mkdir build
cd build
cmake .. -DCMAKE_BUILD_TYPE=Release
cmake --build .
On Linux anyway, the resulting library will be available as
build/libangelsea.a
, which you can now link against in your build. For
vendored dependencies, make sure you link against them:
build/libasea_mir.a
build/vendor/fmt/libfmt.a
(libfmtd.a
in debug)build/vendor/angelscript/sdk/angelscript/projects/cmake/libangelscript.a
If you use CMake to build your project, you can do the following:
add_subdirectory(path/to/angelsea)
target_link_libraries(yourexecutable PRIVATE angelsea)
This will automatically build any vendored dependencies. You can also achieve
the steps in 1b with this setup by adding the following BEFORE the
add_subdirectory
step:
set(ASEA_FMT_SYSTEM OFF CACHE BOOL "")
Note that cache variables tend to be sticky; clear the workspace if the build errors are confusing.
We implement the asIJITCompilerV2
JIT interface.
Enabling the JIT engine amounts to:
#include <angelsea.hpp>
// ...
engine->SetEngineProperty(asEP_INCLUDE_JIT_INSTRUCTIONS, true);
engine->SetEngineProperty(asEP_JIT_INTERFACE_VERSION, 2);
engine->SetEngineProperty(asEP_BUILD_WITHOUT_LINE_CUES, true);
// example config
angelsea::JitConfig config;
angelsea::Jit jit(config, *engine);
assert(engine->SetJITCompiler(&jit) >= 0);
It is strongly encouraged to check JitConfig
tunables
and to adjust it accordingly for your application.
The defaults are overall tuned for the needs of a semi-heavily scripted
commercial application.
For instance, to disable lazy compilation:
angelsea::JitConfig config {
.triggers = {
.hits_before_func_compile = 0,
}
};
It is also possible to specify some per-function JIT tunables with a callback.
For an example that leverages script builder metadata, see tests/configtest.cpp
.
Warning
The JIT compiler itself is not currently thread-safe with regards to multithreaded AngelScript contexts or engines.
As of writing:
- Angelsea is licensed under the BSD-2-Clause license.
- MIR is licensed under the MIT license.
- AngelScript is licensed under the zlib license.
- libfmt is licensed under the MIT license (with optional exception).
Those all have very similar permissive terms, but remember to give attribution in source and binary distributions!
AngelScript uses a bytecode virtual machine, so a JIT compiler has to take this
bytecode and translate some or all of it to native code.
Bytecode functions have one or more "JIT entry points" from which a JIT function
can start. This effectively allows JIT functions to drop down to the VM for
unsupported instructions, but still making it possible to be called again.
Angelsea compiles AngelScript bytecode to C functions. c2mir compiles that to MIR in memory, and MIR ultimately emits machine code.
Lazy compilation works by giving AngelScript dummy JIT functions that merely count how often they were called. Once the configurable threshold is reached, compilation will be triggered, potentially asynchronously.
Currently, the asCALL_GENERIC
calling convention is the best supported (though
some things are not covered).
There is experimental support for the native calling convention (on by default). Many cases should be covered, but some are omitted.
Supported system calls are much faster with the JIT with either the generic or native calling convention.
There is also an experimental "stack elision" optimization
(experimental_stack_elision
) that can improve the native calling convention
performance by bypassing stack pushes entirely when possible.
As of writing, asIScriptGeneric
is not a particularly efficient interface (see
below), but when the time comes, we may try to contribute back design
improvements for it.
Elaborating on the generic calling convention
asIScriptGeneric
is a rather slow interface by design. You might think that
the native calling convention (e.g. asCALL_CDECL
and all) might be faster, but
it's not actually obvious why that would be true. In stock AngelScript (i.e. no
JIT), the native convention is actually fairly slow as it needs to go through
native shims that are fairly complex due to needing to handle arbitrary
signatures, resulting in a rather impressive pile of C++ ABI emulation for each
unique platform.
AngelScript tries to be clever about this in some cases (e.g. asBC_Thiscall1
),
but native calls otherwise carry more overhead than you might think.
In essence, the native calling convention actually fundamentally has to do more work than the generic calling convention! In both cases, all arguments live on the AS stack either way, and have to be pulled out of it sooner or later. Whether AS itself or your app need to look up those arguments doesn't really matter.
That being said, the generic calling convention is actually somewhat slow
out-of-the-box in AngelScript, but this is not an unsolvable problem.
The callee (your code) needs to do, for every argument or other call to the
generic, a virtual function call. This is usually not outrageously expensive,
but it prevents inlining despite the functions being (overall) not very
expensive each.
Worse, some functions also do a lot of work and e.g. need to look up the
script function type information and then loop over arguments,
for every argument lookup you do.
When automatically wrapping functions for the generic calling convention, it
actually is feasible to hack a lot of that complexity away by directly poking at
asCGeneric
.
Angelsea is able to make generic calls much faster by doing a lot less work than
the AngelScript VM, though, largely thanks to the fact we can generate code
tailored for each function, skipping steps and branches we know are
unnecessary. It also has some hacks like pooling the asCGeneric
objects at
function level to skip reinitialization of fields that never change.
This allows angelsea to give a ~5x performance uplift for a 1 million generic
calls benchmark.
It actually makes a lot of sense to take this "lazy" approach.
- Our bytecode2c compiler generates standard (enough) C code. Entry points use
the
asJITFunction
signature. We also try to resolve all references to pointers baked in the bytecode and forward detailed information via a callback.
Fundamentally, nothing about the codegen is really specific to JIT or even to MIR... - ... So nothing really prevents you from AOT compiling AngelScript code to C
using bytecode2c for release builds, which might be interesting for consoles and
certain platforms (e.g. iOS) which notoriously ban JITs. You still would need
the interpreter (if only because Angelsea will fallback to it), but in theory,
all you would need to do is use bytecode2c with appropriate symbol callbacks,
and add some glue code by implementing your own
asIJITCompiler
that map JIT entry points to C++. (This isn't really an easy task yet, and there are still some prerequisite refactors before this is viable, but the overall design fundamentally allows it.) - In theory, it enables the ability to inject native C code. Because MIR is capable of inlining functions, they could be made to implement performance-sensitive things like some array calls and avoid a native function call. (This would still be feasible even if we generated MIR directly, but it is an advantage of using C.)
- Generated C code is a lot like the VM code. This is fairly quick to do and is
surprisingly human-readable even to people with no prior compilation experience.
- We do take more care with strict aliasing rules than AS does, though.
- The fact we can just speak C greatly simplifies interfacing with script engine structures. Dealing with the C++ ABI (such as for certain native function calls, or to call virtual AS engine functions) would be annoying, but we can work around it to some extent.
- In theory, it does mean we can leverage native tooling such as AddressSanitizer and static analysis to debug JIT bugs.
- BlindMindStudio's JIT compiler served its purpose, but it is unmaintained (although people have forked it), has way suboptimal and x86-only code generation, falls back to the interpreter often (in our usecase), and contains subtle bugs.
- I previously worked on asllvm. It was a functional proof-of-concept (in fact some of angelsea's infrastructure originates from it), but it was way too large in scope. It more or less intended to take over the entire interpreter, which meant total coverage of all of AS' low-level semantics before it was any useful. This is doubly a problem, because to speak the C ABI -- let alone the C++ ABI -- you basically need to do it yourself AFAIK. Besides, LLVM is huge, breaks API compatibility on almost every major update, and is rather unreasonable for an embeddable language.
- Hazelight's UnrealEngine-AngelScript is way more involved than I thought and includes a C++ AOT transpiler. To my understanding it is tightly coupled to that project and its fork of AS, and does not feature an actual JIT compiler.
- There is an AOT compiler, but it is unmaintained and I don't know what it is worth nowadays. Due to its approach, if AOT is fine for you, it might actually make sense to use.
To my knowledge, there is no other (public...) project of that kind.
I was looking for puns with "mir" or "mimir" and miserably failed, so all you get is Angel→C, which seems memorable enough.
Footnotes
-
This effectively means that cold functions can be entirely ignored. In real world applications, this can slash down the amount of compiled functions very significantly. This is especially true because the
#include
mechanism is prone to leaving a bunch of functions effectively unused. ↩ -
This is not a fully multi-threaded process, as only one thread will run codegen at once. However, most of the process remains asynchronous, so it is largely suitable for real-time apps. It may also improve script loading times compared to the BMS JIT. ↩
-
MIR isn't particularly designed for huge functions and as such, memory and compute costs can become ridiculous (gigabytes of RAM). We skip compilation past a certain bytecode size as a heuristic, which you can adjust. ↩