Skip to content

Commit 6be5175

Browse files
Add explicit AOT compile/load APIs (#377)
Adds explicit AOT compile/load APIs so callers can compile backend-specific VMFBs separately from runtime loading while keeping `Graph::compile(handle)` as the JIT convenience path over `compileToArtifact(handle.getBackend())` + `loadFromArtifact(handle, vmfbBytes)`. Summary: - Updates `Graph::compileToArtifact(Backend)` to return caller-owned raw VMFB bytes. Internally it still uses the compile-side artifact path from `getCompiledArtifact()` and reads the VMFB before returning. - Updates `Graph::loadFromArtifact(Handle, bytes)` to load from an in-memory VMFB buffer instead of a filesystem path. - Keeps loaded VMFB bytes alive for the lifetime of the IREE VM context and explicitly tears down runtime state before clearing those bytes. - Updates the AOT single-backend and multi-backend samples to pass artifacts between compile and execute phases with in-memory `std::vector<uint8_t>` buffers instead of temporary filesystem paths. - Documents AOT constraints: VMFBs are backend-specific, compile-side cache ownership is not a persistent artifact contract, and callers should keep or serialize byte buffers they need to use later. - Adds focused graph tests covering compile-only artifacts, load/execute round trips, runtime-state replacement, artifact lifetime across graph instances, and backend mismatch errors. Validation: - `./build_tools/scripts/build.sh cpu-debug --iree-source-dir /home/srajeshk/claude-workspace/.cache/docker/iree` - `./build_tools/scripts/test.sh --build-dir build` - `./build_tools/scripts/test.sh --build-dir build --backend cli -R "fusilli_graph_tests_test_graph|fusilli_aot_samples"` - `./build_tools/scripts/build.sh cpu-debug-tidy --iree-source-dir /home/srajeshk/claude-workspace/.cache/docker/iree` - `./build_tools/scripts/build.sh gpu-debug --iree-source-dir /home/srajeshk/claude-workspace/.cache/docker/iree` - `./build_tools/scripts/test.sh --build-dir build --timeout 300 --parallel 1 --extra-verbose -R "fusilli_backend_tests_test_handle|fusilli_pointwise_samples_pointwise_unary_ops"` - `./build_tools/scripts/build.sh gpu-asan --iree-source-dir /home/srajeshk/claude-workspace/.cache/docker/iree` - `./build_tools/scripts/test.sh --build-dir build --timeout 300 --parallel 1 --extra-verbose -R "fusilli_hip_tests_conv_samples_conv_fprop_nchw_kcrs"` - `./build_tools/scripts/test.sh --build-dir build --timeout 300 --parallel 1 --extra-verbose -R "fusilli_graph_tests_test_graph|fusilli_aot_samples"` - `pre-commit run --all-files` --------- Signed-off-by: Sambhav Jain <sambhav@alumni.stanford.edu> Co-authored-by: GPT 5 <codex@openai.com>
1 parent 1458571 commit 6be5175

8 files changed

Lines changed: 631 additions & 66 deletions

File tree

README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,58 @@ software effectively.
3030
3131
![Fusilli](docs/fusilli.png)
3232

33+
## JIT and AOT support
34+
35+
Fusilli's default API is built for the JIT usecase:
36+
37+
```cpp
38+
graph.compile(handle);
39+
graph.execute(handle, variantPack, workspace);
40+
```
41+
42+
`Graph::compile(handle)` compiles for `handle.getBackend()` and immediately loads
43+
the resulting artifact into runtime state owned by that `Graph`. The compiled
44+
artifacts are cached in-process and tied to the lifetime of the `Graph` instance.
45+
Here `Graph::compile` is just an implementation detail that goes hand-in-hand
46+
with `Graph::execute` (in the same process and using the same device handle).
47+
48+
For AOT-style callers we recognize that the compilation may happen in a separate
49+
process, or without access to the specific execution device. To support the AOT
50+
usecase, Fusilli exposes alternate APIs with explicit artifact save/load steps:
51+
52+
```cpp
53+
// Compile doesn't require a device handle
54+
auto vmfbBytes = graph.compileToArtifact(backend);
55+
// Keep, copy, or serialize the VMFB bytes for later execution.
56+
57+
Graph runtimeGraph;
58+
// Rebuild and validate the same logical graph.
59+
runtimeGraph.loadFromArtifact(handle, vmfbBytes);
60+
runtimeGraph.execute(handle, variantPack, workspace);
61+
```
62+
63+
Important constraints for multi-backend AOT compilation:
64+
65+
- Compiled artifacts are backend-specific. An artifact produced for one backend
66+
must be loaded and executed with a handle for the same backend. `execute()`
67+
rejects handles whose backend does not match the currently loaded artifact's
68+
backend.
69+
- `compileToArtifact()` does not load or unload runtime state. If a `Graph`
70+
already has an artifact loaded, compiling another artifact leaves the loaded
71+
artifact executable until `loadFromArtifact()` replaces it.
72+
- A `Graph` owns one loaded runtime state at a time. Loading another artifact
73+
replaces the previous VM context, function and workspace size.
74+
- If compiling artifacts for multiple backends from one `Graph`, keep or
75+
serialize each returned VMFB byte buffer before compiling the next backend.
76+
Fusilli's in-process cache owns only the current compile-side artifact and is
77+
not a persistent cache contract.
78+
- To keep multiple backend artifacts loaded concurrently, use one validated
79+
`Graph` instance per backend. To switch backends on the same `Graph`, call
80+
`loadFromArtifact(handleForSelectedBackend, artifactForSelectedBackend)`
81+
before `execute()`.
82+
83+
AOT samples are under `samples/aot/`, everything else in `samples/` uses the JIT API.
84+
3385
## Developer Guide
3486

3587
### Setup

include/fusilli/backend/runtime.h

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@
4444
#include <iree/hal/api.h>
4545
#include <iree/hal/drivers/hip/api.h>
4646
#include <iree/hal/drivers/init.h>
47-
#include <iree/io/file_contents.h>
4847
#include <iree/modules/hal/module.h>
4948
#include <iree/vm/api.h>
5049
#include <iree/vm/bytecode/module.h>
@@ -240,8 +239,7 @@ inline ErrorObject Handle::createAMDGPUDevice(int deviceId, uintptr_t stream) {
240239
//===----------------------------------------------------------------------===//
241240

242241
// Create IREE VM context for this graph and load the compiled artifact.
243-
inline ErrorObject Graph::createVmContext(const Handle &handle,
244-
const std::string &vmfbPath) {
242+
inline ErrorObject Graph::createVmContext(const Handle &handle) {
245243
// Create a context even if one was created earlier, since the handle
246244
// (hence device) might have changed and we might be re-compiling the graph
247245
// for the new device.
@@ -268,25 +266,16 @@ inline ErrorObject Graph::createVmContext(const Handle &handle,
268266
FUSILLI_CHECK_ERROR(status);
269267
}
270268

271-
// Read the VMFB file and create a bytecode module from it.
269+
// Create a bytecode module from the graph-owned VMFB bytes.
272270
FUSILLI_LOG_LABEL_ENDL("INFO: Loading bytecode module into IREE VM context");
273271
{
274-
iree_io_file_contents_t *fileContents = nullptr;
275-
FUSILLI_CHECK_ERROR(iree_io_file_contents_read(
276-
iree_make_cstring_view(vmfbPath.c_str()), allocator, &fileContents));
277-
278272
iree_vm_module_t *bytecodeModule = nullptr;
279273
iree_status_t status = iree_vm_bytecode_module_create(
280274
handle.getInstance(), IREE_VM_BYTECODE_MODULE_FLAG_NONE,
281-
fileContents->const_buffer,
282-
iree_io_file_contents_deallocator(fileContents), allocator,
283-
&bytecodeModule);
284-
if (!iree_status_is_ok(status)) {
285-
iree_io_file_contents_free(fileContents);
286-
FUSILLI_CHECK_ERROR(status);
287-
}
288-
// File contents ownership transferred to bytecode module on success
289-
// so there's no `iree_io_file_contents_free` on the success path.
275+
iree_make_const_byte_span(loadedArtifactBytes_.data(),
276+
loadedArtifactBytes_.size()),
277+
iree_allocator_null(), allocator, &bytecodeModule);
278+
FUSILLI_CHECK_ERROR(status);
290279

291280
status =
292281
iree_vm_context_register_modules(vmContext_.get(),
@@ -395,6 +384,15 @@ Graph::execute(const Handle &handle,
395384
if (!kBackendExecuteAsync.contains(handle.getBackend())) // C++20
396385
return ErrorObject(ErrorCode::InternalError,
397386
"Graph::execute got an unknown backend");
387+
FUSILLI_RETURN_ERROR_IF(!loadedBackend_.has_value(), ErrorCode::NotCompiled,
388+
"Graph::execute requires a successful compile() first"
389+
" (loaded backend not set)");
390+
FUSILLI_RETURN_ERROR_IF(handle.getBackend() != *loadedBackend_,
391+
ErrorCode::InvalidArgument,
392+
"Graph::execute got a handle for backend " +
393+
kBackendToStr.at(handle.getBackend()) +
394+
", but the loaded artifact uses backend " +
395+
kBackendToStr.at(*loadedBackend_));
398396
bool executeAsync = kBackendExecuteAsync.at(handle.getBackend());
399397

400398
iree_allocator_t allocator = iree_allocator_system();

include/fusilli/graph/graph.h

Lines changed: 116 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
#include <memory>
5353
#include <optional>
5454
#include <set>
55+
#include <span>
5556
#include <sstream>
5657
#include <string>
5758
#include <unordered_map>
@@ -111,29 +112,93 @@ class Graph : public INode {
111112
return ok();
112113
}
113114

114-
// Compiles the graph using IREE compiler and sets up the IREE VM
115-
// context for future g->execute calls.
115+
// Compiles the graph using IREE compiler and sets up the IREE VM context for
116+
// future g->execute calls. This is the default JIT convenience API.
117+
//
118+
// This is equivalent to:
119+
// compileToArtifact(handle.getBackend(), remove)
120+
// loadFromArtifact(handle, vmfbBytes)
121+
//
122+
// The compiled artifact is backend-specific and is immediately loaded using
123+
// the same backend from `handle`.
116124
//
117125
// Set `remove = true` to remove compilation artifacts (cache files) when
118126
// this `Graph` instance goes out of scope.
119127
ErrorObject compile(const Handle &handle, bool remove = false) {
120128
FUSILLI_LOG_LABEL_ENDL("INFO: Compiling Graph");
129+
FUSILLI_ASSIGN_OR_RETURN(auto vmfbBytes,
130+
compileToArtifact(handle.getBackend(), remove));
131+
return loadFromArtifact(handle, vmfbBytes);
132+
}
133+
134+
// Compiles the graph using IREE compiler to produce a backend-specific VMFB
135+
// artifact. This does not create any runtime state; call
136+
// `loadFromArtifact()` before executing.
137+
//
138+
// If callers need artifacts for multiple backends, they should keep or
139+
// serialize each returned VMFB byte buffer before compiling the next backend.
140+
// Fusilli owns only the in-process `cache_` for the most recent compile-side
141+
// artifact; it does not provide persistent artifact storage or invalidation.
142+
//
143+
// Compiling a new artifact does not load or unload runtime state. If this
144+
// `Graph` already has an artifact loaded, that artifact remains executable
145+
// until a later `loadFromArtifact()` replaces it.
146+
//
147+
// Set `remove = true` to remove compilation artifacts (cache files) when
148+
// this `Graph` instance goes out of scope.
149+
ErrorOr<std::vector<uint8_t>> compileToArtifact(Backend backend,
150+
bool remove = false) {
151+
FUSILLI_LOG_LABEL_ENDL("INFO: Compiling Graph to artifact");
121152
FUSILLI_RETURN_ERROR_IF(!isValidated_, ErrorCode::NotValidated,
122153
"Graph must be validated before being compiled");
123154

124155
// Generate MLIR assembly for this graph.
125156
FUSILLI_ASSIGN_OR_RETURN(std::string generatedAsm, emitAsm());
126157

127158
// Compile using IREE compiler or reuse cached artifact.
128-
FUSILLI_ASSIGN_OR_RETURN(auto vmfbPath,
129-
getCompiledArtifact(handle, generatedAsm, remove));
159+
FUSILLI_ASSIGN_OR_RETURN(
160+
auto vmfbPath, getCompiledArtifact(backend, generatedAsm, remove));
130161

131162
FUSILLI_LOG_LABEL_ENDL("INFO: Compiled Graph cached at \"" +
132163
vmfbPath.string() + "\"");
133164

134-
// Create per-graph IREE VM context and load the compiled artifact.
135-
FUSILLI_CHECK_ERROR(createVmContext(handle, vmfbPath.string()));
165+
return readFileBytes(vmfbPath);
166+
}
136167

168+
// Loads a compiled VMFB artifact onto the device owned by `handle`, creating
169+
// per-graph runtime state and querying workspace size. This does not require
170+
// the artifact to have been produced by this `Graph` instance.
171+
//
172+
// The artifact must be compatible with `handle.getBackend()`. A VMFB compiled
173+
// for one backend is not a portable artifact for another backend.
174+
//
175+
// A `Graph` owns one loaded runtime state at a time. Loading a new artifact
176+
// replaces any previously loaded VM context, function, workspace size, and VM
177+
// input list capacity. If loading fails, the `Graph` is left without loaded
178+
// runtime state. To keep multiple backends loaded concurrently, use one
179+
// `Graph` instance per backend artifact. To switch backends on one `Graph`,
180+
// call `loadFromArtifact()` with the selected backend artifact before
181+
// `execute()`.
182+
ErrorObject loadFromArtifact(const Handle &handle,
183+
std::span<const uint8_t> vmfbBytes) {
184+
FUSILLI_LOG_LABEL_ENDL("INFO: Loading compiled artifact into VM context");
185+
FUSILLI_RETURN_ERROR_IF(
186+
!isValidated_, ErrorCode::NotValidated,
187+
"Graph must be validated before loading a compiled artifact");
188+
std::vector<uint8_t> artifactBytes(vmfbBytes.begin(), vmfbBytes.end());
189+
// Loading replaces the currently executable artifact. Clear first so a
190+
// failed load cannot leave execute() using stale runtime state from an
191+
// older artifact.
192+
clearRuntimeState();
193+
loadedArtifactBytes_ = std::move(artifactBytes);
194+
ErrorObject status = createVmContext(handle);
195+
if (isError(status)) {
196+
// createVmContext may have partially populated runtime state before
197+
// failing; leave the graph in a clean "not loaded" state.
198+
clearRuntimeState();
199+
return status;
200+
}
201+
loadedBackend_ = handle.getBackend();
137202
return ok();
138203
}
139204

@@ -235,7 +300,7 @@ class Graph : public INode {
235300
std::shared_ptr<Buffer>> &variantPack,
236301
const std::shared_ptr<Buffer> &workspace) const;
237302

238-
// Delete copy constructors, keep default move constructor and destructor.
303+
// Delete copy constructors and keep default move operations.
239304
Graph(const Graph &) = delete;
240305
Graph &operator=(const Graph &) = delete;
241306
Graph(Graph &&) noexcept = default;
@@ -354,11 +419,11 @@ class Graph : public INode {
354419
// TODO(#13): Make this private. It is public for now to aid testing and
355420
// debuggability, however the intended user facing API is `Graph::compile()`.
356421
ErrorOr<std::filesystem::path>
357-
getCompiledArtifact(const Handle &handle, const std::string &generatedAsm,
422+
getCompiledArtifact(Backend backend, const std::string &generatedAsm,
358423
bool remove, std::optional<bool> *reCompiled = nullptr) {
359424
// Check for cache hit.
360425
FUSILLI_ASSIGN_OR_RETURN(bool cacheValid,
361-
validateCache(handle, generatedAsm));
426+
validateCache(backend, generatedAsm));
362427
if (cacheValid) {
363428
if (reCompiled)
364429
*reCompiled = false;
@@ -367,7 +432,7 @@ class Graph : public INode {
367432
// (Re)generate cache.
368433
FUSILLI_ASSIGN_OR_RETURN(
369434
auto generatedCache,
370-
generateCompiledArtifact(handle, generatedAsm, remove));
435+
generateCompiledArtifact(backend, generatedAsm, remove));
371436
cache_ = std::move(generatedCache);
372437
if (reCompiled)
373438
*reCompiled = true;
@@ -397,8 +462,16 @@ class Graph : public INode {
397462

398463
private:
399464
// Definition in `fusilli/backend/runtime.h`.
400-
ErrorObject createVmContext(const Handle &handle,
401-
const std::string &vmfbPath);
465+
ErrorObject createVmContext(const Handle &handle);
466+
467+
void clearRuntimeState() {
468+
vmFunction_.reset();
469+
vmContext_.reset();
470+
workspaceSize_.reset();
471+
loadedBackend_.reset();
472+
loadedArtifactBytes_.clear();
473+
vmInputListCapacity_ = 0;
474+
}
402475

403476
// Queries the required transient/workspace buffer size from the compiled
404477
// module. Returns the size in bytes, or 0 if no transients are needed.
@@ -410,8 +483,8 @@ class Graph : public INode {
410483
// `remove = true` to remove cache files when returned `CachedAssets` lifetime
411484
// ends.
412485
ErrorOr<CachedAssets>
413-
generateCompiledArtifact(const Handle &handle,
414-
const std::string &generatedAsm, bool remove) {
486+
generateCompiledArtifact(Backend backend, const std::string &generatedAsm,
487+
bool remove) {
415488
FUSILLI_LOG_LABEL_ENDL("INFO: Generating compiled artifacts");
416489

417490
// Create cache files.
@@ -448,16 +521,16 @@ class Graph : public INode {
448521
if (checkCompileBackendEnv()) {
449522
// Use CompileCommand (CLI).
450523
CompileCommand cmd = CompileCommand::build(
451-
handle.getBackend(), cache.input, cache.output, cache.statistics);
524+
backend, cache.input, cache.output, cache.statistics);
452525
FUSILLI_CHECK_ERROR(cmd.writeTo(cache.command));
453526
FUSILLI_LOG_LABEL_ENDL("INFO: iree-compile command (CLI)");
454527
FUSILLI_LOG_ENDL(cmd.toString());
455528
FUSILLI_CHECK_ERROR(cmd.execute());
456529
} else {
457530
// Use CompileSession (C API) - DEFAULT.
458531
FUSILLI_ASSIGN_OR_RETURN(CompileSession session,
459-
CompileSession::build(handle.getBackend(),
460-
cache.input, cache.output,
532+
CompileSession::build(backend, cache.input,
533+
cache.output,
461534
cache.statistics));
462535
FUSILLI_CHECK_ERROR(session.writeTo(cache.command));
463536
FUSILLI_LOG_LABEL_ENDL("INFO: iree-compile command (C API)");
@@ -473,8 +546,8 @@ class Graph : public INode {
473546
// - Graph name (and therefore cache path) has changed
474547
// - Generated assembly differs
475548
// - Compile commands have changed
476-
// - Handle/backend (and therefore compile command) has changed
477-
ErrorOr<bool> validateCache(const Handle &handle,
549+
// - Backend (and therefore compile command) has changed
550+
ErrorOr<bool> validateCache(Backend backend,
478551
const std::string &generatedAsm) {
479552
FUSILLI_LOG_LABEL_ENDL("INFO: Validating cache");
480553

@@ -544,13 +617,13 @@ class Graph : public INode {
544617
if (checkCompileBackendEnv()) {
545618
// Use CompileCommand (CLI).
546619
CompileCommand cmd =
547-
CompileCommand::build(handle.getBackend(), input, output, statistics);
620+
CompileCommand::build(backend, input, output, statistics);
548621
cmdString = cmd.toString();
549622
} else {
550623
// Use CompileSession (C API) - DEFAULT.
551-
FUSILLI_ASSIGN_OR_RETURN(auto session,
552-
CompileSession::build(handle.getBackend(), input,
553-
output, statistics));
624+
FUSILLI_ASSIGN_OR_RETURN(
625+
auto session,
626+
CompileSession::build(backend, input, output, statistics));
554627
cmdString = session.toString();
555628
}
556629

@@ -618,10 +691,14 @@ class Graph : public INode {
618691
// This is set after `validate()` is run at least once successfully.
619692
bool isValidated_ = false;
620693

621-
// Required workspace buffer size in bytes. Set during createVmContext()
622-
// by querying the iree.abi.transients.size.constant attribute.
623-
// std::nullopt indicates the graph has not been compiled yet.
624-
std::optional<size_t> workspaceSize_;
694+
// Bytes backing the currently loaded VMFB artifact. IREE's bytecode module
695+
// may retain references to this archive, so keep it alive for as long as the
696+
// VM context is alive.
697+
//
698+
// Keep this declared before vmContext_: members destruct in reverse
699+
// declaration order, and the context must be released before the backing VMFB
700+
// bytes.
701+
std::vector<uint8_t> loadedArtifactBytes_;
625702

626703
// IREE VM context lifetime managed by the `Graph` object
627704
// (deleted when the `Graph` object goes out of scope).
@@ -631,11 +708,22 @@ class Graph : public INode {
631708
// Avoids repeated function lookup on every execute() call.
632709
std::optional<iree_vm_function_t> vmFunction_;
633710

711+
// Required workspace buffer size in bytes. Set during createVmContext()
712+
// by querying the iree.abi.transients.size.constant attribute.
713+
// std::nullopt indicates the graph has not been compiled yet.
714+
std::optional<size_t> workspaceSize_;
715+
634716
// Pre-computed VM input list capacity for iree_vm_list_create().
635717
// Set during createVmContext() to avoid recomputing on every execute().
636718
iree_host_size_t vmInputListCapacity_ = 0;
637719

638-
// Cache set by `getCompiledArtifact()`.
720+
// Backend for the currently loaded runtime state. `execute()` requires a
721+
// handle for this backend because VMFB artifacts are backend-specific.
722+
std::optional<Backend> loadedBackend_;
723+
724+
// Compile-side cache set by `getCompiledArtifact()`. The AOT
725+
// `loadFromArtifact()` API uses caller-provided VMFB bytes directly and does
726+
// not consult this cache.
639727
//
640728
// Note: new instances should always re-generate cache even if the results
641729
// could be read from the file system. Old results may have been generated

0 commit comments

Comments
 (0)