Skip to content
Merged
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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions examples/arm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ For Cortex-M testing, use a Cortex-M target and bundled I/O:

### Application examples

- [minimal_classic_ml](minimal_classic_ml/) - Minimal single-shot runtime
integration, intended for models such as MobileNetV2 and YOLO.
- [image_classification_example_ethos_u](image_classification_example_ethos_u/)
- End-to-end DEiT-Tiny image classification flow for Ethos-U, including
model fine-tuning, export, bare-metal runtime build, and Corstone-320 FVP
Expand All @@ -90,6 +92,8 @@ For Cortex-M testing, use a Cortex-M target and bundled I/O:

### Utility examples and guides

- [executor_runner](executor_runner/) - Advanced test and diagnostic runner for
BundleIO, ETDump, profiling, semihosted files, and backend regression tests.
- [ethos-u-porting-guide.md](ethos-u-porting-guide.md) - Notes for adapting
the example Ethos-U runtime integration to another target.
- [export_standalone_tosa_graph.py](export_standalone_tosa_graph.py) -
Expand Down
31 changes: 31 additions & 0 deletions examples/arm/minimal_classic_ml/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Copyright 2026 Arm Limited and/or its affiliates.
#
# This source code is licensed under the BSD-style license found in the
# LICENSE file in the root directory of this source tree.

cmake_minimum_required(VERSION 3.20)
project(arm_minimal_classic_ml LANGUAGES C CXX)

get_filename_component(
EXECUTORCH_ROOT "${CMAKE_CURRENT_LIST_DIR}/../../.." ABSOLUTE
)
include(${EXECUTORCH_ROOT}/backends/arm/cmake/ArmRunnerUtils.cmake)

arm_runner_define_cache_options(METHOD_ALLOCATOR_SIZE 0x400000)
arm_runner_validate_model_source()

arm_runner_add_standalone_executorch()

arm_runner_configure_ethos_u_platform(
SDK_PATH "${ETHOS_SDK_PATH}" SYSTEM_CONFIG "${SYSTEM_CONFIG}" MEMORY_MODE
"${MEMORY_MODE}"
)

arm_runner_create_default_selected_ops_libs(
PREFIX arm_classic_ml OP_LIST "${EXECUTORCH_SELECT_OPS_LIST}" OPS_FROM_MODEL
"${ET_PTE_FILE_PATH}"
)

arm_runner_add_minimal_executable(
TARGET arm_classic_ml_runner SOURCE main.cpp OPS_PREFIX arm_classic_ml
)
65 changes: 65 additions & 0 deletions examples/arm/minimal_classic_ml/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Minimal Classic ML runtime

This is the reference ExecuTorch runtime integration for single-shot inference
on Arm bare metal. It loads one embedded program, creates the runtime memory
hierarchy, binds tensor inputs, executes once, and retrieves the outputs.

After running `examples/arm/setup.sh`, export the built-in MobileNetV2 model for
Ethos-U55:

```bash
python3 -m backends.arm.scripts.aot_arm_compiler \
--model_name=mv2 \
--target=ethos-u55-128 \
--delegate \
--quantize \
--intermediate=cmake-out-arm-classic \
--output=cmake-out-arm-classic/mv2.pte \
--system_config=Ethos_U55_High_End_Embedded \
--memory_mode=Shared_Sram
```

Configure and build the standalone minimal application:

```bash
cmake -S examples/arm/minimal_classic_ml -B cmake-out-arm-classic \
-DCMAKE_TOOLCHAIN_FILE="$PWD/examples/arm/ethos-u-setup/arm-none-eabi-gcc.cmake" \
-DCMAKE_BUILD_TYPE=Release \
-DET_PTE_FILE_PATH="$PWD/cmake-out-arm-classic/mv2.pte" \
-DSYSTEM_CONFIG=Ethos_U55_High_End_Embedded \
-DMEMORY_MODE=Shared_Sram
cmake --build cmake-out-arm-classic --target arm_classic_ml_runner -j
```

To load the PTE from a fixed address, set `ET_MODEL_PTE_ADDR`. Set
`ET_MODEL_PTE_SIZE` to the exact PTE size in bytes when it is known; otherwise,
the runner uses the legacy `0x10000000` upper bound. Continue to provide
`ET_PTE_FILE_PATH` for build-time operator selection. In that configuration the
PTE path is not compiled into the ELF. For example:

```bash
cmake -S examples/arm/minimal_classic_ml -B cmake-out-arm-classic \
-DCMAKE_TOOLCHAIN_FILE="$PWD/examples/arm/ethos-u-setup/arm-none-eabi-gcc.cmake" \
-DCMAKE_BUILD_TYPE=Release \
-DET_PTE_FILE_PATH="$PWD/cmake-out-arm-classic/mv2.pte" \
-DET_MODEL_PTE_ADDR=0x70000000 \
-DET_MODEL_PTE_SIZE=$(stat -c%s cmake-out-arm-classic/mv2.pte) \
-DSYSTEM_CONFIG=Ethos_U55_High_End_Embedded \
-DMEMORY_MODE=Shared_Sram
```

Run the result on Corstone-300:

```bash
backends/arm/scripts/run_fvp.sh \
--elf=cmake-out-arm-classic/arm_classic_ml_runner \
--target=ethos-u55-128
```

The target supports the same `SYSTEM_CONFIG`, `MEMORY_MODE`, toolchain, and
allocator-size settings as `arm_executor_runner`. Its input is filled with
deterministic placeholder values; replace that section with the application's
sensor or preprocessing output.

Use `arm_executor_runner` when BundleIO verification, ETDump, semihosted
arbitrary files, profiling, or test instrumentation is required.
272 changes: 272 additions & 0 deletions examples/arm/minimal_classic_ml/main.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
/* Copyright 2026 Arm Limited and/or its affiliates.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree.
*/

#include <executorch/extension/data_loader/buffer_data_loader.h>
#include <executorch/runtime/core/exec_aten/exec_aten.h>
#include <executorch/runtime/core/exec_aten/util/scalar_type_util.h>
#include <executorch/runtime/core/memory_allocator.h>
#include <executorch/runtime/executor/program.h>
#include <executorch/runtime/platform/log.h>
#include <executorch/runtime/platform/platform.h>
#include <executorch/runtime/platform/runtime.h>
#include <stdio.h>
#include <algorithm>
#include <cinttypes>
#include <vector>

#include "arm_memory_allocator.h"

#if defined(ET_COMPILED_PTE)
#include "model_pte.h"
#elif !defined(ET_MODEL_PTE_ADDR) || !defined(ET_MODEL_PTE_SIZE)
#error "Fixed-address models require ET_MODEL_PTE_ADDR and ET_MODEL_PTE_SIZE"
#endif

using executorch::aten::ScalarType;
using executorch::aten::Tensor;
using executorch::aten::TensorImpl;
using executorch::extension::BufferDataLoader;
using executorch::runtime::Error;
using executorch::runtime::EValue;
using executorch::runtime::HierarchicalAllocator;
using executorch::runtime::MemoryManager;
using executorch::runtime::Method;
using executorch::runtime::Program;
using executorch::runtime::Result;
using executorch::runtime::Span;

#if !defined(ET_ARM_BAREMETAL_METHOD_ALLOCATOR_POOL_SIZE)
#define ET_ARM_BAREMETAL_METHOD_ALLOCATOR_POOL_SIZE (4 * 1024 * 1024)
#endif

// Bare-metal targets do not have virtual memory or a large general-purpose
// heap. These statically sized arenas make the runtime's memory use explicit.
// The linker script places the persistent method pool and temporary tensor
// arena in memory that is accessible to both the CPU and Ethos-U.
alignas(16) __attribute__((section("input_data_sec"))) unsigned char method_pool
[ET_ARM_BAREMETAL_METHOD_ALLOCATOR_POOL_SIZE];
alignas(16)
__attribute__((section(".bss.tensor_arena"))) unsigned char temp_pool
[ET_ARM_BAREMETAL_SCRATCH_TEMP_ALLOCATOR_POOL_SIZE];

// Override the platform logging hook so ExecuTorch diagnostics are visible on
// the FVP UART (and on the corresponding output device on real hardware).
[[maybe_unused]] void et_pal_emit_log_message(
ET_UNUSED et_timestamp_t timestamp,
et_pal_log_level_t level,
const char* filename,
ET_UNUSED const char* function,
size_t line,
const char* message,
ET_UNUSED size_t length) {
fprintf(
stderr,
"%c [executorch:%s:%lu] %s\n",
level,
filename,
static_cast<unsigned long>(line),
message);
}

int main() {
// Runtime initialization registers the platform abstraction before any
// Program or Method objects are created.
executorch::runtime::runtime_init();

#if defined(ET_COMPILED_PTE)
// The normal example build converts the PTE into a C array and links it into
// the ELF. sizeof(model_pte) therefore gives the loader an exact bound.
const uint8_t* model_data = model_pte;
const size_t model_size = sizeof(model_pte);
#else
// A production system can keep a large PTE in flash or another
// memory-mapped region instead. The address is supplied by the build.
const uint8_t* model_data =
reinterpret_cast<const uint8_t*>(ET_MODEL_PTE_ADDR);
const size_t model_size = ET_MODEL_PTE_SIZE;
#endif

// BufferDataLoader lets Program read the serialized PTE directly from its
// current memory location, without first copying the whole model.
BufferDataLoader loader(model_data, model_size);
Result<Program> program = Program::load(&loader);
ET_CHECK_MSG(program.ok(), "Program::load failed: 0x%x", program.error());

// Classic ML exports normally contain a single inference method. Reading
// its metadata first tells us how much planned memory must be provided
// before the method itself can be loaded.
const auto method_name = program->get_method_name(0);
ET_CHECK_MSG(method_name.ok(), "Program has no methods");
const auto method_meta = program->method_meta(*method_name);
ET_CHECK_MSG(method_meta.ok(), "Could not load method metadata");

// Persistent allocations, including method state and input storage, come
// from method_pool. Temporary kernel allocations come from temp_pool and
// only need to remain valid during execute().
ArmMemoryAllocator method_allocator(sizeof(method_pool), method_pool);
ArmMemoryAllocator temp_allocator(sizeof(temp_pool), temp_pool);

// The exporter memory-plans intermediate and output tensors into one or more
// numbered buffers. Allocate every requested buffer from the persistent
// arena and preserve the spans for the lifetime of the loaded Method.
std::vector<Span<uint8_t>> planned_spans;
planned_spans.reserve(method_meta->num_memory_planned_buffers());
for (size_t i = 0; i < method_meta->num_memory_planned_buffers(); ++i) {
const size_t size = method_meta->memory_planned_buffer_size(i).get();
auto* buffer = static_cast<uint8_t*>(method_allocator.allocate(size, 16));
ET_CHECK_MSG(buffer != nullptr, "Could not allocate planned buffer %lu", i);
planned_spans.push_back({buffer, size});
}

// HierarchicalAllocator maps the PTE's planned-buffer IDs to the spans above.
// MemoryManager combines planned, persistent, and temporary allocation into
// the three memory classes used while loading and executing a method.
HierarchicalAllocator planned_memory(
{planned_spans.data(), planned_spans.size()});
MemoryManager memory_manager(
&method_allocator, &planned_memory, &temp_allocator);

// Keep Program, all allocators, planned_spans, and Method alive until after
// inference. The Method retains references to memory owned by these objects.
Result<Method> method = program->load_method(*method_name, &memory_manager);
ET_CHECK_MSG(method.ok(), "Could not load method: 0x%x", method.error());

// Graph inputs are caller-owned and are not generally backed by the
// memory-planned buffers. Allocate each input from method_pool, construct a
// tensor with the PTE's exact shape and scalar type, fill it with
// deterministic ones, and bind it with set_input(). Using the method arena
// rather than malloc is important because the bare-metal libc heap is
// intentionally small and cannot hold common image inputs such as
// MobileNetV2's.
const auto loaded_method_meta = method->method_meta();
for (size_t i = 0; i < loaded_method_meta.num_inputs(); ++i) {
const auto input_tag = loaded_method_meta.input_tag(i);
ET_CHECK_MSG(
input_tag.ok(),
"Could not read input %lu metadata",
static_cast<unsigned long>(i));
ET_CHECK_MSG(
input_tag.get() == executorch::runtime::Tag::Tensor,
"Input %lu is not a tensor",
static_cast<unsigned long>(i));

auto tensor_meta = loaded_method_meta.input_tensor_meta(i);
ET_CHECK_MSG(
tensor_meta.ok(),
"Could not read input %lu tensor metadata",
static_cast<unsigned long>(i));
void* input_buffer = method_allocator.allocate(tensor_meta->nbytes(), 16);
ET_CHECK_MSG(
input_buffer != nullptr,
"Could not allocate input %lu",
static_cast<unsigned long>(i));
TensorImpl input_impl(
tensor_meta->scalar_type(),
tensor_meta->sizes().size(),
const_cast<TensorImpl::SizesType*>(tensor_meta->sizes().data()),
input_buffer,
const_cast<TensorImpl::DimOrderType*>(tensor_meta->dim_order().data()));
Tensor input(&input_impl);
switch (input.scalar_type()) {
#define FILL_INPUT(cpp_type, scalar_type) \
case ScalarType::scalar_type: { \
cpp_type* data = input.mutable_data_ptr<cpp_type>(); \
std::fill(data, data + input.numel(), cpp_type(1)); \
break; \
}
ET_FORALL_REALHBBF16_TYPES(FILL_INPUT)
#undef FILL_INPUT
default:
ET_CHECK_MSG(false, "Unsupported input tensor type");
}
ET_CHECK_MSG(
method->set_input(input, i) == Error::Ok,
"Could not prepare input %lu",
static_cast<unsigned long>(i));
}

// All input and planned-buffer storage is now bound, so the method can safely
// execute in place. Delegate calls run on Ethos-U and any remaining
// operators run on the CPU.
ET_CHECK_MSG(method->execute() == Error::Ok, "Inference failed");

// Output EValues reference tensors in planned memory. They do not own or copy
// the tensor data, so consume them while Method and planned_memory are alive.
std::vector<EValue> outputs(method->outputs_size());
ET_CHECK_MSG(
method->get_outputs(outputs.data(), outputs.size()) == Error::Ok,
"Could not get outputs");
ET_LOG(
Info,
"Inference complete: %lu output(s)",
static_cast<unsigned long>(outputs.size()));
for (size_t output_index = 0; output_index < outputs.size(); ++output_index) {
ET_CHECK_MSG(outputs[output_index].isTensor(), "Output is not a tensor");
Tensor output = outputs[output_index].toTensor();

// Print every element so this minimal runner is useful with classification
// logits as well as small integer or boolean test models. Tensor storage is
// interpreted according to ScalarType rather than as untyped bytes.
for (size_t element_index = 0; element_index < output.numel();
++element_index) {
printf(
"Output[%lu][%lu]: ",
static_cast<unsigned long>(output_index),
static_cast<unsigned long>(element_index));
switch (output.scalar_type()) {
case ScalarType::Byte:
printf(
"%u\n",
static_cast<unsigned int>(
output.const_data_ptr<uint8_t>()[element_index]));
break;
case ScalarType::Char:
printf("%d\n", output.const_data_ptr<int8_t>()[element_index]);
break;
case ScalarType::Short:
printf("%d\n", output.const_data_ptr<int16_t>()[element_index]);
break;
case ScalarType::Int:
printf("%d\n", output.const_data_ptr<int32_t>()[element_index]);
break;
case ScalarType::Long:
printf(
"%" PRId64 "\n", output.const_data_ptr<int64_t>()[element_index]);
break;
case ScalarType::Half:
printf(
"%f\n",
static_cast<double>(output.const_data_ptr<
executorch::aten::Half>()[element_index]));
break;
case ScalarType::Float:
printf("%f\n", output.const_data_ptr<float>()[element_index]);
break;
case ScalarType::Double:
printf("%f\n", output.const_data_ptr<double>()[element_index]);
break;
case ScalarType::Bool:
printf(
"%s\n",
output.const_data_ptr<bool>()[element_index] ? "true" : "false");
break;
case ScalarType::BFloat16:
printf(
"%f\n",
static_cast<double>(
output.const_data_ptr<
executorch::aten::BFloat16>()[element_index]));
break;
default:
ET_CHECK_MSG(false, "Unsupported output tensor type");
}
}
}

// ASCII EOT asks the FVP UART model to stop the simulation cleanly.
printf("\04");
return 0;
}
Loading