Skip to content

asumagic/angelsea

Repository files navigation

angelsea

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.

Current status

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.

Performance

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!

Compiler performance

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

Supported platforms

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.

Clone & Build

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:

(*): 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.

1. Use vendored versions

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/*

Optional: Provide specific dependencies yourself

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.

AngelScript

Pass to CMake:

  • -DASEA_ANGELSCRIPT_SYSTEM=1
  • -DASEA_ANGELSCRIPT_ROOT=/path/to/angelscript/sdk/ (defaults to vendor/angelscript/sdk)

fmt

Either:

  • Pass -DASEA_FMT_SYSTEM=1 to CMake to rely on find_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 to vendor/fmt)

MIR

Pass to CMake:

  • -DASEA_MIR_SYSTEM=1
  • -DASEA_MIR_ROOT=/path/to/mir/ (defaults to vendor/mir)

2a. Build the standalone library statically

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

2b. Include as a CMake submodule

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.

Use

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.

License notice

As of writing:

Those all have very similar permissive terms, but remember to give attribution in source and binary distributions!

Documentation

Q&A

How does the JIT compiler work?

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.

What is the best supported calling convention?

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.

Why generate C instead of MIR?

It actually makes a lot of sense to take this "lazy" approach.

  1. 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...
  2. ... 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.)
  3. 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.)
  4. 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.
  5. 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.
  6. In theory, it does mean we can leverage native tooling such as AddressSanitizer and static analysis to debug JIT bugs.

What about other JIT compilers?

  • 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.

Why this name?

I was looking for puns with "mir" or "mimir" and miserably failed, so all you get is Angel→C, which seems memorable enough.

Footnotes

  1. 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.

  2. 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.

  3. 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.

About

AngelScript JIT via C→MIR conversion

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published