| # Basic custom module sample |
| |
| This sample shows how to |
| |
| 1. Create a custom module in C++ that can be used with the IREE runtime |
| 2. Author an MLIR input that uses a custom module including a custom type |
| 3. Compile that program to an IREE VM bytecode module |
| 4. Load the compiled program using a low-level VM interface |
| 5. Call exported functions on the loaded program to exercise the custom module |
| |
| The custom module is declared in [`module.h`](./module.h), implemented using a |
| C++ module wrapper layer in [`module.cc`](./module.cc), and called by example in |
| [`main.c`](./main.c). |
| |
| This document uses terminology that can be found in the documentation of |
| [IREE's execution model](https://iree.dev/developers/design-docs/invocation-execution-model/). |
| See [IREE's extensibility mechanisms](https://iree.dev/reference/extensions/) |
| documentation for more information specific to extenting IREE and |
| alternative approaches to doing so. |
| |
| ## Background |
| |
| IREE's VM is used to dynamically link modules of various types together at |
| runtime (C, C++, IREE's VM bytecode, etc). Via this mechanism any number of |
| modules containing exported functions and types that can be used across modules |
| can extend IREE's base functionality. In most IREE programs the HAL module is |
| used to provide a hardware abstraction layer for execution and both the HAL |
| module itself and the types it exposes (`!hal.buffer`, `!hal.executable`, etc) |
| are implemented using this mechanism. |
| |
| ## Instructions |
| |
| 1. Build or install the `iree-compile` binary: |
| |
| ``` |
| python -m pip install iree-compiler |
| ``` |
| |
| [See here](https://iree.dev/reference/bindings/python/) |
| for general instructions on installing the compiler. |
| |
| 3. Compile the [example module](./test/example.mlir) to a .vmfb file: |
| |
| ``` |
| # This simple sample doesn't use tensors and can be compiled in host-only |
| # mode to avoid the need for the HAL. |
| iree-compile --iree-execution-model=host-only samples/custom_module/basic/test/example.mlir -o=/tmp/example.vmfb |
| ``` |
| |
| 3. Build the `iree_samples_custom_module_run` CMake target : |
| |
| ``` |
| cmake -B ../iree-build/ -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo . \ |
| -DCMAKE_C_FLAGS=-DIREE_VM_EXECUTION_TRACING_FORCE_ENABLE=1 |
| cmake --build ../iree-build/ --target iree_samples_custom_module_basic_run |
| ``` |
| (here we force runtime execution tracing for demonstration purposes) |
| |
| [See here](https://iree.dev/building-from-source/getting-started/) |
| for general instructions on building using CMake. |
| |
| 4. Run the example program to call the main function: |
| |
| ``` |
| ../iree-build/samples/custom_module/basic/custom-module-basic-run \ |
| /tmp/example.vmfb example.main |
| ``` |
| |
| ## Defining Custom Modules in C++ |
| |
| Modules are exposed to applications and the IREE VM via the `iree_vm_module_t` |
| interface. IREE canonically uses C headers to expose module and type functions |
| but the implementation of the module can be anything the user is able to work |
| with (C, C++, rust, etc). |
| |
| A C++ wrapper is provided to ease implementation when minimal code size and overhead is not a focus and provides easy definition of exports and marshaling |
| of types. Utilities such as `iree::Status` and `iree::vm::ref<T>` add safety for |
| managing reference counted resources and can be used within the modules. |
| |
| General flow: |
| |
| 1. Expose module via a C API ([`module.h`](./module.h)): |
| |
| ```c |
| // Ideally all allocations performed by the module should use |allocator|. |
| // The returned module in |out_module| should have a ref count of 1 to transfer |
| // ownership to the caller. |
| iree_status_t iree_table_module_create(iree_allocator_t allocator, |
| iree_vm_module_t** out_module); |
| ``` |
| |
| 2. Implement the module using C/C++/etc ([`module.cc`](./module.cc)): |
| |
| Modules have two parts: a shared module and instantiated state. |
| |
| The `iree::vm::NativeModule` helper is used to handle the shared module |
| declaration and acts as a factory for per-context instantiated state and the |
| methods exported by the module: |
| |
| ```c++ |
| // Any mutable state stored on the module may be accessed from multiple threads |
| // if the module is instantiated in multiple contexts and must be thread-safe. |
| struct TableModule final : public vm::NativeModule<TableModuleState> { |
| // Each time the module is instantiated this will be called to allocate the |
| // context-specific state. The returned state must only be thread-compatible |
| // as invocations within a context will not be made from multiple threads but |
| // the thread on which they are made may change over time; this means no TLS! |
| StatusOr<std::unique_ptr<TableModuleState>> CreateState( |
| iree_allocator_t allocator) override; |
| }; |
| ``` |
| |
| The module implementation is done on the state object so that methods may use |
| `this` to access context-local state: |
| |
| ```c++ |
| struct TableModuleState final { |
| // Local to the context the module was instantiated in and thread-compatible. |
| std::unordered_map<std::string, std::string> mutable_state; |
| |
| // Exported functions must return Status or StatusOr. Failures will result in |
| // program termination and will be propagated up to the top-level invoker. |
| // If a module wants to provide non-fatal errors it can return results to the |
| // program: here we return a 0/1 indicating whether the key was found as well |
| // as the result or null. |
| // |
| // MLIR declaration: |
| // func.func private @table.lookup(!util.buffer) -> (i1, !util.buffer) |
| StatusOr<std::tuple<int32_t, vm::ref<iree_vm_buffer_t>>> Lookup( |
| const vm::ref<iree_vm_buffer_t> key); |
| }; |
| ``` |
| |
| Finally the exported methods are registered and marshaling code is expanded: |
| |
| ```c++ |
| static const vm::NativeFunction<TableModuleState> kTableModuleFunctions[] = { |
| vm::MakeNativeFunction("lookup", &TableModuleState::Lookup), |
| }; |
| extern "C" iree_status_t iree_table_module_create( |
| iree_allocator_t allocator, iree_vm_module_t** out_module) { |
| auto module = std::make_unique<TableModule>( |
| "table", /*version=*/0, allocator, |
| iree::span<const vm::NativeFunction<CustomModuleState>> |
| (kTableModuleFunctions)); |
| *out_module = module.release()->interface(); |
| return iree_ok_status(); |
| } |
| ``` |
| |
| ## Registering Custom Modules at Runtime |
| |
| Once a custom module is defined it needs to be provided to any context that it |
| is going to be used in. Each context may have its own unique mix of modules and |
| it's the hosting application's responsibility to inject the available modules. |
| See [`main.c`](./main.c) for an example showing the entire end-to-end lifetime |
| of loading a compiled bytecode module and providing a custom module for runtime |
| dynamic linking. |
| |
| Since modules themselves can be reused across contexts it can be a way of |
| creating shared caches (requires thread-safety!) that span contexts while the |
| module state is context specific and isolated. |
| |
| Import resolution happens in reverse registration order: the most recently |
| registered modules override previous ones. This combined with optional imports |
| allows overriding behavior and version compatibility shims (though there is |
| still some trickiness involved). |
| |
| ```c |
| // Ensure custom types are registered before loading modules that use them. |
| // This only needs to be done once per instance. |
| IREE_CHECK_OK(iree_basic_custom_module_register_types(instance)); |
| |
| // Create the custom module that can be reused across contexts. |
| iree_vm_module_t* custom_module = NULL; |
| IREE_CHECK_OK(iree_basic_custom_module_create(instance, allocator, |
| &custom_module)); |
| |
| // Create the context for this invocation reusing the loaded modules. |
| // Contexts hold isolated state and can be reused for multiple calls. |
| // Note that the module order matters: the input user module is dependent on |
| // the custom module. |
| iree_vm_module_t* modules[] = {custom_module, bytecode_module}; |
| iree_vm_context_t* context = NULL; |
| IREE_CHECK_OK(iree_vm_context_create_with_modules( |
| instance, IREE_VM_CONTEXT_FLAG_NONE, IREE_ARRAYSIZE(modules), modules, |
| allocator, &context)); |
| ``` |
| |
| ## Calling Custom Modules from Compiled Programs |
| |
| The IREE compiler allows for external functions that are resolved at runtime |
| using the [MLIR `func` dialect](https://mlir.llvm.org/docs/Dialects/Func/). Some |
| optional attributes are used to allow for customization where required but in |
| many cases no additional IREE-specific work is required in the compiled program. |
| A few advanced features of the VM FFI are not currently exposed via this |
| mechanism such as variadic arguments and tuples but the advantage is that users |
| need not customize the IREE compiler in order to use their modules. |
| |
| Prior to passing input programs to the IREE compiler users can insert the |
| imported functions as external |
| [`func.func`](https://mlir.llvm.org/docs/Dialects/Func/#funcfunc-mlirfuncfuncop) |
| ops and calls to those functions using |
| [`func.call`](https://mlir.llvm.org/docs/Dialects/Func/#funccall-mlirfunccallop): |
| |
| ```mlir |
| // An external function declaration. |
| // `custom` is the runtime module and `string.create` is the exported method. |
| // This call uses both IREE types (`!util.buffer`) and custom ones not known to |
| // the compiler but available at runtime (`!custom.string`). |
| func.func private @custom.string.create(!util.buffer) -> !custom.string |
| ``` |
| |
| ```mlir |
| // Call the imported function. |
| %buffer = util.buffer.constant : !util.buffer = "hello world!" |
| %result = func.call @custom.string.create(%buffer) : (!util.buffer) -> !custom.string |
| ``` |
| |
| Users with custom dialects and ops can use |
| [MLIR's dialect conversion](https://mlir.llvm.org/docs/DialectConversion/) |
| framework to rewrite their custom ops to this form and perform additional |
| marshaling logic. For example, the above could have started as this program |
| before the user ran their dialect conversion and passed it in to `iree-compile`: |
| |
| ```mlir |
| %result = custom.string.create "hello world!" : !custom.string |
| ``` |
| |
| See this samples [`example.mlir`](./test/example.mlir) for examples of features |
| such as signature specification and optional import fallback support. |