Hot++

Runtime internals

Notes on the JIT backend, threads, closures and debug information. They help people who work on the runtime.

JIT backend

LLVM and Clang must be installed. CMake uses find_package(LLVM) and find_package(Clang) and links clang-cpp and LLVM. CPM does not fetch them.

How it works (src/runtime/jit_backend.cpp):

  1. hotc::translate makes the C++ text. The slot file text stays in memory in the backend object.
  2. clang::createInvocation builds the cc1 command through the driver (so the libstdc++ include paths are right). The flags are -std=c++20 -O1 -fPIC -gline-tables-only, the include dirs, the resource dir and the extra flags. The input file exists only in an InMemoryFileSystem that overlays the real file system. #line makes the diagnostics name the script.
  3. EmitLLVMOnlyAction gives an llvm::Module. The diagnostics go to a string.
  4. One LLJIT (JITLink) lives for the session. Each load makes a new JITDylib and a ResourceTracker, adds the module, runs the static initializers (LLJIT::initialize) and looks up script_register. The Main JITDylib has a DynamicLibrarySearchGenerator for the process. The executable needs ENABLE_EXPORTS (-rdynamic), and it must link the runtime objects that the script code uses.
  5. The keep_alive of the Module owns the generation. When the last copy goes, it runs LLJIT::deinitialize, ResourceTracker::remove and removeJITDylib. It also holds the LLJIT, so a Module can outlive the backend. As in the design, ORC does not track calls in flight. The safety rules are the same as for dlclose.

No file is written and no process starts. tests/integration/backends/jit_backend_tests.cpp checks this: it sets the current directory and TMPDIR to a new empty directory, loads two generations and a bad script, and asserts that the directory is empty and that no child process ran. strace -f on the same test shows one execve (the test itself), only thread clone3 calls and no file opened for writing. The same file tests exceptions (a throw and a catch in JIT code, and a throw that goes through JIT frames to the host), -include-pch, and the life of a generation.

Facts that differ from the first design:

Load times

Machine: 32 threads, Linux 7.2, clang 23.1.3, -O0/-O1 as noted, the page cache is warm. Tool: bench_backends (tools/bench/bench_backends.cpp, std::chrono). The rows below come from an earlier version of the tool, which had a fixed backend, the options --pch and --make-pch and -O1 for the scripts. The current tool tests all backends in one run (see “Run-time cost” below). Times in ms for one load (translate + compile + load) of player_v1.hot (v1) and player_v2.hot (v2). “Cold” is the first load in a new process (a new session). “Warm” is the median of 5 later loads in the same process. Warm backends share one translator session, except in the rows “no cache” (--no-cache). Cold v1 always parses the headers. Cold v2 and all warm loads use the cache, except v1 in the rows “no cache”.

Configuration v1 cold v2 cold v1 warm v2 warm
JIT, -O1 (default), cache 1306 806 812 808
JIT, -O1, no cache 1295 812 1220 809
JIT, -O0, cache 1283 781 786 789
JIT, -O1, -include-pch, cache 622 166 170 166
JIT, -O1, -include-pch, no cache 637 166 586 169
CLANG_FILES, -O0 -g (default), cache 1370 908 913 907
CLANG_FILES, -O1, cache 1394 941 957 947
CLANG_FILES, -O1, no cache 1395 940 1356 940

CLANG_FILES with a PCH (P1, S19). The earlier tool made a PCH only in the JIT backend (--make-pch). The current tool has no PCH option, but --cxxflag still passes -include-pch. For CLANG_FILES, make the PCH with the compiler and pass it through --cxxflag.

clang++ -std=c++20 -fPIC -g -O1 -resource-dir=$(clang++ -print-resource-dir) -I include -I tests/engine \
    -x c++-header pch.hpp -o p.pch
bench_backends --warm 9 --cxxflag -O1 --cxxflag -include-pch --cxxflag $PWD/p.pch
Configuration (S19 run, same machine, other load) v1 cold v2 cold v1 warm v2 warm
CLANG_FILES, -O1, cache 1693 1111 1110 1104
CLANG_FILES, -O1, -include-pch, cache 931 344 357 349

The PCH takes 1.1 s to make, once, and is 13.5 MB. It saves 755 ms of 1104 ms in a warm load (68%). The numbers of this run are higher than the rows above, because the machine had more load. Compare the two rows of one run only.

P1 criterion (design 8.2): under 2 s for one module after a one-line change, fail over 5 s. Result: pass in every configuration, also CLANG_FILES with no PCH (1.1 s). With a PCH: JIT 166 ms and CLANG_FILES 350 ms. The test headers are small. Measure again with the real engine headers.

Before the cache, the translate time was 411 ms in each load. The split of one translate (v2, std::chrono in translate_impl, measured before the change) was: libclang parse of the imports 406 ms, script parser 0.4 ms, visit of the base class 0.5 ms, text emit 2.7 ms. Only the libclang parse is large.

Translate alone (the bench prints it): 406 ms with a new session (miss), 1.3 ms with the same session (hit). The target was 50 ms.

Phases of one JIT load (v2, warm, cache): translate 1.4, compile 782, ORC (code generation, link, initializers, lookup) 19. With -include-pch: translate 1.4, compile 146, ORC 18. A new session parses the headers again, so v1 warm in the rows “no cache” costs 411 ms more.

Threads and the safe point

The tests are in tests/integration/threading/parallel_tick_tests.cpp (suite ParallelTick).

World::TickParallel(dt, threads) ticks the actors on worker threads. The workers start once. They stop when the world ends. Each worker ticks one chunk of the actor list. The main thread waits at a barrier. After all workers reach the barrier, the main thread runs the frame boundary hook. The workers stay idle until the hook ends.

The reload commit runs only in this hook. At that time no thread is in script code. The barrier also orders the memory, so the workers see the result of the commit.

A compile thread can call Loader::request_reload and Loader::reload_pending at any time. These functions take the mutex of the loader. They only validate. They change no live class and no live object. commit_pending takes the same mutex.

The debug check is the counter hot::in_script_tick(). The engine wraps each actor tick in a hot::ScriptTickScope. commit_pending asserts that the counter is zero. It also counts a violation in hot::unsafe_commit_count(). The check exists in debug builds only. With NDEBUG, hot::kTickChecks is false, ScriptTickScope does nothing and the commit has no check.

A class that a new generation drops is removed (design 6.1). Its record stays. Its live objects keep their state. Each slot points to hot::removed_class_handler(), which logs the first call, counts all calls and returns zero. A floating point result of such a call is not defined. The class can come back in a later generation. Loader::stats() and Loader::stats_text() (the script_stats text) list the generations, the pins, the live objects, the classes with their slots and the removed classes.

The sample samples/hot-reload shows the reload path. The test host tests/engine/Harness ticks a world and reloads a script when the file changes (FileWatcher: inotify on Linux, polling else). The tests in tests/integration/reload/ (integration_reload_tests) test this.

The warnings of a reload use readable names: C++ type names (int, std::string) and class names (Hero, not HeroState_g2).

Rules for threads:

Test results: R5_CommitOnlyAtBarrier runs 20 seeds with 1000 actors and 2 workers. R5_RejectedReloadFromOtherThread runs 5 seeds. Both tests pass in the normal, ASan and TSan builds. They also pass with --gtest_repeat=20 in the normal and TSan builds. TSan reports no race. When the lock in request_reload is removed, TSan reports a race. So the test can find this kind of error.

Entry counters: a commit without a frame boundary

Some engines have no frame boundary. For them, the loader has a second mode (design 6.3):

loader.set_sync_mode(hot::SyncMode::EntryCounters);  // default: hot::SyncMode::StopTheWorld

The mode applies to the whole process. Set it when no thread is in script code. The destructor of the Loader sets StopTheWorld again.

How it works:

Cost: in the default mode, each entry reads one global flag (a relaxed atomic load) and takes no branch. Define HOT_NO_ENTRY_COUNTERS to remove the check at compile time. In the EntryCounters mode, each outermost entry does one atomic increment and one atomic decrement of a shared counter.

Limits:

Diagnostics: hot::active_entries(), hot::entry_depth(), hot::commit_window_closed(), hot::commit_window_count() and hot::blocked_entry_count().

The tests are in tests/unit/runtime/entry_counter_tests.cpp and tests/integration/threading/entry_counter_tests.cpp (suite EntryCounters). In the integration test, 4 worker threads call Tick on 64 actors in a loop. Another thread commits 8 reloads at the same time. Each Tick calls the native Util::Probe, and the probe calls the script TakeDamage. The test checks that no call is lost, that no call runs on a half migrated object, and that a nested call during a waiting commit does not deadlock. The unit test opens 2000 commit windows while 4 threads enter.

Closures and unload

An arrow lambda becomes hot::make_closure(GEN, "Class::fn lambda", [=](params) { ... }). It captures by value. this is the pointer __self. The body reads the state pointer again, so a lambda never keeps a pointer to a state block. The result is a hot::Closure<Sig>. It holds one pin of its generation (hot::pin, unpin, pin_count; the counts are atomic). Each call checks the generation. When the generation is not live, the call logs a stale call, counts it (hot::stale_call_count()) and returns a default value. The body does not run. A Closure converts to std::function and to an engine delegate that has Bind(std::function<Sig>). The std::function holds the guarded lambda (hot::detail::GuardedFn: the pin, the guard and the lambda), not the Closure. So a call through it is one indirect call.

World::AddTimer(seconds, fn) stores a one-shot callable. World::Tick fires it before the frame boundary hook and then destroys it. ClearTimers() drops all timers.

The Loader owns all modules. A reload retires the old generation. Loader::frame_boundary() (call it in the hook, after commit_pending) unloads a retired generation when all of these are true: the pin count is zero, a frame boundary passed after the commit, and code_refs is zero. The unload is dlclose (CLANG_FILES) or the removal of the ORC resource tracker (JIT). Loader::stats() gives the active generation, each generation with its state (active, retired, unloaded), its pins and its code_refs, and the number of live objects.

Result of R3: the stale call is logged once and the old body does not run. No ASan error. Result of R6: generation 2 of the chain 1, 2, 3 unloads after its closure is dropped, and later ticks are clean. Generation 1 unloads too (S10). A core module holds the class record, the factory, the thunks and the vtables. The core is a second instance of the same generation code, and it never unloads. Destroy the last copy of a closure on the main thread. The destructor code is in the module.

Debug information

Diagnostics:

Debug information:

What the debugger shows

What the debugger shows:

For runtime header authors:

Editors: