Deploy a machine learning application to the Arm Ethos-U65 NPU on NXP FRDM i.MX 93 with Topo
Introduction
Understand the architecture of the machine learning application
Understand the toolchains used in the Topo Project
Build the Topo Project from scratch
Clone and deploy the application with Topo
Next Steps
Deploy a machine learning application to the Arm Ethos-U65 NPU on NXP FRDM i.MX 93 with Topo
What you will build
In this section, you’ll build the topo-imx93-npu-deployment Topo Project starting from two non-Topo, non-Compose projects:
- a Cortex-A web application that prepares images, writes model and tensor data into shared memory, and sends inference commands over
RPMsg - a Cortex-M33 ExecuTorch runner firmware project for the FRDM i.MX 93
You’ll combine those sources into one repository, then make the repository a normal Compose project. Then, you’ll add the Topo metadata and Remoteproc Runtime services.
Create the repository from the base projects
Start by copying the original base projects from the Topo Project.
Clone the Topo repository for the Topo Project specification and validation schema, clone the original Topo Project for the source files, and start a new empty repository:
git clone https://github.com/arm/topo.git
git clone https://github.com/Arm-Examples/topo-imx93-npu-deployment.git
mkdir new-topo-npu-template
cd new-topo-npu-template
Create the project layout:
mkdir -p webapp executorch-runner licenses
Copy the relevant webapp files:
cp -R ../topo-imx93-npu-deployment/webapp/src webapp
Copy the Cortex-M33 runner build inputs from the firmware project:
cp ../topo-imx93-npu-deployment/executorch-runner/build-runner.sh executorch-runner/build-runner.sh
cp ../topo-imx93-npu-deployment/executorch-runner/export_mv2_imx93.py executorch-runner/export_mv2_imx93.py
cp ../topo-imx93-npu-deployment/executorch-runner/docker-entrypoint.sh executorch-runner/docker-entrypoint.sh
cp -R ../topo-imx93-npu-deployment/executorch-runner/patches executorch-runner
Add the licenses and ignore rules used by the source projects:
cp ../topo-imx93-npu-deployment/LICENSE.md .
cp -R ../topo-imx93-npu-deployment/licenses .
cp ../topo-imx93-npu-deployment/.gitignore .
You have two sets of source code combined into one repository. It’s not a Compose project and it’s not a Topo Project.
You’ll now create a Compose project and Topo Project around the source code.
The Compose project provides the container build and runtime structure. A Dockerfile describes how to build one image. A Compose file describes the services that use those images, their build contexts, ports, volumes, dependencies, and runtime settings. In this Topo Project:
webapp/Dockerfilebuilds the Flask image.webapp/compose.yamlkeeps the web app’s build context and Linux runtime settings close to the web app source.executorch-runner/Dockerfilebuilds the ExecuTorch.ptemodel and Cortex-M33 runner ELF through multi-stage Docker builds.- the root
compose.yamlis the Topo Project entry point. It combines the web app, artifact build services, the Remoteproc Runtime service, and the root-levelx-topometadata.
For a general introduction to Compose projects, services, and the compose.yaml file, see Docker’s
How Compose works
documentation.
When you creating a file for any of the following steps, paste the complete file contents as shown.
When you update part of an existing Compose file for a step, merge the YAML into the existing top-level key shown by the snippet. For example, if a snippet starts with services:, add the named service under the existing top-level services: map. Don’t create a second services: block in the same file.
Turn the sources into a Compose project
Before adding Topo metadata, make the project work as ordinary Compose. Start by containerizing the Cortex-A web application.
Create webapp/Dockerfile with the following complete contents:
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONUNBUFFERED=1
RUN python -m pip install --no-cache-dir flask==3.0.3
COPY src/data/imagenet_classes.txt /opt/mv2-imx93/imagenet_classes.txt
COPY src/app.py .
COPY src/templates/ templates/
COPY src/static/ static/
EXPOSE 3000
CMD ["python", "app.py"]
Create webapp/compose.yaml with the following complete contents:
services:
webapp:
platform: linux/arm64
build:
context: .
dockerfile: Dockerfile
privileged: true
ports:
- "${WEBAPP_PORT:-3001}:3000"
volumes:
- /sys:/sys
- /dev:/dev
restart: unless-stopped
Create the root compose.yaml with the following complete contents:
services:
webapp:
platform: linux/arm64
extends:
file: webapp/compose.yaml
service: webapp
Check that Compose can read the project:
docker compose config
The output includes the resolved webapp service, and is similar to:
services:
webapp:
build:
context: /path/to/new-topo-npu-template/webapp
dockerfile: Dockerfile
ports:
- mode: ingress
target: 3000
published: "3001"
At this point, Compose can build and run the Cortex-A web application as a normal Linux container. The image runs webapp/src/app.py. It packages the Jinja templates from webapp/src/templates/, the static assets from webapp/src/static/, and the ImageNet labels from webapp/src/data/imagenet_classes.txt. The container listens on port 3000, and Compose publishes it on host port 3001 unless you set WEBAPP_PORT to another value.
Add the ExecuTorch artifact pipeline
The web application needs an ExecuTorch .pte model, and the target needs a Cortex-M33 ELF image. Both artifacts are built by executorch-runner/Dockerfile.
Copy the Dockerfile into the runner build context:
cp ../topo-imx93-npu-deployment/executorch-runner/Dockerfile executorch-runner/
For this multi-stage Dockerfile:
build-base: installs the common Ubuntu build tools.executorch-base: clones ExecuTorch, installs the Arm backend dependencies, and copiesexport_mv2_imx93.pyanddocker-entrypoint.sh.pte-builder: exportsmv2_ethosu65_256.pte.pte-artifacts: packages the.ptefile as a BuildKit artifact context.runner-base: installs the Arm GNU toolchain, MCUX SDK, RPMsg-Lite dependencies, runner sources, and local patches.runner-builder: buildsexecutorch_runner_cm33.elf.runner-artifacts: packages the ELF for inspection or reuse.runner-runtime: produces ascratchimage whose entrypoint is the ELF file.
The important artifact stages look like this:
FROM busybox:1.36 AS pte-artifacts
COPY --from=pte-builder /workspace/build/mv2-imx93/mv2_ethosu65_256.pte /artifacts/mv2_ethosu65_256.pte
FROM busybox:1.36 AS runner-artifacts
COPY --from=runner-builder /artifacts/executorch_runner_cm33.elf /artifacts/executorch_runner_cm33.elf
FROM scratch AS runner-runtime
COPY --from=runner-builder /artifacts/executorch_runner_cm33.elf /executorch_runner_cm33.elf
ENTRYPOINT ["/executorch_runner_cm33.elf"]
Connect the artifact services
Add pte-artifacts and runner-artifacts as siblings of the existing webapp service in the root compose.yaml:
services:
pte-artifacts:
platform: linux/arm64
scale: 0
build:
context: executorch-runner
dockerfile: Dockerfile
target: pte-artifacts
cache_from:
- ${EXECUTORCH_BASE_CACHE_IMAGE:-ghcr.io/arm-examples/topo-imx93-npu-deployment/executorch-base:et-v1.2.0-ubuntu24.04}
runner-artifacts:
platform: linux/arm64
scale: 0
build:
context: executorch-runner
dockerfile: Dockerfile
target: runner-artifacts
cache_from:
- ${IMX93_RUNNER_BUILD_CACHE_IMAGE:-ghcr.io/arm-examples/topo-imx93-npu-deployment/imx93-runner-build:mcux-v25.09.00-armgcc14.2-ubuntu24.04}
Don’t replace the existing root webapp service with this snippet. The root file should now have three service names under the same top-level services: map: webapp, pte-artifacts, and runner-artifacts.
These services are used only to build artifacts. They don’t run as part of the deployed application. scale: 0 tells Compose not to start containers for them, while still allowing other services to copy files from their build outputs.
Replace webapp/compose.yaml with the following version so the Flask image imports the .pte artifact:
services:
webapp:
platform: linux/arm64
build:
context: .
dockerfile: Dockerfile
additional_contexts:
pte_artifacts: service:pte-artifacts
privileged: true
ports:
- "${WEBAPP_PORT:-3001}:3000"
volumes:
- /sys:/sys
- /dev:/dev
restart: unless-stopped
Then add the .pte copy line to webapp/Dockerfile with the other COPY commands:
COPY --from=pte_artifacts /artifacts/mv2_ethosu65_256.pte /opt/mv2-imx93/mv2_ethosu65_256.pte
The /opt/mv2-imx93/ path is the location the Flask application expects for its MobileNetV2 support files. At run time, the app reads the .pte file from this path before copying it into reserved memory for the Cortex-M33 runner.
Add the Remoteproc Runtime service
Add the Cortex-M33 runner as another sibling under the top-level services: map in the root compose.yaml:
services:
cm33-runner:
platform: linux/arm64
build:
context: executorch-runner
dockerfile: Dockerfile
target: runner-runtime
cache_from:
- ${IMX93_RUNNER_BUILD_CACHE_IMAGE:-ghcr.io/arm-examples/topo-imx93-npu-deployment/imx93-runner-build:mcux-v25.09.00-armgcc14.2-ubuntu24.04}
runtime: io.containerd.remoteproc.v1
annotations:
remoteproc.name: imx-rproc
Keep the existing webapp, pte-artifacts, and runner-artifacts services in the same file. This step adds one more service and doesn’t replace any of the previous services.
This is the heterogeneous deployment hook. Docker still builds an image, but the service doesn’t start as a Linux userspace process. The runtime io.containerd.remoteproc.v1 selects Remoteproc Runtime, and the remoteproc.name annotation tells the shim to use the i.MX remote processor driver.
Update the existing root webapp service so it depends on the CM33 runner and passes the cache image values into the build. Keep the existing extends block, then add depends_on and build.args as shown:
services:
webapp:
platform: linux/arm64
extends:
file: webapp/compose.yaml
service: webapp
depends_on:
- cm33-runner
build:
args:
EXECUTORCH_BASE_CACHE_IMAGE: ghcr.io/arm-examples/topo-imx93-npu-deployment/executorch-base:et-v1.2.0-ubuntu24.04
IMX93_RUNNER_BUILD_CACHE_IMAGE: ghcr.io/arm-examples/topo-imx93-npu-deployment/imx93-runner-build:mcux-v25.09.00-armgcc14.2-ubuntu24.04
The web app is privileged and mounts /sys and /dev because it checks the device tree, reads remoteproc state through /sys/class/remoteproc, talks to /dev/ttyRPMSG*, writes shared memory through /dev/mem, and checks for /dev/ethosu0.
Keep the web app build context in webapp/compose.yaml. The root webapp.build.args block supplies only Topo-collected build arguments. It shouldn’t replace the extended build context and Dockerfile from webapp/compose.yaml.
Add Topo metadata and arguments
After the Compose services are complete, add the root-level x-topo block.
Keep it at the root of compose.yaml, as a sibling of services, not under services.
If you want to use an agent skill to perform this step, skip to the optional step.
x-topo:
name: "i.MX93 ExecuTorch runner"
description: "Runs a Cortex-A web application that sends image inference commands to a resident CM33 ExecuTorch runner over RPMsg."
features:
- "remoteproc-runtime"
parameters:
EXECUTORCH_BASE_CACHE_IMAGE:
description: Optional GHCR image used as a BuildKit cache source for the ExecuTorch PTE build.
required: false
default: ghcr.io/arm-examples/topo-imx93-npu-deployment/executorch-base:et-v1.2.0-ubuntu24.04
IMX93_RUNNER_BUILD_CACHE_IMAGE:
description: Optional GHCR image used as a BuildKit cache source for the CM33 runner build.
required: false
default: ghcr.io/arm-examples/topo-imx93-npu-deployment/imx93-runner-build:mcux-v25.09.00-armgcc14.2-ubuntu24.04
The features value tells Topo that this project requires remoteproc-runtime support on the target. This is useful when checking for project compatibility with the topo projects --target <target> command.
The parameters entries describe configurable build inputs. Compose consumes those values through the cache_from interpolation that you added earlier:
cache_from:
- ${EXECUTORCH_BASE_CACHE_IMAGE:-ghcr.io/arm-examples/topo-imx93-npu-deployment/executorch-base:et-v1.2.0-ubuntu24.04}
The root webapp.build.args block also makes the Topo-provided values visible in the Compose build model while preserving the webapp/ build context inherited through extends.
Keep runtime settings such as WEBAPP_PORT as normal Compose interpolation unless you intentionally want Topo to collect them as Topo Project setup arguments.
(Optional) Use an Agent Skill to add the Topo metadata
Topo currently provides the following skills:
topo-project-context: provides Topo and Topo Project reference context for questions about x-topo metadata, schema, docs, and CLI project behavior.topo-project-bootstrap: converts a repository into a Topo Project by adding or improvingcompose.yamlandx-topometadata.topo-project-lint: reviews an existing Topo Project for correctness, consistency, and authoring best practices.topo-project-optimize-deployment: optimizes feedback loop by measuring and applying the highest-leverage Docker build improvement.
Install the skills with npx skills:
npx skills add arm/topo
If your agent doesn’t use npx skills, manually copy or symlink the skill directories from Topo into your agent’s skills directory.
Restart your agent after installing or updating the skills.
From the root of the Compose project, ask your agent to use topo-project-bootstrap:
Use topo-project-bootstrap on this repository.
Treat the root compose.yaml as the project root.
Preserve plain docker compose behavior.
Add x-topo metadata only where it reflects the actual services, hardware requirements, and build arguments.
After bootstrap, ask the agent to use topo-project-lint:
Use topo-project-lint on this repository.
Validate compose.yaml against the Topo Project specification schema.
Check README alignment, Remoteproc Runtime metadata, and x-topo.parameters wiring.
Validate the final Topo Project
Check the Compose model and check that the Topo metadata is present:
docker compose config
In the docker compose config output, check that the resolved webapp service has:
build.contextending in/webappbuild.dockerfileset toDockerfilebuild.additional_contexts.pte_artifactsset toservice:pte-artifacts
Install check-jsonschema if it’s not already available:
brew install check-jsonschema
sudo apt update
sudo apt install -y pipx
pipx ensurepath
pipx install check-jsonschema
export PATH="$HOME/.local/bin:$PATH"
Validate the root Compose file with the schema in the Topo Project specification:
check-jsonschema \
--schemafile ../topo/docs/project-specification/schema/topo-project-specification.json \
compose.yaml
Review these points:
compose.yamlcontains root-levelx-topometadata.x-topo.featuresincludesremoteproc-runtime.- non-remoteproc services set
platform: linux/arm64. pte-artifactsandrunner-artifactsusescale: 0.cm33-runnerusesruntime: io.containerd.remoteproc.v1.cm33-runnerhasremoteproc.name: imx-rproc.webappdepends oncm33-runner.webappimports the.ptefile throughadditional_contexts.- every
x-topo.parametersentry is consumed by Compose interpolation.
What you’ve accomplished and what’s next
You started with two non-Topo, non-Compose projects, made them a standard Compose project, and then converted that Compose project into a Topo Project. You created the web app image, added artifact builds for the ExecuTorch .pte model and Cortex-M33 ELF, packaged the firmware as a Remoteproc Runtime service, and exposed the build cache inputs as Topo parameters.
Next, you’ll prepare the FRDM i.MX 93 target, deploy the project with Topo, and run the image classification application.