GitHunt
AS

asumagic/angelsea

AngelScript JIT via C→MIR conversion

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.