From 000859ea9f7d142bc638d9ba6ca8e79076711c74 Mon Sep 17 00:00:00 2001 From: Jake Stevens Date: Fri, 16 Oct 2026 10:00:45 -0400 Subject: [PATCH 1/2] Espressif: Add ESP-IDF component template for ExecuTorch Add the hand-written glue the component generator (next PR) stamps out: a registry-shaped CMakeLists.txt gated by CONFIG_ET_ENABLE with WHOLE_ARCHIVE kernel registration and size options (-Os, program verification, debug asserts, kernel registry size), the idf_component.yml and README templates, and et_model, a small C inference API over Program/Method whose method, planned and optional scratch arenas are caller-provided or placed with heap_caps. The component carries only the core runtime and BufferDataLoader, and the shared PAL is copied in by the generator rather than forked here. Authored with AI assistance using Claude Code. --- .../idf_component_template/CMakeLists.txt | 136 +++++ .../idf_component_template/README.md | 79 +++ .../idf_component.yml.in | 14 + .../idf_component_template/include/et_model.h | 179 ++++++ .../idf_component_template/src/et_model.cpp | 560 ++++++++++++++++++ 5 files changed, 968 insertions(+) create mode 100644 examples/espressif/idf_component_template/CMakeLists.txt create mode 100644 examples/espressif/idf_component_template/README.md create mode 100644 examples/espressif/idf_component_template/idf_component.yml.in create mode 100644 examples/espressif/idf_component_template/include/et_model.h create mode 100644 examples/espressif/idf_component_template/src/et_model.cpp diff --git a/examples/espressif/idf_component_template/CMakeLists.txt b/examples/espressif/idf_component_template/CMakeLists.txt new file mode 100644 index 00000000000..c5f346bc4d9 --- /dev/null +++ b/examples/espressif/idf_component_template/CMakeLists.txt @@ -0,0 +1,136 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# ExecuTorch runtime as an ESP-IDF component. +# +# GENERATED by examples/espressif/build_idf_component.sh in the ExecuTorch repo +# -- do not edit this tree by hand. Source lists live in et_generated.cmake next +# to this file; the op profile is selected with CONFIG_ET_OP_PROFILE_* in +# Kconfig (no regeneration needed to switch). + +include(${CMAKE_CURRENT_LIST_DIR}/et_generated.cmake) + +set(_et_requires esp_hw_support esp_system esp_timer log heap) + +if(NOT CONFIG_ET_ENABLE) + # ESP-IDF links every component under components/ into the image, and this + # component's WHOLE_ARCHIVE registration (below) is deliberately GC-proof. The + # gate is off by default, so vendoring the component leaves a project's + # firmware unchanged until CONFIG_ET_ENABLE is set or selected. ESP-IDF's + # component-requirements scan runs this file before Kconfig is loaded, so it + # only ever sees this branch; registering the same requirements here keeps + # those components in the build when the component is enabled. No + # INCLUDE_DIRS: including while disabled fails at compile time + # rather than at link time. + idf_component_register(PRIV_REQUIRES ${_et_requires}) + return() +endif() + +# Pick the Kconfig-selected profile (default: ET_DEFAULT_PROFILE). +set(_et_profile "${ET_DEFAULT_PROFILE}") +foreach(_p IN LISTS ET_PROFILES) + string(TOUPPER "${_p}" _p_up) + if(CONFIG_ET_OP_PROFILE_${_p_up}) + set(_et_profile "${_p}") + endif() +endforeach() +string(TOUPPER "${_et_profile}" _ET_UP) + +set(_et_vendored_srcs ${ET_CORE_SRCS} ${ET_PROFILE_SRCS_${_ET_UP}} + ${ET_KERNEL_SRCS_${_ET_UP}} +) + +# WHOLE_ARCHIVE: kernel registration happens in static initializers in the +# profile's register_kernels.cpp; normal archive extraction would drop that +# translation unit (nothing references its symbols) and every operator would be +# missing at load time. --gc-sections still strips unused runtime and kernel +# code. src/platform/esp_pal.cpp is copied in by the generator from the shared +# examples/espressif/platform/esp_pal.cpp. +idf_component_register( + SRCS + ${_et_vendored_srcs} + "src/et_model.cpp" + "src/platform/esp_pal.cpp" + INCLUDE_DIRS + "include" + PRIV_REQUIRES + ${_et_requires} + WHOLE_ARCHIVE +) + +target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_17) + +set(_et_max_kernel_num ${ET_MAX_KERNEL_NUM_${_ET_UP}}) +if(CONFIG_ET_MAX_KERNEL_NUM) + if(CONFIG_ET_MAX_KERNEL_NUM LESS _et_max_kernel_num) + message( + FATAL_ERROR + "CONFIG_ET_MAX_KERNEL_NUM=${CONFIG_ET_MAX_KERNEL_NUM} is below the " + "${_et_profile} profile's ${_et_max_kernel_num} kernels" + ) + endif() + set(_et_max_kernel_num ${CONFIG_ET_MAX_KERNEL_NUM}) +endif() + +if(CONFIG_ET_PROGRAM_VERIFICATION) + set(_et_program_verification 1) +else() + set(_et_program_verification 0) +endif() + +# Consumers including headers need the same c10 macro mode and +# flatbuffers alignment the runtime was built with. __FUNCTION__ instead of +# __builtin_FUNCTION() keeps log function names without full signatures. +target_compile_definitions( + ${COMPONENT_LIB} + PUBLIC C10_USING_CUSTOM_GENERATED_MACROS FLATBUFFERS_MAX_ALIGNMENT=1024 + ET_USE_BUILTIN_FUNCTION_NAME=0 + PRIVATE MAX_KERNEL_NUM=${_et_max_kernel_num} + ET_ENABLE_PROGRAM_VERIFICATION=${_et_program_verification} +) + +if(NOT CONFIG_ET_DEBUG_ASSERTS) + target_compile_definitions(${COMPONENT_LIB} PRIVATE NDEBUG) +endif() + +if(CONFIG_ET_OPTIMIZE_SIZE) + target_compile_options(${COMPONENT_LIB} PRIVATE -Os) +endif() + +if(NOT CONFIG_ET_ENABLE_LOGGING) + target_compile_definitions(${COMPONENT_LIB} PUBLIC ET_LOG_ENABLED=0) +endif() + +# ET_MIN_LOG_LEVEL compiles out ET_LOG calls below the floor; ET_ESP_LOG_LEVEL +# applies the same floor in esp_pal.cpp. ExecuTorch has no warning level, so +# WARN keeps only errors. +if(CONFIG_ET_LOG_LEVEL_ERROR) + set(_et_min_log_level Error) + set(_et_esp_log_level ESP_LOG_ERROR) +elseif(CONFIG_ET_LOG_LEVEL_WARN) + set(_et_min_log_level Error) + set(_et_esp_log_level ESP_LOG_WARN) +elseif(CONFIG_ET_LOG_LEVEL_DEBUG) + set(_et_min_log_level Debug) + set(_et_esp_log_level ESP_LOG_DEBUG) +else() # CONFIG_ET_LOG_LEVEL_INFO (default) + set(_et_min_log_level Info) + set(_et_esp_log_level ESP_LOG_INFO) +endif() +target_compile_definitions( + ${COMPONENT_LIB} + PUBLIC ET_MIN_LOG_LEVEL=${_et_min_log_level} + PRIVATE ET_ESP_LOG_LEVEL=${_et_esp_log_level} +) + +# ESP-IDF builds with warnings-as-errors; upstream ExecuTorch sources are not +# warning-clean under the IDF flag set, so demote for vendored sources only. +set_source_files_properties( + ${_et_vendored_srcs} + PROPERTIES + COMPILE_OPTIONS + "-Wno-error;-Wno-deprecated-declarations;-Wno-unused-parameter;-Wno-sign-compare;-Wno-unused-variable" +) diff --git a/examples/espressif/idf_component_template/README.md b/examples/espressif/idf_component_template/README.md new file mode 100644 index 00000000000..09cee1aee59 --- /dev/null +++ b/examples/espressif/idf_component_template/README.md @@ -0,0 +1,79 @@ + + +# ExecuTorch for ESP-IDF (`executorch` component) + +**This tree is generated. Do not edit it by hand.** It is produced by +`examples/espressif/build_idf_component.sh` in the +[ExecuTorch](https://github.com/pytorch/executorch) repository: + +```sh +executorch/examples/espressif/build_idf_component.sh \ + --profiles @PROFILES@ --out +``` + +`VERSION` in this directory records the ExecuTorch commit, the op +profiles baked in, and each profile's op-set hash. A `.pte` is only +valid against the profile whose hash its export was checked against; +mismatches surface as `OperatorMissing` at load time. + +## Using it + +Add the component to your project's component requirements (`REQUIRES +executorch`), enable it (`CONFIG_ET_ENABLE=y`, or `select ET_ENABLE` +from your own Kconfig), pick an op profile in `menuconfig` (ExecuTorch -> Op +profile, or `CONFIG_ET_OP_PROFILE_=y` in your sdkconfig), then: + +- `#include ` for the small C inference API (flash XIP + model bytes; method, planned and optional scratch arenas either + passed in or allocated with heap_caps internal/PSRAM placement). This + is what most applications should use. The `.pte` bytes must be + 16-byte aligned; `et_model_create` rejects misaligned data. Application pipelines built on it (audio + front ends, detectors, wake word logic) live with the application, + not in this runtime component. +- `#include ` for the core C++ runtime + (`Program`/`Method`) and `BufferDataLoader`. `extension::Module`, the + file and mmap data loaders, and external `.ptd` data maps are not + included. Runtime logging goes through ESP_LOG under the + `executorch` tag. + +`CONFIG_ET_ENABLE` (default `n`) gates the whole component, so vendoring +it leaves a project's firmware unchanged until it is enabled. While +disabled the component registers empty and `et_model.h` is not on the +include path. + +## Op profiles + +@PROFILE_TABLE@ + +Only the selected profile's kernels are compiled in. Switching +profiles is a Kconfig change and a rebuild; no regeneration. + +## Size and debug options + +`CONFIG_ET_OPTIMIZE_SIZE` builds the runtime with `-Os` regardless of +the project's optimization level. `CONFIG_ET_PROGRAM_VERIFICATION` +compiles in full `.pte` verification, which `et_model_config_t.verify` +requires. `CONFIG_ET_DEBUG_ASSERTS` keeps ExecuTorch's debug +assertions (otherwise the runtime builds with `NDEBUG`). +`CONFIG_ET_MAX_KERNEL_NUM` raises the kernel registry size above the +profile's op count, for applications that register their own kernels. + +## Logging + +`CONFIG_ET_ENABLE_LOGGING=n` compiles ExecuTorch logging out entirely. +Otherwise runtime messages go to ESP_LOG (tag `executorch`), and +`CONFIG_ET_LOG_LEVEL_*` is a compile-time floor (`ET_MIN_LOG_LEVEL`) +on top of the usual IDF runtime levels. `et_model` logs its own +errors under the `et_model` tag. + +## Updating + +Re-run the generator against a newer ExecuTorch checkout and replace +this whole directory. Review the `VERSION` diff (commit, op-set +hashes, file counts, size); do not line-review vendored sources. diff --git a/examples/espressif/idf_component_template/idf_component.yml.in b/examples/espressif/idf_component_template/idf_component.yml.in new file mode 100644 index 00000000000..ab0f95d2935 --- /dev/null +++ b/examples/espressif/idf_component_template/idf_component.yml.in @@ -0,0 +1,14 @@ +## ExecuTorch runtime for ESP-IDF. +## +## GENERATED by examples/espressif/build_idf_component.sh -- do not edit. +## Version is "~"; see VERSION +## in this directory for the exact commit and op-set hashes. Vendored +## third-party code (FlatBuffers, flatcc: Apache-2.0) is listed in +## THIRD_PARTY_LICENSES. No targets list: the runtime is portable +## across ESP targets. +version: "@ET_COMPONENT_VERSION@" +description: ExecuTorch runtime for ESP-IDF +url: https://github.com/pytorch/executorch +license: BSD-3-Clause +dependencies: + idf: ">=6.0" diff --git a/examples/espressif/idf_component_template/include/et_model.h b/examples/espressif/idf_component_template/include/et_model.h new file mode 100644 index 00000000000..4dcd2ddd2ab --- /dev/null +++ b/examples/espressif/idf_component_template/include/et_model.h @@ -0,0 +1,179 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +/* + * et_model: a small C wrapper over the ExecuTorch runtime (Program/Method) + * for ESP-IDF applications that just want to run a .pte from flash. + * + * This header carries no ESP-IDF or C++ types. The model bytes are used + * in place (typically flash XIP) and must be 16-byte aligned. Method + * arenas are either caller-provided or allocated with heap_caps in + * et_model_load_method, and the inference path (set_input/execute/output) + * does not allocate unless a kernel requests scratch memory and there is + * no temp arena. + * + * et_model_config_t cfg = ET_MODEL_CONFIG_DEFAULT(model_pte, + * model_pte_size); + * cfg.planned_location = ET_MEM_INTERNAL; // always-on arenas in SRAM + * et_model_t *m = et_model_create(&cfg); + * if (!m || !et_model_load_method(m, NULL)) { handle error } + * et_model_set_input(m, 0, frame, sizeof(frame)); + * et_model_execute(m); + * size_t n; const void *out = et_model_output_data(m, 0, &n); + * + * Method state (mutable buffers, e.g. streaming-model history) persists + * across et_model_execute calls on the same loaded method, as in the C++ + * API. Loading a method again resets that state. + * + * Not thread-safe: use one et_model_t from one task, like the underlying + * executorch::runtime::Method. + */ + +#pragma once + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Where an arena is allocated (mapped to heap_caps capabilities). */ +typedef enum { + ET_MEM_DEFAULT = 0, /* MALLOC_CAP_DEFAULT: IDF heap policy decides */ + ET_MEM_INTERNAL = 1, /* internal SRAM: fastest, scarcest */ + ET_MEM_PSRAM = 2, /* external PSRAM: fails if the board has none */ +} et_mem_location_t; + +typedef struct { + /* .pte bytes, 16-byte aligned; must outlive the et_model_t. */ + const uint8_t* pte; + size_t pte_size; + /* Each region below can instead use a caller-provided buffer: 16-byte + * aligned, outliving the et_model_t, and never freed by et_model. Its + * location is then ignored. */ + + /* Method allocator pool in bytes (method bookkeeping and non-planned + * tensors); 0 selects the default (64 KiB), and must be set when + * arena_buffer is. */ + size_t arena_size; + et_mem_location_t arena_location; + void* arena_buffer; + /* Scratch pool for kernels and delegates that request temporary memory + * (e.g. FFT, topk), reset after every kernel. 0 (default) means no + * pool; the runtime then mallocs scratch through the PAL during + * execute, only for kernels that need it. */ + size_t temp_arena_size; + et_mem_location_t temp_location; + void* temp_buffer; + /* Memory-planned activation/state buffers, zeroed on every method load + * so mutable state without exported initial values starts at zero. + * Always-on models usually want ET_MEM_INTERNAL; large models may need + * ET_MEM_PSRAM. A caller-provided planned_buffer must hold the total + * reported by et_model_planned_info. */ + et_mem_location_t planned_location; + void* planned_buffer; + size_t planned_buffer_size; + /* Fully verify the program flatbuffer at load. Costs load time; use it + * for .pte files that are not trusted at build time (e.g. OTA). + * Requires CONFIG_ET_PROGRAM_VERIFICATION; et_model_create fails + * otherwise. */ + bool verify; +} et_model_config_t; + +#define ET_MODEL_CONFIG_DEFAULT(pte_ptr, pte_len) \ + { \ + .pte = (pte_ptr), .pte_size = (pte_len), .arena_size = 0, \ + .arena_location = ET_MEM_DEFAULT, .arena_buffer = NULL, \ + .temp_arena_size = 0, .temp_location = ET_MEM_DEFAULT, \ + .temp_buffer = NULL, .planned_location = ET_MEM_DEFAULT, \ + .planned_buffer = NULL, .planned_buffer_size = 0, .verify = false, \ + } + +typedef struct et_model et_model_t; + +/* Loads the program (header check only unless cfg->verify is set). + * Returns NULL on failure (reason is logged via ESP_LOG). Does not load + * a method yet. */ +et_model_t* et_model_create(const et_model_config_t* cfg); +void et_model_destroy(et_model_t* model); + +size_t et_model_num_methods(et_model_t* model); +/* Name of method `index`, owned by the program; NULL if out of range. */ +const char* et_model_method_name(et_model_t* model, size_t index); + +/* Loads `name` (NULL = first method), allocating any arenas and planned + * buffers not provided by the caller. Replaces any previously loaded method and + * resets mutable state; on failure no method is loaded. Methods with more than + * 16 memory-planned buffers are rejected. */ +bool et_model_load_method(et_model_t* model, const char* name); + +/* Planned-buffer footprint of the named (NULL = loaded) method, for + * sizing memory budgets or planned_buffer without loading it. The total + * includes the padding that keeps each buffer 16-byte aligned. Returns false if + * the method does not exist, or if `name` is NULL and no method is + * loaded. Either out pointer may be NULL. */ +bool et_model_planned_info( + et_model_t* model, + const char* name, + size_t* out_num_buffers, + size_t* out_total_bytes); + +size_t et_model_num_inputs(et_model_t* model); +size_t et_model_num_outputs(et_model_t* model); + +/* Tensor metadata, mirroring ExecuTorch's TensorInfo. scalar_type uses + * the executorch::aten::ScalarType numbering (see + * executorch/runtime/core/portable_type/scalar_type.h). */ +#define ET_MODEL_MAX_DIMS 8 +typedef struct { + int32_t scalar_type; + size_t ndim; + int32_t dims[ET_MODEL_MAX_DIMS]; + size_t nbytes; +} et_tensor_info_t; + +/* Fail for non-tensor values and tensors with more than + * ET_MODEL_MAX_DIMS dims. */ +bool et_model_input_info( + et_model_t* model, + size_t index, + et_tensor_info_t* out); +bool et_model_output_info( + et_model_t* model, + size_t index, + et_tensor_info_t* out); + +/* Sets tensor input `index` from `data`. `nbytes` must equal the input's + * exported nbytes (et_model_input_info); dynamic shapes are not supported. + * Memory-planned inputs (the export default) are copied, so `data` may be + * reused immediately or live in flash. Inputs exported without memory + * planning keep pointing at `data`, which must then stay valid and + * unmodified until the input is set again. Non-tensor inputs cannot be + * set and keep their exported values. */ +bool et_model_set_input( + et_model_t* model, + size_t index, + const void* data, + size_t nbytes); + +bool et_model_execute(et_model_t* model); + +/* Output tensor storage after a successful execute; valid until the next + * execute/load/destroy. Returns NULL on error. *out_nbytes may be NULL. */ +const void* +et_model_output_data(et_model_t* model, size_t index, size_t* out_nbytes); + +/* Human-readable reason for the most recent failed call on `model`; + * never NULL. */ +const char* et_model_last_error(et_model_t* model); + +#ifdef __cplusplus +} +#endif diff --git a/examples/espressif/idf_component_template/src/et_model.cpp b/examples/espressif/idf_component_template/src/et_model.cpp new file mode 100644 index 00000000000..8cd55411561 --- /dev/null +++ b/examples/espressif/idf_component_template/src/et_model.cpp @@ -0,0 +1,560 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +// Implementation of et_model.h over executorch::runtime::Program/Method. +// +// Program/Method directly rather than extension::Module: Module keeps its +// methods in std::string-keyed maps and shared_ptr state on the global +// heap, while this API uses caller-provided or heap_caps buffers. Module +// is not vendored in this component. + +#include "et_model.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr char kTag[] = "et_model"; +constexpr size_t kDefaultArenaSize = 64 * 1024; +constexpr size_t kAlignment = 16; +constexpr size_t kMaxPlannedBuffers = 16; + +// program.cpp compiles the verifier in unless this is explicitly 0. +#if defined(ET_ENABLE_PROGRAM_VERIFICATION) && !ET_ENABLE_PROGRAM_VERIFICATION +constexpr bool kHasProgramVerification = false; +#else +constexpr bool kHasProgramVerification = true; +#endif + +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::MemoryAllocator; +using executorch::runtime::MemoryManager; +using executorch::runtime::Method; +using executorch::runtime::MethodMeta; +using executorch::runtime::Program; +using executorch::runtime::Result; +using executorch::runtime::TensorInfo; +using SpanU8 = executorch::runtime::Span; + +bool valid_location(et_mem_location_t loc) { + return static_cast(loc) <= ET_MEM_PSRAM; +} + +bool is_aligned(const void* ptr) { + return reinterpret_cast(ptr) % kAlignment == 0; +} + +size_t align_up(size_t size) { + return (size + kAlignment - 1) / kAlignment * kAlignment; +} + +uint32_t caps_for(et_mem_location_t loc) { + switch (loc) { + case ET_MEM_INTERNAL: + return MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT; + case ET_MEM_PSRAM: + return MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT; + case ET_MEM_DEFAULT: + default: + return MALLOC_CAP_DEFAULT; + } +} + +// Zero-sized planned buffers still need a valid pointer for their span. +uint8_t* caps_alloc(size_t size, et_mem_location_t loc) { + return static_cast( + heap_caps_aligned_alloc(kAlignment, size == 0 ? 1 : size, caps_for(loc))); +} + +} // namespace + +struct et_model { + explicit et_model(const et_model_config_t& c) + : cfg(c), loader(c.pte, c.pte_size) {} + + void fail(const char* what, Error err) { + snprintf( + error, + sizeof(error), + "%s failed (error 0x%x)", + what, + static_cast(err)); + ESP_LOGE(kTag, "%s", error); + } + void fail(const char* what) { + snprintf(error, sizeof(error), "%s", what); + ESP_LOGE(kTag, "%s", error); + } + + void free_method() { + meta.reset(); + method.reset(); + manager.reset(); + planned.reset(); + for (size_t i = 0; i < num_planned_owned; i++) { + heap_caps_free(planned_ptrs[i]); + planned_ptrs[i] = nullptr; + } + num_planned_owned = 0; + temp_allocator.reset(); + heap_caps_free(temp_arena); + temp_arena = nullptr; + allocator.reset(); + heap_caps_free(arena); + arena = nullptr; + } + + et_model_config_t cfg; + BufferDataLoader loader; + std::optional program; + + // Loaded-method state (all zero/null until et_model_load_method). arena, + // temp_arena and planned_ptrs hold only allocations owned by et_model. + uint8_t* arena = nullptr; + std::optional allocator; + uint8_t* temp_arena = nullptr; + std::optional temp_allocator; + uint8_t* planned_ptrs[kMaxPlannedBuffers] = {}; + size_t num_planned_owned = 0; + SpanU8 planned_spans[kMaxPlannedBuffers]; + std::optional planned; + std::optional manager; + std::optional method; + std::optional meta; + + char error[128] = "no error"; +}; + +namespace { + +bool fill_info( + et_model* model, + const Result& info, + et_tensor_info_t* out) { + if (!info.ok()) { + model->fail("tensor_meta", info.error()); + return false; + } + if (out == nullptr) { + model->fail("null et_tensor_info_t"); + return false; + } + if (info->sizes().size() > ET_MODEL_MAX_DIMS) { + model->fail("tensor has more than ET_MODEL_MAX_DIMS dims"); + return false; + } + memset(out, 0, sizeof(*out)); + out->scalar_type = static_cast(info->scalar_type()); + out->ndim = info->sizes().size(); + for (size_t i = 0; i < out->ndim; i++) { + out->dims[i] = info->sizes()[i]; + } + out->nbytes = info->nbytes(); + return true; +} + +// Resolves `name` (NULL = first method). The returned name is either +// `name` or owned by the program. +bool method_meta_for( + et_model* model, + const char* name, + std::optional* out_meta, + const char** out_name) { + const char* resolved = name; + if (resolved == nullptr) { + auto name_res = model->program->get_method_name(0); + if (!name_res.ok()) { + model->fail("program has no methods"); + return false; + } + resolved = *name_res; + } + auto meta_res = model->program->method_meta(resolved); + if (!meta_res.ok()) { + model->fail("method_meta", meta_res.error()); + return false; + } + out_meta->emplace(std::move(meta_res.get())); + *out_name = resolved; + return true; +} + +bool require_method(et_model* model) { + if (!model->method) { + model->fail("no method loaded"); + return false; + } + return true; +} + +} // namespace + +extern "C" { + +et_model_t* et_model_create(const et_model_config_t* cfg) { + if (cfg == nullptr || cfg->pte == nullptr || cfg->pte_size == 0) { + ESP_LOGE(kTag, "et_model_create: invalid config"); + return nullptr; + } + if (!is_aligned(cfg->pte) || !is_aligned(cfg->arena_buffer) || + !is_aligned(cfg->temp_buffer) || !is_aligned(cfg->planned_buffer)) { + ESP_LOGE( + kTag, + "et_model_create: pte and caller buffers must be %zu-byte aligned", + kAlignment); + return nullptr; + } + if ((cfg->arena_buffer != nullptr && cfg->arena_size == 0) || + (cfg->temp_buffer != nullptr && cfg->temp_arena_size == 0) || + (cfg->planned_buffer != nullptr && cfg->planned_buffer_size == 0)) { + ESP_LOGE(kTag, "et_model_create: caller buffer given without its size"); + return nullptr; + } + if (!valid_location(cfg->arena_location) || + !valid_location(cfg->temp_location) || + !valid_location(cfg->planned_location)) { + ESP_LOGE(kTag, "et_model_create: invalid et_mem_location_t"); + return nullptr; + } + if (cfg->verify && !kHasProgramVerification) { + ESP_LOGE( + kTag, + "et_model_create: verify requires CONFIG_ET_PROGRAM_VERIFICATION"); + return nullptr; + } + executorch::runtime::runtime_init(); + + std::unique_ptr model(new (std::nothrow) et_model(*cfg)); + if (!model) { + ESP_LOGE(kTag, "et_model_create: out of memory"); + return nullptr; + } + auto program_res = Program::load( + &model->loader, + cfg->verify ? Program::Verification::InternalConsistency + : Program::Verification::Minimal); + if (!program_res.ok()) { + model->fail("Program::load", program_res.error()); + return nullptr; + } + model->program.emplace(std::move(program_res.get())); + ESP_LOGI( + kTag, + "loaded program: %zu bytes, %zu method(s)", + cfg->pte_size, + static_cast(model->program->num_methods())); + return model.release(); +} + +void et_model_destroy(et_model_t* model) { + if (model == nullptr) { + return; + } + model->free_method(); + delete model; +} + +size_t et_model_num_methods(et_model_t* model) { + if (model == nullptr) { + return 0; + } + return static_cast(model->program->num_methods()); +} + +const char* et_model_method_name(et_model_t* model, size_t index) { + if (model == nullptr) { + return nullptr; + } + auto res = model->program->get_method_name(index); + if (!res.ok()) { + model->fail("get_method_name", res.error()); + return nullptr; + } + return *res; +} + +bool et_model_load_method(et_model_t* model, const char* name) { + if (model == nullptr) { + return false; + } + model->free_method(); + + std::optional meta; + const char* resolved = nullptr; + if (!method_meta_for(model, name, &meta, &resolved)) { + return false; + } + const size_t num_planned = meta->num_memory_planned_buffers(); + if (num_planned > kMaxPlannedBuffers) { + model->fail("too many planned buffers"); + return false; + } + + const size_t arena_size = + model->cfg.arena_size ? model->cfg.arena_size : kDefaultArenaSize; + uint8_t* arena = static_cast(model->cfg.arena_buffer); + if (arena == nullptr) { + arena = model->arena = caps_alloc(arena_size, model->cfg.arena_location); + if (arena == nullptr) { + model->fail("allocating method arena failed"); + return false; + } + } + model->allocator.emplace(static_cast(arena_size), arena); + + const size_t temp_arena_size = model->cfg.temp_arena_size; + if (temp_arena_size != 0) { + uint8_t* temp = static_cast(model->cfg.temp_buffer); + if (temp == nullptr) { + temp = model->temp_arena = + caps_alloc(temp_arena_size, model->cfg.temp_location); + if (temp == nullptr) { + model->free_method(); + model->fail("allocating temp arena failed"); + return false; + } + } + model->temp_allocator.emplace(static_cast(temp_arena_size), temp); + } + + uint8_t* planned_base = static_cast(model->cfg.planned_buffer); + size_t planned_offset = 0; + for (size_t i = 0; i < num_planned; i++) { + auto size_res = meta->memory_planned_buffer_size(i); + if (!size_res.ok()) { + model->free_method(); + model->fail("memory_planned_buffer_size", size_res.error()); + return false; + } + const size_t bytes = static_cast(size_res.get()); + uint8_t* buf = nullptr; + if (planned_base != nullptr) { + if (align_up(bytes) > model->cfg.planned_buffer_size - planned_offset) { + model->free_method(); + model->fail("planned_buffer smaller than et_model_planned_info total"); + return false; + } + buf = planned_base + planned_offset; + planned_offset += align_up(bytes); + } else { + buf = caps_alloc(bytes, model->cfg.planned_location); + if (buf == nullptr) { + model->free_method(); + model->fail("allocating planned buffer failed"); + return false; + } + model->planned_ptrs[i] = buf; + model->num_planned_owned = i + 1; + } + // Mutable buffers exported without initial values start from whatever + // the planned memory holds. + memset(buf, 0, bytes); + model->planned_spans[i] = SpanU8(buf, bytes); + } + model->planned.emplace( + executorch::runtime::Span(model->planned_spans, num_planned)); + // A null temp allocator makes Method fall back to PAL allocation. + model->manager.emplace( + &*model->allocator, + &*model->planned, + model->temp_allocator ? &*model->temp_allocator : nullptr); + + auto method_res = model->program->load_method(resolved, &*model->manager); + if (!method_res.ok()) { + Error err = method_res.error(); + model->free_method(); + model->fail("load_method", err); + return false; + } + model->method.emplace(std::move(method_res.get())); + model->meta.emplace(std::move(*meta)); + ESP_LOGI( + kTag, + "loaded method '%s': arena %zu B, temp %zu B, %zu planned buffer(s)", + resolved, + arena_size, + temp_arena_size, + num_planned); + return true; +} + +bool et_model_planned_info( + et_model_t* model, + const char* name, + size_t* out_num_buffers, + size_t* out_total_bytes) { + if (model == nullptr) { + return false; + } + std::optional meta; + if (name == nullptr) { + if (!model->meta) { + model->fail("no method loaded"); + return false; + } + meta = model->meta; + } else { + const char* resolved = nullptr; + if (!method_meta_for(model, name, &meta, &resolved)) { + return false; + } + } + size_t total = 0; + for (size_t i = 0; i < meta->num_memory_planned_buffers(); i++) { + auto size_res = meta->memory_planned_buffer_size(i); + if (!size_res.ok()) { + model->fail("memory_planned_buffer_size", size_res.error()); + return false; + } + total += align_up(static_cast(size_res.get())); + } + if (out_num_buffers != nullptr) { + *out_num_buffers = meta->num_memory_planned_buffers(); + } + if (out_total_bytes != nullptr) { + *out_total_bytes = total; + } + return true; +} + +size_t et_model_num_inputs(et_model_t* model) { + return (model != nullptr && model->meta) ? model->meta->num_inputs() : 0; +} + +size_t et_model_num_outputs(et_model_t* model) { + return (model != nullptr && model->meta) ? model->meta->num_outputs() : 0; +} + +bool et_model_input_info( + et_model_t* model, + size_t index, + et_tensor_info_t* out) { + if (model == nullptr || !require_method(model)) { + return false; + } + return fill_info(model, model->meta->input_tensor_meta(index), out); +} + +bool et_model_output_info( + et_model_t* model, + size_t index, + et_tensor_info_t* out) { + if (model == nullptr || !require_method(model)) { + return false; + } + return fill_info(model, model->meta->output_tensor_meta(index), out); +} + +bool et_model_set_input( + et_model_t* model, + size_t index, + const void* data, + size_t nbytes) { + if (model == nullptr || !require_method(model)) { + return false; + } + auto info = model->meta->input_tensor_meta(index); + if (!info.ok()) { + model->fail("input_tensor_meta", info.error()); + return false; + } + if (info->nbytes() != nbytes) { + model->fail("input size does not match model"); + return false; + } + const size_t ndim = info->sizes().size(); + if (ndim > ET_MODEL_MAX_DIMS) { + model->fail("input has more than ET_MODEL_MAX_DIMS dims"); + return false; + } + // Method copies planned inputs and shares only the data pointer of + // non-planned ones, so the tensor wrapper can live on the stack. + int32_t sizes[ET_MODEL_MAX_DIMS]; + uint8_t dim_order[ET_MODEL_MAX_DIMS]; + for (size_t d = 0; d < ndim; d++) { + sizes[d] = info->sizes()[d]; + dim_order[d] = info->dim_order()[d]; + } + TensorImpl impl( + info->scalar_type(), + static_cast(ndim), + sizes, + const_cast(data), + dim_order); + Error err = model->method->set_input(EValue(Tensor(&impl)), index); + if (err != Error::Ok) { + model->fail("set_input", err); + return false; + } + return true; +} + +bool et_model_execute(et_model_t* model) { + if (model == nullptr || !require_method(model)) { + return false; + } + Error err = model->method->execute(); + if (err != Error::Ok) { + model->fail("execute", err); + return false; + } + return true; +} + +const void* +et_model_output_data(et_model_t* model, size_t index, size_t* out_nbytes) { + if (out_nbytes != nullptr) { + *out_nbytes = 0; + } + if (model == nullptr || !require_method(model)) { + return nullptr; + } + if (index >= model->method->outputs_size()) { + model->fail("output index out of range"); + return nullptr; + } + const EValue& output = model->method->get_output(index); + if (!output.isTensor()) { + model->fail("output is not a tensor"); + return nullptr; + } + const Tensor& tensor = output.toTensor(); + if (out_nbytes != nullptr) { + *out_nbytes = tensor.nbytes(); + } + return tensor.const_data_ptr(); +} + +const char* et_model_last_error(et_model_t* model) { + return model != nullptr ? model->error : "null model"; +} + +} // extern "C" From 81bead7bbb9def8c9343efd0a9e5333299c0006d Mon Sep 17 00:00:00 2001 From: Jake Stevens Date: Fri, 16 Oct 2026 10:00:56 -0400 Subject: [PATCH 2/2] Espressif: Add ESP-IDF component generator and wake word op profiles build_idf_component.sh generates a self-contained, source-vendored ESP-IDF component from this repository, modeled on the Arduino packaging script: - Schema headers generated with a host flatc (built from third-party/flatbuffers when unavailable); consumers need only ESP-IDF. - Per-profile kernel registration via ExecuTorch selective-build codegen. Profiles may mix aten:: ops (portable kernels) with cadence:: ops (Cadence backend generic/portable kernels, via backends/cadence/aot/functions.yaml with the selection filtered to the profile's roots). Each profile gets its own generated registration; Kconfig selects one without regeneration. - Core runtime and kernel sources vendored from the same build_variables.bzl lists the CMake build uses. - VERSION records the ExecuTorch commit and per-profile op-set SHA-256 + kernel counts, so model exports can be checked against the vendored component. - The component is off until CONFIG_ET_ENABLE=y, so vendoring it leaves firmware unchanged. - idf_component_smoke/ is a minimal consumer. CI (test_espressif_idf_component.sh) generates every shipped profile, builds the consumer for ESP32-S3 against each, and runs the smoke profile on the emulator. Profiles: smoke (add/mul, for CI) and wakeword_int8 (Hey Muse StreamingWakeWord, Cadence-backend int8 export). README documents the generated-component flow next to the existing executor runner. Authored with AI assistance using Claude Code. --- .ci/scripts/test_espressif_idf_component.sh | 76 ++ .github/workflows/test-espressif.yml | 23 + examples/espressif/README.md | 34 + examples/espressif/build_idf_component.sh | 813 ++++++++++++++++++ examples/espressif/export_smoke_model.py | 7 + .../idf_component_smoke/CMakeLists.txt | 20 + .../idf_component_smoke/main/CMakeLists.txt | 10 + .../espressif/idf_component_smoke/main/main.c | 63 ++ .../idf_component_smoke/main/model.S | 18 + .../idf_component_smoke/sdkconfig.defaults | 9 + examples/espressif/idf_profiles/smoke.ops | 5 + .../espressif/idf_profiles/wakeword_int8.ops | 22 + 12 files changed, 1100 insertions(+) create mode 100755 .ci/scripts/test_espressif_idf_component.sh create mode 100755 examples/espressif/build_idf_component.sh create mode 100644 examples/espressif/idf_component_smoke/CMakeLists.txt create mode 100644 examples/espressif/idf_component_smoke/main/CMakeLists.txt create mode 100644 examples/espressif/idf_component_smoke/main/main.c create mode 100644 examples/espressif/idf_component_smoke/main/model.S create mode 100644 examples/espressif/idf_component_smoke/sdkconfig.defaults create mode 100644 examples/espressif/idf_profiles/smoke.ops create mode 100644 examples/espressif/idf_profiles/wakeword_int8.ops diff --git a/.ci/scripts/test_espressif_idf_component.sh b/.ci/scripts/test_espressif_idf_component.sh new file mode 100755 index 00000000000..278277dffb9 --- /dev/null +++ b/.ci/scripts/test_espressif_idf_component.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# Generates the ExecuTorch ESP-IDF component with every shipped profile, +# builds the idf_component_smoke consumer for ESP32-S3 against each, and runs +# the smoke profile build on the emulator. + +set -euo pipefail + +et_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +build_dir="${ESPRESSIF_BUILD_DIR:-${et_root}/cmake-out-esp-idf-component}" +export ESPRESSIF_TOOLS_DIR="${ESPRESSIF_TOOLS_DIR:-${RUNNER_TEMP:-/tmp}/executorch-espressif}" +mkdir -p "${build_dir}" +build_dir=$(cd "${build_dir}" && pwd) +cd "${et_root}" + +collect_artifacts() { + if [[ -n "${RUNNER_ARTIFACT_DIR:-}" ]]; then + mkdir -p "${RUNNER_ARTIFACT_DIR}/esp-idf-component" + for file in "${build_dir}"/*.log "${build_dir}/components/executorch/VERSION"; do + if [[ -f "${file}" ]]; then + cp "${file}" "${RUNNER_ARTIFACT_DIR}/esp-idf-component/" + fi + done + fi +} +trap collect_artifacts EXIT + +bash examples/espressif/setup.sh --minimal 2>&1 | tee "${build_dir}/setup.log" +# shellcheck source=/dev/null +source "${ESPRESSIF_TOOLS_DIR}/setup_path.sh" + +python examples/espressif/export_smoke_model.py \ + --output "${build_dir}/smoke.bpte" --pte-output "${build_dir}/smoke.pte" \ + 2>&1 | tee "${build_dir}/export.log" + +component_dir="${build_dir}/components/executorch" +profiles=$(find examples/espressif/idf_profiles -name '*.ops' -exec basename {} .ops \; \ + | sort | paste -sd, -) +bash examples/espressif/build_idf_component.sh --profiles "${profiles}" \ + --out "${component_dir}" 2>&1 | tee "${build_dir}/generate.log" + +project=examples/espressif/idf_component_smoke +for profile in ${profiles//,/ }; do + profile_upper=$(echo "${profile}" | tr '[:lower:]-' '[:upper:]_') + firmware_dir="${build_dir}/firmware-${profile}" + printf 'CONFIG_ET_OP_PROFILE_%s=y\n' "${profile_upper}" \ + > "${build_dir}/${profile}.defaults" + idf.py -C "${project}" -B "${firmware_dir}" \ + -DIDF_TARGET=esp32s3 \ + -DSDKCONFIG="${build_dir}/sdkconfig-${profile}" \ + "-DSDKCONFIG_DEFAULTS=sdkconfig.defaults;${build_dir}/${profile}.defaults" \ + -DET_COMPONENT_DIR="${component_dir}" \ + -DET_SMOKE_PTE="${build_dir}/smoke.pte" build \ + 2>&1 | tee "${build_dir}/firmware-${profile}.log" +done + +firmware_dir="${build_dir}/firmware-smoke" +idf.py -C "${project}" -B "${firmware_dir}" merge-bin \ + 2>&1 | tee "${build_dir}/firmware-merge.log" +timeout 130s esp-emu --chip esp32s3 \ + --firmware "${firmware_dir}/merged-binary.bin" \ + --elf "${firmware_dir}/et_component_smoke.elf" \ + --net user --timeout 120s --exit-on 'Program complete.' \ + 2>&1 | tee "${build_dir}/emulator.log" +for marker in "ET_COMPONENT_SMOKE: PASS" "Program complete."; do + if ! grep -qF "${marker}" "${build_dir}/emulator.log"; then + echo "FAIL: missing '${marker}'; see ${build_dir}/emulator.log" >&2 + exit 1 + fi +done +echo "PASS: generated ESP-IDF component ran the smoke model on the ESP32-S3 emulator." diff --git a/.github/workflows/test-espressif.yml b/.github/workflows/test-espressif.yml index a36d1573ebc..823b6a804c2 100644 --- a/.github/workflows/test-espressif.yml +++ b/.github/workflows/test-espressif.yml @@ -12,6 +12,7 @@ on: - tools/cmake/preset/esp_baremetal.cmake - CMakePresets.json - .ci/scripts/test_espressif.sh + - .ci/scripts/test_espressif_idf_component.sh - .github/workflows/test-espressif.yml schedule: - cron: '0 4 * * 1' @@ -51,3 +52,25 @@ jobs: source .ci/scripts/utils.sh install_executorch --use-pt-pinned-commit --minimal ESPRESSIF_NN_KERNELS="${{ matrix.kernels }}" bash .ci/scripts/test_espressif.sh + + test-idf-component: + name: ESP-IDF component (ESP32-S3) + needs: docker-image + uses: pytorch/test-infra/.github/workflows/linux_job_v3.yml@main + permissions: + id-token: write + contents: read + with: + runner: mt-l-x86iavx512-8-64 + docker-image: ${{ needs.docker-image.outputs.docker-registry }}/ci-image:executorch-ubuntu-22.04-clang12-${{ needs.docker-image.outputs.ci-docker-hash }} + submodules: recursive + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + timeout: 45 + upload-artifact: test-report-esp-idf-component + script: | + set -euo pipefail + CONDA_ENV=$(conda env list --json | jq -r '.envs | .[-1]') + conda activate "${CONDA_ENV}" + source .ci/scripts/utils.sh + install_executorch --use-pt-pinned-commit --minimal + bash .ci/scripts/test_espressif_idf_component.sh diff --git a/examples/espressif/README.md b/examples/espressif/README.md index 85d72f42dfe..8df3e285020 100644 --- a/examples/espressif/README.md +++ b/examples/espressif/README.md @@ -8,6 +8,40 @@ ESP32-S3 microcontrollers. It is based on the [Arm Cortex-M executor runner](../arm/executor_runner/) and adapted for the ESP-IDF build system and ESP32 memory architecture. +## ESP-IDF component (generated, source-vendored) + +For integrating ExecuTorch into an existing ESP-IDF project without the +cross-compile-then-link flow below, generate a self-contained ESP-IDF +component from sources in this repository: + +```bash +./examples/espressif/build_idf_component.sh --profiles wakeword_int8 \ + --out /path/to/your_project/components/executorch +``` + +The output is a registry-shaped component (`idf_component.yml`, +`CMakeLists.txt`, `Kconfig`, `VERSION`) containing the runtime, +per-profile kernel registration (selected via +`CONFIG_ET_OP_PROFILE_*`; the component is off until +`CONFIG_ET_ENABLE=y`), the shared ESP-IDF PAL from +`platform/esp_pal.cpp` (ESP_LOG + `esp_timer` timing), and a C +inference API (`et_model.h`, flash-XIP model bytes +with internal/PSRAM arena placement). Application pipelines built on +the runtime (for example wake word front ends and detectors) stay +with the application. Op profiles are defined in +`idf_profiles/.ops`; `wakeword_int8` is an int8 wake-word +example profile for a Cadence-backend export — profiles may list +`cadence::` ops, which the generator binds to the Cadence backend's +generic (portable) kernels under `backends/cadence/generic`, next to +the `aten::` ops the same profile needs from the portable kernels. +Consumers need only ESP-IDF — no host CMake build, no flatc. +`idf_component_smoke/` is a minimal consumer that CI generates, builds +and runs on the ESP32-S3 emulator +(`.ci/scripts/test_espressif_idf_component.sh`). + +The executor runner below remains the reference for the prebuilt-library +flow and for benchmarking. + ## Supported Targets | Chip | CPU | Internal SRAM | PSRAM (optional) | diff --git a/examples/espressif/build_idf_component.sh b/examples/espressif/build_idf_component.sh new file mode 100755 index 00000000000..160c848a9f8 --- /dev/null +++ b/examples/espressif/build_idf_component.sh @@ -0,0 +1,813 @@ +#!/bin/bash +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# build_idf_component.sh -- Generate the ExecuTorch ESP-IDF component +# (source-vendored, registry-shaped) from this repository. +# +# Usage: +# ./build_idf_component.sh --profiles wakeword_int8 --out /path/to/components/executorch +# ./build_idf_component.sh --version +# ./build_idf_component.sh --out /path/to/components/executorch --clean +# +# Derived from examples/arduino/build_arduino_library.sh with an ESP-IDF +# output layout, the shared ESP-IDF PAL, and per-profile kernel +# registration: each +# profile in idf_profiles/.ops gets its own generated registration +# file + kernel source list, and the component's Kconfig choice selects +# which one compiles. Switching profiles never needs regeneration. +# A profile may also list cadence:: ops; those are codegen'd against +# backends/cadence/aot/functions.yaml and bound to the Cadence backend's +# generic (portable) kernels under backends/cadence/generic. +# +# Host requirements: python3 with ExecuTorch + torchgen importable (for +# kernel codegen), cmake (to build flatc from third-party/flatbuffers +# when no flatc is available), and git submodules third-party/flatcc + +# third-party/flatbuffers initialized. Consumers of the output need +# only ESP-IDF. Set PYTHON=... / FLATC=... to override discovery. +# +# NOTE: This script is coupled to the ExecuTorch source tree layout, but +# its source file lists come from the same build_variables.bzl the +# CMake build uses, so upstream file moves are picked up automatically. +# Consumer builds for Xtensa + RISC-V targets are the safety net. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ET_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +TEMPLATE_DIR="$SCRIPT_DIR/idf_component_template" +ESP_PAL_SRC="$SCRIPT_DIR/platform/esp_pal.cpp" +PYTHON="${PYTHON:-python3}" +PACKAGING_REV=3 # bump when the packaging (not ExecuTorch) changes + +PROFILES_CSV="" +OUT_DIR="" +CLEAN=false + +usage() { + echo "Usage: $0 --profiles --out [--clean]" >&2 + echo " $0 --version" >&2 + echo "Profiles are defined in $SCRIPT_DIR/idf_profiles/.ops" >&2 +} + +while [ $# -gt 0 ]; do + case "$1" in + --profiles) PROFILES_CSV="$2"; shift 2 ;; + --out) OUT_DIR="$2"; shift 2 ;; + --clean) CLEAN=true; shift ;; + --version) + echo "$(cat "$ET_ROOT/version.txt")~$PACKAGING_REV" + exit 0 + ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown argument: $1" >&2; usage; exit 1 ;; + esac +done + +if [ -z "$OUT_DIR" ]; then + echo "ERROR: --out is required." >&2; usage; exit 1 +fi +OUT_DIR_AS_GIVEN="$OUT_DIR" +# Normalize to an absolute path (dir may not exist yet). +case "$OUT_DIR" in + /*) ;; + *) OUT_DIR="$PWD/$OUT_DIR" ;; +esac + +if [ "$CLEAN" = true ]; then + if [ -e "$OUT_DIR/et_generated.cmake" ] || [ -e "$OUT_DIR/.et-generated-component" ]; then + rm -rf "$OUT_DIR" + echo "Cleaned $OUT_DIR" + else + echo "Nothing to clean: $OUT_DIR is not a generated component." >&2 + fi + exit 0 +fi + +if [ -z "$PROFILES_CSV" ]; then + echo "ERROR: --profiles is required (e.g. --profiles wakeword_int8)." >&2 + usage; exit 1 +fi +IFS=',' read -r -a PROFILES <<< "$PROFILES_CSV" +for p in "${PROFILES[@]}"; do + if [ ! -f "$SCRIPT_DIR/idf_profiles/$p.ops" ]; then + echo "ERROR: unknown profile '$p' (no idf_profiles/$p.ops)." >&2 + exit 1 + fi +done + +ET_VERSION=$(cat "$ET_ROOT/version.txt") +if [[ ! "$ET_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "ERROR: version.txt must contain an ExecuTorch version in X.Y.Z form." >&2 + exit 1 +fi +ET_COMPONENT_VERSION="${ET_VERSION}~${PACKAGING_REV}" + +for dep in flatbuffers flatcc; do + if [ ! -f "$ET_ROOT/third-party/$dep/LICENSE" ]; then + echo "ERROR: third-party/$dep is empty. Run: git submodule update --init third-party/$dep" >&2 + exit 1 + fi +done + +# Refuse to clobber a directory that is not a previous run's output. +if [ -d "$OUT_DIR" ] && [ -n "$(ls -A "$OUT_DIR" 2>/dev/null)" ] \ + && [ ! -e "$OUT_DIR/et_generated.cmake" ] \ + && [ ! -e "$OUT_DIR/.et-generated-component" ]; then + echo "ERROR: $OUT_DIR exists and is not a generated component; refusing to overwrite." >&2 + exit 1 +fi + +echo "=== Building ExecuTorch ESP-IDF component ===" +echo " ET repo: $ET_ROOT" +echo " Version: $ET_COMPONENT_VERSION (ExecuTorch $ET_VERSION)" +echo " Profiles: ${PROFILES[*]}" +echo " Output: $OUT_DIR" + +# Print one source list from build/build_variables.bzl (same mechanism +# as tools/cmake/Codegen.cmake:executorch_append_filelist). +bzl_list() { + "$PYTHON" -c " +exec(open('$ET_ROOT/shim_et/xplat/executorch/build/build_variables.bzl').read()) +for f in $1: print(f) +" +} + +# Print the sorted, comma-joined operator names of a selected_operators.yaml. +sel_ops_of_yaml() { + "$PYTHON" - "$1" <<'PYEOF' +import sys +in_ops = False +ops = [] +for line in open(sys.argv[1]): + if line.startswith("operators:"): + in_ops = True + continue + if in_ops: + if line.startswith(" ") and not line.startswith(" ") and ":" in line: + key = line.strip() + ops.append(key[:-1] if key.endswith(":") else key) + elif line and not line[0].isspace(): + break +print(",".join(sorted(ops))) +PYEOF +} + +SCRATCH="$(mktemp -d)" +trap 'rm -rf "$SCRATCH"' EXIT +mkdir -p "$SCRATCH/codegen" + +# ───────────────────────────────────────────────────────── +# 1. Generate schema headers with flatc (host tool only; +# consumers never need it). Same flags + inputs as +# schema/CMakeLists.txt and extension/flat_tensor. +# ───────────────────────────────────────────────────────── +FLATC="${FLATC:-}" +if [ -z "$FLATC" ]; then + if command -v flatc >/dev/null 2>&1; then + FLATC="$(command -v flatc)" + else + for cand in "$ET_ROOT/cmake-out/third-party/flatc_ep/bin/flatc" \ + "$ET_ROOT/cmake-out-mac/third-party/flatc_ep/bin/flatc" \ + "$ET_ROOT/build/third-party/flatc_ep/bin/flatc"; do + if [ -x "$cand" ]; then FLATC="$cand"; break; fi + done + fi +fi +if [ -z "$FLATC" ]; then + echo " Building flatc from third-party/flatbuffers ..." + cmake -S "$ET_ROOT/third-party/flatbuffers" -B "$SCRATCH/flatc-build" \ + -DCMAKE_BUILD_TYPE=Release -DFLATBUFFERS_BUILD_TESTS=OFF \ + -DFLATBUFFERS_BUILD_FLATLIB=OFF -DFLATBUFFERS_BUILD_SHAREDLIB=OFF \ + -DFLATBUFFERS_INSTALL=OFF > "$SCRATCH/flatc-config.log" 2>&1 + cmake --build "$SCRATCH/flatc-build" --target flatc -j 4 \ + > "$SCRATCH/flatc-build.log" 2>&1 || { + echo "ERROR: flatc build failed; see $SCRATCH/flatc-build.log" >&2 + trap - EXIT; exit 1; } + FLATC="$SCRATCH/flatc-build/flatc" +fi +echo " flatc: $FLATC" + +rm -rf "$OUT_DIR" +mkdir -p "$OUT_DIR" +# Marker so a partial output from a failed run is recognised (and may +# be overwritten) by the overwrite guard above. +echo "generated by build_idf_component.sh (in progress)" \ + > "$OUT_DIR/.et-generated-component" +mkdir -p "$OUT_DIR/include/executorch/schema" \ + "$OUT_DIR/include/executorch/extension/flat_tensor/serialize" \ + "$OUT_DIR/src/executorch" "$OUT_DIR/src/profiles" + +( cd "$ET_ROOT/schema" && "$FLATC" --cpp --cpp-std c++11 --gen-mutable \ + --scoped-enums -o "$OUT_DIR/include/executorch/schema" scalar_type.fbs ) +( cd "$ET_ROOT/schema" && "$FLATC" --cpp --cpp-std c++11 --gen-mutable \ + --scoped-enums -o "$OUT_DIR/include/executorch/schema" program.fbs ) +( cd "$ET_ROOT/extension/flat_tensor/serialize" && "$FLATC" --cpp \ + --cpp-std c++11 --gen-mutable --scoped-enums \ + -o "$OUT_DIR/include/executorch/extension/flat_tensor/serialize" \ + scalar_type.fbs flat_tensor.fbs ) + +for h in "$OUT_DIR/include/executorch/schema/program_generated.h" \ + "$OUT_DIR/include/executorch/extension/flat_tensor/serialize/flat_tensor_generated.h"; do + if [ ! -f "$h" ]; then + echo "ERROR: flatc did not produce $h" >&2; exit 1 + fi +done +# program_generated.h must include its sibling scalar_type header in a +# way that resolves through or same-directory; +# anything else means the include layout drifted and builds will break. +if grep -E '#include ".*scalar_type_generated\.h"' \ + "$OUT_DIR/include/executorch/schema/program_generated.h" \ + | grep -qvE '#include "(executorch/schema/)?scalar_type_generated\.h"'; then + echo "ERROR: unexpected include form in program_generated.h" >&2; exit 1 +fi +echo "[1/6] Schema headers generated" + +# ───────────────────────────────────────────────────────── +# 2. Per-profile kernel registration codegen. +# Same recipe as the CMake selective build for portable kernels +# (gen_oplist --root_ops + codegen.gen over kernels/portable/ +# functions.yaml), run once per profile. +# ───────────────────────────────────────────────────────── +if ! TORCHGEN=$("$PYTHON" -c "import torchgen, os; print(os.path.dirname(torchgen.__file__))" 2>/dev/null); then + echo "ERROR: cannot import torchgen with $PYTHON." >&2 + echo " Kernel codegen needs it. Run ./install_executorch.sh, or set" >&2 + echo " PYTHON=... to an interpreter that has ExecuTorch installed." >&2 + exit 1 +fi +for yaml in "$TORCHGEN/packaged/ATen/native/tags.yaml" \ + "$TORCHGEN/packaged/ATen/native/native_functions.yaml"; do + if [ ! -f "$yaml" ]; then + echo "ERROR: $yaml is missing from the torchgen at $TORCHGEN." >&2 + exit 1 + fi +done + +# The exporter and the runtime must be the same ExecuTorch, or models +# fail at Method::execute with errors that do not say why. Warn loudly +# when the installed Python package was built from another commit. +ET_PY_SHA=$("$PYTHON" -c "import executorch.version as v; print(getattr(v, 'git_version', ''))" 2>/dev/null || echo "") +ET_GIT_SHA=$(git -C "$ET_ROOT" rev-parse HEAD 2>/dev/null || echo "") +if [ -z "$ET_PY_SHA" ]; then + echo " NOTE: no executorch Python package found for $PYTHON; codegen may still work." +elif [ -n "$ET_GIT_SHA" ] && [ "$ET_PY_SHA" != "$ET_GIT_SHA" ]; then + echo " WARNING: $PYTHON has executorch built from ${ET_PY_SHA:0:12}, this tree" + echo " is at ${ET_GIT_SHA:0:12}. Models exported with it may not match" + echo " this component. Run ./install_executorch.sh from this checkout." +fi + +mkdir -p "$SCRATCH/kernel_union" +: > "$SCRATCH/kernel_union/files.txt" +: > "$SCRATCH/version_profiles.txt" +: > "$SCRATCH/profile_table.md" + +for p in "${PROFILES[@]}"; do + OPS_FILE="$SCRIPT_DIR/idf_profiles/$p.ops" + ROOT_OPS=$(grep -v '^[[:space:]]*#' "$OPS_FILE" | grep -v '^[[:space:]]*$' \ + | tr '\n' ',' | sed 's/,$//') + NROOT=$(echo "$ROOT_OPS" | tr ',' '\n' | wc -l | tr -d ' ') + # A profile may mix aten:: ops (portable kernels) with cadence:: ops + # (Cadence backend generic/portable kernels); each namespace gets its + # own codegen run + registration file below. + ATEN_OPS=$(echo "$ROOT_OPS" | tr ',' '\n' | grep -v '^cadence::' \ + | tr '\n' ',' | sed 's/,$//') + CADENCE_OPS=$(echo "$ROOT_OPS" | tr ',' '\n' | grep '^cadence::' \ + | tr '\n' ',' | sed 's/,$//' || true) + GEN_DIR="$SCRATCH/codegen/$p" + mkdir -p "$GEN_DIR" + + ( cd "$ET_ROOT" && \ + "$PYTHON" -m codegen.tools.gen_oplist \ + --output_path="$GEN_DIR/selected_operators.yaml" \ + --root_ops="$ATEN_OPS" && \ + "$PYTHON" -m codegen.gen \ + --source-path="$ET_ROOT/codegen" \ + --install-dir="$GEN_DIR" \ + --tags-path="$TORCHGEN/packaged/ATen/native/tags.yaml" \ + --aten-yaml-path="$TORCHGEN/packaged/ATen/native/native_functions.yaml" \ + --op-selection-yaml-path="$GEN_DIR/selected_operators.yaml" \ + --functions-yaml-path="$ET_ROOT/kernels/portable/functions.yaml" \ + ) > "$GEN_DIR/codegen.log" 2>&1 || { + echo "ERROR: codegen failed for profile '$p'; log follows:" >&2 + cat "$GEN_DIR/codegen.log" >&2; trap - EXIT; exit 1; } + # Same content under two names; keeping both is a duplicate symbol. + rm -f "$GEN_DIR/RegisterCodegenUnboxedKernels_0.cpp" + + if [ ! -f "$GEN_DIR/RegisterCodegenUnboxedKernelsEverything.cpp" ]; then + echo "ERROR: codegen produced no registration file for profile '$p'." >&2 + exit 1 + fi + NKERNELS=$(grep -c 'Kernel(' "$GEN_DIR/RegisterCodegenUnboxedKernelsEverything.cpp") + + # Cadence backend ops (cadence::): same two-step codegen against + # backends/cadence/aot/functions.yaml, whose generic kernel bindings + # point at the portable implementations under backends/cadence/generic + # (the same recipe as Codegen.cmake gen_selected_ops + + # generate_bindings_for_kernels). gen_oplist with the Cadence schema + # yaml marks every op in that file as a root (97 ops, mostly aten + # entries this component already registers from kernels/portable), so + # the selection is filtered down to the profile's cadence:: roots. + CAD_NKERNELS=0 + CADENCE_YAML="" + if [ -n "$CADENCE_OPS" ]; then + CADENCE_YAML_FILE="$ET_ROOT/backends/cadence/aot/functions.yaml" + CAD_DIR="$GEN_DIR/cadence" + mkdir -p "$CAD_DIR" + ( cd "$ET_ROOT" && \ + "$PYTHON" -m codegen.tools.gen_oplist \ + --output_path="$CAD_DIR/selected_operators_unfiltered.yaml" \ + --ops_schema_yaml_path="$CADENCE_YAML_FILE" \ + --root_ops="$CADENCE_OPS" \ + ) > "$CAD_DIR/codegen.log" 2>&1 || { + echo "ERROR: cadence gen_oplist failed for profile '$p'; log follows:" >&2 + cat "$CAD_DIR/codegen.log" >&2; trap - EXIT; exit 1; } + if ! "$PYTHON" - "$CAD_DIR/selected_operators_unfiltered.yaml" \ + "$CAD_DIR/selected_operators.yaml" "$CADENCE_OPS" \ + >> "$CAD_DIR/codegen.log" 2>&1 <<'PYEOF' +import sys +import yaml +keep = set(sys.argv[3].split(",")) +d = yaml.safe_load(open(sys.argv[1])) +d["operators"] = {k: v for k, v in d["operators"].items() if k in keep} +d["et_kernel_metadata"] = { + k: v for k, v in d.get("et_kernel_metadata", {}).items() if k in keep +} +missing = keep - set(d["operators"]) +if missing: + sys.exit(f"cadence root ops not in functions.yaml: {sorted(missing)}") +yaml.safe_dump(d, open(sys.argv[2], "w"), sort_keys=True) +PYEOF + then + echo "ERROR: cadence op selection filter failed for profile '$p'; log follows:" >&2 + cat "$CAD_DIR/codegen.log" >&2; trap - EXIT; exit 1 + fi + ( cd "$ET_ROOT" && \ + "$PYTHON" -m codegen.gen \ + --source-path="$ET_ROOT/codegen" \ + --install-dir="$CAD_DIR" \ + --tags-path="$TORCHGEN/packaged/ATen/native/tags.yaml" \ + --aten-yaml-path="$TORCHGEN/packaged/ATen/native/native_functions.yaml" \ + --op-selection-yaml-path="$CAD_DIR/selected_operators.yaml" \ + --functions-yaml-path="$CADENCE_YAML_FILE" \ + ) >> "$CAD_DIR/codegen.log" 2>&1 || { + echo "ERROR: cadence codegen failed for profile '$p'; log follows:" >&2 + cat "$CAD_DIR/codegen.log" >&2; trap - EXIT; exit 1; } + rm -f "$CAD_DIR/RegisterCodegenUnboxedKernels_0.cpp" + if [ ! -f "$CAD_DIR/RegisterCodegenUnboxedKernelsEverything.cpp" ]; then + echo "ERROR: cadence codegen produced no registration file for profile '$p'." >&2 + exit 1 + fi + CAD_NKERNELS=$(grep -c 'Kernel(' "$CAD_DIR/RegisterCodegenUnboxedKernelsEverything.cpp") + NKERNELS=$((NKERNELS + CAD_NKERNELS)) + CADENCE_YAML="$CAD_DIR/selected_operators.yaml" + fi + + ( cd "$ET_ROOT" && "$PYTHON" -m codegen.tools.gen_max_kernel_num \ + --oplist-yaml="$GEN_DIR/selected_operators.yaml" \ + ${CADENCE_YAML:+--oplist-yaml="$CADENCE_YAML"} \ + --prim-ops-source="$ET_ROOT/kernels/prim_ops/register_prim_ops.cpp" \ + --output-path="$GEN_DIR/selected_max_kernel_num.h" ) >/dev/null 2>&1 + MAXK=$(sed -n 's/.*EXECUTORCH_SELECTED_MAX_KERNEL_NUM \([0-9]*\).*/\1/p' \ + "$GEN_DIR/selected_max_kernel_num.h") + if [ -z "$MAXK" ]; then + echo "ERROR: could not determine MAX_KERNEL_NUM for profile '$p'." >&2 + exit 1 + fi + + # Effective op set + stable hash, from selected_operators.yaml. + SEL_OPS=$(sel_ops_of_yaml "$GEN_DIR/selected_operators.yaml") + if [ -n "$CADENCE_YAML" ]; then + SEL_OPS=$(printf '%s,%s' "$SEL_OPS" "$(sel_ops_of_yaml "$CADENCE_YAML")" \ + | tr ',' '\n' | sort -u | tr '\n' ',' | sed 's/,$//') + fi + OP_HASH=$(printf '%s' "$SEL_OPS" | "$PYTHON" -c \ + "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())") + + # Map each referenced kernel function to the portable .cpp defining + # it. Over-inclusion is safe (link-time GC drops the rest); a kernel + # that maps to no file is a hard error here rather than on device. + : > "$GEN_DIR/kernel_files.txt" + MISSING="" + for fn in $(grep -o 'native::[A-Za-z_0-9]*' \ + "$GEN_DIR/RegisterCodegenUnboxedKernelsEverything.cpp" \ + | sed 's/native:://' | sort -u); do + found=false + while IFS= read -r f; do + if grep -q "namespace native" "$f"; then + echo "${f#"$ET_ROOT"/}" >> "$GEN_DIR/kernel_files.txt" + found=true + fi + # Match plain definitions `fn(` and macro definitions + # DEFINE_..._UFUNC(fn, ...) used by the unary/binary patterns. + done < <(grep -rlE "(^|[^A-Za-z0-9_])${fn}[(,]" \ + "$ET_ROOT/kernels/portable/cpu" --include='*.cpp' || true) + if [ "$found" = false ]; then MISSING="$MISSING $fn"; fi + done + # Macro/pattern-generated kernels live here and evade the name grep. + find "$ET_ROOT/kernels/portable/cpu/pattern" -name '*.cpp' \ + | sed "s|^$ET_ROOT/||" >> "$GEN_DIR/kernel_files.txt" + if [ -n "$CADENCE_OPS" ]; then + for fn in $(grep -o 'native::[A-Za-z_0-9]*' \ + "$CAD_DIR/RegisterCodegenUnboxedKernelsEverything.cpp" \ + | sed 's/native:://' | sort -u); do + found=false + while IFS= read -r f; do + if grep -q "namespace native" "$f"; then + echo "${f#"$ET_ROOT"/}" >> "$GEN_DIR/kernel_files.txt" + found=true + fi + done < <(grep -rlE "(^|[^A-Za-z0-9_])${fn}[(,]" \ + "$ET_ROOT/backends/cadence/generic" --include='*.cpp' || true) + if [ "$found" = false ]; then MISSING="$MISSING cadence:$fn"; fi + done + # Shared quantize/requantize helpers the generic operators call. + echo "backends/cadence/generic/kernels/kernels.cpp" \ + >> "$GEN_DIR/kernel_files.txt" + fi + sort -u "$GEN_DIR/kernel_files.txt" -o "$GEN_DIR/kernel_files.txt" + if [ -n "$MISSING" ]; then + echo "ERROR: profile '$p': no portable kernel source found for:$MISSING" >&2 + echo " (they may be macro-generated in an unexpected file; extend the mapping above)" >&2 + exit 1 + fi + + mkdir -p "$OUT_DIR/src/profiles/$p" + cp "$GEN_DIR/RegisterCodegenUnboxedKernelsEverything.cpp" \ + "$OUT_DIR/src/profiles/$p/register_kernels.cpp" + cp "$GEN_DIR/NativeFunctions.h" "$GEN_DIR/Functions.h" \ + "$OUT_DIR/src/profiles/$p/" + { + echo "src/profiles/$p/register_kernels.cpp" + if [ -n "$CADENCE_OPS" ]; then + # The cadence registration file includes its own NativeFunctions.h + # (same file names as the aten ones, different contents), so it + # gets its own directory, like a second generated library. + mkdir -p "$OUT_DIR/src/profiles/$p/cadence" + cp "$CAD_DIR/RegisterCodegenUnboxedKernelsEverything.cpp" \ + "$OUT_DIR/src/profiles/$p/cadence/register_kernels.cpp" + cp "$CAD_DIR/NativeFunctions.h" "$CAD_DIR/Functions.h" \ + "$OUT_DIR/src/profiles/$p/cadence/" + echo "src/profiles/$p/cadence/register_kernels.cpp" + fi + } > "$GEN_DIR/profile_srcs.txt" + + cat "$GEN_DIR/kernel_files.txt" >> "$SCRATCH/kernel_union/files.txt" + { + echo " $p:" + echo " root_ops: $NROOT (idf_profiles/$p.ops)" + echo " registered_kernels: $NKERNELS" + echo " max_kernel_num: $MAXK" + echo " op_set_sha256: $OP_HASH" + echo " selected_ops: $SEL_OPS" + } >> "$SCRATCH/version_profiles.txt" + echo "| \`$p\` | $NROOT root ops, $NKERNELS kernels | \`${OP_HASH:0:12}...\` |" \ + >> "$SCRATCH/profile_table.md" + echo "$MAXK" > "$GEN_DIR/max_kernel_num.txt" + echo " profile $p: $NROOT root ops -> $NKERNELS kernels (registry $MAXK)" +done +sort -u "$SCRATCH/kernel_union/files.txt" -o "$SCRATCH/kernel_union/files.txt" +echo "[2/6] Kernel registration generated for: ${PROFILES[*]}" + +# ───────────────────────────────────────────────────────── +# 3. Vendor runtime + extension sources (build_variables.bzl +# lists) and the union of profile kernel sources. +# Output mirrors upstream paths under src/executorch/. +# ───────────────────────────────────────────────────────── +: > "$SCRATCH/core_srcs.txt" + +copy_bzl_list() { # $1=bzl variable name; appends to core_srcs.txt + local f + bzl_list "$1" | while IFS= read -r f; do + if [ ! -f "$ET_ROOT/$f" ]; then + echo "ERROR: build_variables.bzl lists missing file: $f" >&2; exit 1 + fi + mkdir -p "$OUT_DIR/src/executorch/$(dirname "$f")" + cp "$ET_ROOT/$f" "$OUT_DIR/src/executorch/$f" + echo "src/executorch/$f" >> "$SCRATCH/core_srcs.txt" + done +} + +copy_bzl_list EXECUTORCH_CORE_SRCS +copy_bzl_list EXECUTORCH_SRCS # prim ops +copy_bzl_list KERNELS_UTIL_ALL_DEPS_SRCS + +# Kernel sources needed by at least one shipped profile. +: > "$SCRATCH/kernel_srcs_by_profile.txt" +while IFS= read -r f; do + mkdir -p "$OUT_DIR/src/executorch/$(dirname "$f")" + cp "$ET_ROOT/$f" "$OUT_DIR/src/executorch/$f" +done < "$SCRATCH/kernel_union/files.txt" + +echo "[3/6] Runtime, extension and kernel sources copied" + +# ───────────────────────────────────────────────────────── +# 4. Vendor headers: executorch/** (public), c10/torch, +# flatbuffers, flatcc (+ its C runtime). +# ───────────────────────────────────────────────────────── +copy_headers() { # $1=src dir under ET_ROOT, $2=dest under OUT/include + local src_dir="$1" dest_dir="$2" f + find "$ET_ROOT/$src_dir" \( -name '*.h' -o -name '*.hpp' \) \ + -not -path '*/test/*' | while IFS= read -r f; do + local rel="${f#"$ET_ROOT/$src_dir"/}" + mkdir -p "$OUT_DIR/include/$dest_dir/$(dirname "$rel")" + cp "$f" "$OUT_DIR/include/$dest_dir/$rel" + done +} + +copy_headers runtime executorch/runtime +# c10/torch live inside runtime/core/portable_type/c10 upstream but are +# included as and ; the copies above just placed +# them under executorch/runtime/... where nothing looks. Move them to +# the include root instead. +rm -rf "$OUT_DIR/include/executorch/runtime/core/portable_type/c10" +cp -r "$ET_ROOT/runtime/core/portable_type/c10/c10" "$OUT_DIR/include/c10" +cp -r "$ET_ROOT/runtime/core/portable_type/c10/torch" "$OUT_DIR/include/torch" +find "$OUT_DIR/include/c10" "$OUT_DIR/include/torch" \ + \( -name '*.cpp' -o -name 'BUILD*' -o -name '*.bzl' \) -delete 2>/dev/null || true + +# cmake_macros.h is generated by the PyTorch build; stub it like the +# Arduino packaging does (C10_USING_CUSTOM_GENERATED_MACROS is defined +# publicly, this covers headers that include it unconditionally). +mkdir -p "$OUT_DIR/include/torch/headeronly/macros" +cat > "$OUT_DIR/include/torch/headeronly/macros/cmake_macros.h" <<'STUB' +#pragma once +#define C10_BUILD_SHARED_LIBS +#define C10_USE_GLOG 0 +#define C10_USE_MINIMAL_GLOG 0 +#define C10_USE_GFLAGS 0 +STUB + +copy_headers schema executorch/schema +# Only the header-only BufferDataLoader; Module, the file/mmap loaders +# and .ptd data maps are not vendored. +mkdir -p "$OUT_DIR/include/executorch/extension/data_loader" +cp "$ET_ROOT/extension/data_loader/buffer_data_loader.h" \ + "$OUT_DIR/include/executorch/extension/data_loader/" +copy_headers kernels/portable executorch/kernels/portable +copy_headers kernels/prim_ops executorch/kernels/prim_ops + +# Cadence backend generic (portable) kernel headers, only when some +# shipped profile selects cadence:: ops. +ANY_CADENCE=false +for p in "${PROFILES[@]}"; do + if grep -q '^[[:space:]]*cadence::' "$SCRIPT_DIR/idf_profiles/$p.ops"; then + ANY_CADENCE=true + fi +done +if [ "$ANY_CADENCE" = true ]; then + copy_headers backends/cadence/generic executorch/backends/cadence/generic +fi + +cp -r "$ET_ROOT/third-party/flatbuffers/include/flatbuffers" \ + "$OUT_DIR/include/flatbuffers" +mkdir -p "$OUT_DIR/include/flatcc" "$OUT_DIR/src/flatcc/runtime" +cp "$ET_ROOT/third-party/flatcc/include/flatcc/"*.h "$OUT_DIR/include/flatcc/" +cp -r "$ET_ROOT/third-party/flatcc/include/flatcc/portable" \ + "$OUT_DIR/include/flatcc/" +cp "$ET_ROOT/third-party/flatcc/src/runtime/"*.c "$OUT_DIR/src/flatcc/runtime/" +for f in "$OUT_DIR/src/flatcc/runtime/"*.c; do + echo "src/flatcc/runtime/$(basename "$f")" >> "$SCRATCH/core_srcs.txt" +done + +echo "[4/6] Headers and third-party code copied" + +# ───────────────────────────────────────────────────────── +# 5. Component glue from idf_component_template/ and the shared +# ESP-IDF PAL, plus generated metadata (et_generated.cmake, +# Kconfig, idf_component.yml, README, VERSION, licenses). +# ───────────────────────────────────────────────────────── +cp "$TEMPLATE_DIR/CMakeLists.txt" "$OUT_DIR/CMakeLists.txt" +cp "$TEMPLATE_DIR/include/et_model.h" "$OUT_DIR/include/" +mkdir -p "$OUT_DIR/src/platform" +cp "$TEMPLATE_DIR/src/et_model.cpp" "$OUT_DIR/src/" +cp "$ESP_PAL_SRC" "$OUT_DIR/src/platform/" +cp "$ET_ROOT/LICENSE" "$OUT_DIR/LICENSE" + +LICENSES="$OUT_DIR/THIRD_PARTY_LICENSES" +mkdir -p "$LICENSES" +for dep in flatbuffers flatcc; do + cp "$ET_ROOT/third-party/$dep/LICENSE" "$LICENSES/$dep-LICENSE.txt" +done +cat > "$LICENSES/README.md" <<'NOTICE' +# Third-party licenses + +This component redistributes source from the projects below. +ExecuTorch's own BSD license is in the LICENSE file at the root. + +| Component | Location in this component | License | +|---|---|---| +| FlatBuffers (Google) | `include/flatbuffers/` | Apache-2.0 — `flatbuffers-LICENSE.txt` | +| flatcc (Mikkel F. Jorgensen) | `include/flatcc/`, `src/flatcc/` | Apache-2.0 — `flatcc-LICENSE.txt` | +| PyTorch c10 (Meta) | `include/c10/`, `include/torch/` | BSD-3-Clause, as exact copies from PyTorch core | + +`src/profiles/*/register_kernels.cpp` is generated by ExecuTorch's +codegen from PyTorch's `native_functions.yaml` and carries the same +terms as ExecuTorch itself. +NOTICE + +# et_generated.cmake: baked source lists consumed by CMakeLists.txt. +{ + echo "# GENERATED by examples/espressif/build_idf_component.sh -- do not edit." + echo "set(ET_PROFILES ${PROFILES[*]})" + echo "set(ET_DEFAULT_PROFILE ${PROFILES[0]})" + echo "set(ET_CORE_SRCS" + sort -u "$SCRATCH/core_srcs.txt" | sed 's/.*/ "&"/' + echo ")" + for p in "${PROFILES[@]}"; do + up=$(echo "$p" | tr '[:lower:]-' '[:upper:]_') + echo "set(ET_PROFILE_SRCS_$up" + sed 's|.*| "&"|' "$SCRATCH/codegen/$p/profile_srcs.txt" + echo ")" + echo "set(ET_KERNEL_SRCS_$up" + sed 's|.*| "src/executorch/&"|' "$SCRATCH/codegen/$p/kernel_files.txt" + echo ")" + echo "set(ET_MAX_KERNEL_NUM_$up $(cat "$SCRATCH/codegen/$p/max_kernel_num.txt"))" + done +} > "$OUT_DIR/et_generated.cmake" + +# Kconfig: profile choice generated from the shipped profiles. +{ + cat <<'KHEAD' +# GENERATED by examples/espressif/build_idf_component.sh -- do not edit. +menu "ExecuTorch" + + config ET_ENABLE + bool "Build the ExecuTorch runtime into the firmware" + default n + help + ESP-IDF links every component under components/ into the + image, and this component's kernel registration is + deliberately GC-proof (whole-archive), so it is off by + default: while disabled it registers empty and firmware is + unchanged. Set it, or "select ET_ENABLE" from a consumer's + Kconfig. + + if ET_ENABLE + + choice ET_OP_PROFILE + prompt "Op profile" + help + Which operator set (kernel registration) to compile in. + Baked in at component generation time; see VERSION for the + exact ops and op-set hash of each profile. A .pte only + runs on a profile whose op set covers it. +KHEAD + first=true + for p in "${PROFILES[@]}"; do + up=$(echo "$p" | tr '[:lower:]-' '[:upper:]_') + if [ "$first" = true ]; then + echo " default ET_OP_PROFILE_$up" + first=false + fi + echo " config ET_OP_PROFILE_$up" + echo " bool \"$p\"" + done + cat <<'KTAIL' + endchoice + + config ET_ENABLE_LOGGING + bool "Enable ExecuTorch runtime logging" + default y + help + When disabled, ET_LOG is compiled out entirely + (ET_LOG_ENABLED=0). When enabled, messages go through + ESP_LOG under the "executorch" tag. + + choice ET_LOG_LEVEL + prompt "ExecuTorch log level floor" + default ET_LOG_LEVEL_INFO + depends on ET_ENABLE_LOGGING + help + Compile-time floor (ET_MIN_LOG_LEVEL), also applied in + the PAL, on top of the usual IDF runtime log levels. + + config ET_LOG_LEVEL_ERROR + bool "Error" + config ET_LOG_LEVEL_WARN + bool "Warning" + config ET_LOG_LEVEL_INFO + bool "Info" + config ET_LOG_LEVEL_DEBUG + bool "Debug" + endchoice + + config ET_OPTIMIZE_SIZE + bool "Build the runtime with -Os" + default n + help + Optimize the ExecuTorch runtime for size regardless of the + project's compiler optimization level. + + config ET_PROGRAM_VERIFICATION + bool "Compile in full .pte verification" + default n + help + Required for et_model_config_t.verify; without it + et_model_create rejects verify requests. + + config ET_DEBUG_ASSERTS + bool "Keep ExecuTorch debug assertions" + default n + help + When disabled, the runtime builds with NDEBUG. + + config ET_MAX_KERNEL_NUM + int "Kernel registry size (0 = selected profile's op count)" + default 0 + help + Raise this when the application registers its own kernels. + Must not be below the selected profile's kernel count. + + endif # ET_ENABLE + +endmenu +KTAIL +} > "$OUT_DIR/Kconfig" + +sed "s/@ET_COMPONENT_VERSION@/$ET_COMPONENT_VERSION/" \ + "$TEMPLATE_DIR/idf_component.yml.in" > "$OUT_DIR/idf_component.yml" + +PROFILES_HUMAN=$(IFS=','; echo "${PROFILES[*]}") +{ + echo "| Profile | Ops | Op-set SHA-256 |" + echo "|---|---|---|" + cat "$SCRATCH/profile_table.md" +} > "$SCRATCH/profile_table_full.md" +"$PYTHON" - "$TEMPLATE_DIR/README.md" "$SCRATCH/profile_table_full.md" \ + "$PROFILES_HUMAN" > "$OUT_DIR/README.md" <<'PYEOF' +import sys +readme = open(sys.argv[1]).read() +table = open(sys.argv[2]).read().rstrip("\n") +readme = readme.replace("@PROFILE_TABLE@", table) +readme = readme.replace("@PROFILES@", sys.argv[3]) +sys.stdout.write(readme) +PYEOF + +ET_SHA=$(git -C "$ET_ROOT" rev-parse HEAD 2>/dev/null || echo "unknown") +ET_DIRTY=$(git -C "$ET_ROOT" status --porcelain 2>/dev/null | head -1) +{ + echo "# GENERATED by examples/espressif/build_idf_component.sh -- do not edit." + echo "executorch_version: $ET_VERSION" + echo "component_version: $ET_COMPONENT_VERSION" + echo "executorch_commit: $ET_SHA${ET_DIRTY:+ (tree had uncommitted changes)}" + echo "generator: examples/espressif/build_idf_component.sh --profiles $PROFILES_CSV --out $OUT_DIR_AS_GIVEN" + echo "profiles:" + cat "$SCRATCH/version_profiles.txt" + echo "substitutions:" + echo " - torch/headeronly/macros/cmake_macros.h: packaging stub (as in examples/arduino)" + echo "notes:" + echo " - application pipelines (for example wake word) are not included; they live with the application" + echo " - no .pte models are included; models ship with the application component" + if [ "$ANY_CADENCE" = true ]; then + echo " - cadence:: ops use the Cadence backend's generic (portable) kernels (backends/cadence/generic), not the HiFi/xt-clang kernels" + fi +} > "$OUT_DIR/VERSION" + +echo "[5/6] Component glue and metadata written" + +# ───────────────────────────────────────────────────────── +# 6. Sanity checks + summary +# ───────────────────────────────────────────────────────── +for f in "CMakeLists.txt" "et_generated.cmake" "Kconfig" \ + "idf_component.yml" "VERSION" "LICENSE" "include/et_model.h" \ + "include/executorch/runtime/executor/program.h" \ + "include/executorch/schema/program_generated.h" \ + "src/et_model.cpp" "src/platform/esp_pal.cpp"; do + if [ ! -f "$OUT_DIR/$f" ]; then + echo "ERROR: generated component is missing $f" >&2; exit 1 + fi +done +for p in "${PROFILES[@]}"; do + if [ ! -f "$OUT_DIR/src/profiles/$p/register_kernels.cpp" ]; then + echo "ERROR: generated component is missing profile '$p' registration" >&2 + exit 1 + fi +done +# Every source named in et_generated.cmake must exist on disk. +grep -o '"src/[^"]*"' "$OUT_DIR/et_generated.cmake" | tr -d '"' \ + | while IFS= read -r f; do + if [ ! -f "$OUT_DIR/$f" ]; then + echo "ERROR: et_generated.cmake names missing file: $f" >&2; exit 1 + fi + done + +# Success: et_generated.cmake is the marker now. +rm -f "$OUT_DIR/.et-generated-component" + +NSRC=$(find "$OUT_DIR/src" \( -name '*.cpp' -o -name '*.c' \) | wc -l | tr -d ' ') +NHDR=$(find "$OUT_DIR/include" \( -name '*.h' -o -name '*.hpp' \) | wc -l | tr -d ' ') +SIZE=$(du -sh "$OUT_DIR" | awk '{print $1}') + +echo "[6/6] Done" +echo "" +echo "=== ExecuTorch ESP-IDF component $ET_COMPONENT_VERSION ===" +echo " Output: $OUT_DIR ($SIZE)" +echo " Sources: $NSRC, headers: $NHDR" +echo " Profiles: ${PROFILES[*]}" +echo "" +echo "Next: build a consumer ESP-IDF project that vendors this under" +echo "components/executorch, select a profile (CONFIG_ET_OP_PROFILE_*)," +echo "and build for your target, e.g. idf.py set-target esp32s3 build." diff --git a/examples/espressif/export_smoke_model.py b/examples/espressif/export_smoke_model.py index 35652dd3ed6..9bbcd725a67 100644 --- a/examples/espressif/export_smoke_model.py +++ b/examples/espressif/export_smoke_model.py @@ -26,6 +26,9 @@ def forward(self, x, y): def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--output", type=Path, required=True) + parser.add_argument( + "--pte-output", type=Path, help="Also write the plain (unbundled) .pte" + ) args = parser.parse_args() model = SmokeModel().eval() @@ -43,6 +46,10 @@ def main() -> None: args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_bytes(serialize_from_bundled_program_to_flatbuffer(bundle)) print(f"Exported {args.output}") + if args.pte_output: + args.pte_output.parent.mkdir(parents=True, exist_ok=True) + args.pte_output.write_bytes(program.buffer) + print(f"Exported {args.pte_output}") if __name__ == "__main__": diff --git a/examples/espressif/idf_component_smoke/CMakeLists.txt b/examples/espressif/idf_component_smoke/CMakeLists.txt new file mode 100644 index 00000000000..3c7ec100ddb --- /dev/null +++ b/examples/espressif/idf_component_smoke/CMakeLists.txt @@ -0,0 +1,20 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# Minimal ESP-IDF consumer of the generated ExecuTorch component, used by +# .ci/scripts/test_espressif_idf_component.sh. Pass the generated component +# directory as ET_COMPONENT_DIR and the plain smoke .pte from +# export_smoke_model.py as ET_SMOKE_PTE. + +cmake_minimum_required(VERSION 3.16) + +if(NOT ET_COMPONENT_DIR OR NOT ET_SMOKE_PTE) + message(FATAL_ERROR "Set ET_COMPONENT_DIR and ET_SMOKE_PTE") +endif() +set(EXTRA_COMPONENT_DIRS "${ET_COMPONENT_DIR}") + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(et_component_smoke) diff --git a/examples/espressif/idf_component_smoke/main/CMakeLists.txt b/examples/espressif/idf_component_smoke/main/CMakeLists.txt new file mode 100644 index 00000000000..91a9c3585ac --- /dev/null +++ b/examples/espressif/idf_component_smoke/main/CMakeLists.txt @@ -0,0 +1,10 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +idf_component_register(SRCS "main.c" "model.S" PRIV_REQUIRES executorch) +set_source_files_properties( + model.S PROPERTIES COMPILE_DEFINITIONS "ET_SMOKE_PTE=\"${ET_SMOKE_PTE}\"" +) diff --git a/examples/espressif/idf_component_smoke/main/main.c b/examples/espressif/idf_component_smoke/main/main.c new file mode 100644 index 00000000000..33b0d2f91fe --- /dev/null +++ b/examples/espressif/idf_component_smoke/main/main.c @@ -0,0 +1,63 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +/* + * Runs the add/multiply model from export_smoke_model.py through et_model + * and checks both outputs exactly. + */ + +#include +#include + +#include + +extern const uint8_t smoke_pte_start[]; +extern const uint8_t smoke_pte_end[]; + +static const float kX[8] = {-4.0f, -1.5f, 0.0f, 0.25f, 1.0f, 2.5f, 7.0f, 16.0f}; +static const float kY[8] = {1.0f, 3.0f, -2.0f, 0.25f, -0.5f, 4.0f, -9.0f, 0.125f}; + +static bool run_once(et_model_t* m) { + if (!et_model_set_input(m, 0, kX, sizeof(kX)) || + !et_model_set_input(m, 1, kY, sizeof(kY)) || !et_model_execute(m)) { + return false; + } + size_t sum_bytes = 0; + size_t prod_bytes = 0; + const float* sum = et_model_output_data(m, 0, &sum_bytes); + const float* prod = et_model_output_data(m, 1, &prod_bytes); + if (sum == NULL || prod == NULL || sum_bytes != sizeof(kX) || + prod_bytes != sizeof(kX)) { + return false; + } + for (int i = 0; i < 8; i++) { + if (sum[i] != kX[i] + kY[i] || prod[i] != kX[i] * kY[i]) { + return false; + } + } + return true; +} + +void app_main(void) { + et_model_config_t cfg = ET_MODEL_CONFIG_DEFAULT( + smoke_pte_start, (size_t)(smoke_pte_end - smoke_pte_start)); + et_model_t* m = et_model_create(&cfg); + bool ok = m != NULL && et_model_load_method(m, NULL); + for (int i = 0; ok && i < 3; i++) { + ok = run_once(m); + } + if (ok) { + printf("ET_COMPONENT_SMOKE: PASS\n"); + } else { + printf( + "ET_COMPONENT_SMOKE: FAIL (%s)\n", + m != NULL ? et_model_last_error(m) : "et_model_create failed"); + } + et_model_destroy(m); + printf("Program complete.\n"); +} diff --git a/examples/espressif/idf_component_smoke/main/model.S b/examples/espressif/idf_component_smoke/main/model.S new file mode 100644 index 00000000000..1bbf2a92447 --- /dev/null +++ b/examples/espressif/idf_component_smoke/main/model.S @@ -0,0 +1,18 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + + .section .rodata.smoke_pte, "a" + .balign 16 + .global smoke_pte_start +smoke_pte_start: + .incbin ET_SMOKE_PTE + .global smoke_pte_end +smoke_pte_end: + .byte 0 + + .section .note.GNU-stack, "", @progbits diff --git a/examples/espressif/idf_component_smoke/sdkconfig.defaults b/examples/espressif/idf_component_smoke/sdkconfig.defaults new file mode 100644 index 00000000000..c929c6c5bec --- /dev/null +++ b/examples/espressif/idf_component_smoke/sdkconfig.defaults @@ -0,0 +1,9 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# The generated component is off until enabled. +CONFIG_ET_ENABLE=y +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 diff --git a/examples/espressif/idf_profiles/smoke.ops b/examples/espressif/idf_profiles/smoke.ops new file mode 100644 index 00000000000..db3d3492c88 --- /dev/null +++ b/examples/espressif/idf_profiles/smoke.ops @@ -0,0 +1,5 @@ +# Minimal op profile for the ESP-IDF component smoke test +# (.ci/scripts/test_espressif_idf_component.sh): the add/multiply model +# from export_smoke_model.py. +aten::add.out +aten::mul.out diff --git a/examples/espressif/idf_profiles/wakeword_int8.ops b/examples/espressif/idf_profiles/wakeword_int8.ops new file mode 100644 index 00000000000..4563c5f083e --- /dev/null +++ b/examples/espressif/idf_profiles/wakeword_int8.ops @@ -0,0 +1,22 @@ +# Int8 wake-word op profile for build_idf_component.sh: one root op per line. +# +# Derived from an int8 (Cadence-backend PTQ) export of a streaming +# wake-word model: the op list is `gen_oplist` output on the quantized +# .pte. The cadence:: ops run on the Cadence backend's generic +# (portable) kernel implementations from backends/cadence/generic. The +# Cadence edge lowering always skips dim_order, so dim_order copy +# spellings are not needed here. aten::_to_copy.out is included +# defensively for export-flag variation. +aten::_softmax.out +aten::_to_copy.out +aten::cat.out +aten::copy_ +aten::permute_copy.out +aten::slice_copy.Tensor_out +aten::squeeze_copy.dim_out +aten::transpose_copy.int_out +cadence::dequantize_per_tensor.out +cadence::quantize_per_tensor.out +cadence::quantized_conv2d_nhwc.per_tensor_out +cadence::quantized_fully_connected.per_tensor_out +cadence::quantized_relu.per_tensor_out