Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
118 changes: 118 additions & 0 deletions docs/ir-serialization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# IR Serialization: Compiling from an IR File and Skipping Tracing

Nautilus normally traces a C++ function at runtime to derive its IR before compiling it with a backend.
The IR serialization API decouples the two steps: a module's IR can be serialized to a portable text
file in one process (or one run) and compiled directly in another — the tracing frontend is skipped
entirely when loading.

Typical uses:

- **Faster startup**: trace once, persist the IR, and on subsequent runs compile straight from the file.
- **Build-time tracing**: trace in a build step or offline tool and ship only the IR to the process
that compiles and executes it.
- **Debugging and testing**: hand-inspect, modify, or minimize the IR that a backend compiles.

## Producing an IR file

`NautilusModule::serializeIR()` traces all registered functions and returns the IR in the portable
text format instead of compiling an executable:

```cpp
#include <nautilus/Engine.hpp>

using namespace nautilus;

val<int32_t> addOne(val<int32_t> x) {
return x + 1;
}

auto engine = engine::NautilusEngine();
auto module = engine.createModule();
module.registerFunction("addOne", addOne);

std::string irText = module.serializeIR();
// Persist it wherever convenient, e.g.:
std::ofstream("addOne.nautilus") << irText;
```

The module remains usable afterwards; calling `compile()` traces again as usual.

## Compiling from an IR file

`NautilusEngine::loadModuleFromIR` (text) and `NautilusEngine::loadModuleFromIRFile` (path) parse the
IR, verify it structurally, and hand it directly to the configured backend — no tracing happens:

```cpp
auto loaded = engine.loadModuleFromIRFile("addOne.nautilus");
auto addOne = loaded.getFunction<int32_t(int32_t)>("addOne");
int32_t r = addOne(41); // 42
```

The loaded module supports the same tiered compilation as the traced path: with background promotion
enabled, tier 0 compiles synchronously and tier 1 swaps in when ready. Because a loaded module has no
original C++ callables, it cannot fall back to interpretation — loading requires compilation to be
enabled (`engine.Compilation`).

## External function calls and symbol resolution

Runtime addresses are process-specific, so they are never serialized. A proxy call (created by
`invoke(...)`) is recorded with its mangled symbol and human-readable name:

```
$4 = call @"_Z8myNativei" "myNative(int)"($3) :i32
```

When loading, each referenced function is resolved in the current process:

1. through the optional `IRSymbolResolver` callback passed to `loadModuleFromIR(File)`, then
2. through `dlsym` on the recorded symbol.

Loading fails with a descriptive error if a function cannot be resolved. Symbols of static or
non-exported functions cannot be recovered by `dlsym`; provide a resolver for those:

```cpp
auto resolver = [](const std::string& symbol, const std::string& name) -> void* {
if (name.find("myNative") != std::string::npos) {
return reinterpret_cast<void*>(&myNative);
}
return nullptr; // fall through to dlsym
};
auto loaded = engine.loadModuleFromIRFile("module.nautilus", resolver);
```

## One format

There is exactly one IR text format: `IRGraph::toString` (the `dump.after_ir_creation` /
`dump.after_ir_passes` dumps) and `serializeIR` produce **byte-identical** output. Every dump is a
serialized module and vice versa.

- **Unique value numbering.** Every block argument and value-producing operation is printed with an
id that is unique within its function (assigned block by block, arguments before operations), so
every reference in the text is exact. The in-memory IR distinguishes values by pointer identity
and legitimately reuses numeric ids across blocks, and optimization passes create cross-block
references between them — printing the stored ids would make the text ambiguous. Consumers that
correlate operations with the printed text (e.g. the MLIR debug-info source map) translate through
`computePrintedValueIds`.
- **External symbols.** External functions are printed with their mangled symbol and human-readable
name: `call @"_Z3addii" "add(int, int)"(...)`.
- **Strictness is the only difference.** `toString` never throws: state that cannot be reconstructed
in another process — a non-null pointer constant, i.e. a raw address — is rendered as the `*`
placeholder. `serializeIR` validates the graph first and fails with a descriptive error instead of
writing a file the parser would reject later. Pass such pointers as function parameters instead.
- Optional `; ...` source-location comment trailers (`dump.sourceLocations`) can be appended to
dumps; the parser skips them.

The golden test fixtures normalize the volatile parts of external references (mangled symbols depend
on the build) to `@"<symbol>" "<name>"` placeholders; see
`testing::normalizeExternalFunctionReferences`.

## Guarantees and limits

- Parsed graphs are verified (`IRVerifier`) before compilation; malformed input fails at load, not
in the backend.
- The serialized IR is the *optimized* IR (the standard IR passes run before serialization), so
loading does not re-run the IR pass pipeline.
- The format is text-based and stable under round-trips: `parse(serialize(g))` serializes to the
identical text.
- Requires a build with `ENABLE_COMPILER` and `ENABLE_TRACING` (serialization needs tracing;
loading needs the compilation pipeline).
68 changes: 68 additions & 0 deletions nautilus/include/nautilus/Engine.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,19 @@ class ArenaPool;
} // namespace nautilus::common

namespace nautilus::engine {

/**
* @brief Resolves the runtime address of an external function referenced by
* serialized IR (proxy calls and address-of operations).
*
* Serialized IR identifies external functions by their mangled symbol and
* human-readable name; addresses are process-specific and never serialized.
* When loading an IR file, each referenced function is resolved first through
* this callback (when provided) and then through `dlsym`. Return nullptr to
* fall through to `dlsym`.
*/
using IRSymbolResolver = std::function<void*(const std::string& symbol, const std::string& name)>;

namespace details {

/**
Expand Down Expand Up @@ -200,6 +213,37 @@ class NautilusEngine {
*/
NautilusModule createModule(ModuleOptions overrides) const;

/**
* @brief Compiles a module directly from serialized Nautilus IR text,
* skipping the tracing frontend entirely.
*
* The text must be in the portable IR format produced by
* @ref NautilusModule::serializeIR (a completed superset of the
* `IRGraph::toString` grammar). External functions referenced by the IR
* (proxy calls) are re-resolved in this process via @p symbolResolver
* and `dlsym`.
*
* The returned module exposes the same functions by name as the module
* that produced the IR; retrieve them with
* `CompiledModule::getFunction<Signature>(name)`. Because no original
* callables exist, the module cannot fall back to interpretation:
* loading requires compilation to be enabled.
*
* @param irText serialized IR text
* @param symbolResolver optional resolver for external function symbols
* @return CompiledModule with all functions accessible by name
* @throws RuntimeException on parse errors, unresolved symbols, or when
* compilation is disabled
*/
CompiledModule loadModuleFromIR(const std::string& irText, const IRSymbolResolver& symbolResolver = nullptr) const;

/**
* @brief Reads serialized Nautilus IR from @p path and compiles it,
* skipping tracing. See @ref loadModuleFromIR.
*/
CompiledModule loadModuleFromIRFile(const std::string& path,
const IRSymbolResolver& symbolResolver = nullptr) const;

std::string getNameOfBackend() const {
return jit_->getName();
}
Expand Down Expand Up @@ -329,6 +373,30 @@ class NautilusModule {
#endif
}

#ifdef ENABLE_TRACING
/**
* @brief Traces all registered functions and returns their IR in the
* portable text format, without compiling a backend executable.
*
* The returned text can be persisted (e.g. written to a `.nautilus`
* file) and later compiled directly — skipping tracing — via
* @ref NautilusEngine::loadModuleFromIR. The module remains usable:
* registering further functions or calling @ref compile afterwards
* traces again as usual.
*
* @return Serialized Nautilus IR text
* @throws RuntimeException when compilation is disabled or the IR
* contains process-specific state (e.g. a non-null pointer
* constant) that cannot be reconstructed in another process
*/
std::string serializeIR() {
if (!compiled_) {
throw std::runtime_error("serializeIR requires compilation to be enabled (engine.Compilation)");
}
return jit_.compileToSerializedIR(functions_, moduleOptions_);
}
#endif

/**
* @brief Compile all registered functions together into one compilation unit.
* When compilation is disabled, returns a module that interprets functions directly.
Expand Down
34 changes: 34 additions & 0 deletions nautilus/include/nautilus/JITCompiler.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,40 @@ class JITCompiler {
virtual void compileModule(std::list<CompilableFunction>& functions, const engine::ModuleOptions& moduleOptions,
std::shared_ptr<engine::details::ModuleState> state) const = 0;

/**
* @brief Trace @p functions and return their IR in the portable text
* format instead of compiling a backend executable.
*
* The returned text contains everything needed to reconstruct the IR in
* another process (see IRSerializationUtil.hpp) and can later be compiled
* directly — skipping tracing — via @ref compileIRModule or the
* engine-level `NautilusEngine::loadModuleFromIR`.
*
* @param functions List of named compilable functions to trace.
* @param moduleOptions Per-module options for this compilation.
* @return Serialized Nautilus IR text.
*/
[[nodiscard]] virtual std::string compileToSerializedIR(std::list<CompilableFunction>& functions,
const engine::ModuleOptions& moduleOptions) const = 0;

/**
* @brief Compile a pre-built IR graph and publish the result into
* @p state, skipping the tracing frontend entirely.
*
* The counterpart of @ref compileModule for IR that was loaded from a
* file (or otherwise constructed) rather than traced. Tiered compilers
* compile tier 0 synchronously and promote to tier 1 in the background,
* exactly like the traced path; since a loaded module has no original
* callables to interpret, an interpreter tier 0 falls back to a
* synchronous tier-1 compile.
*
* @param ir The IR graph to compile.
* @param moduleOptions Per-module options for this compilation.
* @param state Module state to populate (and, for tiered compilers, promote).
*/
virtual void compileIRModule(std::shared_ptr<ir::IRGraph> ir, const engine::ModuleOptions& moduleOptions,
std::shared_ptr<engine::details::ModuleState> state) const = 0;

/**
* @brief Get the name of the primary compilation backend.
*/
Expand Down
4 changes: 4 additions & 0 deletions nautilus/src/nautilus/compiler/CompilationPipeline.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ namespace ir {
class IRGraph;
}

/// Creates a fresh, unique compilation-unit id (timestamp + random suffix).
/// Only available in builds with ENABLE_COMPILER and ENABLE_TRACING.
std::string createCompilationUnitID();

/**
* @brief Frontend + backend compilation pipeline shared by all tiers.
*
Expand Down
37 changes: 37 additions & 0 deletions nautilus/src/nautilus/compiler/Engine.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@
#include "nautilus/JITCompiler.hpp"
#include "nautilus/common/Arena.hpp"
#include "nautilus/compiler/TieredCompiler.hpp"
#include "nautilus/exceptions/RuntimeException.hpp"
#include "nautilus/logging.hpp"
#include <fstream>
#include <sstream>

#if defined(ENABLE_COMPILER) && defined(ENABLE_TRACING)
#include "nautilus/compiler/CompilationPipeline.hpp"
#include "nautilus/compiler/ir/util/IRParser.hpp"
#endif

namespace nautilus::engine {

Expand Down Expand Up @@ -43,4 +51,33 @@ NautilusEngine::NautilusEngine(std::unique_ptr<compiler::JITCompiler> jit, const
NautilusEngine::~NautilusEngine() = default;
NautilusEngine::NautilusEngine(NautilusEngine&&) noexcept = default;

CompiledModule NautilusEngine::loadModuleFromIR(const std::string& irText,
const IRSymbolResolver& symbolResolver) const {
#if defined(ENABLE_COMPILER) && defined(ENABLE_TRACING)
if (!isCompiled()) {
throw RuntimeException("loadModuleFromIR requires compilation to be enabled (engine.Compilation): a module "
"loaded from IR has no original callables to interpret");
}
auto ir = compiler::ir::parseIR(irText, *irArenaPool_, compiler::createCompilationUnitID(), symbolResolver);
auto state = std::make_shared<details::ModuleState>();
jit_->compileIRModule(std::move(ir), options.deriveModuleOptions(), state);
return CompiledModule(std::move(state));
#else
(void) irText;
(void) symbolResolver;
throw RuntimeException("loadModuleFromIR requires a build with ENABLE_COMPILER and ENABLE_TRACING");
#endif
}

CompiledModule NautilusEngine::loadModuleFromIRFile(const std::string& path,
const IRSymbolResolver& symbolResolver) const {
std::ifstream file(path);
if (!file) {
throw RuntimeException("Could not open IR file '" + path + "'");
}
std::stringstream content;
content << file.rdbuf();
return loadModuleFromIR(content.str(), symbolResolver);
}

} // namespace nautilus::engine
66 changes: 66 additions & 0 deletions nautilus/src/nautilus/compiler/TieredCompiler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,14 @@

#include "nautilus/CompilableFunction.hpp"
#include "nautilus/compiler/DumpHandler.hpp"
#include "nautilus/exceptions/RuntimeException.hpp"
#include <chrono>
#include <thread>

#ifdef ENABLE_TRACING
#include "nautilus/compiler/ir/util/IRSerializationUtil.hpp"
#endif

namespace nautilus::compiler {

static std::string createPromotionUnitID() {
Expand Down Expand Up @@ -161,6 +166,54 @@ void TieredJITCompiler::compileModule(std::list<CompilableFunction>& functions,
promoteAsync(state, std::move(ir), moduleOptions);
}

std::unique_ptr<Executable> TieredJITCompiler::compileIRTier(const std::shared_ptr<ir::IRGraph>& ir,
const engine::ModuleOptions& moduleOptions,
const std::string& backend,
const std::string& tierLabel) const {
auto statistics = std::make_shared<CompilationStatistics>();
const auto compilationStart = std::chrono::steady_clock::now();

auto executable = pipeline_.compileIR(ir, backend, moduleOptions, statistics.get());

statistics->recordTimingMs("compilation.totalMs", compilationStart);
statistics->set("tier", tierLabel);

if (moduleOptions.getOptionOrDefault("engine.logStatistics", false)) {
const auto id = statistics->find("compilation.unitId") != nullptr
? std::get<std::string>(*statistics->find("compilation.unitId"))
: std::string {};
log::info("\n{}", statistics->formatReport(id, backend));
}

executable->setCompilationStatistics(std::static_pointer_cast<const CompilationStatistics>(std::move(statistics)));
return executable;
}

std::string TieredJITCompiler::compileToSerializedIR(std::list<CompilableFunction>& functions,
const engine::ModuleOptions& moduleOptions) const {
#ifdef ENABLE_TRACING
auto ir = pipeline_.compileToIR(functions, moduleOptions);
return ir::serializeIR(*ir);
#else
(void) functions;
(void) moduleOptions;
throw RuntimeException("Serializing IR requires a build with ENABLE_TRACING");
#endif
}

void TieredJITCompiler::compileIRModule(std::shared_ptr<ir::IRGraph> ir, const engine::ModuleOptions& moduleOptions,
std::shared_ptr<engine::details::ModuleState> state) const {
if (!config_.backgroundPromotion || config_.tier0.backend == engine::INTERPRETER_BACKEND) {
// Single-tier, or interpreter tier 0: a loaded module has no original
// callables to run interpreted, so compile tier 1 synchronously.
state->executable = compileIRTier(ir, moduleOptions, config_.tier1.backend, "tier1");
return;
}
// Two-tier: fast tier-0 now, then promote to tier-1 in the background.
state->executable = compileIRTier(ir, moduleOptions, config_.tier0.backend, "tier0");
promoteAsync(state, std::move(ir), moduleOptions);
}

void TieredJITCompiler::promoteAsync(std::weak_ptr<engine::details::ModuleState> state, std::shared_ptr<ir::IRGraph> ir,
engine::ModuleOptions options) const {
if (!ir) {
Expand Down Expand Up @@ -270,6 +323,19 @@ void TieredJITCompiler::compileModule(std::list<CompilableFunction>&, const engi
std::shared_ptr<engine::details::ModuleState>) const {
throw RuntimeException("Jit not initialised");
}
std::unique_ptr<Executable> TieredJITCompiler::compileIRTier(const std::shared_ptr<ir::IRGraph>&,
const engine::ModuleOptions&, const std::string&,
const std::string&) const {
throw RuntimeException("Jit not initialised");
}
std::string TieredJITCompiler::compileToSerializedIR(std::list<CompilableFunction>&,
const engine::ModuleOptions&) const {
throw RuntimeException("Jit not initialised");
}
void TieredJITCompiler::compileIRModule(std::shared_ptr<ir::IRGraph>, const engine::ModuleOptions&,
std::shared_ptr<engine::details::ModuleState>) const {
throw RuntimeException("Jit not initialised");
}
void TieredJITCompiler::promoteAsync(std::weak_ptr<engine::details::ModuleState>, std::shared_ptr<ir::IRGraph>,
engine::ModuleOptions) const {
}
Expand Down
Loading
Loading