diff --git a/.gitignore b/.gitignore index ee206e23d94..46864c05417 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,10 @@ tokenizer.json *.ptd !test_bpe_tokenizer.bin !test_tiktoken_tokenizer.model +# Arduino examples ship a model, so build_arduino_library.sh has something to +# turn into the model.h their sketches include. 1.1 KB for the add models, +# 53 KB for the keyword spotting DS-CNN. +!examples/arduino/examples/*/model.pte # Editor temporaries *.idea diff --git a/examples/arduino/ExecuTorchArduino.h b/examples/arduino/ExecuTorch.h similarity index 100% rename from examples/arduino/ExecuTorchArduino.h rename to examples/arduino/ExecuTorch.h diff --git a/examples/arduino/README.md b/examples/arduino/README.md index c406168d107..3daf89e3201 100644 --- a/examples/arduino/README.md +++ b/examples/arduino/README.md @@ -15,13 +15,22 @@ Arduino library. A build script vendors the runtime sources from this repository into a self-contained library that Arduino users install through the Library Manager or by copying into their libraries folder. +> **Who this is for.** This README is for maintainers of the packaging. +> `build_arduino_library.sh` is a release tool, not something an Arduino +> developer ever runs. Users install a prebuilt library from the Library +> Manager and never see this directory; their documentation lives in +> [meta-pytorch/executorch-arduino](https://github.com/meta-pytorch/executorch-arduino). +> +> If you are here to change ExecuTorch and want to know whether you broke +> the Arduino library, read [Keeping this working](#keeping-this-working). + ## How It Works ``` PyTorch Model ──► torch.export ──► .pte file ──► model.h (C array) │ Arduino Sketch (.ino) - #include + #include #include "model.h" │ arduino-cli compile ──► Upload ──► Runs on board @@ -29,7 +38,7 @@ PyTorch Model ──► torch.export ──► .pte file ──► model.h (C ar ### The three pieces -1. **The library** (`arduino_lib/ExecuTorchArduino/`) — the ExecuTorch +1. **The library** (`arduino_lib/ExecuTorch/`) — the ExecuTorch runtime, CMSIS-NN kernels, and portable ops packaged for the Arduino build system. Generated by `build_arduino_library.sh`; not checked in. @@ -65,7 +74,7 @@ cd examples/arduino ``` This copies the required ExecuTorch sources from the repository into -`arduino_lib/ExecuTorchArduino/`, ready for Arduino. +`arduino_lib/ExecuTorch/`, ready for Arduino. ### 2. Install the library @@ -73,26 +82,26 @@ Copy the generated library into your Arduino libraries folder: ```bash # macOS: -cp -r arduino_lib/ExecuTorchArduino ~/Documents/Arduino/libraries/ +cp -r arduino_lib/ExecuTorch ~/Documents/Arduino/libraries/ # Linux: -cp -r arduino_lib/ExecuTorchArduino ~/Arduino/libraries/ +cp -r arduino_lib/ExecuTorch ~/Arduino/libraries/ ``` Or with `arduino-cli`: ```bash -cd arduino_lib && zip -r ExecuTorchArduino.zip ExecuTorchArduino && cd .. -arduino-cli lib install --zip-path arduino_lib/ExecuTorchArduino.zip +cd arduino_lib && zip -r ExecuTorch.zip ExecuTorch && cd .. +arduino-cli lib install --zip-path arduino_lib/ExecuTorch.zip ``` ### 3. Export a model Each sketch needs a `model.h` file — a `.pte` model converted to a C -byte array. Use `pte_to_header.py` from the Arm examples to convert +byte array. Use `pte_to_header.py` to convert any `.pte` file: ```bash -python examples/arm/executor_runner/pte_to_header.py \ +python examples/arduino/pte_to_header.py \ -p model.pte -d examples/arduino/examples/AddModel -o model.h ``` @@ -108,7 +117,7 @@ class Add(torch.nn.Module): et = to_edge(export(Add().eval(), (torch.tensor([1.,2.,3.]),))).to_executorch() with open('add.pte','wb') as f: f.write(bytes(et.buffer))" -python examples/arm/executor_runner/pte_to_header.py \ +python examples/arduino/pte_to_header.py \ -p add.pte -d examples/arduino/examples/AddModel -o model.h ``` @@ -150,7 +159,7 @@ cmake build. If you haven't built ExecuTorch yet, run ### 4. Write a sketch ```cpp -#include +#include #include "model.h" using executorch::extension::BufferDataLoader; @@ -196,11 +205,74 @@ Arduino-specific abstractions. ### 5. Compile and upload ```bash -arduino-cli compile --fqbn arduino:zephyr:unoq MySketch -arduino-cli upload --fqbn arduino:zephyr:unoq -p /dev/cu.usbmodem* MySketch +arduino-cli compile --fqbn arduino:zephyr:unoq:link_mode=static MySketch +arduino-cli upload --fqbn arduino:zephyr:unoq:link_mode=static -p /dev/cu.usbmodem* MySketch arduino-cli monitor -p /dev/cu.usbmodem* --config baudrate=115200 ``` +## Keeping this working + +The library is a *generated artifact*. Everything under the generated +`src/` is copied out of this repository, and the example models are +exported by this repository's Python. That gives one failure mode, and it +has cost multiple days: + +**The model and the library must come from the same ExecuTorch commit.** + +Cortex-M operator schemas change. `scratch` was added to the conv operators +on 2026-06-09 and to `avg_pool2d` later still. A `.pte` exported before a +schema change passes `Program::load`, resolves every operator, and then +fails inside `Method::execute` with `InvalidProgram (0x23)`, because the +generated kernel wrapper expects one more argument than the model supplies. +Nothing about that error names the real cause. + +This bites hardest when the Python package and the C++ sources come from +different places. `pip install executorch` gives a release wheel that can be +months behind this checkout; the library you build here is current. Check +which one you are exporting with: + +```bash +python -c "import executorch.backends.cortex_m.ops.operators as o; print(o.__file__)" +``` + +If that prints a `site-packages` path rather than your checkout, run +`./install_executorch.sh` first. Note that ExecuTorch refuses to build from a +directory not named exactly `executorch` (pytorch/executorch#6475), which is +a common reason people end up on a stale wheel without realising. + +To check a model against a library without a board, decode the `.pte` and +compare each `KernelCall`'s argument count against the `stack.size() == N` +in the generated `src/executorch/codegen/RegisterCodegenUnboxedKernels*.cpp`. +A mismatch there is the bug, found in seconds instead of hours. + +### Things that are not obvious + +- **`link_mode=static` is mandatory.** The Uno Q defaults to Dynamic, which + builds the sketch as a Zephyr loadable extension. A library this size never + starts that way: no serial output at all, so the board looks dead and offers + nothing to diagnose. Dynamic also reports only the extension's size, roughly + half the real figure. +- **`ET_LOG` has to be routed somewhere.** `zephyr.cpp` logs through `fprintf`, + and `platform_stubs.c` stubs `fprintf` out. The build script rewrites the + logger to call a weak `et_arduino_log` hook, which the examples implement + against `Serial`. Without it every runtime failure is a bare hex code. +- **Only one platform backend may ship.** `minimal.cpp` and `zephyr.cpp` both + define `et_pal_*`; shipping both leaves the choice to link order, and + `minimal`'s logger is empty and its allocator returns `nullptr`. +- **Compiling proves very little.** Every failure worth finding here compiled + cleanly first. Flash a board. + +### Error codes seen in practice + +| Symptom | Cause | +|---|---| +| No serial output at all | Built in Dynamic link mode, or `Arduino_RouterBridge` missing | +| `Program::load` -> `0x23` | Model header put the array in a section the linker discards; use `pte_to_header.py` from this directory, not the Ethos-U one | +| `load_method` -> `0x14` | Operator not in the registered set; regenerate with `ROOT_OPS=` | +| `load_method` -> `0x21` | `method_pool` too small; the log line gives the exact shortfall | +| `execute` -> `0x23` | Model and library built from different ExecuTorch commits | + + ## What is inside the library The `build_arduino_library.sh` script assembles these components from @@ -229,12 +301,12 @@ Arduino's build system: 2. **`cmake_macros.h` stub** — c10/torch headers expect a cmake-generated file. The build script generates a stub; `C10_USING_CUSTOM_GENERATED_MACROS` - is defined in `ExecuTorchArduino.h` to skip the include. + is defined in `ExecuTorch.h` to skip the include. 3. **`platform_stubs.c`** — provides weak stubs for `_Exit()`, `fprintf()`, and `__aeabi_f2lz` for the LLEXT environment on boards that lack them. -4. **Compile-time defines** — `ExecuTorchArduino.h` sets +4. **Compile-time defines** — `ExecuTorch.h` sets `ET_ENABLE_DEPRECATED_CONSTANT_BUFFER=0` (requires models exported with current ExecuTorch) and `FLATBUFFERS_MAX_ALIGNMENT=1024`. @@ -242,18 +314,53 @@ Arduino's build system: ### Updating the library -After modifying ExecuTorch sources, regenerate the library: - ```bash -./build_arduino_library.sh # rebuild +./build_arduino_library.sh # rebuild ./build_arduino_library.sh --clean # remove generated output +ROOT_OPS="aten::add.out,..." ./build_arduino_library.sh # pick the op set +ALL_OPS=1 ./build_arduino_library.sh # every portable op +``` + +The op set is a size decision. Registering every portable kernel costs about +1.6 MB of text, twice the Uno Q's flash, because portable kernels are +dtype-templated. The default registers the Cortex-M operators plus a small +portable set, which lands around a quarter of flash. + +### Re-exporting the example models + +Each example ships a `model.pte` that the build script converts to the +`model.h` its sketch includes. Regenerate them whenever an operator schema +changes, or the models will fail at `execute` against the new runtime: + +```bash +# keyword spotting, from the checked-in checkpoint (no retraining) +python export_model.py --checkpoint examples/KeywordSpotting/model.pth \ + --output /tmp/kws.h ``` +### Bumping the pin in executorch-arduino + +The published library records the commit it was generated from in +`extras/PROVENANCE.txt`, and pins that commit in `executorch_pin.txt` +alongside it — the same one-SHA-per-file convention ExecuTorch uses in +`.ci/docker/ci_commit_pins/`. To move it forward: + +1. Update `executorch_pin.txt` to the new ExecuTorch commit +2. Regenerate the library from a checkout at that commit +3. Re-export the example models from the same checkout +4. Confirm each model's `KernelCall` argument counts match the regenerated + `RegisterCodegenUnboxedKernels*.cpp` +5. Compile every example at `link_mode=static`, and flash at least one + +Steps 2 and 3 have to happen together. Bumping the library without +re-exporting the models is the mismatch described in +[Keeping this working](#keeping-this-working). + ### Testing ```bash -arduino-cli compile --fqbn arduino:zephyr:unoq examples/HelloExecuTorch -arduino-cli upload --fqbn arduino:zephyr:unoq -p /dev/cu.usbmodem* examples/HelloExecuTorch +arduino-cli compile --fqbn arduino:zephyr:unoq:link_mode=static examples/HelloExecuTorch +arduino-cli upload --fqbn arduino:zephyr:unoq:link_mode=static -p /dev/cu.usbmodem* examples/HelloExecuTorch arduino-cli monitor -p /dev/cu.usbmodem* --config baudrate=115200 ``` @@ -325,20 +432,71 @@ Training and test audio from [Google Speech Commands v2](https://arxiv.org/abs/1 people. Standard dataset used by the MLPerf Tiny benchmark. Download via `torchaudio.datasets.SPEECHCOMMANDS` (2.3 GB). +The dataset is © Google, released under +[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/), which asks for +attribution. The keyword spotting weights checked in here +(`examples/KeywordSpotting/model.pth` and the `.pte` generated from it) are +trained on it and carry the same attribution. + +Only the ten keyword classes are needed, so the full archive never has to +land on disk: + +```bash +mkdir -p outputs/speech_commands/SpeechCommands/speech_commands_v0.02 +cd outputs/speech_commands/SpeechCommands/speech_commands_v0.02 +curl -sL http://download.tensorflow.org/data/speech_commands_v0.02.tar.gz \ + | tar xz ./yes ./no ./up ./down ./left ./right ./on ./off ./stop ./go +``` + +That is 1.2 GB extracted instead of 2.3 GB downloaded plus 2.4 GB unpacked. + +`download.tensorflow.org` serves no usable HTTPS -- its certificate does not +cover that hostname -- which is why the URL is plain HTTP and why +`torchaudio.datasets.SPEECHCOMMANDS` uses HTTP for it too. If transport +integrity matters, download the archive first and check it against the SHA-256 +torchaudio pins for v0.02 before extracting: + +```bash +af14739ee7dc311471de98f5f9d2c9191b18aedfe957f4a6ff791c709868ff58 +``` + +You only need this to retrain. The exported model and its checkpoint are both +checked in, so nothing here is required to build the library. + The DS-CNN KWS benchmark uses 12 output classes (silence, unknown, plus 10 keywords). The Arduino export script trains the 10 keyword classes: yes, no, up, down, left, right, on, off, stop, go. -## LLEXT Memory Budget +## Link Mode and Memory Budget + +This applies to the Zephyr board core, which is the only core the library +currently supports (`architectures=zephyr`). Other Arduino cores do not run +Zephyr and have no link mode setting; they need a platform abstraction layer +port before they can compile at all, and their memory behaviour is untested. -The Arduino Uno Q loads sketches as LLEXT (Loadable Extensions). -Sizes reported by `arduino-cli compile` (Zephyr board core 0.55.2): +On the Zephyr core, the Uno Q defaults to Dynamic link mode, which builds the +sketch as a Zephyr loadable extension. Sketches this size never start that way: no serial output +at all, so the board looks dead and offers nothing to diagnose. Build with +`link_mode=static`. A 2 KB sketch runs fine under Dynamic, so the ceiling sits +somewhere between that and these builds; it has not been pinned down. -| Build | Code | Data | Total | Status | -|-------|------|------|-------|--------| -| HelloExecuTorch (portable ops) | 62 KB | 27 KB | 89 KB | ✅ | -| Add model (portable ops) | 88 KB | 35 KB | 123 KB | ✅ | -| DS-CNN (selective CMSIS-NN) | 87 KB | 57 KB | 144 KB | ✅ | +Dynamic also reports only the extension's own size, which reads far lower than +what the board actually holds. Measured on an Arduino Uno Q, board core 0.55.2, +against 786,432 bytes of flash and 131,072 bytes of RAM: + +| Build | Flash (static) | RAM | Dynamic reported | On hardware | +|-------|---------------|-----|------------------|-------------| +| HelloExecuTorch | 472,728 (60%) | 3,060 (2%) | 27% | `Model loaded OK!`, 1 method | +| AddModel | 507,664 (64%) | 11,252 (8%) | 30% | `[1,2,3] + 1 = [2.00, 3.00, 4.00]` | +| KeywordSpotting (CMSIS-NN) | 557,520 (70%) | 46,068 (35%) | 30% | 10/10 keywords correct | All CMSIS-NN sources are compiled, but the linker's `--gc-sections` discards unused functions from the final binary. + +RAM is the binding constraint, not flash. Zephyr reserves 32 KB of main stack +and a 32 KB heap out of 128 KB before the sketch gets any, and the arena the +sketch hands to `MemoryManager` comes out of what remains. KeywordSpotting's +DS-CNN plans 16 KB of buffers but needs considerably more for the method's own +structures: a 28 KB arena fails `load_method` with `MemoryAllocationFailed` +(0x21), and a 64 KB one loads but then fails `execute` with `InvalidProgram` +(0x23), which is memory being overrun rather than a malformed program. diff --git a/examples/arduino/build_arduino_library.sh b/examples/arduino/build_arduino_library.sh index 1e52b03725a..1c7083d52dc 100755 --- a/examples/arduino/build_arduino_library.sh +++ b/examples/arduino/build_arduino_library.sh @@ -15,7 +15,7 @@ # ./build_arduino_library.sh --bump minor # 0.1.0 → 0.2.0 # ./build_arduino_library.sh --bump major # 0.1.0 → 1.0.0 # -# Output: arduino_lib/ExecuTorchArduino/ (self-contained, installable) +# Output: arduino_lib/ExecuTorch/ (self-contained, installable) # # NOTE: This script is coupled to the ExecuTorch source tree layout. # Long-term, we should use cmake query APIs to deduce required sources @@ -28,8 +28,9 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ET_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -OUT_DIR="$SCRIPT_DIR/arduino_lib/ExecuTorchArduino" +OUT_DIR="$SCRIPT_DIR/arduino_lib/ExecuTorch" PROPS="$SCRIPT_DIR/library.properties" +PYTHON="${PYTHON:-python3}" if [ "${1:-}" = "--clean" ]; then echo "Cleaning generated library..." @@ -66,11 +67,33 @@ mkdir -p "$OUT_DIR/src" "$OUT_DIR/examples" # 1. Copy library metadata, wrapper header, and stubs # ───────────────────────────────────────────────────────── cp "$SCRIPT_DIR/library.properties" "$OUT_DIR/" -cp "$SCRIPT_DIR/ExecuTorchArduino.h" "$OUT_DIR/src/" +cp "$SCRIPT_DIR/ExecuTorch.h" "$OUT_DIR/src/" cp "$SCRIPT_DIR/platform_stubs.c" "$OUT_DIR/src/" cp -r "$SCRIPT_DIR/examples/"* "$OUT_DIR/examples/" +# Training checkpoints are how a model is regenerated, not something the +# library needs at runtime. +find "$OUT_DIR/examples" -name "*.pth" -delete + +# Tooling the README points users at. None of it is reachable once the library +# is installed on its own, so it travels with the library under extras/, which +# the Arduino spec excludes from the build. +mkdir -p "$OUT_DIR/extras/tools" +cp "$SCRIPT_DIR/build_arduino_library.sh" \ + "$SCRIPT_DIR/pte_to_header.py" \ + "$SCRIPT_DIR/export_model.py" \ + "$SCRIPT_DIR/generate_test_input.py" "$OUT_DIR/extras/tools/" + +# An example that ships a model.pte gets a model.h generated from it. Without +# one the sketch #errors the moment it is opened from the IDE menu. Generated +# rather than checked in so the header cannot drift from its .pte. +for pte in "$OUT_DIR/examples/"*/model.pte; do + [ -e "$pte" ] || continue + "$PYTHON" "$SCRIPT_DIR/pte_to_header.py" \ + --pte "$pte" --output "$(dirname "$pte")/model.h" + rm -f "$pte" +done -echo "[1/7] Metadata and examples copied" +echo "[1/7] Metadata, examples and tooling copied" # ───────────────────────────────────────────────────────── # 2. Vendor ET runtime sources @@ -104,6 +127,7 @@ cp "$ET_ROOT/extension/runner_util/"*.h "$ET_SRC/extension/runner_util/" 2>/dev/ # Schema headers (generated — need a prior cmake build) mkdir -p "$ET_SRC/schema" cp "$ET_ROOT/schema/"*.h "$ET_SRC/schema/" 2>/dev/null || true +cp "$ET_ROOT/schema/"*.cpp "$ET_SRC/schema/" 2>/dev/null || true # Look for generated headers in common build dirs for build_dir in "$ET_ROOT/cmake-out" "$ET_ROOT/cmake-out-mac" \ "$ET_ROOT/outputs/build_uno_q"; do @@ -137,6 +161,106 @@ cp "$ET_ROOT/kernels/portable/cpu/pattern/"*.cpp "$ET_SRC/kernels/portable/cpu/p echo "[3/7] Portable kernels copied" +# ───────────────────────────────────────────────────────── +# 3b. Generate the kernel registration translation unit. +# +# Kernels register through codegen, not through self-registering static +# initializers. Without this the ops compile but never reach the operator +# registry, and every Method::load fails with OperatorMissing. +# ───────────────────────────────────────────────────────── +# +# The op set is a size decision, not a detail. Registering every portable op +# costs 1.58 MB of text — twice the Uno Q's 786 KB flash. The default below is +# the Cortex-M op set plus the portable ops a quantized CNN still needs. +# Override with ROOT_OPS="aten::foo.out,..." or ALL_OPS=1 when the target has +# room to spare. +if ! TORCHGEN=$("$PYTHON" -c "import torchgen, os; print(os.path.dirname(torchgen.__file__))" 2>/dev/null); then + echo "ERROR: cannot import torchgen with $PYTHON." + echo " The kernel registration codegen needs it. Run ./install_executorch.sh," + echo " or set PYTHON=... to an interpreter that has ExecuTorch installed." + 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." + echo " That install looks incomplete; reinstall with ./install_executorch.sh." + exit 1 + fi +done + +# The exporter and the runtime must be the same ExecuTorch. A pip release wheel +# can be months behind this checkout, and a model exported against one schema +# fails at Method::execute against a library built from another. +ET_PY=$("$PYTHON" -c "import executorch; print(next(iter(executorch.__path__), ''))" 2>/dev/null || echo "") +case "$ET_PY" in + "$ET_ROOT"*) ;; + "") echo " NOTE: no executorch Python package found; the library will build but" ; + echo " you cannot export models with this interpreter." ;; + *) echo " WARNING: $PYTHON imports executorch from $ET_PY, not $ET_ROOT." ; + echo " Models exported with it may not match this library. See" ; + echo " 'Keeping this working' in examples/arduino/README.md." ;; +esac + +CODEGEN_OUT="$ET_SRC/codegen" +CORTEX_M_YAML="$ET_ROOT/backends/cortex_m/ops/operators.yaml" +mkdir -p "$CODEGEN_OUT" + +# dim_order_ops are not optional: the Cortex-M lowering emits +# _clone_dim_order in place of aten::clone for channels-last models. +DEFAULT_ROOT_OPS="aten::add.out,aten::mul.out,aten::sub.out,aten::div.out,\ +aten::view_copy.out,aten::permute_copy.out,aten::clone.out,aten::cat.out,\ +aten::slice_copy.Tensor_out,aten::_softmax.out,aten::mean.out,aten::relu.out,\ +dim_order_ops::_clone_dim_order.out,dim_order_ops::_to_dim_order_copy.out" +ROOT_OPS="${ROOT_OPS:-$DEFAULT_ROOT_OPS}" + +if [ "${ALL_OPS:-0}" = "1" ]; then + OPLIST_SELECTION=(--include_all_operators) + echo " Op set: every portable op (large - verify it fits your target)" +else + OPLIST_SELECTION=(--root_ops="$ROOT_OPS") + echo " Op set: default ($(echo "$ROOT_OPS" | tr ',' '\n' | wc -l | tr -d ' ') root ops)" +fi + +( cd "$ET_ROOT" && \ + "$PYTHON" -m codegen.tools.gen_oplist \ + --output_path="$CODEGEN_OUT/selected_operators.yaml" \ + --ops_schema_yaml_path="$CORTEX_M_YAML" \ + "${OPLIST_SELECTION[@]}" && \ + "$PYTHON" -m codegen.gen \ + --source-path="$ET_ROOT/codegen" \ + --install-dir="$CODEGEN_OUT" \ + --tags-path="$TORCHGEN/packaged/ATen/native/tags.yaml" \ + --aten-yaml-path="$TORCHGEN/packaged/ATen/native/native_functions.yaml" \ + --op-selection-yaml-path="$CODEGEN_OUT/selected_operators.yaml" \ + --functions-yaml-path="$ET_ROOT/kernels/portable/functions.yaml" \ + --custom-ops-yaml-path="$CORTEX_M_YAML" ) > /dev/null + +# Right-size the operator registry. Without this header it falls back to a +# fixed MAX_KERNEL_NUM sized for a much larger build, which costs RAM the board +# does not have to spare. +( cd "$ET_ROOT" && "$PYTHON" -m codegen.tools.gen_max_kernel_num \ + --oplist-yaml="$CODEGEN_OUT/selected_operators.yaml" \ + --prim-ops-source="$ET_ROOT/kernels/prim_ops/register_prim_ops.cpp" \ + --output-path="$ET_SRC/runtime/kernel/selected_max_kernel_num.h" ) + +# gen writes the same content to both names; keeping both is a duplicate-symbol error. +rm -f "$CODEGEN_OUT/RegisterCodegenUnboxedKernels_0.cpp" +rm -f "$CODEGEN_OUT/selected_operators.yaml" +# These register custom ops into PyTorch, not into the ET runtime. They pull in +# and , which do not exist on device. +rm -f "$CODEGEN_OUT/RegisterCPUCustomOps.cpp" \ + "$CODEGEN_OUT/RegisterCPUStub.cpp" \ + "$CODEGEN_OUT/RegisterSchema.cpp" \ + "$CODEGEN_OUT/CustomOpsNativeFunctions.h" + +if [ ! -f "$CODEGEN_OUT/RegisterCodegenUnboxedKernelsEverything.cpp" ]; then + echo "ERROR: kernel registration codegen produced no output." + exit 1 +fi + +echo "[3b/7] Kernel registration generated" + # ───────────────────────────────────────────────────────── # 4. Vendor Cortex-M backend ops # ───────────────────────────────────────────────────────── @@ -189,13 +313,24 @@ done if [ -n "$CMSIS_NN" ]; then mkdir -p "$OUT_DIR/src/cmsis-nn" cp -r "$CMSIS_NN/Source" "$OUT_DIR/src/cmsis-nn/" + # Bindings are pybind11 host code and cannot be cross-compiled. + rm -rf "$OUT_DIR/src/cmsis-nn/Source/Bindings" + find "$OUT_DIR/src/cmsis-nn" -name "CMakeLists.txt" -delete + # Arduino compiles every source under src/ with no way to pass per-library + # defines, so drop the float extensions that ARM_NN_ENABLE_F32/F16 gate off + # by default. They need CMSIS-DSP types the Cortex-M backend never uses. + find "$OUT_DIR/src/cmsis-nn/Source" \ + \( -name "*_f16.c" -o -name "*_f32.c" -o -name "*_flt.c" \) -delete + cp "$CMSIS_NN/LICENSE" "$OUT_DIR/src/cmsis-nn/" cp "$CMSIS_NN/Include/"*.h "$OUT_DIR/src/" 2>/dev/null || true if [ -d "$CMSIS_NN/Include/Internal" ]; then mkdir -p "$OUT_DIR/src/Internal" cp "$CMSIS_NN/Include/Internal/"*.h "$OUT_DIR/src/Internal/" fi + CMSIS_NN_REV=$(git -C "$CMSIS_NN" rev-parse HEAD 2>/dev/null || echo "unknown") echo "[5/7] CMSIS-NN copied from $CMSIS_NN" else + CMSIS_NN_REV="absent" echo "[5/7] WARNING: CMSIS-NN not found. Cortex-M ops will not link." fi @@ -209,6 +344,41 @@ for candidate in \ fi done +# ───────────────────────────────────────────────────────── +# Third-party notices. The vendored trees are redistributed in source form, +# so Apache-2.0 section 4 and the PyTorch BSD terms require their licenses +# to travel with them. +# ───────────────────────────────────────────────────────── +LICENSES="$OUT_DIR/extras/THIRD_PARTY_LICENSES" +mkdir -p "$LICENSES" +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" + exit 1 + fi + cp "$ET_ROOT/third-party/$dep/LICENSE" "$LICENSES/$dep-LICENSE.txt" +done +if [ -n "$CMSIS_NN" ]; then + cp "$CMSIS_NN/LICENSE" "$LICENSES/CMSIS-NN-LICENSE.txt" +fi + +cat > "$LICENSES/README.md" << 'NOTICE' +# Third-party licenses + +This library redistributes source from the projects below. ExecuTorch's own +BSD license is in the LICENSE file at the root. + +| Component | Location in this library | License | +|---|---|---| +| CMSIS-NN (Arm) | `src/cmsis-nn/`, `src/arm_nn*.h`, `src/Internal/` | Apache-2.0 — `CMSIS-NN-LICENSE.txt` | +| FlatBuffers (Google) | `src/flatbuffers/` | Apache-2.0 — `flatbuffers-LICENSE.txt` | +| flatcc (Mikkel F. Jorgensen) | `src/flatcc/` | Apache-2.0 — `flatcc-LICENSE.txt` | +| PyTorch c10 (Meta) | `src/c10/`, `src/torch/` | BSD-3-Clause, as exact copies from PyTorch core | + +`src/executorch/codegen/` is generated by ExecuTorch's codegen from PyTorch's +`native_functions.yaml` and carries the same terms as ExecuTorch itself. +NOTICE + echo "[6/7] Third-party dependencies copied" # ───────────────────────────────────────────────────────── @@ -221,11 +391,54 @@ find "$OUT_DIR/src/executorch" -name "*.h" -print0 | \ # Remove test files, ATen-specific files, non-Zephyr platform backends find "$OUT_DIR" -path "*testing*" -delete 2>/dev/null || true -find "$OUT_DIR" -name "*_aten.cpp" -delete 2>/dev/null || true +# ATen-mode sources only. *_exec_aten.cpp is portable-mode and required. +find "$OUT_DIR" -name "*_aten.cpp" ! -name "*_exec_aten.cpp" -delete 2>/dev/null || true find "$OUT_DIR" -path "*test*" -name "*.cpp" -delete 2>/dev/null || true rm -f "$OUT_DIR/src/executorch/runtime/platform/default/android.cpp" rm -f "$OUT_DIR/src/executorch/runtime/platform/default/posix.cpp" rm -f "$OUT_DIR/src/executorch/runtime/platform/default/windows.cpp" +# minimal.cpp and zephyr.cpp both define the et_pal_* backend, so shipping both +# leaves the choice to link order. minimal's logger is an empty body and its +# et_pal_allocate returns nullptr, which silently discards every ET_LOG. +rm -f "$OUT_DIR/src/executorch/runtime/platform/default/minimal.cpp" + +# zephyr.cpp logs through fprintf, and platform_stubs.c stubs fprintf out to +# nothing, so runtime diagnostics never reach the user. Route them to a weak +# hook a sketch can implement -- see the examples for a Serial implementation. +ZEPHYR_PAL="$OUT_DIR/src/executorch/runtime/platform/default/zephyr.cpp" +"$PYTHON" - "$ZEPHYR_PAL" << 'PATCH' +import sys +p = sys.argv[1] +s = open(p).read() +old = """ fprintf( + stderr, + "%c [executorch:%s:%zu %s()] %s\\n", + level, + filename, + line, + function, + message);""" +new = """ char et_log_buf[256]; + snprintf( + et_log_buf, + sizeof(et_log_buf), + "%c [ET:%s:%zu] %s", + (char)level, + filename, + line, + message); + et_arduino_log(et_log_buf);""" +if old not in s: + sys.exit("ERROR: zephyr.cpp log call not found; the PAL changed upstream.") +s = s.replace(old, new) +s = s.replace( + "void et_pal_emit_log_message(", + 'extern "C" __attribute__((weak)) void et_arduino_log(const char*) {}\n\n' + "void et_pal_emit_log_message(", + 1, +) +open(p, "w").write(s) +PATCH # Regenerate schema headers if flatc is available FLATC="" @@ -244,6 +457,45 @@ fi echo "[7/7] Arduino patches applied" +# ───────────────────────────────────────────────────────── +# Record what produced this tree. The published library is a generated +# artifact, so without this there is no way back to the sources. +# ───────────────────────────────────────────────────────── +ET_SHA=$(git -C "$ET_ROOT" rev-parse HEAD 2>/dev/null || echo "unknown") +ET_DIRTY=$(git -C "$ET_ROOT" status --porcelain "$SCRIPT_DIR" 2>/dev/null | head -1) +cat > "$OUT_DIR/extras/PROVENANCE.txt" << PROV +This library is generated, not hand-written. Everything under src/ was copied +out of ExecuTorch by examples/arduino/build_arduino_library.sh, and the +model.h in each example was converted from a .pte exported by that same +checkout. Do not edit either by hand; regenerate instead. + +executorch: https://github.com/pytorch/executorch +commit: $ET_SHA${ET_DIRTY:+ (tree had uncommitted changes under examples/arduino)} +CMSIS-NN: $CMSIS_NN_REV +op set: $([ "${ALL_OPS:-0}" = "1" ] && echo "all portable ops" || echo "$ROOT_OPS") +kernels: $(grep -c 'Kernel(' "$CODEGEN_OUT/RegisterCodegenUnboxedKernelsEverything.cpp") + +The commit above is not decoration. Cortex-M operator schemas change between +ExecuTorch releases, and a model exported against one commit fails at +Method::execute against a library built from another - it loads fine, resolves +every operator, then returns InvalidProgram (0x23). The library and the models +it ships must come from this one commit. + +To regenerate: + + git -C checkout \$(cat executorch_pin.txt) + ./install_executorch.sh # so the exporter matches too + examples/arduino/build_arduino_library.sh + +To move to a newer ExecuTorch, bump executorch_pin.txt, regenerate, and +re-export the example models in the same change. Override the op set with +ROOT_OPS="aten::foo.out,..." or ALL_OPS=1. +PROV + +# The pin is the input a maintainer edits; PROVENANCE records what was used. +# One SHA per file, matching .ci/docker/ci_commit_pins/ in ExecuTorch. +echo "$ET_SHA" > "$OUT_DIR/executorch_pin.txt" + # ───────────────────────────────────────────────────────── # Summary # ───────────────────────────────────────────────────────── diff --git a/examples/arduino/examples/AddModel/AddModel.ino b/examples/arduino/examples/AddModel/AddModel.ino index fa58490b085..79698f2c0c1 100644 --- a/examples/arduino/examples/AddModel/AddModel.ino +++ b/examples/arduino/examples/AddModel/AddModel.ino @@ -21,10 +21,10 @@ // def forward(self, x): return x + 1.0 // et = to_edge(export(Add().eval(), (torch.tensor([1.,2.,3.]),))).to_executorch() // with open('add.pte','wb') as f: f.write(bytes(et.buffer))" -// 2. Convert to header: python examples/arm/executor_runner/pte_to_header.py \ +// 2. Convert to header: python examples/arduino/pte_to_header.py \ // -p add.pte -o model.h -#include +#include #if __has_include("model.h") #include "model.h" #else @@ -48,6 +48,14 @@ alignas(16) static uint8_t method_pool[8 * 1024]; static BufferDataLoader* g_loader = nullptr; static Program* g_prog = nullptr; +// ExecuTorch logs go to a weak hook so the library does not depend on Serial. +// Without this the runtime's own diagnostics -- allocation failures, operator +// mismatches -- are discarded, and errors surface only as bare hex codes. +extern "C" void et_arduino_log(const char* msg) { + Serial.print("ET| "); + Serial.println(msg); +} + void setup() { Serial.begin(115200); delay(2000); diff --git a/examples/arduino/examples/AddModel/model.pte b/examples/arduino/examples/AddModel/model.pte new file mode 100644 index 00000000000..58b1bb235d2 Binary files /dev/null and b/examples/arduino/examples/AddModel/model.pte differ diff --git a/examples/arduino/examples/HelloExecuTorch/HelloExecuTorch.ino b/examples/arduino/examples/HelloExecuTorch/HelloExecuTorch.ino index d4aeae3ffbc..b96667d3d1d 100644 --- a/examples/arduino/examples/HelloExecuTorch/HelloExecuTorch.ino +++ b/examples/arduino/examples/HelloExecuTorch/HelloExecuTorch.ino @@ -12,7 +12,7 @@ // ET library (portable ops only, no hardware-specific backends). // Use this to verify the library works on your board. -#include +#include #if __has_include("model.h") #include "model.h" #else @@ -24,6 +24,15 @@ using executorch::runtime::MemoryAllocator; using executorch::runtime::Program; using executorch::runtime::Result; +static bool g_loaded = false; + +// ExecuTorch logs go to a weak hook so the library does not depend on Serial. +// Without this the runtime's own diagnostics -- allocation failures, operator +// mismatches -- are discarded, and errors surface only as bare hex codes. +extern "C" void et_arduino_log(const char* msg) { + Serial.print("ET| "); + Serial.println(msg); +} void setup() { Serial.begin(115200); @@ -43,12 +52,17 @@ void setup() { Serial.println(" bytes"); Serial.print(" Methods: "); Serial.println(program->num_methods()); + g_loaded = true; } else { - Serial.println("ERROR: Model load failed"); + Serial.print("ERROR: Model load failed 0x"); + Serial.println((int)program.error(), HEX); } } void loop() { - Serial.println("ExecuTorch ready"); + // Report the real state. Printing a fixed string here would look identical + // whether or not the model loaded, and setup() has already scrolled away by + // the time a serial monitor attaches. + Serial.println(g_loaded ? "ExecuTorch ready" : "ExecuTorch FAILED to load"); delay(5000); } diff --git a/examples/arduino/examples/HelloExecuTorch/model.pte b/examples/arduino/examples/HelloExecuTorch/model.pte new file mode 100644 index 00000000000..58b1bb235d2 Binary files /dev/null and b/examples/arduino/examples/HelloExecuTorch/model.pte differ diff --git a/examples/arduino/examples/KeywordSpotting/KeywordSpotting.ino b/examples/arduino/examples/KeywordSpotting/KeywordSpotting.ino index 7d94c85e821..59be3623d92 100644 --- a/examples/arduino/examples/KeywordSpotting/KeywordSpotting.ino +++ b/examples/arduino/examples/KeywordSpotting/KeywordSpotting.ino @@ -26,7 +26,7 @@ // mfcc_yes.h, mfcc_no.h, mfcc_up.h, mfcc_down.h, mfcc_left.h, // mfcc_right.h, mfcc_on.h, mfcc_off.h, mfcc_stop.h, mfcc_go.h -#include +#include #include #include #if __has_include("model.h") @@ -56,6 +56,14 @@ static const char* kLabels[] = { alignas(16) static uint8_t method_pool[28 * 1024]; +// ExecuTorch logs go to a weak hook so the library does not depend on Serial. +// Without this the runtime's own diagnostics -- allocation failures, operator +// mismatches -- are discarded, and errors surface only as bare hex codes. +extern "C" void et_arduino_log(const char* msg) { + Serial.print("ET| "); + Serial.println(msg); +} + void setup() { Serial.begin(115200); delay(3000); diff --git a/examples/arduino/examples/KeywordSpotting/model.pte b/examples/arduino/examples/KeywordSpotting/model.pte new file mode 100644 index 00000000000..ab7e2a64d1e Binary files /dev/null and b/examples/arduino/examples/KeywordSpotting/model.pte differ diff --git a/examples/arduino/examples/KeywordSpotting/model.pth b/examples/arduino/examples/KeywordSpotting/model.pth new file mode 100644 index 00000000000..175ea338ff7 Binary files /dev/null and b/examples/arduino/examples/KeywordSpotting/model.pth differ diff --git a/examples/arduino/export_model.py b/examples/arduino/export_model.py index 19347c7f5f3..a8966b8b8bd 100644 --- a/examples/arduino/export_model.py +++ b/examples/arduino/export_model.py @@ -24,6 +24,7 @@ import numpy as np import soundfile as sf import torch + from executorch.backends.cortex_m.passes.cortex_m_pass_manager import CortexMPassManager from executorch.backends.cortex_m.quantizer.quantizer import CortexMQuantizer from executorch.backends.cortex_m.target_config import CortexM, CortexMTargetConfig @@ -32,6 +33,7 @@ ) from executorch.examples.models.mlperf_tiny.ds_cnn import DSCNNKWS from executorch.exir import EdgeCompileConfig, to_edge +from pte_to_header import to_header from torch.export import export from torchao.quantization.pt2e.quantize_pt2e import convert_pt2e, prepare_pt2e @@ -169,17 +171,6 @@ def export_model( return et.buffer -def buffer_to_header(buffer: bytes) -> str: - """Convert .pte bytes to a C header string.""" - h = "#pragma once\n#include \n#include \n\n" - h += "alignas(16) static const uint8_t model_pte[] = {\n" - for i in range(0, len(buffer), 16): - h += " " + ",".join(f"0x{b:02x}" for b in buffer[i : i + 16]) + ",\n" - h += "};\n" - h += f"static const size_t model_pte_size = {len(buffer)};\n" - return h - - def main(): parser = argparse.ArgumentParser( description="Export DS-CNN keyword spotting model for Arduino" @@ -216,7 +207,7 @@ def main(): torch.save(model.state_dict(), args.output.replace(".h", ".pth")) buffer = export_model(model, args.data_dir, args.target) - header = buffer_to_header(buffer) + header = to_header(buffer, source="the exported DS-CNN") with open(args.output, "w") as f: f.write(header) diff --git a/examples/arduino/library.properties b/examples/arduino/library.properties index 7e500dd5ff7..17cc51a885b 100644 --- a/examples/arduino/library.properties +++ b/examples/arduino/library.properties @@ -4,13 +4,14 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. -name=ExecuTorchArduino +name=ExecuTorch version=0.1.0 author=Meta Platforms maintainer=ExecuTorch Team sentence=Run PyTorch models on Arduino microcontrollers with ExecuTorch. paragraph=ExecuTorch is a PyTorch runtime optimized for on-device inference. This library packages the ExecuTorch runtime with CMSIS-NN acceleration for ARM Cortex-M boards, enabling quantized model inference directly from Arduino sketches. category=Data Processing -url=https://github.com/pytorch/executorch +url=https://github.com/meta-pytorch/executorch-arduino architectures=zephyr -includes=ExecuTorchArduino.h +includes=ExecuTorch.h +depends=Arduino_RouterBridge diff --git a/examples/arduino/platform_stubs.c b/examples/arduino/platform_stubs.c index da62195a36a..cf85f429eff 100644 --- a/examples/arduino/platform_stubs.c +++ b/examples/arduino/platform_stubs.c @@ -25,6 +25,14 @@ __attribute__((weak)) int fprintf(FILE* stream, const char* fmt, ...) { return 0; } +// The Zephyr core builds against picolibc but pulls math from newlib's +// libm_nano, which calls __errno(). Nothing in picolibc provides it. Kernels +// never read errno, so backing it with fixed storage is sufficient. +__attribute__((weak)) int* __errno(void) { + static int errno_storage; + return &errno_storage; +} + #if defined(__ARM_EABI__) // Use double intermediate to avoid the compiler lowering (long long)f back // into a call to __aeabi_f2lz, which would cause infinite recursion. diff --git a/examples/arduino/pte_to_header.py b/examples/arduino/pte_to_header.py new file mode 100644 index 00000000000..b6f14df0e1b --- /dev/null +++ b/examples/arduino/pte_to_header.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +# 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. + +"""Convert a .pte into a C header an Arduino sketch can include. + + python pte_to_header.py -p model.pte -o model.h + +examples/arm/executor_runner/pte_to_header.py places the array in a +network_model_sec section for the Ethos-U linker script. No Arduino core +defines that section, so this emits a plain rodata array instead. +""" + +import argparse +import os +import re + +BANNER = """\ +/* + * 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. + */ + +// Generated from {source} by pte_to_header.py. Do not edit. +""" + + +C_IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +def c_identifier(value: str) -> str: + """argparse type that rejects names that cannot appear in C++.""" + if not C_IDENTIFIER.match(value): + raise argparse.ArgumentTypeError( + f"{value!r} is not a C identifier; it is emitted verbatim as an " + "array name, so anything else fails to compile" + ) + return value + + +def to_header(buffer: bytes, name: str = "model_pte", source: str = "a .pte") -> str: + if not C_IDENTIFIER.match(name): + raise ValueError( + f"--name must be a C identifier, got {name!r}. The value is emitted " + "verbatim as an array name, so anything else fails to compile." + ) + out = [BANNER.format(source=source)] + out.append("#pragma once") + out.append("#include ") + out.append("#include ") + out.append("") + out.append(f"alignas(16) static const uint8_t {name}[] = {{") + for i in range(0, len(buffer), 16): + out.append(" " + ",".join(f"0x{b:02x}" for b in buffer[i : i + 16]) + ",") + out.append("};") + out.append(f"static const size_t {name}_size = {len(buffer)};") + return "\n".join(out) + "\n" + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("-p", "--pte", required=True, help="Input .pte file") + parser.add_argument("-o", "--output", required=True, help="Output .h file") + parser.add_argument("-d", "--outdir", default="", help="Directory for --output") + parser.add_argument( + "-n", + "--name", + default="model_pte", + type=c_identifier, + help="C array name (must be a valid C identifier)", + ) + args = parser.parse_args() + + out = os.path.join(args.outdir, args.output) if args.outdir else args.output + + with open(args.pte, "rb") as f: + buffer = f.read() + + with open(out, "w") as f: + f.write(to_header(buffer, args.name, os.path.basename(args.pte))) + + print(f"{out}: {len(buffer)} bytes ({len(buffer) / 1024:.1f} KB)") + + +if __name__ == "__main__": + main()